// 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") } }