// 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" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" ) func TestProcessEventsTerminalScrollbackDeletionBothSessions(t *testing.T) { for _, sessionType := range []string{"wayland", "x11"} { t.Run(sessionType, func(t *testing.T) { t.Setenv("XDG_SESSION_TYPE", sessionType) // Both sessions must apply the same event policy, including // an X11 launcher which also supplies WAYLAND_DISPLAY. t.Setenv("WAYLAND_DISPLAY", "wayland-test") speech := &fakeSpeechQueue{spoken: make(chan string, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} ctx, cancel := context.WithCancel(context.Background()) completed := make(chan struct{}) go func() { defer close(completed) processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) }() t.Cleanup(func() { cancel() select { case <-completed: case <-time.After(time.Second): t.Error("event loop did not stop") } }) terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Username@Host> ") terminal.TextCaretKnown = true terminal.BrailleLineStart = 300 terminal.BrailleCaret = 315 oldPrompt := "Username@Host> old command\n" deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, oldPrompt) output := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Current command output") output.Detail1 = 200 // A caret event can arrive between the scroll and its insertion. caret := atspi.NewEvent(atspi.CaretEvent, terminal) adapter.events <- deletion adapter.events <- caret adapter.events <- output for _, want := range []string{"Current command output"} { select { case got := <-speech.spoken: if got != want { t.Fatalf("speech = %q, want %q", got, want) } case <-time.After(time.Second): t.Fatalf("timed out waiting for %q", want) } } for _, deleted := range []string{"x", "command"} { edit := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, deleted) edit.Detail1 = terminal.BrailleLineStart adapter.events <- edit select { case got := <-speech.spoken: if got != deleted { t.Fatalf("editing speech = %q, want %q", got, deleted) } case <-time.After(time.Second): t.Fatalf("timed out waiting for deletion %q", deleted) } } }) } } func TestTerminalScrollbackDeletionPreservesEditing(t *testing.T) { t.Setenv("XDG_SESSION_TYPE", "wayland") terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Prompt") terminal.TextCaretKnown = true terminal.BrailleLineStart = 100 terminal.BrailleCaret = 110 base := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old prompt\n") for _, test := range []struct { name string modify func(*atspi.Event) ignore bool }{ {"scroll", func(event *atspi.Event) {}, true}, {"unicode offsets", func(event *atspi.Event) { *event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, strings.Repeat("猫", 40)+"\n") }, true}, {"backspace", func(event *atspi.Event) { event.Payload = "x"; event.Detail1 = 109 }, false}, {"delete", func(event *atspi.Event) { event.Payload = "x"; event.Detail1 = 110 }, false}, {"delete word", func(event *atspi.Event) { event.Payload = "command"; event.Detail1 = 100 }, false}, {"single character at start", func(event *atspi.Event) { // The caret line starts at 100, so a deletion at offset 0 is a // wrap/scroll shift, not an edit on the caret line. A real // first-line backspace has a line start of 0 (see above). *event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x") }, true}, {"newline", func(event *atspi.Event) { event.Payload = "\n"; event.Detail1 = 100 }, false}, {"current first line", func(event *atspi.Event) { event.Source.BrailleLineStart = 0 }, false}, {"range reaches current line", func(event *atspi.Event) { event.Source.BrailleLineStart = 5 }, false}, {"unknown caret", func(event *atspi.Event) { event.Source.TextCaretKnown = false }, false}, {"partial line", func(event *atspi.Event) { // VTE wrapped rows have no trailing newline, so a scrollback // deletion entirely before the caret line is still a scroll, // never an edit on the caret line. *event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old prompt") }, true}, {"editor", func(event *atspi.Event) { event.Source.Role = "text" }, false}, {"insertion", func(event *atspi.Event) { event.Type = atspi.TextInsertedEvent }, false}, } { t.Run(test.name, func(t *testing.T) { event := base test.modify(&event) if got := should_ignore_terminal_scrollback(event); got != test.ignore { t.Fatalf("ignore = %v, want %v", got, test.ignore) } }) } } func TestProcessEventsCoalescesTerminalDeletionWithReplacementInsertion(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) }() terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") output := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Command output") deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "old prompt") deletion.Detail1 = 12 prompt := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "new prompt") prompt.Detail1 = 12 adapter.events <- output adapter.events <- deletion adapter.events <- prompt for _, expected := range []string{"Command output", "new prompt"} { select { case text := <-speech.spoken: if text != expected { t.Fatalf("spoken text = %q, want %q", text, expected) } case <-time.After(time.Second): t.Fatalf("timed out waiting for terminal speech %q", expected) } } select { case text := <-speech.spoken: t.Fatalf("terminal redraw deletion was presented as %q", text) case <-time.After(2 * terminalMutationDebounce): } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsCoalescesTerminalRepaintAcrossScrollShift(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) }() // A TUI repaints its live region: it deletes the old region, a // one-character scroll shift at the start of the buffer moves every // later offset back by one, then it inserts the new region. Only the // insertion may speak. terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") terminal.TextCaretKnown = true terminal.BrailleLineStart = 190 terminal.BrailleCaret = 195 repaint := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, strings.Repeat("x", 50)) repaint.Detail1 = 100 shift := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, " ") replacement := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "new region text") replacement.Detail1 = 99 adapter.events <- repaint adapter.events <- shift adapter.events <- replacement select { case text := <-speech.spoken: if text != "new region text" { t.Fatalf("spoken text = %q, want only the replacement insertion", text) } case <-time.After(time.Second): t.Fatal("timed out waiting for replacement insertion") } select { case text := <-speech.spoken: t.Fatalf("terminal repaint deletion was presented as %q", text) case <-time.After(2 * terminalMutationDebounce): } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func repaintSequence(adapter *fakeAdapter, terminal atspi.Snapshot, detail1 int, texts ...string) { for _, text := range texts { deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, strings.Repeat("x", len(text))) deletion.Detail1 = detail1 insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, text) insertion.Detail1 = detail1 adapter.events <- deletion adapter.events <- insertion } } func TestProcessEventsLatestTerminalRepaintWins(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 4)} adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} 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) }() terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") terminal.TextCaretKnown = true terminal.BrailleLineStart = 200 terminal.BrailleCaret = 205 repaintSequence(adapter, terminal, 100, "Thinking (1s)", "Thinking (2s)", "Thinking (3s)") select { case text := <-speech.spoken: if text != "Thinking (3s)" { t.Fatalf("spoken text = %q, want only the latest repaint", text) } case <-time.After(3 * time.Second): t.Fatal("latest repaint was not presented") } select { case text := <-speech.spoken: t.Fatalf("superseded repaint was presented as %q", text) case <-time.After(terminalRepaintDebounce): } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsTerminalRepaintsInDifferentRegionsBothSpeak(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 4)} adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} 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) }() terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") terminal.TextCaretKnown = true terminal.BrailleLineStart = 400 terminal.BrailleCaret = 405 repaintSequence(adapter, terminal, 100, "First region") repaintSequence(adapter, terminal, 300, "Second region") for _, expected := range []string{"First region", "Second region"} { select { case text := <-speech.spoken: if text != expected { t.Fatalf("spoken text = %q, want %q", text, expected) } case <-time.After(3 * time.Second): t.Fatalf("timed out waiting for %q", expected) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsPreservesShortTerminalRepaints(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 4)} adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} 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) }() terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") terminal.TextCaretKnown = true terminal.BrailleLineStart = 200 terminal.BrailleCaret = 205 for _, expected := range []string{"40", "OK", "猫"} { repaintSequence(adapter, terminal, 100, expected) select { case text := <-speech.spoken: if text != expected { t.Fatalf("short repaint = %q, want %q", text, expected) } case <-time.After(3 * time.Second): t.Fatalf("short repaint %q was lost", expected) } } repaintSequence(adapter, terminal, 100, "Thinking (4s)") select { case text := <-speech.spoken: if text != "Thinking (4s)" { t.Fatalf("spoken text = %q, want the full repaint", text) } case <-time.After(3 * time.Second): t.Fatal("full repaint was not presented") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsPresentsUserTerminalDeletionDespiteRedraw(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} tracker := &input.IntentTracker{} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, tracker, nil, nil) close(completed) }() // Backspace in a terminal input redraws the erased cell: the deleted // character is replaced by a blank at the same offset. The deletion is // the user's confirmation that the edit took and must still speak. terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") terminal.TextCaretKnown = true terminal.BrailleLineStart = 100 terminal.BrailleCaret = 105 tracker.Observe(input.NewKeyEvent(0, true, "BackSpace")) deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x") deletion.Detail1 = 105 redraw := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, " ") redraw.Detail1 = 105 adapter.events <- deletion adapter.events <- redraw select { case text := <-speech.spoken: if text != "x" { t.Fatalf("spoken text = %q, want the deleted character", text) } case <-time.After(time.Second): t.Fatal("user deletion was not presented") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestUserTerminalDeletionClassification(t *testing.T) { terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") terminal.TextCaretKnown = true terminal.BrailleLineStart = 100 terminal.BrailleCaret = 105 for _, test := range []struct { name string intent input.Intent modify func(*atspi.Event) want bool }{ {"backspace at caret", input.DeleteIntent, func(event *atspi.Event) { event.Detail1 = 105 }, true}, {"backspace before caret", input.DeleteIntent, func(event *atspi.Event) { event.Detail1 = 104 }, true}, {"typing intent", input.TypingIntent, func(event *atspi.Event) { event.Detail1 = 105 }, false}, {"no intent", input.NoIntent, func(event *atspi.Event) { event.Detail1 = 105 }, false}, {"repaint above caret line", input.DeleteIntent, func(event *atspi.Event) { event.Detail1 = 40 }, false}, {"unknown caret", input.DeleteIntent, func(event *atspi.Event) { event.Detail1 = 105 event.Source.TextCaretKnown = false }, false}, } { t.Run(test.name, func(t *testing.T) { event := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x") test.modify(&event) if got := is_user_terminal_deletion(event, test.intent); got != test.want { t.Fatalf("is_user_terminal_deletion = %v, want %v", got, test.want) } }) } } func TestProcessEventsPresentsStandaloneTerminalDeletion(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} 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) }() terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x") select { case text := <-speech.spoken: if text != "x" { t.Fatalf("spoken deletion = %q, want %q", text, "x") } case <-time.After(time.Second): t.Fatal("standalone terminal deletion was not presented") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsDoesNotCoalesceTerminalChangesAtDifferentOffsets(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 2)} 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, 0) close(completed) }() terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x") deletion.Detail1 = 12 insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "y") insertion.Detail1 = 13 adapter.events <- deletion adapter.events <- insertion for _, expected := range []string{"x", "y"} { select { case text := <-speech.spoken: if text != expected { t.Fatalf("spoken text = %q, want %q", text, expected) } case <-time.After(time.Second): t.Fatalf("timed out waiting for terminal speech %q", expected) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsCoalescesTerminalFocusWithFollowingInsertion(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 2)} 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", "terminal", "", "Old prompt") focused := atspi.NewEvent(atspi.FocusedStateEvent, terminal) focused.Detail1 = 1 adapter.events <- focused adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "New prompt") select { case text := <-speech.spoken: if text != "New prompt" { t.Fatalf("speech = %q, want only following insertion", text) } case <-time.After(time.Second): t.Fatal("following terminal insertion was not presented") } select { case text := <-speech.spoken: t.Fatalf("redundant terminal focus was presented as %q", text) default: } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsPresentsTerminalFocusWithoutFollowingInsertion(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} 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) }() terminal := atspi.NewSnapshot("/accessible/terminal", "Terminal", "terminal", "", "Prompt") terminal.ApplicationName = "TestTerminal" focused := atspi.NewEvent(atspi.FocusedStateEvent, terminal) focused.Detail1 = 1 adapter.events <- focused select { case text := <-speech.spoken: if text != "Terminal, terminal, Prompt" { t.Fatalf("terminal focus speech = %q", text) } case <-time.After(time.Second): t.Fatal("terminal focus was not presented after debounce") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } // Run the real event handler synchronously so coalescing assertions do not // depend on a scheduler processing an entire burst before a timer fires. func terminalHandlerFixture(t *testing.T) (*accessibilityEventHandler, *[]string) { t.Helper() state := &eventLoopState{} t.Cleanup(state.pending.clear) spoken := []string{} handler := &accessibilityEventHandler{ state: state, logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{}, syncForeground: func() {}, webScripts: &webScriptController{}, speak: func(event atspi.Event, _ ...input.Intent) { spoken = append(spoken, event.Payload) }, } handler.flushTerminalDeletion = func() { event, _ := state.pending.terminalDeletion.take() handler.speak(event) } return handler, &spoken } func TestTerminalRepaintsKeepTerminalIdentityAndFirstDeadline(t *testing.T) { for _, differentTerminal := range []bool{false, true} { t.Run(map[bool]string{false: "same terminal", true: "different bus"}[differentTerminal], func(t *testing.T) { handler, spoken := terminalHandlerFixture(t) terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"} repaint := func(text string) { deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old status") insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, text) handler.handle(deletion) handler.handle(insertion) } repaint("First status") firstTimer := handler.state.pending.terminalRepaint.timer if differentTerminal { terminal.BusName = ":1.11" } repaint("Second status") pending := &handler.state.pending.terminalRepaint if differentTerminal { if len(*spoken) != 1 || (*spoken)[0] != "First status" { t.Fatalf("different terminal discarded earlier repaint: %v", *spoken) } } else if pending.timer != firstTimer { t.Fatal("continuous repaint postponed the first deadline") } event, _ := pending.take() if event.Payload != "Second status" { t.Fatalf("latest repaint = %q", event.Payload) } }) } } func TestTerminalSeparateDeletionsPreserveEarlierFeedback(t *testing.T) { handler, spoken := terminalHandlerFixture(t) terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"} first := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "First deletion") first.Detail1 = 100 second := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Second deletion") second.Detail1 = 300 handler.handle(first) handler.handle(second) if len(*spoken) != 1 || (*spoken)[0] != first.Payload { t.Fatalf("unrelated deletion discarded earlier feedback: %v", *spoken) } if handler.state.pending.terminalDeletion.event.Payload != second.Payload { t.Fatal("later deletion was lost") } } func TestTerminalHeldRepaintPreservesOrderAndRetiresOnFocusChange(t *testing.T) { for _, next := range []string{"append", "deletion", "focus"} { t.Run(next, func(t *testing.T) { handler, spoken := terminalHandlerFixture(t) terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"} deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old status") insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Latest status") handler.handle(deletion) handler.handle(insertion) event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Appended output") event.Detail1 = 300 switch next { case "deletion": event.Type, event.Payload = atspi.TextDeletedEvent, "Later deletion" case "focus": event = atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.10", Path: "/entry", Role: "text", Focused: true}) } handler.handle(event) if handler.state.pending.terminalRepaint.event.Type != "" { t.Fatal("old repaint remains pending") } if next == "focus" { for _, text := range *spoken { if text == "Latest status" { t.Fatal("old terminal spoke after focus moved to another control") } } } else if len(*spoken) == 0 || (*spoken)[0] != "Latest status" { t.Fatalf("later output overtook pending repaint: %v", *spoken) } }) } }