// 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" "testing" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/speech" ) func TestSpeechShortcutsApplySaveAndFailure(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") rate := 50.0 settings.DefaultVoice.Rate = &rate var applied speech.Settings var updateError error worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, next speech.Settings) error { if updateError != nil { return updateError } applied = next return nil })) worker.live = &liveSettings{} worker.live.Store(settings) message := "" worker.announce = func(text string) { message = text } command := func(c input.Command) { t.Helper(); worker.handleSpeechCommand(context.Background(), c) } command(input.IncreaseSpeechSetting) if message != "Rate 51" || applied.Rate == nil || *applied.Rate != 4 || *worker.live.Load().DefaultVoice.Rate != 51 || rate != 50 { t.Fatalf("rate: message %q, applied %+v, original %g", message, applied, rate) } updateError = errors.New("backend unavailable") command(input.IncreaseSpeechSetting) if message != "Speech settings were not applied" || *worker.live.Load().DefaultVoice.Rate != 51 { t.Fatal("failed update changed session") } updateError = nil worker.catalog = speech.Catalog{"": {{Name: "default", Language: "en"}}, "alpha": {{Name: "one", Language: "en"}, {Name: "two", Language: "en"}}, "beta": {{Name: "three", Language: "en"}}} for _, want := range []string{"Pitch server default", "Volume server default", "Speech-dispatcher module server default"} { command(input.SelectNextSpeechSetting) if message != want { t.Fatalf("got %q, want %q", message, want) } } command(input.IncreaseSpeechSetting) if applied.OutputModule != "alpha" || applied.SynthesisVoice != "one" { t.Fatalf("module: %+v", applied) } command(input.SelectNextSpeechSetting) command(input.IncreaseSpeechSetting) if applied.SynthesisVoice != "two" { t.Fatalf("voice: %+v", applied) } command(input.SelectNextSpeechSetting) if worker.speechSetting != 4 { t.Fatal("selector wrapped") } command(input.SaveSpeechSettings) if message != "Speech settings saved" { t.Fatal(message) } store, err := config.NewStore(settings.Path) if err != nil { t.Fatal(err) } saved, err := store.Load() if err != nil { t.Fatal(err) } if saved.SpeechOutputModule != "alpha" || saved.DefaultVoice.Family.Name != "two" || *saved.DefaultVoice.Rate != 51 { t.Fatalf("saved settings did not retain speech choices: %+v", saved.DefaultVoice) } worker.settings.Path = t.TempDir() worker.globalSettings.Path = worker.settings.Path command(input.SaveSpeechSettings) if message != "Speech settings could not be saved" { t.Fatal(message) } // A backend failure must not affect later selection or saving. command(input.SelectPreviousSpeechSetting) if worker.speechSetting != 3 { t.Fatal("selector did not recover") } } func TestQuickSpeechFollowsWindowSwitchesAndSavesGlobally(t *testing.T) { for index, name := range []string{"rate", "pitch", "volume", "synthesizer", "voice"} { t.Run(name, func(t *testing.T) { global := config.DefaultSettings() global.Path = filepath.Join(t.TempDir(), "settings.toml") rate, pitch, gain := 50.0, 5.0, 5.0 global.DefaultVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain, Family: &config.VoiceFamily{Name: "one", Lang: "en"}} global.SpeechOutputModule = "alpha" application := global appRate, appPitch, appGain := 25.0, 2.0, 2.0 application.DefaultVoice = config.Voice{Rate: &appRate, AveragePitch: &appPitch, Gain: &appGain, Family: &config.VoiceFamily{Name: "three", Lang: "en"}} application.SpeechOutputModule = "beta" application.TimeFormat = "24-hour" store, err := config.NewStore(global.Path) if err != nil { t.Fatal(err) } if err := store.Save(global); err != nil { t.Fatal(err) } for _, app := range []string{"Cached Application", "New Application"} { if err := store.SaveForApplication(app, application); err != nil { t.Fatal(err) } } original, err := os.ReadFile(global.Path) if err != nil { t.Fatal(err) } var applied speech.Settings worker := newPreferencesWorker(slog.New(slog.DiscardHandler), global, settingsUpdateFunc(func(_ context.Context, next speech.Settings) error { applied = next; return nil })) worker.live = &liveSettings{} worker.live.Store(global) worker.catalog = speech.Catalog{"alpha": {{Name: "one", Language: "en"}, {Name: "two", Language: "en"}}, "beta": {{Name: "three", Language: "en"}}} ctx := t.Context() worker.switchApplication(ctx, "Cached Application") worker.switchApplication(ctx, "Presentation") worker.speechSetting = index worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting) adjusted := worker.settings check := func(base config.Settings) { t.Helper() switch index { case 0: value := 51.0 base.DefaultVoice.Rate = &value case 1: value := 5.1 base.DefaultVoice.AveragePitch = &value case 2: value := 5.1 base.DefaultVoice.Gain = &value case 3: base.SpeechOutputModule = "beta" base.DefaultVoice.Family = &config.VoiceFamily{Name: "three", Lang: "en"} case 4: base.SpeechOutputModule = "alpha" base.DefaultVoice.Family = &config.VoiceFamily{Name: "two", Lang: "en"} } if !reflect.DeepEqual(speechSettingsFromConfig(worker.settings), speechSettingsFromConfig(base)) || !reflect.DeepEqual(speechSettingsFromConfig(worker.live.Load()), speechSettingsFromConfig(base)) || !reflect.DeepEqual(applied, speechSettingsFromConfig(base)) { t.Fatalf("speech choice lost in %q: got %+v, want %+v", worker.activeApp, worker.settings.DefaultVoice, base.DefaultVoice) } if worker.settings.TimeFormat != base.TimeFormat { t.Fatal("quick speech changed unrelated application preferences") } } check(global) for _, app := range []string{"Cached Application", "New Application", "Other Application", "Presentation"} { worker.switchApplication(ctx, app) base := global if app == "Cached Application" || app == "New Application" { base = application } check(base) } contents, err := os.ReadFile(global.Path) if err != nil || string(contents) != string(original) { t.Fatalf("unsaved shortcuts wrote settings: %v", err) } // Saving while an application profile is active must update the global // speech choice without copying its other preferences into the default. worker.switchApplication(ctx, "Cached Application") worker.handleSpeechCommand(ctx, input.SaveSpeechSettings) loaded, err := store.Load() if err != nil || !reflect.DeepEqual(speechSettingsFromConfig(loaded), speechSettingsFromConfig(adjusted)) || loaded.TimeFormat != global.TimeFormat { t.Fatalf("global speech settings were not saved: %+v, %v", loaded, err) } appLoaded, mapped, err := store.LoadForApplication("Cached Application") if err != nil || !mapped || !reflect.DeepEqual(speechSettingsFromConfig(appLoaded), speechSettingsFromConfig(application)) { t.Fatalf("save shortcut overwrote application profile: %+v, %v", appLoaded, err) } if _, mapped, err := store.LoadForApplication("Presentation"); err != nil || mapped { t.Fatalf("quick speech created an application profile: %t, %v", mapped, err) } worker.switchApplication(ctx, "Presentation") check(global) worker.switchApplication(ctx, "Cached Application") check(application) restarted := newPreferencesWorker(slog.New(slog.DiscardHandler), loaded, settingsUpdateFunc(func(_ context.Context, next speech.Settings) error { applied = next; return nil })) restarted.switchApplication(ctx, "Presentation") if !reflect.DeepEqual(speechSettingsFromConfig(restarted.settings), speechSettingsFromConfig(adjusted)) { t.Fatal("saved global speech did not survive restart") } }) } } func TestQuickSpeechFailureAndPreferencesReplacement(t *testing.T) { global := config.DefaultSettings() global.Path = filepath.Join(t.TempDir(), "settings.toml") rate := 50.0 global.DefaultVoice.Rate = &rate var updateError error worker := newPreferencesWorker(slog.New(slog.DiscardHandler), global, settingsUpdateFunc(func(context.Context, speech.Settings) error { return updateError })) application := global appRate := 25.0 application.DefaultVoice.Rate = &appRate worker.appSettings["Example Editor"] = application ctx := t.Context() worker.switchApplication(ctx, "Presentation") worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting) worker.handleSpeechCommand(ctx, input.SelectNextSpeechSetting) worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting) message := "" worker.announce = func(text string) { message = text } updateError = errors.New("backend unavailable") worker.handleSpeechCommand(ctx, input.IncreaseSpeechSetting) updateError = nil path := worker.globalSettings.Path worker.globalSettings.Path = t.TempDir() worker.handleSpeechCommand(ctx, input.SaveSpeechSettings) if message != "Speech settings could not be saved" { t.Fatal(message) } worker.globalSettings.Path = path worker.switchApplication(ctx, "Example Editor") if *worker.settings.DefaultVoice.Rate != 51 || *worker.settings.DefaultVoice.AveragePitch != 5.1 { t.Fatal("failed adjustment or save lost prior session choices") } if !worker.beginEdit(ctx, "") { t.Fatal("could not open global preferences") } values := preferences.FromSettings(worker.editing) appliedRate := 60.0 values.Rate = &appliedRate updateError = errors.New("backend unavailable") if response := worker.apply(ctx, preferences.Request{Values: values}); response.Accepted { t.Fatal("failed preferences apply was accepted") } if settings, err := worker.applicationSettings("Example Editor"); err != nil || *settings.DefaultVoice.Rate != 51 { t.Fatalf("failed preferences apply discarded quick speech: %v", err) } updateError = nil if response := worker.apply(ctx, preferences.Request{Values: values}); !response.Accepted { t.Fatal(response.Message) } if *worker.settings.DefaultVoice.Rate != 25 || *worker.globalSettings.DefaultVoice.Rate != 60 { t.Fatal("explicit preferences apply did not restore application profile ownership") } worker.editingActive = false worker.switchApplication(ctx, "Presentation") if *worker.settings.DefaultVoice.Rate != 60 { t.Fatal("old quick speech choice replaced applied preferences") } } func TestSpeechShortcutReadsAndSavesRoundedNumbers(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") rate, pitch, gain := 89.99999999999999, 4.499999999999998, 4.999999999999999 settings.DefaultVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain} worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) message := "" worker.announce = func(text string) { message = text } for index, want := range []string{"Rate 90", "Pitch 4.5", "Volume 5"} { command := input.SelectNextSpeechSetting if index == 0 { command = input.SelectPreviousSpeechSetting } worker.handleSpeechCommand(t.Context(), command) if message != want { t.Fatalf("got %q, want %q", message, want) } } if _, err := os.Stat(settings.Path); !errors.Is(err, os.ErrNotExist) { t.Fatalf("checking speech settings created a file: %v", err) } worker.handleSpeechCommand(t.Context(), input.SaveSpeechSettings) store, err := config.NewStore(settings.Path) if err != nil { t.Fatal(err) } saved, err := store.Load() if err != nil || *saved.DefaultVoice.Rate != 90 || *saved.DefaultVoice.AveragePitch != 4.5 || *saved.DefaultVoice.Gain != 5 { t.Fatalf("speech numbers were not saved rounded: %+v, %v", saved.DefaultVoice, err) } } func TestSpeechShortcutNumericBounds(t *testing.T) { for index, maximum := range []float64{99, 9, 9} { settings := config.DefaultSettings() worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil })) worker.speechSetting = index fields := []**float64{&worker.settings.DefaultVoice.Rate, &worker.settings.DefaultVoice.AveragePitch, &worker.settings.DefaultVoice.Gain} *fields[index] = &maximum worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting) if **fields[index] != maximum { t.Fatal("upper bound exceeded") } zero := 0.0 *fields[index] = &zero worker.handleSpeechCommand(context.Background(), input.DecreaseSpeechSetting) if **fields[index] != 0 { t.Fatal("lower bound exceeded") } worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting) want := 0.1 if index == 0 { want = 1 } if **fields[index] != want { t.Fatalf("step %g, want %g", **fields[index], want) } } } func TestSpeechShortcutUnavailableDiscovery(t *testing.T) { worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil) worker.speechSetting = 3 worker.discover = func(context.Context, string) (speech.Catalog, error) { return nil, errors.New("offline") } message := "" worker.announce = func(text string) { message = text } worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting) if message != "Speech settings unavailable" || worker.settings.SpeechOutputModule != "" { t.Fatal(message) } } func TestQuickSpeechESpeakLocale(t *testing.T) { voices := []speech.Voice{ {Name: "de+max", Language: "de"}, {Name: "en-us+max", Language: "en-us"}, {Name: "en-gb", Language: "en-gb"}, {Name: "en-us", Language: "en-us", Variant: "male"}, {Name: "en", Language: "en"}, {Name: "en-us+Storm", Language: "en-us"}, } for _, tc := range []struct { name, all, messages, lang, module string want []string }{ {"exact", "en_US.UTF-8", "de_DE", "en_GB", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}}, {"messages", "", "en_US@custom", "de_DE", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}}, {"lang", "", "", "en-GB.UTF-8", "espeak-ng", []string{"en-gb"}}, {"language fallback", "en_AU", "", "", "espeak-ng", []string{"en"}}, {"C fallback", "C.UTF-8", "", "", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}}, {"missing locale", "", "", "", "espeak-ng", []string{"en-us", "en-us+max", "en-us+Storm"}}, {"unavailable language", "zz_ZZ", "", "", "espeak-ng", []string{"de+max", "en-us+max", "en-gb", "en-us", "en", "en-us+Storm"}}, {"other module", "en_US", "", "", "other", []string{"de+max", "en-us+max", "en-gb", "en-us", "en", "en-us+Storm"}}, } { t.Run(tc.name, func(t *testing.T) { t.Setenv("LC_ALL", tc.all) t.Setenv("LC_MESSAGES", tc.messages) t.Setenv("LANG", tc.lang) got := quickSpeechVoices(tc.module, voices) if len(got) != len(tc.want) { t.Fatalf("voices = %v, want %v", got, tc.want) } for i, want := range tc.want { if got[i].Name != want { t.Fatalf("voices = %v, want %v", got, tc.want) } } if voices[0].Name != "de+max" || voices[3].Name != "en-us" { t.Fatal("catalog mutated") } }) } } func TestSpeechShortcutESpeakDefaultAndVariants(t *testing.T) { t.Setenv("LC_ALL", "en_US.UTF-8") var applied speech.Settings worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), settingsUpdateFunc(func(_ context.Context, next speech.Settings) error { applied = next return nil })) worker.catalog = speech.Catalog{"espeak-ng": { {Name: "de", Language: "de"}, {Name: "en-us+max", Language: "en-us"}, {Name: "en-us", Language: "en-us"}, {Name: "fr", Language: "fr"}, }} worker.speechSetting = 3 worker.handleSpeechCommand(context.Background(), input.IncreaseSpeechSetting) if applied.OutputModule != "espeak-ng" || applied.SynthesisVoice != "en-us" || applied.Language != "en-us" { t.Fatalf("module switch: %+v", applied) } worker.handleSpeechCommand(context.Background(), input.SelectNextSpeechSetting) for _, tc := range []struct { command input.Command want string }{ {input.IncreaseSpeechSetting, "en-us+max"}, {input.IncreaseSpeechSetting, "en-us+max"}, {input.DecreaseSpeechSetting, "en-us"}, {input.DecreaseSpeechSetting, "en-us"}, } { worker.handleSpeechCommand(context.Background(), tc.command) if applied.SynthesisVoice != tc.want { t.Fatalf("voice = %q, want %q", applied.SynthesisVoice, tc.want) } } }