Files
cthulhu/internal/review/document_inline_replacement_test.go

270 lines
10 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"fmt"
"testing"
"time"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func inline_replacement_cursor(t *testing.T, role string, slot int) (*DocumentCursor, caretTextReader, []atspi.TreeNode) {
t.Helper()
reader := caretTextReader{webID("/before"): "Published", webID("/old"): "↺ by Publisher", webID("/after"): "Article continues"}
paths := []string{"/before", "/after"}
paths = append(paths[:slot], append([]string{"/old"}, paths[slot:]...)...)
nodes := []atspi.TreeNode{
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/paragraph")}},
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/paragraph", Role: role}},
}
if role == "link" {
nodes[1].Snapshot.Focusable, nodes[1].Snapshot.SupportsClick = true, true
}
for _, path := range paths {
nodes[1].Children = append(nodes[1].Children, webID(path))
nodes = append(nodes, atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID(path).BusName,
Path: path, Role: "static", CharacterCount: utf8.RuneCountInString(reader[webID(path)])}})
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(context.Background(), webID("/paragraph")); err != nil {
t.Fatal(err)
}
if _, err := cursor.MoveCaret(context.Background(), reader, CaretReadLine); err != nil {
t.Fatal(err)
}
for index := 0; index < slot; index++ {
if _, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine); err != nil {
t.Fatal(err)
}
}
return cursor, reader, nodes
}
func TestDocumentCaretRecoversReplacedInlineText(t *testing.T) {
for _, role := range []string{"paragraph", "heading", "section", "link"} {
for slot := 0; slot < 3; slot++ {
t.Run(fmt.Sprintf("%s/%d", role, slot), func(t *testing.T) {
cursor, reader, nodes := inline_replacement_cursor(t, role, slot)
ctx := context.Background()
for generation := 0; generation < 4; generation++ {
path := fmt.Sprintf("/replacement%d", generation)
reader[webID(path)] = "↺ by Publisher"
nodes[1].Children[slot] = webID(path)
nodes[slot+2].Snapshot.Path = path
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
position, recovery, err := cursor.sync_focus(ctx, webID("/paragraph"))
if err != nil || recovery != "replaced-text-child" || position.Object.ID() != webID(path) {
t.Fatalf("replacement %d: %+v, %s, %v", generation, position, recovery, err)
}
move := func(command DocumentCaretCommand, want string) {
t.Helper()
result, err := cursor.MoveCaret(ctx, reader, command)
if err != nil || !result.Moved || result.Text != want {
t.Fatalf("%s after replacement %d: %+v, %v; want %q", command, generation, result, err, want)
}
}
move(CaretNextCharacter, " ") // Unicode offsets remain character based.
move(CaretPreviousCharacter, "↺")
if slot == 2 {
move(CaretPreviousLine, "Article continues")
move(CaretNextLine, "↺ by Publisher")
} else {
want := "Article continues"
if slot == 0 {
want = "Published"
}
move(CaretNextLine, want)
move(CaretPreviousLine, "↺ by Publisher")
}
}
})
}
}
}
func TestDocumentLinkReplacementPreservesActivationOwnership(t *testing.T) {
for _, kind := range []string{"same link", "replaced link", "named link", "editable link"} {
t.Run(kind, func(t *testing.T) {
cursor, _, nodes := inline_replacement_cursor(t, "link", 1)
ctx := context.Background()
originalTarget, err := cursor.ActivationTarget()
if err != nil || originalTarget.ID() != webID("/paragraph") {
t.Fatalf("original link target: %v, %v", originalTarget.ID(), err)
}
nodes[1].Children[1] = webID("/new")
nodes[3].Snapshot.Path = "/new"
switch kind {
case "replaced link":
cursor.tree.Invalidate(webID("/paragraph"))
case "named link":
nodes[1].Snapshot.ExplicitName = true
case "editable link":
nodes[1].Snapshot.Editable = true
}
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
if _, err := cursor.syncActionFocus(ctx, webID("/paragraph")); err == nil {
t.Fatal("activation silently recovered a missing text target")
}
position, recovery, err := cursor.sync_focus(ctx, webID("/paragraph"))
if err != nil {
t.Fatal(err)
}
if kind != "same link" {
if recovery == "replaced-text-child" || position.Object.ID() == webID("/new") {
t.Fatal("changed link inherited the old text cursor")
}
return
}
target, err := cursor.ActivationTarget()
if err != nil || recovery != "replaced-text-child" || target != originalTarget {
t.Fatalf("navigation changed link ownership: target=%v recovery=%s err=%v", target.ID(), recovery, err)
}
})
}
}
func TestReadAllAfterInlineReplacementRecovery(t *testing.T) {
cursor, reader, nodes := inline_replacement_cursor(t, "paragraph", 1)
nodes[1].Children[1] = webID("/new")
nodes[3].Snapshot.Path = "/new"
reader[webID("/new")] = reader[webID("/old")]
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
worker, _ := NewWorker(&readAllCapturer{tree: cursor.tree, caretTextReader: reader}, time.Second)
worker.SyncFocus(webID("/paragraph"))
result := worker.readAll(context.Background(), cursor)
if result.Err != nil || len(result.ReadAllLines) != 2 {
t.Fatalf("Read All after inline replacement: %+v", result)
}
for index, want := range []string{"↺ by Publisher", "Article continues"} {
if got := result.ReadAllLines[index].Contents[0].Text; got != want {
t.Fatalf("line %d = %q, want %q", index, got, want)
}
}
}
func TestReadAllRecoveryRejectsReplacedDocument(t *testing.T) {
for _, refresh := range []bool{false, true} {
t.Run(fmt.Sprint(refresh), func(t *testing.T) {
cursor, reader, nodes := inline_replacement_cursor(t, "link", 1)
cursor.tree.Invalidate(webID("/doc"))
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
worker, _ := NewWorker(&readAllCapturer{tree: cursor.tree, caretTextReader: reader}, time.Second)
worker.SyncFocus(webID("/paragraph"))
if refresh {
if _, err := worker.refresh_read_all_cursor(context.Background(), cursor, webID("/paragraph")); err == nil {
t.Fatal("Read All refresh crossed a document generation")
}
return
}
result := worker.readAll(context.Background(), cursor)
if result.Err == nil || len(result.ReadAllLines) != 0 {
t.Fatal("Read All inherited the position in a replacement document")
}
})
}
}
func TestDocumentInlineReplacementRetainsAndClampsUnicodeOffset(t *testing.T) {
for _, replacement := range []string{"↺ by Another publisher", "↺"} {
t.Run(replacement, func(t *testing.T) {
cursor, reader, nodes := inline_replacement_cursor(t, "paragraph", 1)
ctx := context.Background()
move, err := cursor.MoveCaret(ctx, reader, CaretNextWord)
if err != nil || move.Position.Offset != 4 {
t.Fatalf("word movement: offset=%d err=%v", move.Position.Offset, err)
}
nodes[1].Children[1] = webID("/new")
nodes[3].Snapshot.Path = "/new"
nodes[3].Snapshot.CharacterCount = utf8.RuneCountInString(replacement)
reader[webID("/new")] = replacement
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
position, recovery, err := cursor.sync_focus(ctx, webID("/paragraph"))
wantOffset := min(move.Position.Offset, utf8.RuneCountInString(replacement)-1)
if err != nil || recovery != "replaced-text-child" || position.Offset != wantOffset {
t.Fatalf("replacement: offset=%d want=%d recovery=%s err=%v", position.Offset, wantOffset, recovery, err)
}
})
}
}
func TestDocumentInlineReplacementRejectsAmbiguousOrInteractiveChanges(t *testing.T) {
for _, kind := range []string{"inserted child", "removed neighbor", "replaced neighbor", "reordered neighbors",
"replaced parent", "replaced document", "hidden parent", "editable parent", "clickable parent", "focusable parent",
"hidden child", "editable child", "clickable child", "focusable child", "named child", "password child", "native origin"} {
t.Run(kind, func(t *testing.T) {
cursor, reader, nodes := inline_replacement_cursor(t, "paragraph", 1)
nodes[1].Children[1] = webID("/new")
nodes[3].Snapshot.Path = "/new"
reader[webID("/new")] = reader[webID("/old")]
switch kind {
case "inserted child":
nodes[1].Children = append(nodes[1].Children[:2], webID("/extra"), webID("/after"))
nodes = append(nodes, atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID("/extra").BusName, Path: "/extra", Role: "static", CharacterCount: 5}})
case "removed neighbor":
nodes[1].Children = nodes[1].Children[1:]
nodes = append(nodes[:2], nodes[3:]...)
case "replaced neighbor":
cursor.tree.Invalidate(webID("/before"))
case "reordered neighbors":
nodes[1].Children[0], nodes[1].Children[2] = nodes[1].Children[2], nodes[1].Children[0]
case "replaced parent":
cursor.tree.Invalidate(webID("/paragraph"))
case "replaced document":
cursor.tree.Invalidate(webID("/doc"))
case "hidden parent":
nodes[1].Geometry.VisibilityKnown = true
case "editable parent":
nodes[1].Snapshot.Editable = true
case "clickable parent":
nodes[1].Snapshot.SupportsClick = true
case "focusable parent":
nodes[1].Snapshot.Focusable = true
case "hidden child":
nodes[3].Geometry.VisibilityKnown = true
case "editable child":
nodes[3].Snapshot.Editable = true
case "clickable child":
nodes[3].Snapshot.SupportsClick = true
case "focusable child":
nodes[3].Snapshot.Focusable = true
case "named child":
nodes[3].Snapshot.ExplicitName = true
case "password child":
nodes[3].Snapshot.Role = "password text"
case "native origin":
cursor.places[0].origin = NativeNavigation
}
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
position, recovery, err := cursor.sync_focus(context.Background(), webID("/paragraph"))
if err != nil || recovery == "replaced-text-child" || position.Object.ID() == webID("/new") {
t.Fatalf("unsafe replacement accepted: object=%+v recovery=%s err=%v", position.Object.ID(), recovery, err)
}
})
}
}