Recreated-node caret recovery, tolerant caret validation, per-root retained document trees and cursors across tab switches, document warm-up, coalesced structural moves, and hung-provider fast-fail with an 'Application is not responding' announcement.
308 lines
13 KiB
Go
308 lines
13 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package review
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
func TestDocumentCaretRecoversSingleReplacedTextChild(t *testing.T) {
|
|
for _, kind := range []string{"single text", "multiple children", "editable", "replaced paragraph", "replaced document"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
reader := caretTextReader{webID("/old"): "Current total 10000 units", webID("/new"): "Current total 10001 units"}
|
|
node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode {
|
|
return atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: role,
|
|
CharacterCount: utf8.RuneCountInString(reader[webID(path)])}, Children: children}
|
|
}
|
|
nodes := []atspi.TreeNode{node("/doc", "document web", webID("/paragraph")),
|
|
node("/paragraph", "paragraph", webID("/old")), node("/old", "static")}
|
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, _ := NewDocumentCursor(tree)
|
|
ctx := context.Background()
|
|
if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
move, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
|
if err != nil || move.Text != reader[webID("/old")] {
|
|
t.Fatalf("initial line = %+v, %v", move, err)
|
|
}
|
|
nodes[1].Children = []atspi.ObjectID{webID("/new")}
|
|
nodes[2] = node("/new", "static")
|
|
switch kind {
|
|
case "multiple children":
|
|
nodes[1].Children = append(nodes[1].Children, webID("/extra"))
|
|
nodes = append(nodes, node("/extra", "static"))
|
|
case "editable":
|
|
nodes[1].Snapshot.Editable = true
|
|
case "replaced paragraph":
|
|
tree.Invalidate(webID("/paragraph"))
|
|
case "replaced document":
|
|
tree.Invalidate(webID("/doc"))
|
|
}
|
|
if err := tree.Replace(webID("/doc"), nodes); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
position, recovery, err := cursor.sync_focus(ctx, webID("/doc"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if kind != "single text" {
|
|
wantRecovery := "saved-object-missing"
|
|
if kind == "replaced document" {
|
|
wantRecovery = "document-replaced"
|
|
} else if kind == "multiple children" {
|
|
wantRecovery = "surviving-parent"
|
|
}
|
|
if recovery != wantRecovery {
|
|
t.Fatalf("recovery = %q, want %q", recovery, wantRecovery)
|
|
}
|
|
if position.Object.ID() == webID("/new") {
|
|
t.Fatal("ambiguous or replaced context inherited the old caret")
|
|
}
|
|
return
|
|
}
|
|
if recovery != "replaced-text-child" {
|
|
t.Fatalf("replacement recovery = %q", recovery)
|
|
}
|
|
move, err = cursor.MoveCaret(ctx, reader, CaretNextCharacter)
|
|
if err != nil || move.Text != "u" || move.Position.Offset != 1 || move.Position.Object.ID() != webID("/new") {
|
|
t.Fatalf("Right after text replacement = %+v, %v", move, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A ticking page (incremental game) recreates every text leaf under a
|
|
// surviving parent at once: sibling identities all change, but child count
|
|
// and order do not. Recovery must restore the same line so the next caret
|
|
// move progresses instead of restarting at the parent's first line forever.
|
|
func TestDocumentCaretRecoversRecreatedTextSiblings(t *testing.T) {
|
|
ctx := context.Background()
|
|
reader := caretTextReader{
|
|
webID("/l1a"): "Counter one 100", webID("/l2a"): "Counter two 200", webID("/l3a"): "Counter three 300",
|
|
webID("/l1b"): "Counter one 101", webID("/l2b"): "Counter two 201", webID("/l3b"): "Counter three 301",
|
|
webID("/l1c"): "Counter one 102", webID("/l2c"): "Counter two 202", webID("/l3c"): "Counter three 302",
|
|
}
|
|
node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode {
|
|
return atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: role,
|
|
CharacterCount: utf8.RuneCountInString(reader[webID(path)])}, Children: children}
|
|
}
|
|
generation := func(suffix string) []atspi.TreeNode {
|
|
return []atspi.TreeNode{
|
|
node("/doc", "document web", webID("/paragraph")),
|
|
node("/paragraph", "paragraph", webID("/l1"+suffix), webID("/l2"+suffix), webID("/l3"+suffix)),
|
|
node("/l1"+suffix, "static"), node("/l2"+suffix, "static"), node("/l3"+suffix, "static"),
|
|
}
|
|
}
|
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), generation("a"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, _ := NewDocumentCursor(tree)
|
|
if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if move, err := cursor.MoveCaret(ctx, reader, CaretNextLine); err != nil || move.Text != "Counter one 100" {
|
|
t.Fatalf("first line = %+v, %v", move, err)
|
|
}
|
|
if move, err := cursor.MoveCaret(ctx, reader, CaretNextLine); err != nil || move.Text != "Counter two 200" ||
|
|
move.Position.Object.ID() != webID("/l2a") {
|
|
t.Fatalf("second line = %+v, %v", move, err)
|
|
}
|
|
if err := tree.Replace(webID("/doc"), generation("b")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
position, recovery, err := cursor.sync_focus(ctx, webID("/doc"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if position.Object.ID() != webID("/l2b") {
|
|
t.Fatalf("recovery = %q onto %v, want recreated second line /l2b", recovery, position.Object.ID())
|
|
}
|
|
if move, err := cursor.MoveCaret(ctx, reader, CaretNextLine); err != nil || !move.Moved || move.Text != "Counter three 301" {
|
|
t.Fatalf("line after tick = %+v, %v; caret did not progress", move, err)
|
|
}
|
|
if err := tree.Replace(webID("/doc"), generation("c")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := cursor.sync_focus(ctx, webID("/doc")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if move, err := cursor.MoveCaret(ctx, reader, CaretNextLine); err != nil || move.Moved {
|
|
t.Fatalf("past last ticking line = %+v, %v; want boundary, not a repeat", move, err)
|
|
}
|
|
}
|
|
|
|
// Every visible counter on a ticking page marks its text stale on each game
|
|
// tick. A caret move reads its target line live, so text staleness between
|
|
// caret and target (or on the current line) must not by itself force a
|
|
// recapture-and-retry cycle that a faster tick can always outrun.
|
|
// The text-leaf index recovery does not cover recreated controls: a game
|
|
// can rebuild a whole button row at once. The same bounded evidence (same
|
|
// generation-checked parent, every sibling retired, same child count)
|
|
// re-resolves the caret's button so the next move progresses instead of
|
|
// restarting at the row's container.
|
|
func TestDocumentCaretRecoversRecreatedChildByIndex(t *testing.T) {
|
|
ctx := context.Background()
|
|
reader := caretTextReader{}
|
|
node := func(path, role, name string, children ...atspi.ObjectID) atspi.TreeNode {
|
|
snapshot := atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: role, Name: name}
|
|
if role == "button" {
|
|
snapshot.Focusable = true
|
|
}
|
|
return atspi.TreeNode{Snapshot: snapshot, Children: children}
|
|
}
|
|
generation := func(suffix string) []atspi.TreeNode {
|
|
return []atspi.TreeNode{
|
|
node("/doc", "document web", "", webID("/section")),
|
|
node("/section", "section", "", webID("/b1"+suffix), webID("/b2"+suffix), webID("/b3"+suffix)),
|
|
node("/b1"+suffix, "button", "Buy one"), node("/b2"+suffix, "button", "Buy two"), node("/b3"+suffix, "button", "Buy three"),
|
|
}
|
|
}
|
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), generation("a"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, _ := NewDocumentCursor(tree)
|
|
if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"/b1a", "/b2a"} {
|
|
move, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
|
if err != nil || !move.Moved || move.Position.Object.ID() != webID(want) {
|
|
t.Fatalf("move onto %s = %+v, %v", want, move, err)
|
|
}
|
|
}
|
|
if err := tree.Replace(webID("/doc"), generation("b")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
position, recovery, err := cursor.sync_focus(ctx, webID("/doc"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if position.Object.ID() != webID("/b2b") {
|
|
t.Fatalf("recovery = %q onto %v, want recreated middle button /b2b", recovery, position.Object.ID())
|
|
}
|
|
move, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
|
if err != nil || !move.Moved || move.Position.Object.ID() != webID("/b3b") {
|
|
t.Fatalf("move after recreation = %+v, %v; caret did not progress", move, err)
|
|
}
|
|
}
|
|
|
|
func TestDocumentCaretMoveCrossesTextInvalidatedLines(t *testing.T) {
|
|
ctx := context.Background()
|
|
reader := caretTextReader{
|
|
webID("/first"): "Counter first 100", webID("/second"): "Counter second 200",
|
|
}
|
|
node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode {
|
|
return atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: role,
|
|
CharacterCount: utf8.RuneCountInString(reader[webID(path)])}, Children: children}
|
|
}
|
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
|
|
node("/doc", "document web", webID("/paragraph")),
|
|
node("/paragraph", "paragraph", webID("/first"), webID("/second")),
|
|
node("/first", "static"), node("/second", "static"),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, _ := NewDocumentCursor(tree)
|
|
if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if move, err := cursor.MoveCaret(ctx, reader, CaretNextLine); err != nil || move.Text != "Counter first 100" {
|
|
t.Fatalf("first line = %+v, %v", move, err)
|
|
}
|
|
tree.InvalidateText(webID("/first"))
|
|
tree.InvalidateText(webID("/second"))
|
|
if _, _, err := cursor.sync_focus(ctx, webID("/doc")); err != nil {
|
|
t.Fatalf("sync across ticking text: %v", err)
|
|
}
|
|
move, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
|
if err != nil || !move.Moved || move.Text != "Counter second 200" {
|
|
t.Fatalf("move across ticking text = %+v, %v", move, err)
|
|
}
|
|
}
|
|
|
|
func TestDocumentRecoveryKeepsVirtualPlaceWhenNativeAnchorDisappears(t *testing.T) {
|
|
ctx := context.Background()
|
|
reader := caretTextReader{webID("/text"): "First paragraph", webID("/next"): "Selected paragraph"}
|
|
cursor := newCaretTextCursor(t, reader)
|
|
move, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
|
if err != nil || move.Position.Object.ID() != webID("/next") {
|
|
t.Fatalf("select second paragraph: %+v, %v", move, err)
|
|
}
|
|
worker, _ := NewWorker(&readAllCapturer{tree: cursor.tree}, time.Second)
|
|
worker.SyncFocus(webID("/text"))
|
|
nodes := []atspi.TreeNode{
|
|
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/next")}},
|
|
{Snapshot: atspi.Snapshot{BusName: webID("/next").BusName, Path: "/next", Role: "static", CharacterCount: len(reader[webID("/next")])}},
|
|
}
|
|
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result := Result{DocumentFocus: webID("/text")}
|
|
if !worker.recoverCapturedDocumentFocus(cursor, cursor.tree, &result) {
|
|
t.Fatal("removed native anchor was not recovered")
|
|
}
|
|
if _, err := cursor.SyncFocus(ctx, result.DocumentFocus); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
move, err = cursor.MoveCaret(ctx, reader, CaretNextCharacter)
|
|
if err != nil || move.Text != "e" || move.Position.Object.ID() != webID("/next") || move.Position.Offset != 1 {
|
|
t.Fatalf("virtual position reset after native anchor removal: %+v, %v", move, err)
|
|
}
|
|
}
|
|
|
|
func TestDocumentRecoveryUsesSurvivingContainerWithoutTransferringOffset(t *testing.T) {
|
|
for _, command := range []DocumentCaretCommand{CaretNextLine, CaretPreviousLine} {
|
|
t.Run(command.String(), func(t *testing.T) {
|
|
ctx := context.Background()
|
|
reader := caretTextReader{webID("/before"): "Previous paragraph", webID("/old"): "Old contents", webID("/new"): "Replacement contents"}
|
|
node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode {
|
|
return atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: role,
|
|
CharacterCount: len(reader[webID(path)])}, Children: children}
|
|
}
|
|
nodes := []atspi.TreeNode{node("/doc", "document web", webID("/before"), webID("/section")),
|
|
node("/before", "static"), node("/section", "section", webID("/paragraph")),
|
|
node("/paragraph", "paragraph", webID("/old")), node("/old", "static")}
|
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, _ := NewDocumentCursor(tree)
|
|
cursor.SyncFocus(ctx, webID("/doc"))
|
|
cursor.MoveCaret(ctx, reader, CaretNextLine)
|
|
cursor.MoveCaret(ctx, reader, CaretNextLine)
|
|
cursor.MoveCaret(ctx, reader, CaretNextWord)
|
|
nodes[2].Children = []atspi.ObjectID{webID("/new")}
|
|
nodes = append(nodes[:3], node("/new", "static"))
|
|
if err := tree.Replace(webID("/doc"), nodes); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
position, recovery, err := cursor.sync_focus(ctx, webID("/doc"))
|
|
if err != nil || recovery != "surviving-parent" || position.Object.ID() != webID("/section") || position.Offset != 0 {
|
|
t.Fatalf("container recovery: %+v, %s, %v", position, recovery, err)
|
|
}
|
|
move, err := cursor.MoveCaret(ctx, reader, command)
|
|
want := "Replacement contents"
|
|
if command == CaretPreviousLine {
|
|
want = "Previous paragraph"
|
|
}
|
|
if err != nil || !move.Moved || move.Text != want {
|
|
t.Fatalf("navigation after replacement: %+v, %v", move, err)
|
|
}
|
|
})
|
|
}
|
|
}
|