Files
cthulhu/internal/review/document_visual_line_test.go

260 lines
9.3 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"
"strings"
"testing"
"time"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
type fragmentTextReader struct {
caretTextReader
bounds map[atspi.ObjectID]atspi.Rectangle
err error
}
func (reader fragmentTextReader) ReadDocumentCaret(ctx context.Context, ref atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
if reader.err != nil {
return atspi.DocumentCaretText{}, reader.err
}
text, err := reader.caretTextReader.ReadDocumentCaret(ctx, ref, offset, unit)
text.RawText, text.Bounds = text.Text, reader.bounds[ref.ID()]
return text, err
}
func fragment_cursor(t *testing.T, boundary string) (*DocumentCursor, fragmentTextReader) {
t.Helper()
reader := fragmentTextReader{caretTextReader: caretTextReader{
webID("/one"): "One ", webID("/two"): "two.", webID("/three"): "Three ", webID("/four"): "four.", webID("/after"): "Next paragraph",
}, bounds: make(map[atspi.ObjectID]atspi.Rectangle)}
for _, row := range []struct {
path string
x, y int32
}{{"/one", 0, 10}, {"/two", 40, 10}, {"/three", 0, 30}, {"/four", 50, 30}, {"/after", 100, 30}} {
reader.bounds[webID(row.path)] = atspi.Rectangle{X: row.x, Y: row.y, Width: 30, Height: 18}
}
node := func(path, role string, children ...string) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, "", role, "", "")
snapshot.BusName, snapshot.CharacterCount = webID(path).BusName, utf8.RuneCountInString(reader.caretTextReader[webID(path)])
result := atspi.TreeNode{Snapshot: snapshot}
for _, child := range children {
result.Children = append(result.Children, webID(child))
}
return result
}
block := node("/paragraph", boundary, "/one", "/span", "/three", "/four")
span := node("/span", "section", "/two")
span.Snapshot.HTMLTag = "span"
if boundary == "pre" || boundary == "code" {
block.Snapshot.HTMLTag = boundary
}
if boundary == "entry" {
block.Snapshot.Editable = true
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
node("/doc", "document web", "/paragraph", "/next"), block,
node("/one", "static"), span, node("/two", "static"),
node("/three", "static"), node("/four", "static"), node("/next", "paragraph", "/after"), node("/after", "static"),
})
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(context.Background(), webID("/one")); err != nil {
t.Fatal(err)
}
return cursor, reader
}
func TestDocumentFragmentLines(t *testing.T) {
cursor, reader := fragment_cursor(t, "paragraph")
ctx := context.Background()
for _, step := range []struct {
command DocumentCaretCommand
path, text string
offset int
}{
{CaretDocumentStart, "/one", "One two.", 0},
{CaretNextCharacter, "/one", "n", 1},
{CaretNextLine, "/three", "Three four.", 0},
{CaretPreviousLine, "/one", "One two.", 0},
{CaretLineEnd, "/two", ".", 3},
{CaretLineStart, "/one", "O", 0},
{CaretNextLine, "/three", "Three four.", 0},
{CaretNextLine, "/after", "Next paragraph", 0},
{CaretPreviousLine, "/three", "Three four.", 0},
} {
move, err := cursor.MoveCaret(ctx, 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: %+v, %v; want %s:%d %q", step.command, move, err, step.path, step.offset, step.text)
}
if move.End > 0 && move.End > move.Position.Snapshot.CharacterCount {
t.Fatal("joined offset escaped native object")
}
}
}
func TestDocumentFragmentSpacingAndPunctuation(t *testing.T) {
for _, test := range []struct {
first, second, want string
x int32
}{
{"One ", "two.", "One two.", 40},
{"One", "two.", "One two.", 40},
{"Hello", ",", "Hello,", 30},
} {
cursor, reader := fragment_cursor(t, "paragraph")
reader.caretTextReader[webID("/one")] = test.first
reader.caretTextReader[webID("/two")] = test.second
bounds := reader.bounds[webID("/two")]
bounds.X = test.x
reader.bounds[webID("/two")] = bounds
// Range lengths come from the provider, just as in a native capture.
doc, _ := cursor.tree.Document(context.Background(), cursor.places[0].object)
var nodes []atspi.TreeNode
for _, node := range doc.Nodes {
if text, ok := reader.caretTextReader[node.Reference.ID()]; ok {
node.Snapshot.CharacterCount = utf8.RuneCountInString(text)
}
nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children})
}
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
move, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart)
if err != nil || move.Text != test.want {
t.Fatalf("fragments %q/%q: %+v, %v; want %q", test.first, test.second, move, err, test.want)
}
}
}
type fragmentReadAllCapturer struct {
*readAllCapturer
fragmentTextReader
}
func (capturer *fragmentReadAllCapturer) ReadDocumentCaret(ctx context.Context, ref atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
return capturer.fragmentTextReader.ReadDocumentCaret(ctx, ref, offset, unit)
}
func TestReadAllFragmentLinesAndRemainder(t *testing.T) {
cursor, reader := fragment_cursor(t, "paragraph")
ctx := context.Background()
if _, err := cursor.MoveCaret(ctx, reader, CaretNextCharacter); err != nil {
t.Fatal(err)
}
capturer := &fragmentReadAllCapturer{readAllCapturer: &readAllCapturer{tree: cursor.tree}, fragmentTextReader: reader}
worker, _ := NewWorker(capturer, time.Second)
worker.SyncFocus(webID("/one"))
result := worker.readAll(ctx, cursor)
if result.Err != nil || len(result.ReadAllLines) != 3 {
t.Fatalf("read all: %+v", result)
}
for index, want := range []string{"ne two.", "Three four.", "Next paragraph"} {
if got := result.ReadAllLines[index].Contents[0].Text; got != want {
t.Fatalf("line %d: %q, want %q", index, got, want)
}
}
if cursor.places[0].offset != 1 {
t.Fatal("assembly moved the user's cursor")
}
result.ReadAllProgress(1)
request := <-worker.requests
followed, ok := worker.followReadAll(ctx, cursor, request)
if !ok {
t.Fatal("progress lost ownership")
}
move, err := followed.MoveCaret(ctx, reader, CaretNextLine)
if err != nil || move.Text != "Next paragraph" {
t.Fatalf("Down after progress: %+v, %v", move, err)
}
}
func TestDocumentFragmentBoundaries(t *testing.T) {
for _, boundary := range []string{"heading", "link", "table cell", "pre", "code", "entry"} {
t.Run(boundary, func(t *testing.T) {
cursor, reader := fragment_cursor(t, boundary)
move, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart)
want := "One two."
if boundary == "pre" || boundary == "code" || boundary == "entry" {
want = "One "
}
if boundary == "entry" {
want = ""
}
if err != nil || move.Text != want {
t.Fatalf("%s: %+v, %v; want %q", boundary, move, err, want)
}
})
}
}
func TestDocumentFragmentErrorsDoNotMoveCursor(t *testing.T) {
for _, failure := range []error{context.Canceled, atspi.ErrStaleObject} {
cursor, reader := fragment_cursor(t, "paragraph")
reader.err = failure
before := cursor.places[0]
_, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine)
if !errors.Is(err, failure) || cursor.places[0] != before {
t.Fatalf("error %v: %v; changed cursor", failure, err)
}
}
}
type wrappedFragmentReader struct{ fragmentTextReader }
func (reader wrappedFragmentReader) ReadDocumentCaret(ctx context.Context, ref atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
text, err := reader.fragmentTextReader.ReadDocumentCaret(ctx, ref, offset, unit)
if ref.ID() == webID("/one") && offset >= 6 {
text.Bounds.Y = 30
}
return text, err
}
func TestDocumentFragmentLineAcrossWrappedObject(t *testing.T) {
cursor, base := fragment_cursor(t, "paragraph")
base.caretTextReader[webID("/one")] = "First\nSecond "
base.bounds[webID("/two")] = atspi.Rectangle{X: 60, Y: 30, Width: 30, Height: 18}
base.bounds[webID("/three")] = atspi.Rectangle{X: 0, Y: 50, Width: 30, Height: 18}
base.bounds[webID("/four")] = atspi.Rectangle{X: 50, Y: 50, Width: 30, Height: 18}
ref, _ := cursor.tree.Reference(webID("/one"))
doc, err := cursor.tree.Document(context.Background(), ref)
if err != nil {
t.Fatal(err)
}
nodes := make([]atspi.TreeNode, 0, len(doc.Nodes))
for _, node := range doc.Nodes {
if node.Reference == ref {
node.Snapshot.CharacterCount = utf8.RuneCountInString(base.caretTextReader[webID("/one")])
}
nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children})
}
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
reader := wrappedFragmentReader{base}
for _, step := range []struct {
command DocumentCaretCommand
want string
}{
{CaretDocumentStart, "First"}, {CaretNextLine, "Second two."},
{CaretPreviousLine, "First"}, {CaretNextLine, "Second two."}, {CaretNextLine, "Three four."},
} {
move, err := cursor.MoveCaret(context.Background(), reader, step.command)
if err != nil || strings.TrimSpace(move.Text) != step.want {
t.Fatalf("%s: %+v, %v; want %q", step.command, move, err, step.want)
}
}
}