// 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. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package config import ( "os" "path/filepath" "reflect" "strings" "testing" ) func TestLoadVersionedSettingsMatchesStore(t *testing.T) { path := filepath.Join("testdata", "settings-v2.toml") store, err := NewStore(path) if err != nil { t.Fatal(err) } want, err := store.Load() if err != nil { t.Fatal(err) } got, err := Load(path) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(got, want) { t.Fatalf("runtime settings = %#v, want store settings %#v", got, want) } } func TestLegacySayAllStyleMapsToRuntimeChoice(t *testing.T) { for legacy, want := range map[string]string{"0": "line", "1": "sentence"} { settings, err := Load(writeConfig(t, "[general]\nsayAllStyle = "+legacy+"\n")) if err != nil { t.Fatal(err) } if settings.SayAllStyle != want { t.Fatalf("legacy Say All style %s loaded as %q", legacy, settings.SayAllStyle) } } } func TestLoadRejectsInvalidVersionedSettingsWithoutWriting(t *testing.T) { for name, contents := range map[string]string{ "old": "schema-version = 1\n[general]\n", "future": "schema-version = 3\nactive-profile = 'default'\n[profiles.default]\n", "zero": "schema-version = 0\n[general]\n", "negative": "schema-version = -1\n[general]\n", "wrong type": "schema-version = '1'\n[general]\n", "unknown field": "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\nfuture-setting = true\n", "legacy field": "schema-version = 2\nactive-profile = 'default'\n[profiles.default.speech]\nenableSpeech = false\n", "invalid value": "schema-version = 2\nactive-profile = 'default'\n[profiles.default.sound]\nvolume = 2.0\n", "malformed": "schema-version = 2\n[profiles\n", } { t.Run(name, func(t *testing.T) { path := writeConfig(t, contents) if _, err := Load(path); err == nil { t.Fatal("Load accepted invalid versioned settings") } got, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(got) != contents { t.Fatal("Load changed the settings file") } }) } } func TestLoadMergesGeneralAndStartingProfile(t *testing.T) { path := writeConfig(t, ` [general] enableSpeech = true speechServerFactory = "speechdispatcherfactory" speakDescription = true speechVerbosityLevel = 1 verbalizePunctuationStyle = 2 capitalizationStyle = "spell" keyboardLayout = 1 cthulhuModifierKeys = ["Insert", "KP_Insert"] enableKeyEcho = true enableEchoByCharacter = false enableEchoByWord = true enableEchoBySentence = false enableAlphabeticKeys = true enableNumericKeys = true enablePunctuationKeys = false enableSpace = true enableModifierKeys = true enableFunctionKeys = true enableActionKeys = true enableNavigationKeys = false enableDiacriticalKeys = false startingProfile = ["Quiet", "quiet"] unknownFutureSetting = true [general.voices.default] rate = 40.0 average-pitch = 5.0 gain = 8.0 [profiles.quiet] speakDescription = false enableKeyEcho = false enableEchoByCharacter = true enableEchoByWord = false enableEchoBySentence = true enableAlphabeticKeys = false speechServerFactory = "cthulhu.speechdispatcherfactory" [profiles.quiet.voices.default] rate = 75.0 [profiles.quiet.voices.default.family] name = "Generic Voice" lang = "en" dialect = "US" `) settings, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } if !settings.Loaded || settings.ActiveProfile != "quiet" { t.Fatalf("loaded/profile = %v/%q, want true/quiet", settings.Loaded, settings.ActiveProfile) } if settings.SpeakDescription || settings.EnableKeyEcho { t.Fatal("profile boolean overrides were not applied") } if !settings.EnableEchoByCharacter || settings.EnableEchoByWord || !settings.EnableEchoBySentence || settings.EnableAlphabeticKeys || settings.EnablePunctuationKeys || !settings.EnableNumericKeys || !settings.EnableSpace || !settings.EnableModifierKeys || !settings.EnableFunctionKeys || !settings.EnableActionKeys || settings.EnableNavigationKeys || settings.EnableDiacriticalKeys { t.Fatalf("typing echo settings = %+v", settings) } if settings.SpeechServerFactory != "cthulhu.speechdispatcherfactory" { t.Fatalf("speech server factory = %q", settings.SpeechServerFactory) } if settings.DefaultVoice.Rate == nil || *settings.DefaultVoice.Rate != 75 { t.Fatalf("voice rate = %v, want 75", settings.DefaultVoice.Rate) } if settings.DefaultVoice.Gain == nil || *settings.DefaultVoice.Gain != 8 { t.Fatalf("voice gain = %v, want inherited value 8", settings.DefaultVoice.Gain) } if settings.DefaultVoice.Family == nil || settings.DefaultVoice.Family.Name != "Generic Voice" { t.Fatalf("voice family = %#v", settings.DefaultVoice.Family) } } func TestLoadMissingFileUsesDefaults(t *testing.T) { settings, err := Load(filepath.Join(t.TempDir(), "missing.toml")) if err != nil { t.Fatalf("Load: %v", err) } if settings.Loaded { t.Fatal("missing file reported as loaded") } if !settings.EnableSpeech || !settings.SpeakDescription || !settings.Reading.EnableMnemonicSpeaking || settings.KeyboardLayout != 1 { t.Fatalf("unexpected defaults: %#v", settings) } } func TestDefaultPathUsesGoDevelopmentFile(t *testing.T) { dataHome := filepath.Join(t.TempDir(), "data") t.Setenv("XDG_DATA_HOME", dataHome) path, err := DefaultPath() if err != nil { t.Fatal(err) } if want := filepath.Join(dataHome, "cthulhu", "user-settings-go.toml"); path != want { t.Fatalf("DefaultPath = %q, want %q", path, want) } } func TestDefaultLoadDoesNotFallBackToPythonSettings(t *testing.T) { dataHome := filepath.Join(t.TempDir(), "data") t.Setenv("XDG_DATA_HOME", dataHome) directory := filepath.Join(dataHome, "cthulhu") if err := os.MkdirAll(directory, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(directory, "user-settings.toml"), []byte("[general]\nenableSpeech = false\n"), 0o600); err != nil { t.Fatal(err) } settings, err := Load("") if err != nil { t.Fatal(err) } if settings.Loaded || !settings.EnableSpeech || filepath.Base(settings.Path) != "user-settings-go.toml" { t.Fatalf("default settings = %#v", settings) } } func TestLoadRejectsInvalidSettings(t *testing.T) { path := writeConfig(t, "[general]\nkeyboardLayout = 9\n") _, err := Load(path) if err == nil || !strings.Contains(err.Error(), "keyboardLayout") { t.Fatalf("Load error = %v, want keyboardLayout validation error", err) } } func TestLoadRejectsOversizedFile(t *testing.T) { path := filepath.Join(t.TempDir(), "user-settings.toml") if err := os.WriteFile(path, []byte(strings.Repeat("#", maxConfigSize+1)), 0o600); err != nil { t.Fatalf("write oversized config: %v", err) } _, err := Load(path) if err == nil || !strings.Contains(err.Error(), "exceeds") { t.Fatalf("Load error = %v, want size error", err) } } func writeConfig(t *testing.T, contents string) string { t.Helper() path := filepath.Join(t.TempDir(), "user-settings.toml") if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { t.Fatalf("write config: %v", err) } return path } func TestSoundSettingsMergeAndValidate(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") if err := os.WriteFile(path, []byte(`[general] soundVolume = 0.4 soundTheme = "default" [profiles.default] enableSound = true soundVolume = 0.7 soundSink = "pulse" playSoundForRole = true playSoundForState = true roleSoundPresentation = "sound_only" `), 0o600); err != nil { t.Fatal(err) } settings, err := Load(path) if err != nil { t.Fatal(err) } if settings.SoundVolume != 0.7 || !settings.PlaySoundForRole || !settings.PlaySoundForState || settings.RoleSoundPresentation != "sound_only" { t.Fatalf("sound settings = %+v", settings) } if err := os.WriteFile(path, []byte("[general]\nroleSoundPresentation = \"speech_only\"\n"), 0o600); err != nil { t.Fatal(err) } if _, err := Load(path); err != nil { t.Fatalf("speech-only role presentation was rejected: %v", err) } for _, line := range []string{`soundVolume = -0.1`, `soundVolume = 1.1`, `soundVolume = nan`, `soundTheme = "../other"`, `soundSink = "invalid"`, `roleSoundPresentation = "invalid"`} { if err := os.WriteFile(path, []byte("[general]\n"+line+"\n"), 0o600); err != nil { t.Fatal(err) } if _, err := Load(path); err == nil { t.Fatalf("accepted %s", line) } } } func TestLoadRejectsNonFiniteVoiceValues(t *testing.T) { for _, value := range []string{"nan", "+inf", "-inf"} { path := writeConfig(t, "[general.voices.default]\nrate = "+value+"\n") if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "default voice rate") { t.Fatalf("Load rate %s error = %v", value, err) } } }