260 lines
9.3 KiB
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)
|
|
}
|
|
}
|
|
}
|