Files

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)
}
}