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