// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. package config import ( "errors" "os" "path/filepath" "reflect" "strings" "testing" ) func TestStoreSaveAndLoadRoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), "cthulhu", "settings.toml") store, err := NewStore(path) if err != nil { t.Fatal(err) } settings := DefaultSettings() settings.EnableSpeech = false settings.SpeechHistoryLimit = 1000 settings.SayAllStyle = "sentence" settings.Reading.EnableMnemonicSpeaking = false settings.Reading.SayAllContextList = false settings.Reading.SayAllContextPanel = false settings.Reading.PresentTooltips = true settings.Reading.EnableMouseReview = true settings.EnableBraille = true settings.EnableContractedBraille = true settings.BrailleContractionTable = "en-us-g2.ctb" settings.Braille = BrailleOptions{AbbreviateRoles: true, DisableEOL: true, WordWrap: true, Verbosity: 0, SelectionIndicator: 0x40, LinkIndicator: 0x80, FlashMessages: true, FlashTime: 3200, FlashPersistent: true, FlashDetailed: false} settings.VerbalizePunctuationStyle = 3 settings.CthulhuModifierKeys = []string{"Insert"} rate := 72.0 settings.DefaultVoice.Rate = &rate if err := store.Save(settings); err != nil { t.Fatalf("Save: %v", err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } if permissions := info.Mode().Perm(); permissions != 0o600 { t.Fatalf("settings permissions = %04o, want 0600", permissions) } contents, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !strings.Contains(string(contents), "schema-version = 2") || !strings.Contains(string(contents), "[profiles.default.speech]") || !strings.Contains(string(contents), "history-limit = 1000") { t.Fatalf("settings do not contain schema version:\n%s", contents) } reopened, err := NewStore(path) if err != nil { t.Fatal(err) } loaded, err := reopened.Load() if err != nil { t.Fatalf("Load: %v", err) } settings.Path, settings.Loaded, settings.ActiveProfile = path, true, "default" if !reflect.DeepEqual(loaded, settings) { t.Fatalf("loaded settings differ:\n got: %#v\nwant: %#v", loaded, settings) } } func TestLoadUpgradesLegacyDateFormatIDs(t *testing.T) { for source, target := range map[string]string{ "ymd": "numbers-ymd", "mdy": "numbers-mdy", "dmy": "numbers-dmy", } { path := filepath.Join(t.TempDir(), "settings.toml") contents := "schema-version = 2\nactive-profile = 'default'\n[profiles.default.presentation]\ntime-format = '24-hour'\ndate-format = '" + source + "'\n" if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { t.Fatal(err) } settings, err := Load(path) if err != nil { t.Fatalf("%s: Load: %v", source, err) } if settings.TimeFormat != "24-hour" || settings.DateFormat != target { t.Fatalf("%s: formats = (%s, %s), want (24-hour, %s)", source, settings.TimeFormat, settings.DateFormat, target) } } } func TestStoreMissingFileUsesDefaults(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") store, err := NewStore(path) if err != nil { t.Fatal(err) } settings, err := store.Load() if err != nil { t.Fatal(err) } if settings.Loaded || settings.Path != path || !settings.EnableSpeech || settings.SpeechHistoryLimit != DefaultSpeechHistoryLimit { t.Fatalf("missing settings = %#v", settings) } } func TestStoreRejectsSchemaVersionsAndUnknownFields(t *testing.T) { for name, contents := range map[string]string{ "missing": `[general]`, "future": "schema-version = 3\nactive-profile = 'default'\n[profiles.default]\n", "unknown": "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\nfuture-setting = true\n", } { t.Run(name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { t.Fatal(err) } store, err := NewStore(path) if err != nil { t.Fatal(err) } loaded, err := store.Load() if err == nil { t.Fatal("Load accepted incompatible settings") } if !reflect.DeepEqual(loaded, store.Current()) || loaded.Loaded { t.Fatalf("last good settings were not retained: %#v", loaded) } }) } } func TestStoreVersionFixtures(t *testing.T) { current, err := os.ReadFile(filepath.Join("testdata", "settings-v2.toml")) if err != nil { t.Fatal(err) } settings, err := decodeWritable(current) if err != nil { t.Fatalf("decode current fixture: %v", err) } if settings.SpeakDescription || settings.VerbalizePunctuationStyle != 2 || settings.DefaultVoice.Rate == nil || *settings.DefaultVoice.Rate != 65 { t.Fatalf("current fixture settings = %#v", settings) } future, err := os.ReadFile(filepath.Join("testdata", "settings-v3.toml")) if err != nil { t.Fatal(err) } if _, err := decodeWritable(future); err == nil || !strings.Contains(err.Error(), "schema version 3") { t.Fatalf("future fixture error = %v", err) } } func TestStoreLoadFailureRetainsLastGood(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") store, err := NewStore(path) if err != nil { t.Fatal(err) } settings := DefaultSettings() settings.SpeakDescription = false if err := store.Save(settings); err != nil { t.Fatal(err) } good := store.Current() if err := os.WriteFile(path, []byte("schema-version = 2\n[profiles\n"), 0o600); err != nil { t.Fatal(err) } loaded, err := store.Load() if err == nil { t.Fatal("Load accepted malformed settings") } if !reflect.DeepEqual(loaded, good) || !reflect.DeepEqual(store.Current(), good) { t.Fatalf("last good changed:\n got: %#v\nwant: %#v", loaded, good) } } func TestStoreValidationFailureDoesNotTouchFile(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") store, err := NewStore(path) if err != nil { t.Fatal(err) } settings := DefaultSettings() if err := store.Save(settings); err != nil { t.Fatal(err) } original, err := os.ReadFile(path) if err != nil { t.Fatal(err) } settings.SoundVolume = 2 if err := store.Save(settings); err == nil { t.Fatal("Save accepted invalid settings") } after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(after, original) { t.Fatal("validation failure changed the settings file") } } func TestStoreRefusesToReplaceLegacyFile(t *testing.T) { path := filepath.Join(t.TempDir(), "user-settings.toml") legacy := []byte("[general]\nenableSpeech = false\nunknownPythonSetting = true\n") if err := os.WriteFile(path, legacy, 0o600); err != nil { t.Fatal(err) } store, err := NewStore(path) if err != nil { t.Fatal(err) } if err := store.Save(DefaultSettings()); err == nil || !strings.Contains(err.Error(), "refuse to replace") { t.Fatalf("Save error = %v", err) } after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(after, legacy) { t.Fatal("legacy file changed") } } func TestDefaultStorePathUsesGoDevelopmentSettingsPath(t *testing.T) { t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "data")) path, err := DefaultStorePath() if err != nil { t.Fatal(err) } want := filepath.Join(os.Getenv("XDG_DATA_HOME"), "cthulhu", "user-settings-go.toml") if path != want { t.Fatalf("DefaultStorePath = %q, want %q", path, want) } } func TestStoreRenameFailurePreservesOriginalAndCleansTemporary(t *testing.T) { directory := t.TempDir() path := filepath.Join(directory, "settings.toml") store, err := NewStore(path) if err != nil { t.Fatal(err) } settings := DefaultSettings() if err := store.Save(settings); err != nil { t.Fatal(err) } original, err := os.ReadFile(path) if err != nil { t.Fatal(err) } store.rename = func(_, _ string) error { return errors.New("injected rename failure") } settings.EnableSpeech = false if err := store.Save(settings); err == nil || !strings.Contains(err.Error(), "injected rename failure") { t.Fatalf("Save error = %v", err) } after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(after, original) { t.Fatal("rename failure changed the settings file") } temporary, err := filepath.Glob(filepath.Join(directory, ".settings-*.toml")) if err != nil { t.Fatal(err) } if len(temporary) != 0 { t.Fatalf("temporary files remain: %v", temporary) } } func TestStoreCopiesMutableSettings(t *testing.T) { store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml")) if err != nil { t.Fatal(err) } settings := DefaultSettings() rate := 40.0 settings.DefaultVoice.Rate = &rate if err := store.Save(settings); err != nil { t.Fatal(err) } settings.CthulhuModifierKeys[0] = "changed" rate = 99 current := store.Current() if current.CthulhuModifierKeys[0] != "Insert" || current.DefaultVoice.Rate == nil || *current.DefaultVoice.Rate != 40 { t.Fatalf("stored settings alias caller memory: %#v", current) } current.CthulhuModifierKeys[0] = "changed again" *current.DefaultVoice.Rate = 1 again := store.Current() if again.CthulhuModifierKeys[0] != "Insert" || *again.DefaultVoice.Rate != 40 { t.Fatalf("Current aliases store memory: %#v", again) } } func TestStoreSavePreservesOtherProfilesAndApplicationMappings(t *testing.T) { directory := t.TempDir() path := filepath.Join(directory, "user-settings-go.toml") fixture, err := os.ReadFile(filepath.Join("testdata", "settings-v2.toml")) if err != nil { t.Fatal(err) } if err := os.WriteFile(path, fixture, 0o600); err != nil { t.Fatal(err) } store, err := NewStore(path) if err != nil { t.Fatal(err) } settings, err := store.Load() if err != nil { t.Fatal(err) } settings.VerbalizePunctuationStyle = 3 if err := store.Save(settings); err != nil { t.Fatal(err) } contents, err := os.ReadFile(path) if err != nil { t.Fatal(err) } document, loaded, err := decodeDocument(contents) if err != nil { t.Fatal(err) } if loaded.VerbalizePunctuationStyle != 3 || loaded.ActiveProfile != "default" { t.Fatalf("saved active profile = %#v", loaded) } if document.Applications["org.example.Editor"] != "quiet" { t.Fatalf("application mappings = %#v", document.Applications) } quiet := DefaultSettings() applyProfile(&quiet, document.Profiles["quiet"]) if quiet.EnableSpeech { t.Fatal("save changed the unrelated quiet profile") } } func TestStoreRejectsInvalidProfileTopology(t *testing.T) { for name, contents := range map[string]string{ "missing active profile": "schema-version = 2\nactive-profile = 'missing'\n[profiles.default]\n", "missing application profile": "schema-version = 2\nactive-profile = 'default'\n[applications]\n'org.example.Editor' = 'missing'\n[profiles.default]\n", "invalid inactive profile": "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\n[profiles.broken.sound]\nvolume = 2.0\n", } { t.Run(name, func(t *testing.T) { if _, err := decodeWritable([]byte(contents)); err == nil { t.Fatal("invalid profile topology was accepted") } }) } } func TestEnsureUnchangedDetectsExternalCreationAndModification(t *testing.T) { path := filepath.Join(t.TempDir(), "user-settings-go.toml") if err := ensureUnchanged(path, nil); err != nil { t.Fatalf("missing file changed unexpectedly: %v", err) } if err := os.WriteFile(path, []byte("created"), 0o600); err != nil { t.Fatal(err) } if err := ensureUnchanged(path, nil); err == nil || !strings.Contains(err.Error(), "changed while being saved") { t.Fatalf("external creation error = %v", err) } if err := ensureUnchanged(path, []byte("original")); err == nil || !strings.Contains(err.Error(), "changed while being saved") { t.Fatalf("external modification error = %v", err) } if err := ensureUnchanged(path, []byte("created")); err != nil { t.Fatalf("unchanged file rejected: %v", err) } }