Files
cthulhu/internal/review/document_replacement_test.go
hjozwiak 7eef6e8be6 Make web document navigation survive live-updating pages
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.
2026-11-03 10:34:55 -05:00

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