Files
cthulhu/internal/review/document_hypertext_test.go

281 lines
11 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"errors"
"testing"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func hypertextCursor(t *testing.T) (*DocumentCursor, caretTextReader) {
t.Helper()
return hypertextCursorWithLabel(t, "")
}
func hypertextCursorWithLabel(t *testing.T, label string) (*DocumentCursor, caretTextReader) {
t.Helper()
reader := caretTextReader{webID("/body"): "Before \ufffc after.", webID("/link"): "Read more", webID("/tail"): "Next paragraph"}
node := func(path, role string, children ...string) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, "", role, "", "")
snapshot.BusName = webID(path).BusName
snapshot.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
result := atspi.TreeNode{Snapshot: snapshot}
for _, child := range children {
result.Children = append(result.Children, webID(child))
}
return result
}
body := node("/body", "section", "/link")
body.Snapshot.TextChildren = []atspi.TextChild{{Offset: 7, Child: webID("/link")}}
link := node("/link", "link")
if label != "" {
link.Snapshot.Name, link.Snapshot.ExplicitName, link.Snapshot.CharacterCount = label, true, 0
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
node("/doc", "document web", "/body", "/tail"), body, link, node("/tail", "paragraph"),
})
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(context.Background(), webID("/doc")); err != nil {
t.Fatal(err)
}
return cursor, reader
}
func TestDocumentCaretReadsTextAroundEmbeddedLink(t *testing.T) {
cursor, reader := hypertextCursor(t)
for _, step := range []struct {
command DocumentCaretCommand
text, path string
offset int
}{
{CaretDocumentStart, "Before ", "/body", 0},
{CaretNextLine, "Read more", "/link", 0},
{CaretNextLine, " after.", "/body", 8},
{CaretNextLine, "Next paragraph", "/tail", 0},
{CaretPreviousLine, " after.", "/body", 8},
{CaretPreviousLine, "Read more", "/link", 0},
{CaretPreviousLine, "Before ", "/body", 0},
} {
move, err := cursor.MoveCaret(context.Background(), reader, step.command)
if err != nil || !move.Moved || move.Text != step.text || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset {
t.Fatalf("%s: move=%+v err=%v; want %q at %s:%d", step.command, move, err, step.text, step.path, step.offset)
}
}
}
func TestDocumentCaretCharactersCrossEmbeddedLink(t *testing.T) {
cursor, reader := hypertextCursor(t)
if _, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart); err != nil {
t.Fatal(err)
}
var text string
for i := 0; i < 64; i++ {
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter)
if err != nil {
t.Fatal(err)
}
if move.Position.Object.ID() == webID("/tail") {
break
}
text += move.Text
}
if text != "efore Read more after." {
t.Fatalf("characters=%q", text)
}
}
func TestNativeCaretKeepsHypertextParentOffsets(t *testing.T) {
cursor, reader := hypertextCursor(t)
if err := cursor.syncNativeCaret(context.Background(), reader, webID("/body"), 9); err != nil {
t.Fatal(err)
}
move, err := cursor.MoveCaret(context.Background(), reader, CaretPreviousCharacter)
if err != nil || move.Text != " " || move.Position.Offset != 8 || move.Position.Object.ID() != webID("/body") {
t.Fatalf("native hypertext caret: text=%q offset=%d err=%v", move.Text, move.Position.Offset, err)
}
}
func TestDocumentCaretWordsCrossEmbeddedLink(t *testing.T) {
cursor, reader := hypertextCursor(t)
if _, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart); err != nil {
t.Fatal(err)
}
for _, step := range []struct {
command DocumentCaretCommand
text, path string
offset int
}{
{CaretNextWord, "Before ", "/body", 6},
{CaretNextWord, "Read ", "/link", 4},
{CaretNextWord, "more", "/link", 9},
{CaretNextWord, "after.", "/body", 15},
{CaretPreviousWord, "after.", "/body", 9},
{CaretPreviousWord, "more", "/link", 5},
} {
move, err := cursor.MoveCaret(context.Background(), reader, step.command)
if err != nil || move.Text != step.text || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset {
t.Fatalf("%s: text=%q offset=%d err=%v; want %q at %s:%d", step.command, move.Text, move.Position.Offset, err, step.text, step.path, step.offset)
}
}
}
func TestNativeCaretOnAdjacentEmbeddedLinks(t *testing.T) {
reader := caretTextReader{webID("/body"): "A\ufffc\ufffcZ", webID("/link1"): "One", webID("/link2"): "Two"}
node := func(path, role string, children ...string) atspi.TreeNode {
s := atspi.NewSnapshot(path, "", role, "", "")
s.BusName = webID(path).BusName
s.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
n := atspi.TreeNode{Snapshot: s}
for _, child := range children {
n.Children = append(n.Children, webID(child))
}
return n
}
body := node("/body", "paragraph", "/link1", "/link2")
body.Snapshot.TextChildren = []atspi.TextChild{{Offset: 1, Child: webID("/link1")}, {Offset: 2, Child: webID("/link2")}}
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{node("/doc", "document web", "/body"), body, node("/link1", "link"), node("/link2", "link")})
if err != nil {
t.Fatal(err)
}
for _, cmd := range []DocumentCaretCommand{CaretNextCharacter, CaretPreviousCharacter} {
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
if err = cursor.syncNativeCaret(context.Background(), reader, webID("/body"), 2); err != nil {
t.Fatal(err)
}
move, err := cursor.MoveCaret(context.Background(), reader, cmd)
t.Logf("command=%s text=%q object=%s offset=%d err=%v", cmd, move.Text, move.Position.Object.ID().Path, move.Position.Offset, err)
want := "w"
if cmd == CaretPreviousCharacter {
want = "e"
}
if move.Text != want {
t.Errorf("want %q", want)
}
}
}
func TestDocumentCaretEmbeddedNamedLinkKeepsLabelOffsets(t *testing.T) {
cursor, reader := hypertextCursorWithLabel(t, "É猫 Info")
for _, step := range []struct {
command DocumentCaretCommand
text, path string
offset int
}{
{CaretDocumentStart, "Before ", "/body", 0},
{CaretNextLine, "", "/link", 0},
{CaretNextCharacter, "猫", "/link", 1},
{CaretNextWord, "Info", "/link", 7},
{CaretNextCharacter, " ", "/body", 8},
{CaretPreviousCharacter, "o", "/link", 6},
{CaretLineStart, "É", "/link", 0},
{CaretPreviousCharacter, " ", "/body", 6},
} {
move, err := cursor.MoveCaret(context.Background(), reader, step.command)
if err != nil || !move.Moved || move.Text != step.text || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset {
t.Fatalf("%s: text=%q at %s:%d err=%v; want %q at %s:%d", step.command, move.Text, move.Position.Object.ID().Path, move.Position.Offset, err, step.text, step.path, step.offset)
}
}
}
func TestDocumentCaretHypertextCannotEscapePasswordSubtree(t *testing.T) {
cursor, _ := hypertextCursor(t)
doc, err := cursor.tree.Document(context.Background(), cursor.places[0].object)
if err != nil {
t.Fatal(err)
}
// Even if editable state is missing, no nested hypertext may reintroduce
// the text suppressed by the password ancestor.
doc.Nodes[0].Snapshot.Role = "password text"
if ranges := caretNodeRanges(doc.Nodes); len(ranges) != 0 {
t.Fatalf("password subtree exposed caret ranges: %+v", ranges)
}
}
// Plain-text changes need not change accessible children or their identities.
func TestDocumentCaretRefreshesChangedHypertextAncestor(t *testing.T) {
for _, command := range []DocumentCaretCommand{CaretNextCharacter, CaretPreviousCharacter, CaretNextWord, CaretPreviousWord, CaretNextLine, CaretPreviousLine, CaretReadLine} {
t.Run(command.String(), func(t *testing.T) {
reader := caretTextReader{webID("/body"): "\ufffc\ufffc", webID("/one"): "A", webID("/two"): "B"}
makeNode := func(path, role string, children ...string) atspi.TreeNode {
s := atspi.NewSnapshot(path, "", role, "", "")
s.BusName = webID(path).BusName
s.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
n := atspi.TreeNode{Snapshot: s}
for _, child := range children {
n.Children = append(n.Children, webID(child))
}
return n
}
body := makeNode("/body", "paragraph", "/one", "/two")
body.Snapshot.TextChildren = []atspi.TextChild{{Offset: 0, Child: webID("/one")}, {Offset: 1, Child: webID("/two")}}
nodes := []atspi.TreeNode{makeNode("/doc", "document web", "/body"), body, makeNode("/one", "link"), makeNode("/two", "link")}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, _ := NewDocumentCursor(tree)
path := "/one"
if command == CaretPreviousCharacter || command == CaretPreviousWord || command == CaretPreviousLine {
path = "/two"
}
if _, err = cursor.SyncFocus(context.Background(), webID(path)); err != nil {
t.Fatal(err)
}
reader[webID("/body")] = "\ufffc inserted \ufffc"
tree.InvalidateText(webID("/body"))
if command == CaretReadLine {
cursor.places[0].offset = 1
}
before := cursor.places[0]
move, err := cursor.MoveCaret(context.Background(), reader, command)
if !errors.Is(err, atspi.ErrStaleText) {
t.Fatalf("skipped parent insertion: move=%+v error=%v", move, err)
}
if after := cursor.places[0]; after.object != before.object || after.offset != before.offset {
t.Fatal("failed move changed position")
}
// A successful refresh must expose the insertion on the retry,
// retaining both embedded link identities and the original position.
nodes[1].Snapshot.CharacterCount = utf8.RuneCountInString(reader[webID("/body")])
nodes[1].Snapshot.TextChildren = []atspi.TextChild{{Offset: 0, Child: webID("/one")}, {Offset: nodes[1].Snapshot.CharacterCount - 1, Child: webID("/two")}}
if err := tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
move, err = cursor.MoveCaret(context.Background(), reader, command)
if err != nil || !move.Moved || move.Position.Object.ID() != webID("/body") {
t.Fatalf("refreshed move skipped insertion: %+v error=%v", move, err)
}
})
}
}
func TestDocumentCaretLocalMoveSurvivesHypertextInsertion(t *testing.T) {
cursor, reader := hypertextCursor(t)
if _, err := cursor.SyncFocus(context.Background(), webID("/link")); err != nil {
t.Fatal(err)
}
cursor.tree.InvalidateText(webID("/body"))
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter)
if err != nil || move.Text != "e" {
t.Fatalf("local character: %+v %v", move, err)
}
if _, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine); !errors.Is(err, atspi.ErrStaleText) {
t.Fatalf("crossing changed hypertext: %v", err)
}
}