// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "path/filepath" "reflect" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/preferences" ) func TestEveryEchoPreferenceRoundTrips(t *testing.T) { settings := config.DefaultSettings() if !settings.EnableKeyEcho { t.Fatal("key echo must default on") } settings.Path = filepath.Join(t.TempDir(), "settings.toml") values := preferences.FromSettings(settings) // Flip each echo switch, including false defaults, to exercise both directions. value := reflect.ValueOf(&values).Elem() for i := 0; i < value.NumField(); i++ { if value.Field(i).Kind() == reflect.Bool { value.Field(i).SetBool(!value.Field(i).Bool()) } } rate, pitch, volume := 100.0, 10.0, 10.0 values.EchoVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &volume, Family: &config.VoiceFamily{Name: "Test Voice", Lang: "en", Variant: "test"}} values.EchoSpeechServer = "test-module" next, err := values.Merge(settings) if err != nil { t.Fatal(err) } store, err := config.NewStore(settings.Path) if err != nil { t.Fatal(err) } if _, err = store.Load(); err != nil { t.Fatal(err) } if err = store.Save(next); err != nil { t.Fatal(err) } loaded, err := config.Load(settings.Path) if err != nil { t.Fatal(err) } if got := preferences.FromSettings(loaded); !reflect.DeepEqual(got, values) { t.Fatalf("round trip = %+v; want %+v", got, values) } } func TestApplyUpdatesRunningTextAndKeyboardEcho(t *testing.T) { settings := config.DefaultSettings() settings.EnableModifierKeys = false settings.Path = filepath.Join(t.TempDir(), "settings.toml") live := &liveSettings{} live.Store(settings) worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) worker.live = live queue := &fakeSpeechQueue{spoken: make(chan string, 10), interrupt: make(chan struct{}, 4)} adapter := &fakeAdapter{events: make(chan atspi.Event, 10)} listener := &fakeInputListener{events: make(chan input.Event, 10), modifierKeycodes: input.NewModifierKeycodes(37)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}, 2) go func() { processEventsWithSettings(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, live) done <- struct{}{} }() go func() { processInputEvents(ctx, slog.New(slog.DiscardHandler), queue, listener, inputEchoSettingsFromConfig(settings), live, nil) done <- struct{}{} }() defer func() { cancel(); <-done; <-done }() expect := func(want string) { t.Helper() select { case got := <-queue.spoken: if got != want { t.Fatalf("speech %q, want %q", got, want) } case <-time.After(time.Second): t.Fatalf("missing %q", want) } } snapshot := atspi.NewSnapshot("/editor", "", "text", "", "") snapshot.Editable = true insert := func(text string) { adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, text) } insert("a") expect("a") listener.events <- input.NewKeyEvent(67, true, "F1") expect("f1") <-queue.interrupt values := preferences.FromSettings(settings) values.EnableKeyEcho = false // Echo-only changes must work without a speech settings updater. if r := worker.apply(ctx, preferences.Request{Values: values, Save: true}); !r.Accepted || r.Close { t.Fatalf("Apply = %+v", r) } insert("b") insert("barrier") expect("barrier") listener.events <- input.NewKeyEvent(68, true, "F2") listener.events <- input.NewKeyEvent(37, true, "Control_L") for range 2 { select { case <-queue.interrupt: case <-time.After(time.Second): t.Fatal("keyboard barrier missing") } } values.EnableKeyEcho = true if r := worker.apply(ctx, preferences.Request{Values: values, Save: true, Close: true}); !r.Accepted || !r.Close { t.Fatalf("OK = %+v", r) } insert("c") expect("c") listener.events <- input.NewKeyEvent(69, true, "F3") expect("f3") loaded, err := config.Load(settings.Path) if err != nil || !loaded.EnableKeyEcho { t.Fatalf("saved state: %+v, %v", loaded, err) } } func TestCustomEchoVoiceInheritanceAndRouting(t *testing.T) { settings := config.DefaultSettings() settings.UseCustomEchoVoice = true rate, pitch, volume := 65.0, 10.0, 10.0 settings.DefaultVoice = config.Voice{Rate: &rate, Family: &config.VoiceFamily{Name: "base", Lang: "en"}} settings.EchoVoice = config.Voice{AveragePitch: &pitch, Gain: &volume} settings.UseCustomEchoSpeechServer = true settings.EchoSpeechServer = "echo-module" got := echoSpeechSettings(settings) if *got.Rate != 32 || *got.Pitch != 90 || *got.Volume != 100 || got.OutputModule != "echo-module" || got.SynthesisVoice != "base" { t.Fatalf("echo settings = %+v", got) } if !customEcho(settings, "key") || !customEcho(settings, "character") || customEcho(settings, "word") || customEcho(settings, "sentence") || customEcho(settings, "") { t.Fatal("wrong default custom voice routing") } settings.UseCustomEchoForWord = true settings.UseCustomEchoForSentence = true if !customEcho(settings, "word") || !customEcho(settings, "sentence") { t.Fatal("custom routing flags ignored") } settings.EnableKeyEcho = false if customEcho(settings, "key") { t.Fatal("custom echo survived master disable") } } func TestEditFieldsOnlyKeyNamesAndVoice(t *testing.T) { for _, tc := range []struct { name string only, enabled, focused, known bool want bool }{ {"all fields", false, true, false, false, true}, {"disabled", true, false, true, true, false}, {"editor", true, true, true, true, true}, {"button", true, true, false, true, false}, {"unknown", true, true, false, false, false}, } { t.Run(tc.name, func(t *testing.T) { settings := config.DefaultSettings() settings.EnableKeyEcho = tc.enabled settings.KeyEchoInEditFieldsOnly = tc.only settings.UseCustomEchoVoice = true settings.UseCustomEchoForKey = true settings.UseCustomEchoForCharacter = false live := &liveSettings{} live.Store(settings) if tc.known { live.editFieldFocused = func() bool { return tc.focused } } listener := &fakeInputListener{events: make(chan input.Event, 1)} listener.events <- input.NewKeyEvent(67, true, "F1") close(listener.events) speech := &remoteSpeechQueue{} processInputEvents(context.Background(), slog.New(slog.DiscardHandler), speech, listener, inputEchoSettingsFromConfig(settings), live, nil) if (len(speech.requests) != 0) != tc.want { t.Fatalf("requests=%+v", speech.requests) } if tc.want && (speech.requests[0].Text != "f1" || !speech.requests[0].CustomEcho) { t.Fatalf("key voice lost: %+v", speech.requests[0]) } }) } } func TestEditFieldsOnlyPreferencesApplyAndReload(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") live := &liveSettings{} live.Store(settings) worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) worker.live = live values := preferences.FromSettings(settings) values.KeyEchoInEditFieldsOnly = true response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) if !response.Accepted { t.Fatalf("Apply: %+v", response) } loaded, err := config.Load(settings.Path) if err != nil || !loaded.EnableKeyEcho || !loaded.KeyEchoInEditFieldsOnly { t.Fatalf("reload: %+v, %v", loaded, err) } if !live.Load().KeyEchoInEditFieldsOnly || !presentationSettingsFromConfig(live.Load(), false).KeyEchoInEditFieldsOnly { t.Fatal("Apply did not reach typing policy") } values.KeyEchoInEditFieldsOnly = false response = worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) if !response.Accepted || live.Load().KeyEchoInEditFieldsOnly { t.Fatal("could not return to unrestricted echo") } } func TestEditFieldsOnlyNativeTypingVoice(t *testing.T) { settings := config.DefaultSettings() settings.KeyEchoInEditFieldsOnly = true settings.UseCustomEchoVoice = true settings.UseCustomEchoForKey = true settings.UseCustomEchoForCharacter = false live := &liveSettings{} live.Store(settings) queue := &remoteSpeechQueue{} state := &eventLoopState{} state.presentationOptions = presentationSettingsFromConfig(settings, false) presenter := eventPresenter{state: state, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)} source := atspi.Snapshot{Role: "text", Editable: true, Focused: true} presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Source: source, Payload: "A"}) if len(queue.requests) != 1 || !queue.requests[0].CustomEcho || !queue.requests[0].Character { t.Fatalf("typed key voice: %+v", queue.requests) } source.Role = "terminal" source.Editable = false presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Source: source, Payload: "b"}) if len(queue.requests) != 1 { t.Fatal("terminal echoed typed key") } source.Role = "section" source.LiveRegion = true source.LivePoliteness = "polite" presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Source: source, Payload: "c"}) if len(queue.requests) != 2 || queue.requests[1].Text != "c" || queue.requests[1].CustomEcho { t.Fatalf("live region changed: %+v", queue.requests) } }