277 lines
12 KiB
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)
|
|
}
|