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.
1325 lines
48 KiB
Go
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())
|
|
}
|
|
}
|