// 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" "reflect" "strings" "testing" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/preferences" ) type echoPreviewUpdater func(context.Context, config.Settings) error func (update echoPreviewUpdater) UpdateConfiguration(ctx context.Context, settings config.Settings) error { return update(ctx, settings) } func TestPreferencesEchoSliderPreviewsDraftWithoutChangingMainVoice(t *testing.T) { settings := config.DefaultSettings() settings.SpeechOutputModule = "main-module" updater := &voiceConfigurationRecorder{} worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, updater) live := &liveSettings{} live.Store(settings) worker.live = live values := preferences.FromSettings(settings) rate, pitch, volume := 78.2, 6.3, 10.0 values.UseCustomEchoVoice, values.UseCustomEchoSpeechServer = true, true values.EchoSpeechServer = "echo-module" values.EchoVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &volume, Family: &config.VoiceFamily{Name: "Echo voice", Lang: "en"}} // Unapplied Speech-tab choices must not become the normal dialog voice. values.SpeechOutputModule = "unapplied-module" values.VoiceType = "uppercase" values.Rate = &rate for _, test := range []struct{ slider, want string }{{"rate", "78.2"}, {"pitch", "6.3"}, {"volume", "10.0"}, {"synthesizer", "echo-module"}, {"person", "Echo voice (en)"}} { announced := "" worker.announceEchoPreview = func(_ string, value string) { if updater.calls == 0 || !reflect.DeepEqual(updater.settings.EchoVoice, values.EchoVoice) { t.Fatal("value announced before installing draft echo voice") } announced = value } response := worker.preview(t.Context(), preferences.Request{Values: values, Preview: true, EchoPreview: test.slider}) if !response.Accepted || !response.Preview || announced != test.want { t.Fatalf("%s: response=%+v speech=%q", test.slider, response, announced) } if updater.settings.SpeechOutputModule != settings.SpeechOutputModule || !reflect.DeepEqual(updater.settings.DefaultVoice, settings.DefaultVoice) || !reflect.DeepEqual(updater.settings.SystemVoice, settings.SystemVoice) { t.Fatal("echo preview changed ordinary voices") } if updater.settings.EchoSpeechServer != "echo-module" || !updater.settings.UseCustomEchoVoice { t.Fatal("echo preview ignored the selected module") } if !reflect.DeepEqual(worker.settings, settings) || !reflect.DeepEqual(live.Load(), settings) { t.Fatal("preview committed session or live settings") } } if response := worker.restorePreview(t.Context()); !response.Accepted || !reflect.DeepEqual(updater.settings, settings) { t.Fatal("Cancel did not restore committed speech settings") } } func TestPreferencesEchoPreviewRejectsInvalidDraftAndBackendFailure(t *testing.T) { for _, test := range []string{"unknown slider", "disabled", "missing value", "out of range", "backend failure"} { t.Run(test, func(t *testing.T) { calls := 0 settings := config.DefaultSettings() worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, echoPreviewUpdater(func(context.Context, config.Settings) error { calls++ return errors.New("preview unavailable") })) worker.announceEchoPreview = func(string, string) { t.Fatal("rejected preview spoke a value") } values := preferences.FromSettings(settings) volume := 5.0 values.UseCustomEchoVoice = true values.EchoVoice.Gain = &volume request := preferences.Request{Values: values, Preview: true, EchoPreview: "volume"} switch test { case "unknown slider": request.EchoPreview = "other" case "disabled": request.Values.UseCustomEchoVoice = false case "missing value": request.Values.EchoVoice.Gain = nil case "out of range": volume = 11 } if response := worker.preview(t.Context(), request); response.Accepted || !response.Preview { t.Fatalf("invalid preview response: %+v", response) } wantCalls := 0 if test == "backend failure" { wantCalls = 1 } if calls != wantCalls { t.Fatalf("backend updates=%d, want %d", calls, wantCalls) } }) } } func TestEchoPreviewValueKeepsBrailleWithoutDuplicateSpeech(t *testing.T) { settings := config.DefaultSettings() live := &liveSettings{} live.Store(settings) live.preferencesPID.Store(123) source := atspi.Snapshot{BusName: ":1.42", Path: "/echo-volume", Name: "Key echo volume", Role: "slider", Focused: true, ValueText: "5.0", ApplicationPID: 123} state := &eventLoopState{} state.current, state.focusedID = source, source.ID() queue := &remoteSpeechQueue{} frames := frameRecorder{frames: make(chan braille.Frame, 2)} logger := slog.New(slog.DiscardHandler) presenter := eventPresenter{state: state, logger: logger, speech: queue, live: live} handler := accessibilityEventHandler{state: state, logger: logger, live: live, brailleOutput: frames} for _, kind := range []string{atspi.ValueChangedEvent, atspi.AccessibleValueEvent} { source.ValueText = "5.1" event := atspi.NewEvent(kind, source) handler.acceptFocusedEvent(event, input.Activity{}) presenter.speak(event) if len(queue.requests) != 0 { t.Fatal("AT-SPI repeated the echo preview in the main voice") } if len(frames.frames) != 1 { t.Fatal("echo preview suppressed Braille value updates") } if frame := <-frames.frames; !strings.Contains(frame.Text, "5.1") { t.Fatalf("Braille did not receive the updated value: %+v", frame) } } presenter.speak(atspi.NewEvent(atspi.FocusEvent, source)) if len(queue.requests) != 0 { t.Fatal("AT-SPI repeated the separately queued focus preview") } } func TestPreferencesEchoComboDefaultValuesUseDraftVoice(t *testing.T) { settings := config.DefaultSettings() updater := &voiceConfigurationRecorder{} worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, updater) values := preferences.FromSettings(settings) values.UseCustomEchoVoice, values.UseCustomEchoSpeechServer = true, true values.EchoSpeechServer, values.EchoVoice.Family = "", nil announced := "" worker.announceEchoPreview = func(_ string, value string) { announced = value } for _, control := range []string{"synthesizer", "person"} { if response := worker.preview(t.Context(), preferences.Request{Values: values, Preview: true, EchoPreview: control}); !response.Accepted || announced != "Default" { t.Fatalf("%s default preview: response=%+v text=%q", control, response, announced) } } } func TestEchoPreviewGTKSelectedMenuItemCannotRepeat(t *testing.T) { // Reproduce the native log sequence: combo focus, name update, then a // selected menu-item snapshot whose OriginPath is the original combo. for _, control := range []string{"Key echo speech synthesizer", "Key echo person"} { settings := config.DefaultSettings() live := &liveSettings{} live.Store(settings) live.preferencesPID.Store(123) adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} source := atspi.Snapshot{BusName: ":1.42", Path: "/combo", Role: "combo box", Name: control, ValueText: "Default", Focused: true, ApplicationPID: 123} adapter.events <- atspi.NewEvent(atspi.FocusEvent, source) source.ValueText = "Selected voice" adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, source) selected := atspi.NewEvent(atspi.SelectionChangedEvent, atspi.Snapshot{BusName: source.BusName, Path: "/selected", Name: "Selected voice", Role: "menu item", Selected: true}) selected.OriginPath = source.Path adapter.events <- selected adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, source) adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: source.BusName, Path: "/apply", Name: "Apply", Role: "button", Focused: true}) close(adapter.events) queue := &remoteSpeechQueue{} if err := processEventsWithSettings(t.Context(), slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, live); err != nil { t.Fatal(err) } if len(queue.requests) != 1 || queue.requests[0].Text != "Apply, button" { t.Fatalf("%s: native duplicate escaped preview ownership: %+v", control, queue.requests) } } } func TestEchoPreviewSelectionOwnershipIsBoundedToChildAndControls(t *testing.T) { preview := echoPreviewPresentation{} source := atspi.Snapshot{BusName: ":1.42", Path: "/combo", Name: "Key echo person", Role: "combo box", ApplicationPID: 123} if !preview.owns(atspi.NewEvent(atspi.FocusEvent, source), 123) { t.Fatal("owned focus was not recognized") } selected := atspi.NewEvent(atspi.SelectionChangedEvent, atspi.Snapshot{BusName: source.BusName, Path: "/item", Role: "menu item"}) selected.OriginPath = source.Path if !preview.owns(selected, 123) { t.Fatal("selected descendant was not recognized") } selected.Source.BusName = ":1.43" if preview.owns(selected, 123) { t.Fatal("selection in another application was suppressed") } selected.Source.BusName = source.BusName selected.OriginPath = "/other-combo" if preview.owns(selected, 123) { t.Fatal("selection in another combo was suppressed") } selected.OriginPath = source.Path if preview.owns(selected, 0) || preview.owns(selected, 456) { t.Fatal("selection ownership survived preferences exit or replacement") } if preview.owns(atspi.NewEvent(atspi.FocusEvent, source), 456) { t.Fatal("old child acquired the new preferences session") } } type echoPreviewWaitSpeaker struct{ controlledSpeaker } func (speaker *echoPreviewWaitSpeaker) SpeakAndWait(ctx context.Context, text string, _ func()) error { return speaker.call(ctx, "default:"+text) } func (speaker *echoPreviewWaitSpeaker) SpeakVoiceAndWait(ctx context.Context, text, voice string, _ func()) error { return speaker.call(ctx, voice+":"+text) } func TestEchoPreviewFocusSpeechWaitsForBothVoices(t *testing.T) { speaker := &echoPreviewWaitSpeaker{controlledSpeaker{calls: make(chan speechCall, 8)}} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) if err := worker.Start(t.Context()); err != nil { t.Fatal(err) } t.Cleanup(func() { worker.Stop(context.Background()) }) worker.Enqueue(echo_preview_speech("Key echo person", "Selected voice")) label := expectSpeechCall(t, &speaker.controlledSpeaker, "default:Key echo person") worker.Enqueue(speechRequest{Text: "Later control"}) select { case call := <-speaker.calls: t.Fatalf("queued output overtook the focus label: %s", call.text) default: } close(label.done) value := expectSpeechCall(t, &speaker.controlledSpeaker, "echo:Selected voice") select { case call := <-speaker.calls: t.Fatalf("queued output cut off the echo value: %s", call.text) default: } close(value.done) close(expectSpeechCall(t, &speaker.controlledSpeaker, "Later control").done) } func TestEchoPreviewFocusSpeechRemainsInterruptible(t *testing.T) { speaker := &echoPreviewWaitSpeaker{controlledSpeaker{calls: make(chan speechCall, 8)}} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) if err := worker.Start(t.Context()); err != nil { t.Fatal(err) } t.Cleanup(func() { worker.Stop(context.Background()) }) worker.Enqueue(echo_preview_speech("Key echo person", "Selected voice")) label := expectSpeechCall(t, &speaker.controlledSpeaker, "default:Key echo person") close(label.done) value := expectSpeechCall(t, &speaker.controlledSpeaker, "echo:Selected voice") worker.InterruptForInput(input.Event{Pressed: true, Name: "Tab", Timestamp: 100}) close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) close(value.done) }