// 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" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) 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 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") } }