Files
cthulhu/internal/review/document_worker_test.go

928 lines
33 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"
"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)
}
})
}
}