// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package app import ( "context" "log/slog" "reflect" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/review" ) func TestProcessEventsSpeaksChangedCaretLines(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 2)} adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) close(completed) }() adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "First line")) select { case text := <-speech.spoken: if text != "First line" { t.Fatalf("spoken text = %q, want %q", text, "First line") } case <-time.After(time.Second): t.Fatal("timed out waiting for first caret speech") } adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "First line")) adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Second line")) select { case text := <-speech.spoken: if text != "Second line" { t.Fatalf("spoken text = %q, want %q", text, "Second line") } case <-time.After(time.Second): t.Fatal("timed out waiting for second caret speech") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestProcessEventsSuppressesCaretAfterEditableTextChange(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) close(completed) }() paragraph := atspi.Snapshot{Path: "/writer/paragraph", Role: "paragraph", Editable: true, Text: "Hello", CaretCharacter: "o"} adapter.events <- atspi.NewEvent(atspi.FocusEvent, paragraph) <-speech.spoken adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "o") if got := <-speech.spoken; got != "o" { t.Fatalf("insert speech = %q", got) } adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph) adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, atspi.Snapshot{Path: "/status", Role: "label"}, "status update") select { case got := <-speech.spoken: t.Fatalf("unexpected duplicate or status speech: %q", got) case <-time.After(30 * time.Millisecond): } cancel() <-completed } func TestProcessEventsReadOnlyComboUsesNameInsteadOfEditorCaret(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 16)} adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} combo := atspi.Snapshot{BusName: ":1.7", Path: "/combo", Role: "combo box", Name: "Classic Doom", Focused: true} adapter.events <- atspi.NewEvent(atspi.FocusEvent, combo) editor := atspi.Snapshot{BusName: combo.BusName, Path: "/combo/editor", Role: "text", Text: "Funny Pack - Classic Doom", BrailleLine: "Funny Pack - Classic Doom", TextCaretKnown: true, Editable: true, ReadOnly: true, Focused: true, ReadOnlyCombo: combo.ID()} for _, value := range []string{"Funny Pack - Classic Doom", "Toby Doom", "Classic Doom"} { editor.Text, editor.BrailleLine = value, value adapter.events <- atspi.NewEvent(atspi.CaretEvent, editor) combo.Name = value adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, combo) } close(adapter.events) processEvents(context.Background(), slog.New(slog.DiscardHandler), speech, adapter, 0) close(speech.spoken) var got []string for text := range speech.spoken { got = append(got, text) } want := []string{"Classic Doom, combo box", "Funny Pack - Classic Doom", "Toby Doom", "Classic Doom"} if !reflect.DeepEqual(got, want) { t.Fatalf("combo speech = %q, want %q", got, want) } } func TestReadOnlyComboCaretRequiresExactOwnedCombo(t *testing.T) { combo := atspi.Snapshot{BusName: ":1.7", Path: "/combo", Role: "combo box"} for _, test := range []struct { name string owner atspi.ObjectID skip bool }{ {"owned", combo.ID(), true}, {"different combo", atspi.NewObjectID(combo.BusName, "/other-combo"), false}, {"different provider", atspi.NewObjectID(":1.8", combo.Path), false}, {"ordinary editor", atspi.ObjectID{}, false}, } { t.Run(test.name, func(t *testing.T) { state := &eventLoopState{} state.current, state.focusedID = combo, combo.ID() handler := accessibilityEventHandler{state: state, logger: slog.New(slog.DiscardHandler), debounce: time.Hour} event := atspi.NewEvent(atspi.CaretEvent, atspi.Snapshot{BusName: combo.BusName, Path: "/editor", Role: "text", ReadOnlyCombo: test.owner}) handler.handleCaret(event, input.Activity{}, input.NoIntent) defer state.pending.clear() if skipped := state.pending.caret.event.Type == ""; skipped != test.skip { t.Fatalf("caret skipped = %v, want %v", skipped, test.skip) } }) } } func TestTerminalCaretSeedsReviewFocusWithoutFocusEvent(t *testing.T) { // A reader started while the terminal already has focus may never see // a focus-gain event. Caret movement from the focused terminal is the // evidence flat review needs to anchor to it. worker, err := review.NewWorker(&documentLineCapturer{calls: make(chan documentLineCaptureCall, 1)}, time.Second) if err != nil { t.Fatal(err) } state := &eventLoopState{} defer state.pending.clear() handler := accessibilityEventHandler{state: state, logger: slog.New(slog.DiscardHandler), objectReview: worker, debounce: time.Hour} terminal := atspi.Snapshot{BusName: ":1.9", Path: "/terminal", Role: "terminal", Focused: true} // Output churn without user typing must not claim review focus. handler.handleCaret(atspi.NewEvent(atspi.CaretEvent, terminal), input.Activity{}, input.NoIntent) if got := worker.Focus(); got.Valid() { t.Fatalf("review focus seeded without typing = %+v", got) } handler.handleCaret(atspi.NewEvent(atspi.CaretEvent, terminal), input.Activity{}, input.TypingIntent) if got := worker.Focus(); got != terminal.ID() { t.Fatalf("review focus = %+v, want terminal %+v", got, terminal.ID()) } if state.focusedID != terminal.ID() || state.current.ID() != terminal.ID() { t.Fatalf("focused state = %+v/%+v, want terminal", state.focusedID, state.current.ID()) } // An unfocused terminal caret must not steal review focus. other := atspi.Snapshot{BusName: ":1.9", Path: "/other-terminal", Role: "terminal"} handler.handleCaret(atspi.NewEvent(atspi.CaretEvent, other), input.Activity{}, input.TypingIntent) if got := worker.Focus(); got != terminal.ID() { t.Fatalf("review focus after unfocused caret = %+v, want %+v", got, terminal.ID()) } // Another widget in the same application can retain a stale focused // bit. Its caret must not move review away from the known focus. other.Focused = true handler.handleCaret(atspi.NewEvent(atspi.CaretEvent, other), input.Activity{}, input.TypingIntent) if got := worker.Focus(); got != state.focusedID { t.Fatalf("stale focused bit split review and input ownership: %+v != %+v", got, state.focusedID) } } func TestProcessEventsSpeaksCaretCharacters(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) close(completed) }() adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter( "/accessible/terminal", "", "terminal", "", "prompt ls", "l", )) select { case text := <-speech.spoken: if text != "l" { t.Fatalf("spoken text = %q, want %q", text, "l") } case <-time.After(time.Second): t.Fatal("timed out waiting for first caret character speech") } adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter( "/accessible/terminal", "", "terminal", "", "prompt ll", "l", )) select { case text := <-speech.spoken: if text != "l" { t.Fatalf("repeated caret character = %q, want l", text) } case <-time.After(time.Second): t.Fatal("timed out waiting for repeated caret character speech") } adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter( "/accessible/terminal", "", "terminal", "", "prompt ls", "s", )) select { case text := <-speech.spoken: if text != "s" { t.Fatalf("spoken text = %q, want %q", text, "s") } case <-time.After(time.Second): t.Fatal("timed out waiting for second caret character speech") } adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter( "/accessible/terminal", "", "terminal", "", "prompt ls", " ", )) select { case text := <-speech.spoken: if text != "space" { t.Fatalf("spoken caret space = %q, want %q", text, "space") } case <-time.After(time.Second): t.Fatal("timed out waiting for caret space speech") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestProcessEventsTextChangeCancelsPendingCaretLine(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, time.Hour) close(completed) }() terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "prompt l") adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal) adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "l") select { case text := <-speech.spoken: if text != "l" { t.Fatalf("spoken text = %q, want %q", text, "l") } case <-time.After(time.Second): t.Fatal("timed out waiting for inserted text speech") } select { case text := <-speech.spoken: t.Fatalf("unexpected additional speech: %q", text) default: } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestCaretEventRequestsCharacterVoice(t *testing.T) { queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done); processEvents(ctx, slog.New(slog.DiscardHandler), queue, adapter, 0) }() defer func() { cancel(); <-done }() adapter.events <- atspi.NewEvent(atspi.CaretEvent, atspi.NewSnapshotWithCaretCharacter("/terminal", "", "terminal", "", "Ab", "A")) select { case request := <-queue.requests: if request.Text != "A" || !request.Character { t.Fatalf("caret request = %+v", request) } case <-time.After(time.Second): t.Fatal("caret speech missing") } }