// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "log/slog" "os" "path/filepath" "reflect" "strings" "testing" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/sound" "git.stormux.org/storm/cthulhu/internal/speech" ) type settingsUpdateFunc func(context.Context, speech.Settings) error func TestPreferencesInitialRetainsLargeVoiceCatalog(t *testing.T) { catalog := speech.Catalog{ "module": make([]speech.Voice, 6000), "other": {{Name: "Other voice", Language: "en"}}, } for index := range catalog["module"] { catalog["module"][index].Name = strings.Repeat("v", 100) } worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil) worker.discover = func(context.Context, string) (speech.Catalog, error) { return catalog, nil } response, err := worker.initial_response(t.Context()) if err != nil || !reflect.DeepEqual(response.Catalog, catalog) { t.Fatalf("initial response lost discovered voices: err=%v", err) } } func TestPreferencesWindowReportsHelperExit(t *testing.T) { worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil) worker.discover = func(context.Context, string) (speech.Catalog, error) { return nil, nil } // The test executable does not implement --preferences-ui. It exits before // opening a dialog, as a broken helper would on the affected machine. err := worker.window(t.Context()) if err == nil || !strings.Contains(err.Error(), "helper exit") || !strings.Contains(err.Error(), "preferences-ui") { t.Fatalf("helper startup error = %v", err) } } func (update settingsUpdateFunc) UpdateSettings(ctx context.Context, settings speech.Settings) error { return update(ctx, settings) } type recordingSoundSettings struct { configurations []sound.Configuration } type recordingBrailleSettings struct { values []bool contraction []bool tables []string err error } func (recorder *recordingBrailleSettings) SetContraction(enabled bool, table string) { recorder.contraction = append(recorder.contraction, enabled) recorder.tables = append(recorder.tables, table) } func (recorder *recordingBrailleSettings) Configure(_ context.Context, enabled bool) error { if recorder.err != nil { return recorder.err } recorder.values = append(recorder.values, enabled) return nil } func TestPreferencesBrailleApplyAndFailure(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) braille := &recordingBrailleSettings{} worker.braille = braille values := preferences.FromSettings(settings) values.EnableBraille = true values.EnableContractedBraille = true values.BrailleContractionTable = "en-us-g2.ctb" response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) if !response.Accepted || len(braille.values) != 1 || !braille.values[0] || len(braille.contraction) != 1 || !braille.contraction[0] || braille.tables[0] != "en-us-g2.ctb" { t.Fatalf("enable: response=%+v calls=%v", response, braille.values) } loaded, err := config.Load(settings.Path) if err != nil || !loaded.EnableBraille || !loaded.EnableContractedBraille || loaded.BrailleContractionTable != "en-us-g2.ctb" { t.Fatalf("saved braille setting: %+v, %v", loaded, err) } braille.err = errors.New("helper unavailable") values.EnableBraille = false response = worker.apply(context.Background(), preferences.Request{Values: values}) if response.Accepted || !worker.settings.EnableBraille { t.Fatalf("failed disable changed setting: %+v", response) } } func (recorder *recordingSoundSettings) Configure(configuration sound.Configuration) { recorder.configurations = append(recorder.configurations, configuration) } func TestPreferencesApplySoundSettingsLiveAndPersisted(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") live := &liveSettings{} live.Store(settings) recorder := &recordingSoundSettings{} geometry := &fakeAdapter{} worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) worker.live = live worker.sound = recorder worker.geometry = geometry values := preferences.FromSettings(settings) values.EnableSound = false values.SoundVolume = 0.8 values.SoundSink = "pulse" values.SoundTheme = "none" values.PlaySoundForRole = false values.PlaySoundForState = false values.PlaySoundForPositionInSet = false values.SpatializeObjectSounds = true values.ProgressBar.OutputMode = "off" values.ProgressBar.ReportBackground = true values.RoleSoundPresentation = "speech_only" response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) if !response.Accepted || len(recorder.configurations) != 1 { t.Fatalf("apply = %+v; sound updates = %+v", response, recorder.configurations) } wantConfiguration := sound.Configuration{Enabled: false, Sink: "pulse", Theme: "none", Volume: 0.8} if recorder.configurations[0] != wantConfiguration { t.Fatalf("sound configuration = %+v, want %+v", recorder.configurations[0], wantConfiguration) } if geometry.captureSoundGeometry { t.Fatal("disabled sound retained synchronous event geometry capture") } if got := preferences.FromSettings(live.Load()); !reflect.DeepEqual(got, values) { t.Fatalf("live values = %+v, want %+v", got, values) } loaded, err := config.Load(settings.Path) if err != nil { t.Fatal(err) } if got := preferences.FromSettings(loaded); !reflect.DeepEqual(got, values) { t.Fatalf("saved values = %+v, want %+v", got, values) } } func TestCaptureSoundGeometryRequiresSpatializedObjectSounds(t *testing.T) { settings := config.DefaultSettings() settings.EnableSound = true settings.SoundTheme = "default" settings.SoundVolume = 1 settings.SpatializeObjectSounds = true settings.PlaySoundForRole = true if !captureSoundGeometry(settings) { t.Fatal("spatialized role sounds did not enable event geometry capture") } settings.PlaySoundForRole = false settings.PlaySoundForState = false if !captureSoundGeometry(settings) { t.Fatal("spatialized position sounds did not enable event geometry capture") } settings.PlaySoundForPositionInSet = false if captureSoundGeometry(settings) { t.Fatal("geometry capture remained enabled without object sound categories") } } func TestPreferencesApplySaveAndRejectedDraft(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") var updates []speech.Settings worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, value speech.Settings) error { updates = append(updates, value) return nil })) values := preferences.FromSettings(settings) rate := 75.0 values.Rate = &rate response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) if !response.Accepted || len(updates) != 1 || *updates[0].Rate != 52 { t.Fatalf("apply = %+v; updates = %+v", response, updates) } if _, err := os.Stat(settings.Path); err != nil || response.Close { t.Fatalf("Apply did not save and stay open: %v", err) } badRate := 100.0 bad := values bad.Rate = &badRate response = worker.apply(context.Background(), preferences.Request{Values: bad, Save: true}) if response.Accepted || len(updates) != 1 || *worker.settings.DefaultVoice.Rate != rate { t.Fatal("invalid draft changed session") } response = worker.apply(context.Background(), preferences.Request{Values: values, Save: true, Close: true}) if !response.Accepted || !response.Close { t.Fatalf("save = %+v", response) } loaded, err := config.Load(settings.Path) if err != nil || loaded.DefaultVoice.Rate == nil || *loaded.DefaultVoice.Rate != rate { t.Fatalf("saved settings = %+v; error %v", loaded, err) } if !reflect.DeepEqual(loaded.CthulhuModifierKeys, settings.CthulhuModifierKeys) || loaded.EnableSound != settings.EnableSound { t.Fatal("saving changed unrelated settings") } } func TestPreferencesLegacyFileRemainsUntouched(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") contents := []byte("[general]\nenableSpeech = true\nunknownSetting = 'preserve me'\n") if err := os.WriteFile(path, contents, 0o600); err != nil { t.Fatal(err) } settings, err := config.Load(path) if err != nil { t.Fatal(err) } calls := 0 worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { calls++; return nil })) request := preferences.Request{Values: preferences.FromSettings(settings), Save: true} if response := worker.apply(context.Background(), request); response.Accepted || response.CanSave || calls != 0 { t.Fatalf("legacy save = %+v, calls %d", response, calls) } request.Save = false if response := worker.apply(context.Background(), request); !response.Accepted || calls != 0 { t.Fatalf("session apply = %+v", response) } got, err := os.ReadFile(path) if err != nil || string(got) != string(contents) { t.Fatal("legacy file changed") } } func TestPreferencesBackendFailureDoesNotCommit(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { return errors.New("backend rejected voice") })) values := preferences.FromSettings(settings) values.Punctuation = 3 response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) if response.Accepted || !reflect.DeepEqual(worker.settings, settings) { t.Fatalf("failed apply committed: %+v", response) } if _, err := os.Stat(settings.Path); !errors.Is(err, os.ErrNotExist) { t.Fatal("failed apply wrote a file") } } func TestPreferencesSpeechChoicesApplyAndPersist(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") var applied speech.Settings worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, value speech.Settings) error { applied = value return nil })) values := preferences.FromSettings(settings) values.SpeechOutputModule = "espeak-ng" values.SpeechVoiceFamily = &config.VoiceFamily{Name: "Generic Voice", Lang: "en", Dialect: "US", Variant: "male"} response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) if !response.Accepted || applied.OutputModule != "espeak-ng" || applied.Language != "en-US" || applied.SynthesisVoice != "Generic Voice" { t.Fatalf("apply = %+v; applied = %+v", response, applied) } if worker.settings.SpeechOutputModule != "espeak-ng" || worker.settings.DefaultVoice.Family == nil || worker.settings.DefaultVoice.Family.Name != "Generic Voice" { t.Fatalf("session settings = %+v", worker.settings) } loaded, err := config.Load(settings.Path) if err != nil || loaded.SpeechOutputModule != "espeak-ng" || loaded.DefaultVoice.Family == nil || loaded.DefaultVoice.Family.Name != "Generic Voice" { t.Fatalf("loaded settings = %+v; error %v", loaded, err) } } func TestPreferencesVoicePreviewDoesNotCommitAndCancelRestores(t *testing.T) { settings := config.DefaultSettings() originalRate, originalPitch, originalVolume := 40.0, 4.0, 6.0 settings.DefaultVoice.Rate = &originalRate settings.DefaultVoice.AveragePitch = &originalPitch settings.DefaultVoice.Gain = &originalVolume var updates []speech.Settings worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, value speech.Settings) error { updates = append(updates, value) return nil })) values := preferences.FromSettings(settings) previewRate, previewPitch, previewVolume := 75.0, 7.5, 8.0 values.Rate = &previewRate values.Pitch = &previewPitch values.Volume = &previewVolume response := worker.preview(context.Background(), preferences.Request{Values: values, Preview: true}) if !response.Accepted || !response.Preview || len(updates) != 1 || updates[0].Rate == nil || *updates[0].Rate != 52 || updates[0].Pitch == nil || *updates[0].Pitch != 60 || updates[0].Volume == nil || *updates[0].Volume != 85 { t.Fatalf("preview = %+v; updates = %+v", response, updates) } if worker.settings.DefaultVoice.Rate == nil || *worker.settings.DefaultVoice.Rate != originalRate || worker.settings.DefaultVoice.AveragePitch == nil || *worker.settings.DefaultVoice.AveragePitch != originalPitch || worker.settings.DefaultVoice.Gain == nil || *worker.settings.DefaultVoice.Gain != originalVolume { t.Fatal("preview committed session settings") } response = worker.restorePreview(context.Background()) if !response.Accepted || !response.Close || len(updates) != 2 || updates[1].Rate == nil || *updates[1].Rate != -18 || updates[1].Pitch == nil || *updates[1].Pitch != -10 || updates[1].Volume == nil || *updates[1].Volume != 55 { t.Fatalf("restore = %+v; updates = %+v", response, updates) } } func TestPreferencesSaveFailureReportsSessionCommit(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { // Another writer changes the file after preflight, before Store.Save. return os.WriteFile(settings.Path, []byte("schema-version = 999\n"), 0o600) })) values := preferences.FromSettings(settings) values.Punctuation = 3 response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) if !response.Accepted || !strings.Contains(response.Message, "could not save") || worker.settings.VerbalizePunctuationStyle != 3 { t.Fatalf("partial save result = %+v", response) } got, _ := os.ReadFile(settings.Path) if string(got) != "schema-version = 999\n" { t.Fatal("save replaced another writer's incompatible file") } } func TestPreferencesOpenCoalescesAndStopJoins(t *testing.T) { worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil) for range 100 { worker.Open() } if len(worker.open) != 1 { t.Fatal("open requests did not coalesce") } <-worker.open ctx, cancel := context.WithCancel(context.Background()) if err := worker.Start(ctx); err != nil { t.Fatal(err) } cancel() if err := worker.Stop(context.Background()); err != nil { t.Fatal(err) } }