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