Files
cthulhu/internal/app/preferences_echo_preview_test.go

277 lines
12 KiB
Go

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