270 lines
10 KiB
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)
|
|
}
|
|
})
|
|
}
|
|
}
|