219 lines
8.0 KiB
Go
219 lines
8.0 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package review
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
type readAllCapturer struct {
|
|
*fakeCapturer
|
|
caretTextReader
|
|
tree *atspi.AccessibleTree
|
|
nativePlacements int
|
|
}
|
|
|
|
func (capturer *readAllCapturer) CaptureDocument(ctx context.Context, _ atspi.ObjectID) (*atspi.AccessibleTree, error) {
|
|
return capturer.tree, ctx.Err()
|
|
}
|
|
|
|
type sequenceReadAllCapturer struct {
|
|
*fakeCapturer
|
|
caretTextReader
|
|
trees []*atspi.AccessibleTree
|
|
index int
|
|
invalidateTree *atspi.AccessibleTree
|
|
staleOnce bool
|
|
staleAt int
|
|
reads int
|
|
invalidateAfter bool
|
|
}
|
|
|
|
func (capturer *sequenceReadAllCapturer) CaptureDocument(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error) {
|
|
if capturer.index >= len(capturer.trees) {
|
|
return nil, errors.New("no more trees")
|
|
}
|
|
tree := capturer.trees[capturer.index]
|
|
capturer.index++
|
|
return tree, nil
|
|
}
|
|
|
|
func (capturer *sequenceReadAllCapturer) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
|
|
capturer.reads++
|
|
if capturer.staleOnce || capturer.staleAt == capturer.reads {
|
|
capturer.staleOnce = false
|
|
capturer.invalidateTree.InvalidateSnapshot(reference.ID())
|
|
return atspi.DocumentCaretText{}, atspi.ErrStaleSnapshot
|
|
}
|
|
result, err := capturer.caretTextReader.ReadDocumentCaret(ctx, reference, offset, unit)
|
|
if capturer.invalidateAfter {
|
|
capturer.invalidateAfter = false
|
|
capturer.invalidateTree.InvalidateSnapshot(reference.ID())
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
func TestReadAllProgressRebindsEarlierLineAfterRecapture(t *testing.T) {
|
|
text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"}
|
|
cursor := newCaretTextCursor(t, text)
|
|
fresh := newCaretTextCursor(t, text).tree
|
|
// Give the recaptured text a new generation, as happens when a provider
|
|
// removes and recreates the object while keeping its accessible path.
|
|
fresh.Invalidate(webID("/text"))
|
|
nodes := make([]atspi.TreeNode, 0, 3)
|
|
for _, path := range []string{"/doc", "/text", "/next"} {
|
|
snapshot := atspi.NewSnapshot(path, "", "static", "", "")
|
|
snapshot.BusName = webID(path).BusName
|
|
snapshot.CharacterCount = len([]rune(text[webID(path)]))
|
|
node := atspi.TreeNode{Snapshot: snapshot}
|
|
if path == "/doc" {
|
|
node.Snapshot.Role = "document web"
|
|
node.Children = []atspi.ObjectID{webID("/text"), webID("/next")}
|
|
}
|
|
nodes = append(nodes, node)
|
|
}
|
|
if err := fresh.Replace(webID("/doc"), nodes); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capturer := &sequenceReadAllCapturer{
|
|
caretTextReader: text, trees: []*atspi.AccessibleTree{fresh},
|
|
invalidateTree: cursor.tree, staleAt: 2,
|
|
}
|
|
worker, _ := NewWorker(capturer, time.Second)
|
|
worker.SyncFocus(webID("/text"))
|
|
result := worker.readAll(context.Background(), cursor)
|
|
if result.Err != nil || len(result.ReadAllLines) < 2 {
|
|
t.Fatalf("read all after recapture: %+v", result)
|
|
}
|
|
result.ReadAllProgress(0)
|
|
request := <-worker.requests
|
|
if request.readTree != fresh {
|
|
t.Fatal("progress did not use recaptured tree")
|
|
}
|
|
followedCursor, followed := worker.followReadAll(context.Background(), cursor, request)
|
|
if !followed || followedCursor.places[len(followedCursor.places)-1].object != request.readPosition.Object {
|
|
t.Fatalf("first spoken line did not move the browse cursor after recapture: %+v", request.readPosition)
|
|
}
|
|
current, err := fresh.Reference(webID("/text"))
|
|
if err != nil || request.readPosition.Object != current {
|
|
t.Fatalf("spoken line did not rebind to the current object: %v, %v", request.readPosition.Object, err)
|
|
}
|
|
}
|
|
|
|
func TestReadAllRetriesSecondStaleSnapshotCapture(t *testing.T) {
|
|
text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"}
|
|
stale := newCaretTextCursor(t, text).tree
|
|
stale.InvalidateSnapshot(webID("/text"))
|
|
fresh := newCaretTextCursor(t, text).tree
|
|
capturer := &sequenceReadAllCapturer{caretTextReader: text, trees: []*atspi.AccessibleTree{stale, fresh}}
|
|
worker, _ := NewWorker(capturer, time.Second)
|
|
worker.SyncFocus(webID("/text"))
|
|
cursor := newCaretTextCursor(t, text)
|
|
cursor.tree.InvalidateSnapshot(webID("/text"))
|
|
result := worker.readAll(context.Background(), cursor)
|
|
if result.Err != nil || len(result.ReadAllLines) == 0 {
|
|
t.Fatalf("read all after stale snapshot retry: %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestReadAllRetriesStaleLineMovementWithoutSkipping(t *testing.T) {
|
|
text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"}
|
|
cursor := newCaretTextCursor(t, text)
|
|
fresh := newCaretTextCursor(t, text).tree
|
|
secondFresh := newCaretTextCursor(t, text).tree
|
|
thirdFresh := newCaretTextCursor(t, text).tree
|
|
fourthFresh := newCaretTextCursor(t, text).tree
|
|
capturer := &sequenceReadAllCapturer{
|
|
caretTextReader: text,
|
|
trees: []*atspi.AccessibleTree{fresh, secondFresh, thirdFresh, fourthFresh},
|
|
invalidateTree: cursor.tree,
|
|
staleOnce: true,
|
|
}
|
|
worker, _ := NewWorker(capturer, time.Second)
|
|
worker.SyncFocus(webID("/text"))
|
|
result := worker.readAll(context.Background(), cursor)
|
|
if result.Err != nil || len(result.ReadAllLines) < 2 {
|
|
t.Fatalf("read all stale line movement retry: %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestReadAllContinuesWhenAncestorContextBecomesStale(t *testing.T) {
|
|
text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"}
|
|
cursor := newCaretTextCursor(t, text)
|
|
fresh := newCaretTextCursor(t, text).tree
|
|
capturer := &sequenceReadAllCapturer{
|
|
caretTextReader: text,
|
|
trees: []*atspi.AccessibleTree{fresh, fresh},
|
|
invalidateTree: cursor.tree,
|
|
invalidateAfter: true,
|
|
}
|
|
worker, _ := NewWorker(capturer, time.Second)
|
|
worker.SyncFocus(webID("/text"))
|
|
result := worker.readAll(context.Background(), cursor)
|
|
if result.Err != nil || len(result.ReadAllLines) < 2 {
|
|
t.Fatalf("read all stale ancestor context: %+v", result)
|
|
}
|
|
}
|
|
|
|
func (capturer *readAllCapturer) SetDocumentCaretPosition(context.Context, atspi.ObjectReference, atspi.ObjectID, int) error {
|
|
capturer.nativePlacements++
|
|
return nil
|
|
}
|
|
|
|
func TestReadAllStartsAtUnicodeOffsetAndPreservesCursorUntilSpeech(t *testing.T) {
|
|
text := caretTextReader{webID("/text"): "Café 猫\nSecond line\n", webID("/next"): "Last line"}
|
|
cursor := newCaretTextCursor(t, text)
|
|
cursor.places[0].offset = 5
|
|
reader := &readAllCapturer{caretTextReader: text, tree: cursor.tree}
|
|
worker, _ := NewWorker(reader, time.Second)
|
|
worker.SyncFocus(webID("/text"))
|
|
result := worker.readAll(context.Background(), cursor)
|
|
if result.Err != nil {
|
|
t.Fatal(result.Err)
|
|
}
|
|
var lines []string
|
|
for _, line := range result.ReadAllLines {
|
|
lines = append(lines, strings.TrimSpace(line.Contents[0].Text))
|
|
}
|
|
if got := strings.Join(lines, "|"); got != "猫|Second line|Last line" {
|
|
t.Fatalf("read all = %q", got)
|
|
}
|
|
if cursor.places[0].offset != 5 || cursor.places[0].object.ID() != webID("/text") {
|
|
t.Fatal("capture advanced user's cursor before speech")
|
|
}
|
|
result.ReadAllProgress(1)
|
|
request := <-worker.requests
|
|
cursor, _ = worker.followReadAll(context.Background(), cursor, request)
|
|
if cursor.places[0].offset != 7 {
|
|
t.Fatalf("speech progress offset = %d", cursor.places[0].offset)
|
|
}
|
|
if reader.nativePlacements != 0 {
|
|
t.Fatalf("read-all progress moved the native caret %d times", reader.nativePlacements)
|
|
}
|
|
resumed := worker.readAll(context.Background(), cursor)
|
|
if resumed.Err != nil || strings.TrimSpace(resumed.ReadAllLines[0].Contents[0].Text) != "Second line" {
|
|
t.Fatalf("resumed = %+v", resumed)
|
|
}
|
|
}
|
|
|
|
func TestReadAllCancellation(t *testing.T) {
|
|
text := caretTextReader{webID("/text"): "First", webID("/next"): "Last"}
|
|
cursor := newCaretTextCursor(t, text)
|
|
worker, _ := NewWorker(&readAllCapturer{caretTextReader: text, tree: cursor.tree}, time.Second)
|
|
worker.SyncFocus(webID("/text"))
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
result := worker.readAll(ctx, cursor)
|
|
if !errors.Is(result.Err, context.Canceled) || len(result.ReadAllLines) != 0 {
|
|
t.Fatalf("canceled read all = %+v", result)
|
|
}
|
|
}
|