// 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" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" "git.stormux.org/storm/cthulhu/internal/sound" ) type documentLineCaptureCall struct { focus atspi.ObjectID result chan *atspi.AccessibleTree } type documentLineCapturer struct { calls chan documentLineCaptureCall } func (capturer *documentLineCapturer) capture(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { call := documentLineCaptureCall{focus: focus, result: make(chan *atspi.AccessibleTree, 1)} capturer.calls <- call select { case tree := <-call.result: return tree, nil case <-ctx.Done(): return nil, ctx.Err() } } func (capturer *documentLineCapturer) CaptureDesktop(ctx context.Context) (*atspi.AccessibleTree, error) { return capturer.capture(ctx, atspi.ObjectID{}) } func (capturer *documentLineCapturer) CaptureReviewWindow(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { return capturer.capture(ctx, focus) } func (capturer *documentLineCapturer) CaptureDocument(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { return capturer.capture(ctx, focus) } func (capturer *documentLineCapturer) CaptureDocumentText(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { return capturer.capture(ctx, focus) } type documentLineKeys struct{} func (keys *documentLineKeys) SetBrowseNavigation(bool) {} func documentLineTree(t *testing.T, staticText string) *atspi.AccessibleTree { t.Helper() baseNode := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode { snapshot := atspi.NewSnapshot(path, path, role, "", "") snapshot.BusName = ":1.42" return atspi.TreeNode{Snapshot: snapshot, Children: children} } lineNode := func(path, role, text string, bounds atspi.Rectangle, caret bool) atspi.TreeNode { snapshot := atspi.NewSnapshot(path, path, role, "", text) snapshot.BusName = ":1.42" geometry := atspi.Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} visible := atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{ Start: 0, End: utf8.RuneCountInString(text), Text: text, Bounds: bounds, }}} if caret { visible.CaretKnown, visible.CaretOffset = true, 1 } return atspi.TreeNode{Snapshot: snapshot, Geometry: geometry, Text: visible} } tree, err := atspi.NewAccessibleTree(atspi.NewObjectID(":1.42", "/root"), []atspi.TreeNode{ baseNode("/root", "frame", atspi.NewObjectID(":1.42", "/document")), baseNode("/document", "document web", atspi.NewObjectID(":1.42", "/static"), atspi.NewObjectID(":1.42", "/link")), lineNode("/static", "static", staticText, atspi.Rectangle{X: 0, Y: 100, Width: 60, Height: 20}, false), lineNode("/link", "link", "the link", atspi.Rectangle{X: 100, Y: 100, Width: 80, Height: 20}, false), }) if err != nil { t.Fatal(err) } return tree } func TestProcessEventsSpeaksNewestDocumentCaretLine(t *testing.T) { capturer := &documentLineCapturer{calls: make(chan documentLineCaptureCall, 2)} worker, err := review.NewWorker(capturer, time.Second) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := worker.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopContext, stopCancel := context.WithTimeout(context.Background(), time.Second) defer stopCancel() if err := worker.Stop(stopContext); err != nil { t.Fatal(err) } }() tracker := &input.IntentTracker{} tracker.Observe(input.NewKeyEvent(0, true, "Down")) browser := newBrowserModeController(tracker, &documentLineKeys{}, nil) browser.mode = review.FocusMode browser.ready = true adapter := &fakeAdapter{events: make(chan atspi.Event)} speech := &fakeSpeechQueue{spoken: make(chan string, 2)} done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, worker, 0, nil, browser, nil, nil) }() static := atspi.NewSnapshot("/static", "/static", "static", "", "First ") static.BusName = ":1.42" staticEvent := atspi.NewEvent(atspi.CaretEvent, static) staticEvent.Detail1 = 1 adapter.events <- staticEvent firstCapture := <-capturer.calls link := atspi.NewSnapshot("/link", "/link", "link", "", "the link") link.BusName = ":1.42" linkEvent := atspi.NewEvent(atspi.CaretEvent, link) linkEvent.Detail1 = 1 adapter.events <- linkEvent // Native capture observes the current document when it completes, not the // event which originally caused the capture. firstCapture.result <- documentLineTree(t, "Second ") select { case got := <-speech.spoken: if got != "Second, the link, link" { t.Fatalf("document line speech = %q", got) } case <-time.After(time.Second): t.Fatal("timed out waiting for document line speech") } cancel() select { case <-done: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } select { case got := <-speech.spoken: t.Fatalf("unexpected stale document line speech = %q", got) default: } } func TestProcessEventsUsesCapturedLineInBrowseModeWithoutReviewWorker(t *testing.T) { tracker := &input.IntentTracker{} tracker.Observe(input.NewKeyEvent(0, true, "Down")) browser := newBrowserModeController(tracker, &documentLineKeys{}, nil) browser.mode = review.BrowseMode browser.ready = true adapter := &fakeAdapter{events: make(chan atspi.Event)} speech := &fakeSpeechQueue{spoken: make(chan string, 1)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) _ = processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, browser, nil, nil) }() retainedFocus := atspi.NewSnapshot("/old-heading", "Retained heading", "heading", "", "") retainedFocus.BusName = ":1.42" retainedFocus.Focusable = true adapter.events <- atspi.NewEvent(atspi.FocusEvent, retainedFocus) snapshot := atspi.NewSnapshot("/paragraph", "", "paragraph", "", "Current browser line") snapshot.BusName = ":1.42" event := atspi.NewEvent(atspi.CaretEvent, snapshot) event.Detail1 = 4 adapter.events <- event select { case got := <-speech.spoken: if got != "Current browser line" { t.Fatalf("browse line speech = %q", got) } case <-time.After(time.Second): t.Fatal("timed out waiting for browse line speech") } if !browser.ready || browser.mode != review.BrowseMode { t.Fatalf("retained DOM focus changed browse state: ready=%v mode=%v", browser.ready, browser.mode) } cancel() select { case <-done: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestBlankBrowseCaretDoesNotCaptureWholeDocument(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() capturer := &documentLineCapturer{calls: make(chan documentLineCaptureCall, 1)} worker, err := review.NewWorker(capturer, time.Second) if err != nil { t.Fatal(err) } if err := worker.Start(ctx); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) tracker := &input.IntentTracker{} tracker.Observe(input.NewKeyEvent(0, true, "Down")) browser := newBrowserModeController(tracker, &documentLineKeys{}, nil) browser.mode, browser.ready = review.BrowseMode, true adapter := &fakeAdapter{events: make(chan atspi.Event)} speech := &fakeSpeechQueue{spoken: make(chan string, 1)} done := make(chan error, 1) go func() { done <- processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, worker, 0, nil, browser, nil, nil) }() blank := atspi.NewSnapshot("/link", "", "link", "", "") blank.BusName = ":1.42" adapter.events <- atspi.NewEvent(atspi.CaretEvent, blank) select { case <-capturer.calls: t.Fatal("blank browse caret started full document text capture") case <-time.After(50 * time.Millisecond): } cancel() select { case err := <-done: if err != nil { t.Fatal(err) } case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestBrowserCaretUpdatesStructuralPositionAndSounds(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() capturer := &documentLineCapturer{calls: make(chan documentLineCaptureCall, 2)} worker, err := review.NewWorker(capturer, time.Second) if err != nil { t.Fatal(err) } if err := worker.Start(ctx); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) tracker := &input.IntentTracker{} tracker.Observe(input.NewKeyEvent(0, true, "Down")) browser := newBrowserModeController(tracker, &documentLineKeys{}, nil) browser.ready, browser.mode = true, review.BrowseMode adapter := &fakeAdapter{events: make(chan atspi.Event)} queue := &modeSoundQueue{speech: &fakeSpeechQueue{spoken: make(chan string, 4)}, sounds: make(chan sound.Cue, 4)} options := presentation.DefaultOptions() options.EnableSound, options.PlaySoundForRole = true, true options.RoleSoundPresentation = "sound_only" done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, worker, 0, nil, browser, nil, nil, options) }() defer func() { cancel(); <-done }() snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/link", Role: "link", Text: "the link", Visited: true} adapter.events <- atspi.NewEvent(atspi.CaretEvent, snapshot) select { case text := <-queue.speech.spoken: if text != "the link" { t.Fatalf("caret speech = %q", text) } case <-time.After(time.Second): t.Fatal("missing caret speech") } select { case cue := <-queue.sounds: if cue.Name != "link_visited" { t.Fatalf("caret cue = %#v", cue) } case <-time.After(time.Second): t.Fatal("missing caret sound") } worker.MoveDocument(review.DocumentHeading, 0, false) select { case call := <-capturer.calls: if call.focus != snapshot.ID() { t.Fatalf("structural navigation anchored at %v, want caret %v", call.focus, snapshot.ID()) } case <-time.After(time.Second): t.Fatal("structural navigation did not follow the caret") } }