// 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" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) type documentPlacement struct { position atspi.ObjectReference focus atspi.ObjectID } type boundaryNavigator struct { *fakeCapturer caretTextReader position atspi.ObjectReference focus atspi.ObjectID offset int } func (navigator *boundaryNavigator) SetDocumentCaretPosition(_ context.Context, position atspi.ObjectReference, focus atspi.ObjectID, offset int) error { navigator.position, navigator.focus, navigator.offset = position, focus, offset return nil } func TestDocumentTimeoutRetiresQueuedNavigation(t *testing.T) { text := caretTextReader{webID("/text"): "First line", webID("/next"): "Last line"} capturer := &boundaryNavigator{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, caretTextReader: text} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() worker.SyncFocus(webID("/text")) if err := worker.MoveDocumentCaret(CaretNextLine); err != nil { t.Fatal(err) } call := <-capturer.calls for range 8 { if err := worker.MoveDocumentCaret(CaretNextLine); err != nil { t.Fatal(err) } } if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil { t.Fatal(err) } if err := worker.ActivateDocumentCaret(); err != nil { t.Fatal(err) } call.result <- captureResult{err: context.DeadlineExceeded} if result := waitResult(t, worker); !errors.Is(result.Err, context.DeadlineExceeded) { t.Fatalf("initial result = %+v", result) } // A fresh command must recover without eight more failed captures, a // heading move, or activation of a target selected before the timeout. if err := worker.MoveDocumentCaret(CaretDocumentStart); err != nil { t.Fatal(err) } call = <-capturer.calls call.result <- captureResult{tree: newCaretTextCursor(t, text).tree} result := waitResult(t, worker) if result.Err != nil || !result.Moved || result.DocumentCaretCommand != CaretDocumentStart || result.DocumentCaretText != "First line" { t.Fatalf("recovery result = %+v", result) } } func TestDocumentSuccessfulQueuedStepsRemainOrdered(t *testing.T) { text := caretTextReader{webID("/text"): "First line", webID("/next"): "Last line"} capturer := &boundaryNavigator{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, caretTextReader: text} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() worker.SyncFocus(webID("/text")) if err := worker.MoveDocumentCaret(CaretNextCharacter); err != nil { t.Fatal(err) } call := <-capturer.calls for range 2 { if err := worker.MoveDocumentCaret(CaretNextCharacter); err != nil { t.Fatal(err) } } call.result <- captureResult{tree: newCaretTextCursor(t, text).tree} for index, expected := range []string{"i", "r", "s"} { result := waitResult(t, worker) if result.Err != nil || !result.Moved || result.DocumentCaretText != expected || result.DocumentPosition.Offset != index+1 { t.Fatalf("step %d = %+v", index, result) } } } func TestDocumentOldTimeoutPreservesNewPageCommands(t *testing.T) { text := caretTextReader{webID("/text"): "First line", webID("/next"): "Last line"} capturer := &boundaryNavigator{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, caretTextReader: text} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() worker.SyncFocus(webID("/text")) if err := worker.MoveDocumentCaret(CaretNextLine); err != nil { t.Fatal(err) } oldCall := <-capturer.calls worker.ResetDocument(webID("/text")) if err := worker.MoveDocumentCaret(CaretDocumentStart); err != nil { t.Fatal(err) } oldCall.result <- captureResult{err: context.DeadlineExceeded} oldResult := waitResult(t, worker) if worker.CurrentDocumentResult(oldResult) { t.Fatal("old timeout retained current page ownership") } call := <-capturer.calls call.result <- captureResult{tree: newCaretTextCursor(t, text).tree} result := waitResult(t, worker) if result.Err != nil || !result.Moved || result.DocumentCaretCommand != CaretDocumentStart || !worker.CurrentDocumentResult(result) { t.Fatalf("new page result = %+v", result) } } func TestDocumentCaretMovePlacesNativeCaretForTabTraversal(t *testing.T) { for _, command := range []DocumentCaretCommand{CaretNextLine, CaretPreviousLine, CaretDocumentStart, CaretDocumentEnd, CaretNextCharacter, CaretNextWord, CaretLineStart, CaretLineEnd} { text := caretTextReader{webID("/text"): "First line", webID("/next"): "Last line"} navigator := &boundaryNavigator{fakeCapturer: &fakeCapturer{}, caretTextReader: text} worker, err := NewWorker(navigator, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/text")) cursor := newCaretTextCursor(t, text) if command == CaretDocumentStart || command == CaretPreviousLine { if _, err := cursor.MoveCaret(context.Background(), text, CaretDocumentEnd); err != nil { t.Fatal(err) } } result, _ := worker.moveDocumentCaret(context.Background(), cursor, command, worker.documentEpoch.Load()) if result.Err != nil || result.CaretErr != nil || !result.Moved || navigator.position != result.DocumentPosition.Object || navigator.focus != webID("/text") || navigator.offset != result.DocumentPosition.Offset { t.Fatalf("command %v: result=%+v, placement=%+v", command, result, navigator) } if (command == CaretNextLine || command == CaretPreviousLine || command == CaretDocumentStart || command == CaretDocumentEnd) && (result.DocumentCaretStart != 0 || result.DocumentCaretEnd != len(text[result.DocumentPosition.Object.ID()])) { t.Fatalf("command %v: native text range = %d:%d", command, result.DocumentCaretStart, result.DocumentCaretEnd) } } } type recordingDocumentActivator struct { *fakeCapturer target atspi.ObjectReference focus atspi.ObjectID } func (activator *recordingDocumentActivator) ActivateDocumentObject(_ context.Context, target atspi.ObjectReference, focus atspi.ObjectID) error { activator.target, activator.focus = target, focus return nil } func TestDocumentActivationUsesBrowseCaretInsteadOfFocusedTopic(t *testing.T) { node := func(path, role string, children ...string) atspi.TreeNode { snapshot := atspi.NewSnapshot(path, path, role, "", "") snapshot.BusName = webID(path).BusName result := atspi.TreeNode{Snapshot: snapshot} for _, child := range children { result.Children = append(result.Children, webID(child)) } return result } tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ node("/doc", "document web", "/topic", "/new_posts"), node("/topic", "link"), node("/new_posts", "link", "/new_posts_text"), node("/new_posts_text", "static"), }) if err != nil { t.Fatal(err) } cursor, err := NewDocumentCursor(tree) if err != nil { t.Fatal(err) } if _, err := cursor.SyncFocus(context.Background(), webID("/topic")); err != nil { t.Fatal(err) } staticRef, err := tree.Reference(webID("/new_posts_text")) if err != nil { t.Fatal(err) } cursor.places[len(cursor.places)-1].object = staticRef cursor.places[len(cursor.places)-1].origin = CaretNavigation activator := &recordingDocumentActivator{fakeCapturer: &fakeCapturer{}} worker, err := NewWorker(activator, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/topic")) result, _ := worker.activateDocumentCaret(context.Background(), cursor, 0) if result.Err != nil || activator.target.ID() != webID("/new_posts") || activator.focus != webID("/topic") { t.Fatalf("activation result=%+v target=%v focus=%v", result, activator.target.ID(), activator.focus) } } type waitingCaretReader struct { *fakeCapturer deadline time.Time } type refreshingCaretReader struct { *fakeCapturer text caretTextReader staleOnce bool } type invalidatingCaretReader struct { *fakeCapturer text caretTextReader tree *atspi.AccessibleTree } func (reader *invalidatingCaretReader) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { result, err := reader.text.ReadDocumentCaret(ctx, reference, offset, unit) reader.tree.Invalidate(reference.ID()) return result, err } func TestDocumentCaretSpeaksReadTextWhenAncestorDisappears(t *testing.T) { text := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"} cursor := newCaretTextCursor(t, text) reader := &invalidatingCaretReader{fakeCapturer: &fakeCapturer{}, text: text, tree: cursor.tree} worker, err := NewWorker(reader, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/text")) result, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0) if result.Err != nil || !result.Moved || result.DocumentCaretText != "l" || result.DocumentPosition.Offset != 1 { t.Fatalf("stale ancestor after caret read: %+v", result) } } func (reader *refreshingCaretReader) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { if reader.staleOnce { reader.staleOnce = false return atspi.DocumentCaretText{}, atspi.ErrStaleObject } return reader.text.ReadDocumentCaret(ctx, reference, offset, unit) } func TestDocumentCaretRetriesStaleRecaptureWithoutSkipping(t *testing.T) { text := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"} oldCursor := newCaretTextCursor(t, text) oldCursor.tree.Invalidate(webID("/text")) first := newCaretTextCursor(t, text).tree fresh := newCaretTextCursor(t, text).tree reader := &refreshingCaretReader{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, text: text, staleOnce: true} worker, err := NewWorker(reader, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/text")) done := make(chan Result, 1) go func() { result, _ := worker.moveDocumentCaret(context.Background(), oldCursor, CaretNextCharacter, 0) done <- result }() initial := <-reader.calls initial.result <- captureResult{tree: first} retry := <-reader.calls if retry.reviewFocus != webID("/text") { t.Fatalf("retry focus = %v", retry.reviewFocus) } retry.result <- captureResult{tree: fresh} result := <-done if result.Err != nil || !result.Moved || result.DocumentCaretText != "l" || result.DocumentPosition.Offset != 1 { t.Fatalf("stale recapture retry: %+v", result) } } func (reader *waitingCaretReader) ReadDocumentCaret(ctx context.Context, _ atspi.ObjectReference, _ int, _ atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { reader.deadline, _ = ctx.Deadline() <-ctx.Done() return atspi.DocumentCaretText{}, ctx.Err() } func TestDocumentCaretNavigationDeadlinePreservesPosition(t *testing.T) { text := caretTextReader{webID("/text"): "Alpha beta", webID("/next"): "Next"} cursor := newCaretTextCursor(t, text) reader := &waitingCaretReader{fakeCapturer: &fakeCapturer{}} worker, err := NewWorker(reader, time.Millisecond) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/text")) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextWord, 0) parentDeadline, _ := ctx.Deadline() if !errors.Is(result.Err, context.DeadlineExceeded) || result.Moved || !reader.deadline.Before(parentDeadline) { t.Fatalf("navigation deadline: result %+v, deadline %v", result, reader.deadline) } move, err := cursor.MoveCaret(context.Background(), text, CaretNextCharacter) if err != nil || move.Text != "l" || move.Position.Offset != 1 { t.Fatalf("position changed after timeout: %+v, %v", move, err) } } func TestRecoverRemovedDocumentFocusStaysInActiveDocument(t *testing.T) { _, cursor := webCursor(t) if _, err := cursor.SyncFocus(context.Background(), webID("/first")); err != nil { t.Fatal(err) } nodes := webNodes() // Recapture of a dynamic page no longer contains the old caret object. nodes[2].Children = []atspi.ObjectID{webID("/second")} tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{nodes[2], nodes[5]}) if err != nil { t.Fatal(err) } worker, err := NewWorker(&fakeCapturer{}, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/first")) result := Result{DocumentFocus: webID("/first"), CaptureReason: atspi.ErrObjectNotFound} if !worker.recoverCapturedDocumentFocus(cursor, tree, &result) || result.DocumentFocus != webID("/doc") || worker.focus() != webID("/doc") { t.Fatalf("same-document recovery failed: %+v, focus %v", result, worker.focus()) } worker.SyncFocus(webID("/chrome")) result.DocumentFocus = webID("/first") if worker.recoverCapturedDocumentFocus(cursor, tree, &result) || worker.focus() != webID("/chrome") { t.Fatal("recovery displaced browser chrome focus") } worker.SyncFocus(webID("/first")) other := nodes[8] other.Children = nil otherTree, err := atspi.NewAccessibleTree(webID("/other"), []atspi.TreeNode{other}) if err != nil { t.Fatal(err) } if worker.recoverCapturedDocumentFocus(cursor, otherTree, &result) || worker.focus() != webID("/first") { t.Fatal("recovery crossed to another document") } } func TestDocumentMoveRecoversAfterDynamicObjectRemoval(t *testing.T) { oldTree, cursor := webCursor(t) if _, err := cursor.SyncFocus(context.Background(), webID("/first")); err != nil { t.Fatal(err) } nodes := webNodes() nodes[2].Children = nodes[2].Children[1:] withoutFirst := append(append([]atspi.TreeNode{}, nodes[:3]...), nodes[4:]...) if err := oldTree.Replace(webID("/root"), withoutFirst); err != nil { t.Fatal(err) } freshDocument := nodes[2] freshDocument.Children = []atspi.ObjectID{webID("/second")} freshTree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{freshDocument, nodes[5]}) if err != nil { t.Fatal(err) } capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, err := NewWorker(capturer, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/first")) type moveResult struct { result Result } done := make(chan moveResult, 1) go func() { result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0) done <- moveResult{result: result} }() call := <-capturer.calls if call.reviewFocus != webID("/first") { t.Fatalf("capture anchor = %v", call.reviewFocus) } call.result <- captureResult{tree: freshTree} result := (<-done).result if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID("/second") || result.DocumentFocus != webID("/doc") { t.Fatalf("document move after removal: %+v", result) } } func TestDocumentMoveRecoversFocusRemovedDuringTextRecapture(t *testing.T) { tree, cursor := webCursor(t) if _, err := cursor.SyncFocus(context.Background(), webID("/first")); err != nil { t.Fatal(err) } // The initial failure describes stale text, but the ensuing capture can // discover that the old focus has since been removed altogether. tree.InvalidateText(webID("/first")) nodes := webNodes() nodes[2].Children = []atspi.ObjectID{webID("/second")} fresh, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{nodes[2], nodes[5]}) if err != nil { t.Fatal(err) } capturer := &fakeCapturer{calls: make(chan captureCall, 2)} worker, err := NewWorker(capturer, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/first")) done := make(chan Result, 1) go func() { result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0) done <- result }() deadline := time.After(3 * time.Second) for { select { case call := <-capturer.calls: call.result <- captureResult{tree: fresh} case result := <-done: if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID("/second") || result.DocumentFocus != webID("/doc") { t.Fatalf("focus removed during recapture: %+v", result) } return case <-deadline: t.Fatal("navigation did not finish") } } } func TestDocumentMoveRetriesStaleRecaptureWithoutSkipping(t *testing.T) { oldTree, cursor := webCursor(t) if _, err := cursor.SyncFocus(context.Background(), webID("/first")); err != nil { t.Fatal(err) } oldTree.Invalidate(webID("/first")) first, _ := webCursor(t) first.Invalidate(webID("/first")) fresh, _ := webCursor(t) capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, err := NewWorker(capturer, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/first")) done := make(chan Result, 1) go func() { result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0) done <- result }() initial := <-capturer.calls initial.result <- captureResult{tree: first} retry := <-capturer.calls if retry.reviewFocus != webID("/first") { t.Fatalf("retry focus = %v", retry.reviewFocus) } retry.result <- captureResult{tree: fresh} result := <-done if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID("/second") { t.Fatalf("stale heading recapture retry: %+v", result) } } func TestDocumentMoveRetriesStaleSnapshotWithoutSkipping(t *testing.T) { oldTree, cursor := webCursor(t) if _, err := cursor.SyncFocus(context.Background(), webID("/first")); err != nil { t.Fatal(err) } oldTree.InvalidateSnapshot(webID("/first")) first, _ := webCursor(t) first.InvalidateSnapshot(webID("/first")) fresh, _ := webCursor(t) capturer := &fakeCapturer{calls: make(chan captureCall, 2)} worker, err := NewWorker(capturer, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/first")) done := make(chan Result, 1) go func() { result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0) done <- result }() initial := <-capturer.calls initial.result <- captureResult{tree: first} retry := <-capturer.calls if retry.reviewFocus != webID("/first") { t.Fatalf("retry focus = %v", retry.reviewFocus) } retry.result <- captureResult{tree: fresh} result := <-done if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID("/second") { t.Fatalf("stale snapshot recapture retry: %+v", result) } } func TestDocumentCaretRetriesStaleSnapshotWithoutSkipping(t *testing.T) { text := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"} oldCursor := newCaretTextCursor(t, text) oldCursor.tree.InvalidateSnapshot(webID("/text")) first := newCaretTextCursor(t, text).tree first.InvalidateSnapshot(webID("/text")) fresh := newCaretTextCursor(t, text).tree reader := &refreshingCaretReader{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 2)}, text: text} worker, err := NewWorker(reader, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/text")) done := make(chan Result, 1) go func() { result, _ := worker.moveDocumentCaret(context.Background(), oldCursor, CaretNextCharacter, 0) done <- result }() initial := <-reader.calls initial.result <- captureResult{tree: first} retry := <-reader.calls if retry.reviewFocus != webID("/text") { t.Fatalf("retry focus = %v", retry.reviewFocus) } retry.result <- captureResult{tree: fresh} result := <-done if result.Err != nil || !result.Moved || result.DocumentCaretText != "l" || result.DocumentPosition.Offset != 1 { t.Fatalf("stale snapshot caret retry: %+v", result) } } type fakeDocumentNavigator struct { *fakeCapturer calls chan documentPlacement placementErr chan error } func newFakeDocumentNavigator() *fakeDocumentNavigator { return &fakeDocumentNavigator{ fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, calls: make(chan documentPlacement, 1), placementErr: make(chan error, 1), } } func TestWorkerJumpsToLoadedDocumentFragment(t *testing.T) { node := func(path, name, role, id string, children ...string) atspi.TreeNode { snapshot := atspi.NewSnapshot(path, name, role, "", "") snapshot.BusName = webID(path).BusName snapshot.ElementID = id result := atspi.TreeNode{Snapshot: snapshot} for _, child := range children { result.Children = append(result.Children, webID(child)) } return result } tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ node("/doc", "Forum", "document web", "", "/post"), node("/post", "", "section", "p71", "/heading"), node("/heading", "#71 User", "heading", "", "/permalink"), node("/permalink", "#71", "link", ""), }) if err != nil { t.Fatal(err) } navigator := newFakeDocumentNavigator() worker, err := NewWorker(navigator, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/doc")) results := make(chan Result, 1) go func() { result, _ := worker.jumpDocumentFragment(context.Background(), nil, "p71", worker.documentEpoch.Load()) results <- result }() capture := <-navigator.fakeCapturer.calls if capture.reviewFocus != webID("/doc") { t.Fatalf("capture focus = %v", capture.reviewFocus) } capture.result <- captureResult{tree: tree} placement := <-navigator.calls if placement.position.ID() != webID("/permalink") || placement.focus != webID("/doc") { t.Fatalf("fragment placement = %+v", placement) } result := <-results if result.Err != nil || result.CaretErr != nil || !result.Moved || result.DocumentPosition.Snapshot.Name != "#71" { t.Fatalf("fragment result = %+v", result) } } func (navigator *fakeDocumentNavigator) SetDocumentPosition(ctx context.Context, position atspi.ObjectReference, focus atspi.ObjectID) error { call := documentPlacement{position: position, focus: focus} select { case navigator.calls <- call: select { case err := <-navigator.placementErr: return err default: return nil } default: return errors.New("unexpected duplicate document placement") } } func TestWorkerRetriesStaleDocumentCaretOnSameTarget(t *testing.T) { for _, failure := range []error{atspi.ErrStaleObject, atspi.ErrObjectNotFound} { t.Run(failure.Error(), func(t *testing.T) { tree, _ := webCursor(t) navigator := newFakeDocumentNavigator() worker, cancel := startWorker(t, navigator, time.Second) defer worker.Stop(context.Background()) defer cancel() worker.SyncFocus(webID("/first")) navigator.placementErr <- failure if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil { t.Fatal(err) } initial := <-navigator.fakeCapturer.calls initial.result <- captureResult{tree: tree} first := <-navigator.calls if first.position.ID() != webID("/second") { t.Fatalf("first placement = %+v", first) } retry := <-navigator.fakeCapturer.calls if retry.reviewFocus != webID("/first") { t.Fatalf("retry focus = %v", retry.reviewFocus) } root, err := tree.Root() if err != nil { t.Fatal(err) } tree.Invalidate(root.ID()) if err := tree.Replace(root.ID(), webNodes()); err != nil { t.Fatal(err) } retry.result <- captureResult{tree: tree} second := <-navigator.calls if second.position.ID() != first.position.ID() || second.position == first.position { t.Fatalf("retry must rebind same target: first=%+v second=%+v", first, second) } result := waitResult(t, worker) if result.Err != nil || result.CaretErr != nil || result.DocumentPosition.Object != second.position { t.Fatalf("retry result = %+v", result) } if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil { t.Fatal(err) } <-navigator.calls next := waitResult(t, worker) if next.Err != nil || next.CaretErr != nil || next.CaptureReason != nil || next.DocumentPosition.Object.ID() != webID("/first") { t.Fatalf("next move after retry = %+v", next) } }) } } func TestWorkerDocumentNavigation(t *testing.T) { tree, _ := webCursor(t) capturer := &fakeCapturer{calls: make(chan captureCall, 2)} worker, cancel := startWorker(t, capturer, time.Second) defer worker.Stop(context.Background()) defer cancel() worker.SyncFocus(webID("/first")) if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil { t.Fatal(err) } call := <-capturer.calls if call.reviewFocus != webID("/first") { t.Fatal("capture not scoped to focus") } call.result <- captureResult{tree: tree} if r := waitResult(t, worker); r.Err != nil || !errors.Is(r.CaptureReason, ErrNoCurrent) || !r.Moved || r.DocumentPosition.Object.ID() != webID("/second") { t.Fatalf("heading: %+v", r) } for _, tc := range []struct { target DocumentTarget back bool path string moved bool wrapped bool }{ {DocumentLink, false, "/link", true, false}, {DocumentLink, false, "/link", true, true}, {DocumentButton, false, "/button", true, false}, {DocumentCheckBox, false, "/checkbox", true, false}, {DocumentList, false, "/list", true, false}, {DocumentSeparator, false, "/separator", true, false}, {DocumentSeparator, false, "/separator", true, true}, {DocumentHeading, true, "/second", true, false}, {DocumentHeading, true, "/first", true, false}, } { worker.MoveDocument(tc.target, 0, tc.back) if r := waitResult(t, worker); r.Err != nil || r.Moved != tc.moved || r.DocumentWrapped != tc.wrapped || r.DocumentPosition.Object.ID() != webID(tc.path) { t.Fatalf("navigation: %+v", r) } } worker.SyncFocus(webID("/chrome")) worker.MoveDocument(DocumentHeading, 0, false) if r := waitResult(t, worker); !errors.Is(r.Err, atspi.ErrNotInDocument) { t.Fatalf("chrome: %+v", r) } select { case <-capturer.calls: t.Fatal("unexpected recapture") default: } } func TestWorkerDocumentLoadRetiresOutstandingResult(t *testing.T) { tree, _ := webCursor(t) capturer := &fakeCapturer{calls: make(chan captureCall, 2)} worker, cancel := startWorker(t, capturer, time.Second) defer worker.Stop(context.Background()) defer cancel() worker.SyncFocus(webID("/first")) worker.MoveDocument(DocumentHeading, 0, false) call := <-capturer.calls worker.ResetDocument(webID("/first")) call.result <- captureResult{tree: tree} if result := waitResult(t, worker); worker.CurrentDocumentResult(result) { t.Fatal("old page result accepted after same-path load") } worker.MoveDocument(DocumentHeading, 0, false) select { case call = <-capturer.calls: call.result <- captureResult{tree: tree} case <-time.After(time.Second): t.Fatal("new page reused the old cursor instead of recapturing") } if result := waitResult(t, worker); !worker.CurrentDocumentResult(result) || result.Err != nil || result.DocumentPosition.Object.ID() != webID("/second") { t.Fatalf("new page navigation = %+v", result) } } func TestWorkerDocumentCaptureUsesLatestFocus(t *testing.T) { tree, _ := webCursor(t) capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, time.Second) defer worker.Stop(context.Background()) defer cancel() worker.SyncFocus(webID("/first")) worker.MoveDocument(DocumentHeading, 0, false) call := <-capturer.calls worker.SyncFocus(webID("/chrome")) call.result <- captureResult{tree: tree} r := waitResult(t, worker) if r.DocumentFocus != webID("/chrome") || !errors.Is(r.Err, atspi.ErrNotInDocument) { t.Fatalf("stale focus used: %+v", r) } } func TestWorkerDocumentRecapturePreservesPosition(t *testing.T) { tree, _ := webCursor(t) capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, time.Second) defer worker.Stop(context.Background()) defer cancel() worker.SyncFocus(webID("/first")) worker.MoveDocument(DocumentHeading, 0, false) call := <-capturer.calls call.result <- captureResult{tree: tree} waitResult(t, worker) tree.InvalidateText(webID("/link")) worker.MoveDocument(DocumentHeading, 0, true) call = <-capturer.calls nodes := webNodes() nodes[4].Geometry = atspi.Geometry{VisibilityKnown: true} if err := tree.Replace(webID("/root"), nodes); err != nil { t.Fatal(err) } call.result <- captureResult{tree: tree} r := waitResult(t, worker) if r.Err != nil || !errors.Is(r.CaptureReason, atspi.ErrStaleText) || !r.Moved || r.DocumentWrapped || r.DocumentPosition.Object.ID() != webID("/first") { t.Fatalf("recapture lost place: %+v", r) } } func TestWorkerPlaceDocumentAcceptsSameDocumentFocusTransition(t *testing.T) { _, cursor := webCursor(t) ctx := context.Background() _, err := cursor.SyncFocus(ctx, webID("/first")) if err != nil { t.Fatal(err) } move, err := cursor.Move(ctx, DocumentHeading, 2, false) if err != nil || !move.Moved { t.Fatalf("move: %+v err=%v", move, err) } worker, err := NewWorker(newFakeDocumentNavigator(), time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/button")) result := worker.placeDocument(ctx, cursor, Result{ DocumentTarget: DocumentHeading, DocumentFocus: webID("/first"), DocumentPosition: move.Position, Moved: true, }) if result.Err != nil || result.CaretErr != nil { t.Fatalf("same-document transition rejected: %+v", result) } if result.DocumentFocus != webID("/button") { t.Fatalf("document focus = %v, want /button", result.DocumentFocus) } navigator, _ := worker.capturer.(*fakeDocumentNavigator) select { case call := <-navigator.calls: if call.position != move.Position.Object || call.focus != webID("/button") { t.Fatalf("placement = %+v", call) } default: t.Fatal("document placement was not called") } } func TestWorkerPlaceDocumentRejectsCrossDocumentFocusTransition(t *testing.T) { _, cursor := webCursor(t) ctx := context.Background() _, err := cursor.SyncFocus(ctx, webID("/first")) if err != nil { t.Fatal(err) } move, err := cursor.Move(ctx, DocumentHeading, 2, false) if err != nil || !move.Moved { t.Fatalf("move: %+v err=%v", move, err) } navigator := newFakeDocumentNavigator() worker, err := NewWorker(navigator, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/other")) result := worker.placeDocument(ctx, cursor, Result{ DocumentTarget: DocumentHeading, DocumentFocus: webID("/first"), DocumentPosition: move.Position, Moved: true, }) if result.Err == nil || result.Err.Error() != "document focus changed" { t.Fatalf("cross-document transition accepted: %+v", result) } select { case <-navigator.calls: t.Fatal("cross-document placement was called") default: } } func TestWorkerPlaceDocumentPreservesErrorResult(t *testing.T) { _, cursor := webCursor(t) navigator := newFakeDocumentNavigator() worker, err := NewWorker(navigator, time.Second) if err != nil { t.Fatal(err) } result := Result{Err: context.Canceled, DocumentFocus: webID("/first"), Moved: true} got := worker.placeDocument(context.Background(), cursor, result) if !errors.Is(got.Err, context.Canceled) { t.Fatalf("result error = %v, want canceled", got.Err) } select { case <-navigator.calls: t.Fatal("failed navigation placement was called") default: } } func TestDocumentCaretKeepsOffsetAfterDocumentRefresh(t *testing.T) { for _, kind := range []string{"semantics", "children"} { t.Run(kind, func(t *testing.T) { text := caretTextReader{webID("/text"): "First\nSecond\nThird", webID("/next"): "End"} cursor := newCaretTextCursor(t, text) navigator := &boundaryNavigator{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, caretTextReader: text} worker, err := NewWorker(navigator, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(webID("/doc")) ctx := context.Background() for _, want := range []string{"First\n", "Second\n"} { result, next := worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load()) cursor = next if result.Err != nil || result.DocumentCaretText != want { t.Fatalf("initial navigation = %+v, want %q", result, want) } } docRef, _ := cursor.tree.Reference(webID("/doc")) doc, err := cursor.tree.Document(ctx, docRef) if err != nil { t.Fatal(err) } nodes := make([]atspi.TreeNode, 0, len(doc.Nodes)) for _, node := range doc.Nodes { nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children, Geometry: node.Geometry, Text: node.Text}) } wantReason := atspi.ErrStaleSnapshot if kind == "children" { cursor.tree.MarkChildrenChanged(webID("/doc")) wantReason = atspi.ErrIncompleteTree } else { cursor.tree.InvalidateSnapshot(webID("/text")) } go func() { call := <-navigator.calls err := cursor.tree.Replace(webID("/doc"), nodes) call.result <- captureResult{tree: cursor.tree, err: err} }() result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load()) if result.Err != nil || !errors.Is(result.CaptureReason, wantReason) || result.DocumentCaretText != "Third" { t.Fatalf("semantic refresh restarted or skipped reading: %+v", result) } }) } }