281 lines
11 KiB
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)
|
|
}
|
|
}
|