// 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 review import ( "context" "errors" "reflect" "sync" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) type captureCall struct { ctx context.Context reviewFocus atspi.ObjectID result chan captureResult } type captureResult struct { tree *atspi.AccessibleTree err error } type fakeCapturer struct { calls chan captureCall } func (capturer *fakeCapturer) CaptureDesktop(ctx context.Context) (*atspi.AccessibleTree, error) { return capturer.capture(ctx, atspi.ObjectID{}) } func (capturer *fakeCapturer) CaptureReviewWindow(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { return capturer.capture(ctx, focus) } func (capturer *fakeCapturer) CaptureDocument(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { return capturer.capture(ctx, focus) } func (capturer *fakeCapturer) CaptureDocumentText(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) { return capturer.capture(ctx, focus) } func (capturer *fakeCapturer) capture(ctx context.Context, reviewFocus atspi.ObjectID) (*atspi.AccessibleTree, error) { call := captureCall{ctx: ctx, reviewFocus: reviewFocus, result: make(chan captureResult, 1)} capturer.calls <- call select { case result := <-call.result: return result.tree, result.err case <-ctx.Done(): return nil, ctx.Err() } } func startWorker(t *testing.T, capturer TreeCapturer, timeout time.Duration) (*Worker, context.CancelFunc) { t.Helper() worker, err := NewWorker(capturer, timeout) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) if err := worker.Start(ctx); err != nil { cancel() t.Fatal(err) } return worker, cancel } func waitResult(t *testing.T, worker *Worker) Result { t.Helper() select { case result := <-worker.Results(): return result case <-time.After(time.Second): t.Fatal("timed out waiting for object review result") return Result{} } } func TestWorkerCaptureTimeoutDoesNotBlockCallers(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, 20*time.Millisecond) defer cancel() worker.SyncFocus(objectID("/first")) if err := worker.Move(NextSibling); err != nil { t.Fatal(err) } call := <-capturer.calls select { case <-call.ctx.Done(): case <-time.After(time.Second): t.Fatal("capture context did not time out") } result := waitResult(t, worker) if !errors.Is(result.Err, context.DeadlineExceeded) { t.Fatalf("result error = %v, want deadline exceeded", result.Err) } } func TestWorkerReadsDocumentLineFromTextCapture(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() focus := objectID("/link") request, err := worker.ReadDocumentLine(focus, 6) if err != nil { t.Fatal(err) } call := <-capturer.calls if call.reviewFocus != focus { t.Fatalf("capture focus = %#v", call.reviewFocus) } call.result <- captureResult{tree: documentLineTree(t)} result := waitResult(t, worker) if result.Err != nil || !result.DocumentLine || !result.Moved { t.Fatalf("line result = %#v", result) } if result.DocumentLineFocus != focus { t.Fatalf("result focus = %#v", result.DocumentLineFocus) } if result.DocumentLineRequest != request { t.Fatalf("result request = %d, want %d", result.DocumentLineRequest, request) } var contents []string for _, content := range result.DocumentLineData.Contents { contents = append(contents, content.Text) } if !reflect.DeepEqual(contents, []string{"Read ", "the link"}) { t.Fatalf("line contents = %#v", contents) } } func TestWorkerReusesDocumentTextCaptureForCaretLines(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 2)} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() focus := objectID("/link") if _, err := worker.ReadDocumentLine(focus, 1); err != nil { t.Fatal(err) } call := <-capturer.calls call.result <- captureResult{tree: documentLineTree(t)} if result := waitResult(t, worker); result.Err != nil { t.Fatalf("first line result = %#v", result) } if _, err := worker.ReadDocumentLine(focus, 2); err != nil { t.Fatal(err) } if result := waitResult(t, worker); result.Err != nil { t.Fatalf("cached line result = %#v", result) } select { case call := <-capturer.calls: t.Fatalf("unexpected repeated document capture for %#v", call.reviewFocus) case <-time.After(20 * time.Millisecond): } } func TestWorkerUsesNewestFocusAfterConcurrentCapture(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() worker.SyncFocus(objectID("/first")) if err := worker.Move(NextSibling); err != nil { t.Fatal(err) } call := <-capturer.calls worker.SyncFocus(objectID("/second")) call.result <- captureResult{tree: threeNodeTree(t)} result := waitResult(t, worker) if result.Err != nil || !result.Moved || result.Position.Current.Name != "Third" { t.Fatalf("result = %#v, want move from newest focus to Third", result) } } func TestWorkerRecapturesAndRepairsStaleCursor(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 2)} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() firstTree := threeNodeTree(t) worker.SyncFocus(objectID("/first")) if err := worker.Move(NextSibling); err != nil { t.Fatal(err) } firstCall := <-capturer.calls firstCall.result <- captureResult{tree: firstTree} if result := waitResult(t, worker); result.Err != nil || result.Position.Current.Name != "Second" { t.Fatalf("initial result = %#v", result) } firstTree.Invalidate(objectID("/second")) if err := worker.Move(NextSibling); err != nil { t.Fatal(err) } secondCall := <-capturer.calls secondCall.result <- captureResult{tree: threeNodeTree(t)} result := waitResult(t, worker) if result.Err != nil || !result.Moved || result.Position.Current.Name != "Second" { t.Fatalf("recovered result = %#v, want Second", result) } } func TestWorkerStopsCleanlyDuringCapture(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, time.Minute) worker.SyncFocus(objectID("/first")) if err := worker.Move(NextSibling); err != nil { t.Fatal(err) } call := <-capturer.calls cancel() stopContext, stopCancel := context.WithTimeout(context.Background(), time.Second) defer stopCancel() if err := worker.Stop(stopContext); err != nil { t.Fatal(err) } select { case <-call.ctx.Done(): default: t.Fatal("shutdown did not cancel in-flight capture") } } func documentLineTree(t *testing.T) *atspi.AccessibleTree { t.Helper() baseNode := func(path, name, role string, children ...atspi.ObjectID) atspi.TreeNode { snapshot := atspi.NewSnapshot(path, name, role, "", "") snapshot.BusName = ":1.42" return atspi.TreeNode{Snapshot: snapshot, Children: children} } lineText := 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: len(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(objectID("/root"), []atspi.TreeNode{ baseNode("/root", "Window", "frame", objectID("/doc")), baseNode("/doc", "Document", "document web", objectID("/text"), objectID("/link")), lineText("/text", "static", "Read ", atspi.Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, false), lineText("/link", "link", "the link", atspi.Rectangle{X: 100, Y: 100, Width: 80, Height: 20}, false), }) if err != nil { t.Fatal(err) } return tree } func TestWorkerAcceptsConcurrentFocusUpdates(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() var wait sync.WaitGroup for index := 0; index < 20; index++ { wait.Add(1) go func() { defer wait.Done() worker.SyncFocus(objectID("/second")) }() } wait.Wait() if err := worker.Move(NextSibling); err != nil { t.Fatal(err) } call := <-capturer.calls call.result <- captureResult{tree: threeNodeTree(t)} result := waitResult(t, worker) if result.Err != nil || result.Position.Current.Name != "Third" { t.Fatalf("result = %#v", result) } } func threeNodeTree(t *testing.T) *atspi.AccessibleTree { t.Helper() tree, err := atspi.NewAccessibleTree(objectID("/root"), []atspi.TreeNode{ node("/root", "Window", objectID("/first"), objectID("/second"), objectID("/third")), node("/first", "First"), node("/second", "Second"), node("/third", "Third"), }) if err != nil { t.Fatal(err) } return tree } func TestWorkerRecapturesIncompleteChildrenAtIndependentPosition(t *testing.T) { for _, outcome := range []string{"added", "empty", "failure", "focus-changed", "invalidated-again"} { t.Run(outcome, func(t *testing.T) { tree := threeNodeTree(t) cursor, err := NewCursor(tree) if err != nil { t.Fatal(err) } if _, err := cursor.SyncFocus(objectID("/first")); err != nil { t.Fatal(err) } if _, _, err := cursor.Move(NextSibling); err != nil { t.Fatal(err) } tree.InvalidateChildren(objectID("/second")) capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, err := NewWorker(capturer, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(objectID("/first")) results := make(chan moveResult, 1) go func() { results <- worker.move(context.Background(), cursor, FirstChild) }() var call captureCall select { case call = <-capturer.calls: case result := <-results: t.Fatalf("navigation did not recapture: %#v", result) case <-time.After(time.Second): t.Fatal("capture did not start") } captureErr := errors.New("provider unavailable") if outcome == "failure" { call.result <- captureResult{err: captureErr} } else { nodes := []atspi.TreeNode{ node("/root", "Window", objectID("/first"), objectID("/second")), node("/first", "First"), node("/second", "Second"), } if outcome != "empty" { nodes[2].Children = []atspi.ObjectID{objectID("/added")} nodes = append(nodes, node("/added", "Added")) } if err := tree.Replace(objectID("/root"), nodes); err != nil { t.Fatal(err) } if outcome == "focus-changed" { worker.SyncFocus(objectID("/root")) } if outcome == "invalidated-again" { tree.InvalidateChildren(objectID("/second")) } call.result <- captureResult{tree: tree} } var result moveResult select { case result = <-results: case <-time.After(2 * time.Second): t.Fatal("navigation did not finish after one recapture") } switch outcome { case "failure": if !errors.Is(result.Err, captureErr) || result.cursor != cursor { t.Fatalf("failed recovery = %#v", result) } position, err := cursor.Current() if err != nil || position.Current.Name != "Second" { t.Fatalf("lost review position: %#v %v", position, err) } case "invalidated-again": if !errors.Is(result.Err, atspi.ErrIncompleteTree) { t.Fatalf("repeated invalidation = %#v", result) } default: want, moved := "Added", true if outcome == "empty" { want, moved = "Second", false } if outcome == "focus-changed" { want = "First" } if result.Err != nil || result.Moved != moved || result.Position.Current.Name != want { t.Fatalf("result = %#v, want %s moved=%v", result, want, moved) } } }) } }