Files
cthulhu/internal/review/document_worker_test.go
Storm Dragon 4da61465b0 Fix document retention and helper retry regressions
Keep failed cursor trials and native recapture anchored to the original document, and restore the requested cached root before LRU eviction. Base braille retry resets on established connection time rather than stalled startup. Poll private AT-SPI daemon ownership without triggering service activation and clean up partial startup failures. Add focused regressions and update browser testing notes.
2026-11-03 11:37:36 -05:00

1325 lines
48 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
package review
import (
"context"
"errors"
"fmt"
"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
}
type refreshingCaretNavigator struct {
*readAllCapturer
caretTextReader
placements []int
}
func (navigator *refreshingCaretNavigator) SetDocumentCaretPosition(_ context.Context, _ atspi.ObjectReference, _ atspi.ObjectID, offset int) error {
navigator.placements = append(navigator.placements, offset)
if len(navigator.placements) == 1 {
return atspi.ErrStaleSnapshot
}
return nil
}
func TestWorkerRetriesNativeCaretPlacementWithoutRepeatingMovement(t *testing.T) {
text := caretTextReader{webID("/text"): "First line"}
cursor := newCaretTextCursor(t, text)
navigator := &refreshingCaretNavigator{readAllCapturer: &readAllCapturer{tree: cursor.tree}, caretTextReader: text}
worker, _ := NewWorker(navigator, time.Second)
worker.SyncFocus(webID("/text"))
result, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0, 0)
if result.Err != nil || result.CaretErr != nil || result.DocumentCaretText != "i" || result.DocumentPosition.Offset != 1 ||
len(navigator.placements) != 2 || navigator.placements[0] != 1 || navigator.placements[1] != 1 {
t.Fatalf("retry skipped or lost requested character: %+v, placements %v", result, navigator.placements)
}
next, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0, 0)
if next.Err != nil || next.DocumentCaretText != "r" || next.DocumentPosition.Offset != 2 {
t.Fatalf("retry lost the saved position: %+v", next)
}
}
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(), worker.documentLoadEpoch.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, 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, 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)
}
}
// Keys pressed while a capture is in flight queue up. Identical queued
// document moves must execute as one batch: one placement and one result,
// landing the full count of steps from the original position.
func TestWorkerCoalescesQueuedIdenticalDocumentMoves(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"))
if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil {
t.Fatal(err)
}
initial := <-navigator.fakeCapturer.calls
// Two more identical keys arrive while the first capture is in flight.
for range 2 {
if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil {
t.Fatal(err)
}
}
initial.result <- captureResult{tree: tree}
first := <-navigator.calls
if first.position.ID() != webID("/second") {
t.Fatalf("first placement = %+v", first)
}
if result := waitResult(t, worker); result.Err != nil || result.DocumentPosition.Object.ID() != webID("/second") {
t.Fatalf("first result = %+v", result)
}
second := <-navigator.calls
if second.position.ID() != webID("/second") {
t.Fatalf("batched placement = %+v, want final /second after wrap", second)
}
result := waitResult(t, worker)
if result.Err != nil || !result.Moved || !result.DocumentWrapped || result.DocumentPosition.Object.ID() != webID("/second") {
t.Fatalf("batched result = %+v", result)
}
select {
case placement := <-navigator.calls:
t.Fatalf("unexpected extra placement %+v; queued repeats were not coalesced", placement)
case extra := <-worker.Results():
t.Fatalf("unexpected extra result %+v; queued repeats were not coalesced", extra)
default:
}
}
type retainedDocumentCapturer struct {
trees map[atspi.ObjectID]*atspi.AccessibleTree
captures []atspi.ObjectID
placements []atspi.ObjectID
}
func (capturer *retainedDocumentCapturer) CaptureDocument(_ context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
capturer.captures = append(capturer.captures, focus)
if tree := capturer.trees[focus]; tree != nil {
return tree, nil
}
return nil, atspi.ErrObjectNotFound
}
func (capturer *retainedDocumentCapturer) CaptureDocumentText(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
return capturer.CaptureDocument(ctx, focus)
}
func (capturer *retainedDocumentCapturer) CaptureReviewWindow(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
return capturer.CaptureDocument(ctx, focus)
}
func (capturer *retainedDocumentCapturer) CaptureDesktop(context.Context) (*atspi.AccessibleTree, error) {
return nil, atspi.ErrObjectNotFound
}
func (capturer *retainedDocumentCapturer) SetDocumentPosition(_ context.Context, position atspi.ObjectReference, _ atspi.ObjectID) error {
capturer.placements = append(capturer.placements, position.ID())
return nil
}
func retainedDocumentTree(t *testing.T, root string, headings ...string) *atspi.AccessibleTree {
t.Helper()
node := func(path, role string, children ...string) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, path, role, "", "")
snapshot.BusName = webID(path).BusName
if role == "heading" {
snapshot.HeadingLevel = 2
}
result := atspi.TreeNode{Snapshot: snapshot}
for _, child := range children {
result.Children = append(result.Children, webID(child))
}
return result
}
nodes := []atspi.TreeNode{node(root, "document web", headings...)}
for _, heading := range headings {
nodes = append(nodes, node(heading, "heading"))
}
tree, err := atspi.NewAccessibleTree(webID(root), nodes)
if err != nil {
t.Fatal(err)
}
return tree
}
// Returning to a tab (or browser workspace) must resume that document's
// browse place from its retained cursor instead of recapturing cold and
// restarting at the top, even across document epoch resets.
func TestWorkerRetainsDocumentCursorsAcrossSwitches(t *testing.T) {
capturer := &retainedDocumentCapturer{trees: map[atspi.ObjectID]*atspi.AccessibleTree{
webID("/docA"): retainedDocumentTree(t, "/docA", "/a1", "/a2", "/a3"),
webID("/docB"): retainedDocumentTree(t, "/docB", "/b1", "/b2"),
}}
worker, cancel := startWorker(t, capturer, time.Second)
defer worker.Stop(context.Background())
defer cancel()
move := func(want string) {
t.Helper()
if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil {
t.Fatal(err)
}
result := waitResult(t, worker)
if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID(want) {
t.Fatalf("heading move = %+v, want %s", result, want)
}
}
worker.SyncFocus(webID("/docA"))
move("/a1")
move("/a2")
worker.SuspendDocument(webID("/docB"))
move("/b1")
capturesA := 0
for _, focus := range capturer.captures {
if focus == webID("/docA") {
capturesA++
}
}
worker.SuspendDocument(webID("/docA"))
move("/a3")
for _, focus := range capturer.captures {
if focus == webID("/docA") {
capturesA--
}
}
if capturesA != 0 {
t.Fatalf("returning to first document recaptured it; captures=%v", capturer.captures)
}
}
// Warming runs the first capture and focus sync in the background, so the
// first navigation key afterwards needs no capture of its own.
func TestWorkerWarmDocumentPreparesFirstMove(t *testing.T) {
capturer := &retainedDocumentCapturer{trees: map[atspi.ObjectID]*atspi.AccessibleTree{
webID("/docA"): retainedDocumentTree(t, "/docA", "/a1", "/a2"),
}}
worker, cancel := startWorker(t, capturer, time.Second)
defer worker.Stop(context.Background())
defer cancel()
worker.SyncFocus(webID("/docA"))
worker.WarmDocument()
if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil {
t.Fatal(err)
}
result := waitResult(t, worker)
if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID("/a1") {
t.Fatalf("warmed first move = %+v", result)
}
if len(capturer.captures) != 1 {
t.Fatalf("captures = %v, want only the warm capture", capturer.captures)
}
}
// A ticking page can destroy the focused anchor itself. Capture keyed on
// that dead focus fails with ErrObjectNotFound forever; the move must
// recapture through the saved document instead of losing the location on
// every following keypress.
func TestDocumentCaretRecapturesThroughDocumentWhenFocusAnchorDies(t *testing.T) {
text := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"}
oldCursor := newCaretTextCursor(t, text)
oldCursor.tree.Invalidate(webID("/text"))
fresh := newCaretTextCursor(t, text).tree
reader := &refreshingCaretReader{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, 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, 0)
done <- result
}()
initial := <-reader.calls
if initial.reviewFocus != webID("/text") {
t.Fatalf("initial capture focus = %v", initial.reviewFocus)
}
initial.result <- captureResult{err: fmt.Errorf("%w: document focus", atspi.ErrObjectNotFound)}
fallback := <-reader.calls
if fallback.reviewFocus != webID("/doc") {
t.Fatalf("fallback capture focus = %v, want saved document /doc", fallback.reviewFocus)
}
fallback.result <- captureResult{tree: fresh}
result := <-done
if result.Err != nil || !result.Moved || result.DocumentCaretText != "l" || result.DocumentPosition.Offset != 1 {
t.Fatalf("dead anchor recapture: %+v", result)
}
}
func TestRetainedCursorTrialKeepsDeadFocusInOriginalDocument(t *testing.T) {
treeA := retainedDocumentTree(t, "/docA", "/a1", "/a2")
treeB := retainedDocumentTree(t, "/docB", "/b1", "/b2")
cursorA, _ := NewDocumentCursor(treeA)
cursorB, _ := NewDocumentCursor(treeB)
ctx := context.Background()
if _, err := cursorA.SyncFocus(ctx, webID("/a1")); err != nil {
t.Fatal(err)
}
if _, err := cursorB.SyncFocus(ctx, webID("/b1")); err != nil {
t.Fatal(err)
}
treeA.Invalidate(webID("/a1"))
capturer := &retainedDocumentCapturer{trees: map[atspi.ObjectID]*atspi.AccessibleTree{
webID("/docA"): retainedDocumentTree(t, "/docA", "/a2"),
webID("/docB"): treeB,
}}
worker, err := NewWorker(capturer, time.Second)
if err != nil {
t.Fatal(err)
}
worker.SyncFocus(webID("/a1"))
worker.rememberDocumentCursor(cursorA, 0)
worker.rememberDocumentCursor(cursorB, 0)
result, _ := worker.moveDocument(ctx, cursorA, DocumentHeading, 0, false, 0, 0)
if result.Err != nil || !result.Moved || result.DocumentPosition.Document.ID() != webID("/docA") ||
result.DocumentPosition.Object.ID() != webID("/a2") {
t.Fatalf("dead focus recovery: document=%v object=%v moved=%v error=%v captures=%v",
result.DocumentPosition.Document.ID(), result.DocumentPosition.Object.ID(), result.Moved, result.Err, capturer.captures)
}
}
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, 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, 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, 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, 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, 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, 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(), worker.documentLoadEpoch.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, atspi.ErrDeadObject} {
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 TestWorkerRemovedEntryIsNotPresentedAndNextCommandRecovers(t *testing.T) {
tree, _ := webCursor(t)
nodes := webNodes()
for index := range nodes {
if nodes[index].Snapshot.Path == "/first" || nodes[index].Snapshot.Path == "/second" {
nodes[index].Snapshot.Role = "combo box"
nodes[index].Snapshot.Editable = true
nodes[index].Snapshot.Focusable = true
}
}
if err := tree.Replace(webID("/root"), nodes); err != nil {
t.Fatal(err)
}
navigator := newFakeDocumentNavigator()
worker, cancel := startWorker(t, navigator, time.Second)
defer worker.Stop(context.Background())
defer cancel()
worker.SyncFocus(webID("/button"))
navigator.placementErr <- atspi.ErrDeadObject
if err := worker.MoveDocument(DocumentEntry, 0, true); 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("initial entry target = %+v", first)
}
retry := <-navigator.fakeCapturer.calls
// The native adapter retires a confirmed missing target before recapture.
tree.Invalidate(webID("/second"))
retry.result <- captureResult{tree: tree}
result := waitResult(t, worker)
if result.Moved || !errors.Is(result.Err, atspi.ErrDeadObject) {
t.Fatalf("removed entry reported as a successful move: %+v", result)
}
if err := worker.MoveDocument(DocumentEntry, 0, true); err != nil {
t.Fatal(err)
}
placement := <-navigator.calls
if placement.position.ID() != webID("/first") {
t.Fatalf("next command revisited removed entry: %+v", placement)
}
next := waitResult(t, worker)
if next.Err != nil || next.CaretErr != nil || !next.Moved {
t.Fatalf("next entry navigation failed: %+v", next)
}
}
func TestWorkerCaretRefusalKeepsVirtualMove(t *testing.T) {
_, cursor := webCursor(t)
navigator := newFakeDocumentNavigator()
worker, err := NewWorker(navigator, time.Second)
if err != nil {
t.Fatal(err)
}
worker.SyncFocus(webID("/first"))
failure := errors.New("browser refused document caret")
navigator.placementErr <- failure
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, worker.DocumentEpoch(), worker.documentLoadEpoch.Load())
if result.Err != nil || result.CaretErr != failure || !result.Moved || result.DocumentPosition.Object.ID() != webID("/second") {
t.Fatalf("ordinary caret refusal lost virtual reading position: %+v", result)
}
if len(navigator.fakeCapturer.calls) != 0 {
t.Fatal("ordinary caret refusal unnecessarily recaptured the document")
}
}
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)
// Only a traversed branch requires recapture now. The hidden heading is
// between the current and requested positions; it cannot be skipped until
// its invalidated data has been refreshed.
tree.InvalidateText(webID("/hidden"))
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)
}
diagnostics := r.DocumentDiagnostics
if diagnostics.CaptureAttempts != 1 || diagnostics.CursorRecreated || len(diagnostics.Attempts) != 2 {
t.Fatalf("recapture diagnostics = %+v", diagnostics)
}
failed, recovered := diagnostics.Attempts[0], diagnostics.Attempts[1]
if failed.Stage != "move" || failed.Recovery != "preserved" || failed.Error == "" ||
recovered.Recovery != "preserved" || recovered.Error != "" || recovered.Before.Object != webID("/second") ||
recovered.Synced.Object != webID("/second") || recovered.After.Object != webID("/first") {
t.Fatalf("recapture lost diagnostic history: %+v", diagnostics)
}
}
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(), worker.documentLoadEpoch.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})
}
var wantReason error = atspi.ErrStaleSnapshot
if kind == "children" {
cursor.tree.MarkChildrenChanged(webID("/doc"))
wantReason = nil
} else {
cursor.tree.InvalidateSnapshot(webID("/text"))
}
refresh := func() {
call := <-navigator.calls
err := cursor.tree.Replace(webID("/doc"), nodes)
call.result <- captureResult{tree: cursor.tree, err: err}
}
if kind != "children" {
go refresh()
}
result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load(), worker.documentLoadEpoch.Load())
if result.Err != nil || !errors.Is(result.CaptureReason, wantReason) || result.DocumentCaretText != "Third" {
t.Fatalf("semantic refresh restarted or skipped reading: %+v", result)
}
if kind == "children" {
if result.DocumentDiagnostics.CaptureAttempts != 0 {
t.Fatal("local text movement recaptured unrelated child order")
}
// Crossing follows captured child order with live reads and
// generation-checked endpoints; it no longer recaptures.
result, _ = worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load(), worker.documentLoadEpoch.Load())
if result.Err != nil || result.CaptureReason != nil || result.DocumentDiagnostics.CaptureAttempts != 0 || result.DocumentCaretText != "End" {
t.Fatalf("crossing changed children lost position: %+v", result)
}
}
})
}
}
func TestDocumentWorkerUsesSeparateCaptureDeadline(t *testing.T) {
capturer := &fakeCapturer{calls: make(chan captureCall, 1)}
worker, err := NewWorker(capturer, time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.SetDocumentCaptureTimeout(15 * time.Second); err != nil {
t.Fatal(err)
}
if err := worker.Start(context.Background()); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
if err := worker.SetDocumentCaptureTimeout(time.Second); err == nil {
t.Fatal("changed document capture deadline after start")
}
worker.SyncFocus(webID("/doc"))
if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil {
t.Fatal(err)
}
call := <-capturer.calls
deadline, ok := call.ctx.Deadline()
if !ok || time.Until(deadline) < 13*time.Second {
t.Fatalf("document capture deadline = %v, present = %t", deadline, ok)
}
// A shutdown must still cancel a capture with the larger budget.
if err := worker.Stop(context.Background()); err != nil {
t.Fatal(err)
}
if !errors.Is(call.ctx.Err(), context.Canceled) {
t.Fatalf("capture was not canceled: %v", call.ctx.Err())
}
}