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