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