193 lines
8.6 KiB
Go
193 lines
8.6 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"
|
|
"testing"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
)
|
|
|
|
func TestPauseChunksInterruptRemainingAnnouncement(t *testing.T) {
|
|
speaker := &controlledSpeaker{calls: make(chan speechCall, 10)}
|
|
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
|
if err := worker.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { worker.Stop(context.Background()) })
|
|
presenter := eventPresenter{state: &eventLoopState{},
|
|
logger: slog.New(slog.DiscardHandler), speech: worker}
|
|
presenter.state.presentationOptions = presentation.DefaultOptions()
|
|
presenter.speak(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{Name: "Save, draft", Role: "button", Description: "Save document"}))
|
|
close(expectSpeechCall(t, speaker, "cancel").done)
|
|
first := expectSpeechCall(t, speaker, "Save, draft")
|
|
worker.InterruptForUser()
|
|
close(expectSpeechCall(t, speaker, "cancel").done)
|
|
worker.Enqueue(speechRequest{Text: "New focus"})
|
|
close(first.done)
|
|
close(expectSpeechCall(t, speaker, "cancel").done) // Settle the interrupted call.
|
|
close(expectSpeechCall(t, speaker, "cancel").done) // Begin the new announcement.
|
|
close(expectSpeechCall(t, speaker, "New focus").done)
|
|
}
|
|
|
|
func TestPauseChunksStopOnBackendFailure(t *testing.T) {
|
|
failure := errors.New("speech failed")
|
|
speaker := &fakeSpeaker{speakError: failure}
|
|
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
|
worker.history = newSpeechHistory(10)
|
|
spoken := presentation.FocusSpeechWithOptions(atspi.Snapshot{Name: "Save", Role: "button"}, presentation.DefaultOptions())
|
|
err := worker.deliver(context.Background(), speechRequest{Text: spoken.Text, Chunks: spoken.Chunks})
|
|
if !errors.Is(err, failure) {
|
|
t.Fatalf("delivery error = %v", err)
|
|
}
|
|
if got := worker.history.snapshot(); !reflect.DeepEqual(got, []string{"Save"}) {
|
|
t.Fatalf("undelivered chunks entered history: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestPauseChunksPreservePresentationDecorations(t *testing.T) {
|
|
settings := config.DefaultSettings()
|
|
settings.EnableSound = false
|
|
settings.Reading.PresentTooltips = true
|
|
live := &liveSettings{}
|
|
live.Store(settings)
|
|
options := presentationSettingsFromConfig(settings, true)
|
|
logger := slog.New(slog.DiscardHandler)
|
|
for _, test := range []struct {
|
|
name string
|
|
present func(*fakeSpeechQueue)
|
|
want []string
|
|
}{
|
|
{"window title", func(queue *fakeSpeechQueue) {
|
|
presenter := eventPresenter{state: &eventLoopState{}, logger: logger, speech: queue, live: live}
|
|
event := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.Snapshot{Name: "Save", Role: "button"})
|
|
event.WindowTitle = "Save, button"
|
|
presenter.speak(event)
|
|
}, []string{"Save, button", "Save", "button"}},
|
|
{"table", func(queue *fakeSpeechQueue) {
|
|
presenter := eventPresenter{state: &eventLoopState{}, logger: logger, speech: queue, live: live}
|
|
event := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/cell")
|
|
event.Source.Name, event.Source.Role, event.Source.GUITable = "One, Two", "table cell", true
|
|
event.Source.TableName, event.Source.TablePath = "Example, table", "/table"
|
|
event.WindowDescription = "Table help, details"
|
|
presenter.speak(event)
|
|
}, []string{"Example, table", "One, Two", "Table help, details"}},
|
|
{"prompt", func(queue *fakeSpeechQueue) {
|
|
presenter := eventPresenter{state: &eventLoopState{}, logger: logger, speech: queue, live: live}
|
|
event := ownerEvent(atspi.WindowActivatedEvent, ":1.42", "/window", "/button")
|
|
event.Source.Name, event.Source.Role = "Save", "button"
|
|
event.WindowTitle, event.WindowMessage = "Question, details", "Save draft, or discard?"
|
|
presenter.speak(event)
|
|
}, []string{"Question, details", "Save draft, or discard?"}},
|
|
{"browser wrapping", func(queue *fakeSpeechQueue) {
|
|
presentDocumentResult(logger, queue, review.Result{Moved: true, DocumentWrapped: true,
|
|
DocumentPosition: review.DocumentPosition{Snapshot: atspi.Snapshot{Name: "Read, draft", Role: "link"}},
|
|
CaretErr: errors.New("caret unavailable")}, options)
|
|
}, []string{"Read, draft", "link", "Wrapping to top.", "Could not move the browser caret."}},
|
|
{"tooltip", func(queue *fakeSpeechQueue) {
|
|
handler := accessibilityEventHandler{state: &eventLoopState{}, speech: queue, live: live}
|
|
event := atspi.NewEvent(atspi.ShowingStateEvent, atspi.Snapshot{Name: "Save, draft", Role: "tool tip"})
|
|
event.Detail1 = 1
|
|
handler.handleTooltip(event)
|
|
}, []string{"Save, draft", "tool tip"}},
|
|
{"object review", func(queue *fakeSpeechQueue) {
|
|
presentObjectReviewResult(logger, queue, review.Result{Moved: true,
|
|
Position: review.Position{Current: atspi.Snapshot{Name: "Read, draft", Role: "link"}}}, options)
|
|
}, []string{"Read, draft", "link"}},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)}
|
|
test.present(queue)
|
|
speaker := &spellingTestSpeaker{}
|
|
worker := newSpeechWorker(logger, speaker)
|
|
if len(queue.requests) != 1 {
|
|
t.Fatal("presentation did not queue one announcement")
|
|
}
|
|
if err := worker.deliver(context.Background(), <-queue.requests); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(speaker.parts, test.want) {
|
|
t.Fatalf("backend chunks = %q, want %q", speaker.parts, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type PauseWaitSpeaker struct {
|
|
spellingTestSpeaker
|
|
voices []string
|
|
progress []int
|
|
line int
|
|
}
|
|
|
|
func (speaker *PauseWaitSpeaker) SpeakVoiceAndWait(ctx context.Context, text, voice string, began func()) error {
|
|
if began != nil {
|
|
began()
|
|
}
|
|
speaker.voices = append(speaker.voices, voice)
|
|
speaker.progress = append(speaker.progress, speaker.line)
|
|
return speaker.Speak(ctx, text)
|
|
}
|
|
|
|
func (speaker *PauseWaitSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error {
|
|
return speaker.SpeakVoiceAndWait(ctx, text, "default", began)
|
|
}
|
|
|
|
func TestPauseChunksSayAllKeepsLineProgressAndVoice(t *testing.T) {
|
|
for _, enabled := range []bool{true, false} {
|
|
for _, style := range []string{"line", "sentence"} {
|
|
speaker := &PauseWaitSpeaker{line: -1}
|
|
settings := config.DefaultSettings()
|
|
settings.Reading.EnablePauseBreaks = enabled
|
|
settings.Reading.EnableIndentation = true
|
|
settings.SayAllStyle = style
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)}
|
|
lines := []atspi.DocumentLine{
|
|
{Contents: []atspi.DocumentContent{{Text: "First, sentence. Second!", RawText: " First, sentence. Second!",
|
|
Snapshot: atspi.Snapshot{Path: "/link", Role: "link", Visited: true}}}},
|
|
{Contents: []atspi.DocumentContent{{Text: "Next line", Snapshot: atspi.Snapshot{Path: "/text", Role: "text"}}}},
|
|
}
|
|
var callbacks []int
|
|
result := review.Result{ReadAllContext: context.Background(), ReadAllLines: lines,
|
|
ReadAllProgress: func(line int) { callbacks = append(callbacks, line); speaker.line = line }}
|
|
presentReadAllResult(slog.New(slog.DiscardHandler), queue, result, presentationSettingsFromConfig(settings, true))
|
|
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
|
worker.reading = func() config.ReadingSettings { return settings.Reading }
|
|
if err := worker.deliver(context.Background(), <-queue.requests); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"indentation 2 columns, First, sentence. Second!", "visited link", "indentation 0 columns, Next line"}
|
|
wantProgress := []int{0, 0, 1}
|
|
wantVoices := []string{"hyperlink", "hyperlink", "default"}
|
|
if !enabled {
|
|
want = []string{"indentation 2 columns, First, sentence. Second! visited link", "indentation 0 columns, Next line"}
|
|
wantProgress, wantVoices = []int{0, 1}, []string{"hyperlink", "default"}
|
|
}
|
|
if style == "sentence" {
|
|
want[0] = "indentation 2 columns, First, sentence."
|
|
second := "Second!"
|
|
if !enabled {
|
|
want = []string{want[0], second, "visited link", want[1]}
|
|
} else {
|
|
want = []string{want[0], second, want[1], want[2]}
|
|
}
|
|
wantProgress, wantVoices = []int{0, 0, 0, 1}, []string{"hyperlink", "hyperlink", "hyperlink", "default"}
|
|
}
|
|
if !reflect.DeepEqual(speaker.parts, want) || !reflect.DeepEqual(speaker.progress, wantProgress) ||
|
|
!reflect.DeepEqual(speaker.voices, wantVoices) || !reflect.DeepEqual(callbacks, []int{0, 1}) {
|
|
t.Fatalf("enabled=%v style=%s: speech=%v progress=%v voices=%v callbacks=%v", enabled, style,
|
|
speaker.parts, speaker.progress, speaker.voices, callbacks)
|
|
}
|
|
}
|
|
}
|
|
}
|