Files

474 lines
18 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package atspi
import (
"context"
"errors"
"reflect"
"strings"
"sync"
"testing"
)
type fakeReviewText struct {
characters int
lines map[int]TextRange
rectangles map[[2]int]Rectangle
failure error
cancel context.CancelFunc
}
func (s fakeReviewText) count(ctx context.Context) (int, error) { return s.characters, s.failure }
func (s fakeReviewText) line(ctx context.Context, offset int) (TextRange, error) {
if s.cancel != nil {
s.cancel()
}
return s.lines[offset], s.failure
}
func (s fakeReviewText) bounds(ctx context.Context, start, end int) (Rectangle, error) {
if bounds, ok := s.rectangles[[2]int{start, end}]; ok {
return bounds, ctx.Err()
}
for span, bounds := range s.rectangles {
if span[0] <= start && end <= span[1] {
return bounds, ctx.Err()
}
}
return Rectangle{}, ctx.Err()
}
func TestCaptureVisibleTextUnicodeClippingAndEmbeddedObjects(t *testing.T) {
clip := Rectangle{-10, 0, 20, 20}
source := fakeReviewText{characters: 8, lines: map[int]TextRange{
0: {Start: 0, End: 4, Text: "é猫x\n"}, 4: {Start: 4, End: 8, Text: "a\ufffcbz"},
}, rectangles: map[[2]int]Rectangle{
{0, 3}: {-20, 0, 30, 10}, {0, 1}: {-20, 0, 10, 10}, {1, 2}: {-10, 0, 10, 10}, {2, 3}: {0, 0, 10, 10},
{4, 5}: {-10, 10, 5, 10}, {6, 8}: {-5, 10, 10, 10},
}}
got, err := captureVisibleText(context.Background(), source, clip)
want := VisibleText{Known: true, Ranges: []TextRange{
{1, 3, "猫x", Rectangle{-10, 0, 20, 10}},
{4, 5, "a", Rectangle{-10, 10, 5, 10}}, {6, 8, "bz", Rectangle{-5, 10, 10, 10}},
}}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("capture = %#v %v, want %#v", got, err, want)
}
}
func TestCaptureVisibleTextRejectsBadProgressAndCancellation(t *testing.T) {
for _, line := range []TextRange{
{Start: 0, End: 0, Text: ""}, {Start: 1, End: 2, Text: "x"},
{Start: 0, End: 3, Text: "abc"}, {Start: 0, End: 2, Text: "é"}, {Start: 0, End: 2, Text: "\xffx"},
} {
got, err := captureVisibleText(context.Background(), fakeReviewText{characters: 2, lines: map[int]TextRange{0: line}}, Rectangle{0, 0, 10, 10})
if err == nil || got.Known || len(got.Ranges) != 0 {
t.Fatalf("bad line accepted: %#v => %#v %v", line, got, err)
}
}
ctx, cancel := context.WithCancel(context.Background())
source := fakeReviewText{characters: 1, lines: map[int]TextRange{0: {Start: 0, End: 1, Text: "x"}}, rectangles: map[[2]int]Rectangle{{0, 1}: {0, 0, 1, 1}}, cancel: cancel}
if _, err := captureVisibleText(ctx, source, Rectangle{0, 0, 10, 10}); !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation = %v", err)
}
}
func TestCaptureVisibleTextRetriesProviderLineStartAheadOfOffset(t *testing.T) {
source := fakeReviewText{characters: 5, lines: map[int]TextRange{
0: {Start: 0, End: 2, Text: "ab"},
1: {Start: 2, End: 5, Text: "cde"},
2: {Start: 3, End: 5, Text: "de"},
}, rectangles: map[[2]int]Rectangle{
{0, 2}: {0, 0, 2, 1}, {2, 5}: {0, 1, 3, 1},
}}
got, err := captureVisibleText(context.Background(), source, Rectangle{0, 0, 10, 10})
want := VisibleText{Known: true, Ranges: []TextRange{
{0, 2, "ab", Rectangle{0, 0, 2, 1}}, {2, 5, "cde", Rectangle{0, 1, 3, 1}},
}}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("capture = %#v %v, want %#v", got, err, want)
}
}
func TestCaptureVisibleTextSeeksTerminalViewportBeforeReadingLines(t *testing.T) {
const count = 1000000
source := fakeReviewText{characters: count, lines: map[int]TextRange{
420000: {Start: 420000, End: 420005, Text: "first"},
420005: {Start: 420005, End: 420011, Text: "second"},
}, rectangles: map[[2]int]Rectangle{
{0, 420000}: {0, -20, 1, 10},
{420000, 420005}: {0, 0, 5, 10},
{420005, 420011}: {0, 10, 6, 10},
{420011, count}: {0, 20, 1, 10},
}}
got, err := captureVisibleText(context.Background(), source, Rectangle{0, 0, 100, 20})
want := VisibleText{Known: true, Ranges: []TextRange{
{420000, 420005, "first", Rectangle{0, 0, 5, 10}},
{420005, 420011, "second", Rectangle{0, 10, 6, 10}},
}}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("capture = %#v %v, want %#v", got, err, want)
}
got.CaretKnown, got.CaretOffset = true, 420010
if !got.valid(Geometry{BoundsKnown: true, Bounds: Rectangle{0, 0, 100, 20}}) {
t.Fatal("visible terminal scrollback offsets rejected by tree validation")
}
}
func TestCaptureVisibleTextLargeTerminalScreen(t *testing.T) {
const columns, rows = 160, 55
source := fakeReviewText{characters: columns * rows, lines: map[int]TextRange{}, rectangles: map[[2]int]Rectangle{}}
for row := 0; row < rows; row++ {
start := row * columns
source.lines[start] = TextRange{Start: start, End: start + columns, Text: strings.Repeat("x", columns)}
source.rectangles[[2]int{start, start + columns}] = Rectangle{0, int32(row * 17), columns * 8, 17}
}
clip := Rectangle{0, 0, columns * 8, rows * 17}
got, err := captureVisibleText(context.Background(), source, clip)
if err != nil || !got.Known || got.Limited || len(got.Ranges) != rows {
t.Fatalf("large terminal capture: known=%v limited=%v ranges=%d err=%v", got.Known, got.Limited, len(got.Ranges), err)
}
got.CaretKnown, got.CaretOffset = true, source.characters-1
if !got.valid(Geometry{BoundsKnown: true, Bounds: clip}) {
t.Fatal("large terminal capture rejected by tree validation")
}
}
func TestCaptureVisibleTextLimitsAndEmpty(t *testing.T) {
for _, count := range []int{0, maxReviewCharacters + 1, -1} {
got, err := captureVisibleText(context.Background(), fakeReviewText{characters: count}, Rectangle{0, 0, 10, 10})
if count == 0 && (err != nil || !got.Known || len(got.Ranges) != 0) {
t.Fatalf("empty = %#v %v", got, err)
}
if count > maxReviewCharacters && (err != nil || !got.Limited || got.Known || len(got.Ranges) != 0) {
t.Fatalf("limit = %#v %v", got, err)
}
if count < 0 && err == nil {
t.Fatal("negative count accepted")
}
}
count := maxReviewCharacters + 1
oversized := fakeReviewText{characters: count, rectangles: map[[2]int]Rectangle{{0, count}: {0, 0, 10, 10}}}
if got, err := captureVisibleText(context.Background(), oversized, Rectangle{0, 0, 10, 10}); err != nil || !got.Limited || got.Known || len(got.Ranges) != 0 {
t.Fatalf("oversized visible viewport: %#v %v", got, err)
}
source := fakeReviewText{characters: maxReviewRanges + 1, lines: map[int]TextRange{}, rectangles: map[[2]int]Rectangle{}}
for i := 0; i < source.characters; i++ {
source.lines[i] = TextRange{Start: i, End: i + 1, Text: "x"}
source.rectangles[[2]int{i, i + 1}] = Rectangle{0, 0, 1, 1}
}
got, err := captureVisibleText(context.Background(), source, Rectangle{0, 0, 10, 10})
if err != nil || !got.Limited || len(got.Ranges) != 0 {
t.Fatalf("range limit = %#v %v", got, err)
}
}
// A horizontally clipped viewport has hundreds of visible characters, but
// only a few visual rows. The range budget must count spans, not characters.
type clippedReviewText struct{ rows, columns int }
func (s clippedReviewText) count(context.Context) (int, error) { return s.rows * s.columns, nil }
func (s clippedReviewText) line(_ context.Context, offset int) (TextRange, error) {
start := offset / s.columns * s.columns
return TextRange{Start: start, End: start + s.columns, Text: strings.Repeat("é", s.columns)}, nil
}
func (s clippedReviewText) bounds(_ context.Context, start, end int) (Rectangle, error) {
return Rectangle{X: int32(start%s.columns*10 - 50), Y: int32(start / s.columns * 12), Width: int32((end - start) * 10), Height: 12}, nil
}
func TestCaptureVisibleTextClippedViewportUsesSpans(t *testing.T) {
source := clippedReviewText{rows: 20, columns: 400}
clip := Rectangle{X: 3, Width: 394, Height: int32(source.rows * 12)}
got, err := captureVisibleText(context.Background(), source, clip)
if err != nil || !got.Known || got.Limited || len(got.Ranges) != source.rows {
t.Fatalf("clipped capture: known=%v limited=%v ranges=%d err=%v", got.Known, got.Limited, len(got.Ranges), err)
}
for row, span := range got.Ranges {
want := TextRange{Start: row*source.columns + 5, End: row*source.columns + 45, Text: strings.Repeat("é", 40), Bounds: Rectangle{X: 3, Y: int32(row * 12), Width: 394, Height: 12}}
if span != want {
t.Fatalf("row %d: %+v, want %+v", row, span, want)
}
}
if !got.valid(Geometry{BoundsKnown: true, Bounds: clip}) {
t.Fatal("clipped viewport rejected by tree validation")
}
}
func TestCaptureVisibleTextClippedSpanCrossesGeometryBlocks(t *testing.T) {
source := clippedReviewText{rows: 1, columns: 400}
got, err := captureVisibleText(context.Background(), source, Rectangle{X: 3, Width: 2594, Height: 12})
want := VisibleText{Known: true, Ranges: []TextRange{
{5, 265, strings.Repeat("é", 260), Rectangle{3, 0, 2594, 12}},
}}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("cross-block capture = %+v %v, want %+v", got, err, want)
}
}
func TestCaptureVisibleTextClippedDisjointSpanLimit(t *testing.T) {
count := (maxReviewRanges + 1) * 2
source := fakeReviewText{characters: count, lines: map[int]TextRange{
0: {Start: 0, End: count, Text: strings.Repeat("x", count)},
}, rectangles: map[[2]int]Rectangle{{0, count}: {-10, 0, 30, 10}}}
for i := 0; i < count; i++ {
source.rectangles[[2]int{i, i + 1}] = Rectangle{X: int32(i % 2 * 20), Width: 10, Height: 10}
}
got, err := captureVisibleText(context.Background(), source, Rectangle{0, 0, 10, 10})
if err != nil || !got.Limited || got.Known || len(got.Ranges) != 0 {
t.Fatalf("disjoint span limit: %+v %v", got, err)
}
}
func TestCaptureVisibleTextClippedBidiKeepsHiddenGaps(t *testing.T) {
source := fakeReviewText{characters: 5, lines: map[int]TextRange{
0: {Start: 0, End: 5, Text: "אבXגד"},
}, rectangles: map[[2]int]Rectangle{
{0, 5}: {-10, 0, 70, 10},
{0, 1}: {30, 0, 10, 10}, {1, 2}: {20, 0, 10, 10},
{2, 3}: {50, 0, 10, 10},
{3, 4}: {10, 0, 10, 10}, {4, 5}: {0, 0, 10, 10},
}}
got, err := captureVisibleText(context.Background(), source, Rectangle{0, 0, 40, 10})
want := VisibleText{Known: true, Ranges: []TextRange{
{0, 2, "אב", Rectangle{20, 0, 20, 10}},
{3, 5, "גד", Rectangle{0, 0, 20, 10}},
}}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("bidi capture = %+v %v, want %+v", got, err, want)
}
}
func BenchmarkCaptureVisibleTextClipped(b *testing.B) {
source := clippedReviewText{rows: 4, columns: 100}
clip := Rectangle{X: 3, Width: 394, Height: int32(source.rows * 12)}
b.ReportAllocs()
for b.Loop() {
got, err := captureVisibleText(context.Background(), source, clip)
if err != nil || !got.Known || got.Limited {
b.Fatalf("clipped capture: known=%v limited=%v err=%v", got.Known, got.Limited, err)
}
}
}
func textNode() TreeNode {
node := testNode("/root", "Text")
node.Geometry = Geometry{Bounds: Rectangle{-10, 0, 20, 20}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
node.Text = VisibleText{Known: true, Ranges: []TextRange{{0, 2, "é猫", Rectangle{-10, 0, 20, 10}}}}
return node
}
func TestTreeTextOwnershipInvalidationAndReplacement(t *testing.T) {
node := textNode()
tree, err := NewAccessibleTree(node.Snapshot.ID(), []TreeNode{node})
if err != nil {
t.Fatal(err)
}
ref, _ := tree.Root()
node.Text.Ranges[0].Text = "xx"
got, _ := tree.VisibleText(ref)
if got.Ranges[0].Text != "é猫" {
t.Fatal("tree retained caller slice")
}
got.Ranges[0].Text = "yy"
got, _ = tree.VisibleText(ref)
if got.Ranges[0].Text != "é猫" {
t.Fatal("tree exposed owned slice")
}
if n := tree.InvalidateText(ref.ID()); n != 1 {
t.Fatalf("invalidated %d", n)
}
if n := tree.InvalidateText(ref.ID()); n != 0 {
t.Fatalf("repeat invalidated %d", n)
}
if _, err := tree.VisibleText(ref); !errors.Is(err, ErrStaleText) {
t.Fatalf("stale = %v", err)
}
if _, err := tree.Geometry(ref); err != nil {
t.Fatal(err)
}
if _, err := tree.Resolve(ref); err != nil {
t.Fatal(err)
}
if err := tree.Replace(node.Snapshot.ID(), []TreeNode{node}); err != nil {
t.Fatal(err)
}
if got, err := tree.VisibleText(ref); err != nil || got.Ranges[0].Text != "xx" {
t.Fatalf("replace = %#v %v", got, err)
}
tree.InvalidateGeometry(ref.ID())
if _, err := tree.VisibleText(ref); !errors.Is(err, ErrStaleText) {
t.Fatalf("layout = %v", err)
}
tree.Invalidate(ref.ID())
if _, err := tree.VisibleText(ref); !errors.Is(err, ErrStaleObject) {
t.Fatalf("removed = %v", err)
}
}
func TestTreeRejectsMalformedText(t *testing.T) {
for _, text := range []VisibleText{
{Ranges: []TextRange{{0, 1, "x", Rectangle{0, 0, 1, 1}}}},
{Known: true, Limited: true},
{Known: true, Ranges: []TextRange{{0, 2, "é", Rectangle{0, 0, 1, 1}}}},
{Known: true, Ranges: []TextRange{{0, 1, "x", Rectangle{10, 0, 1, 1}}}},
{Known: true, Ranges: []TextRange{{0, 1, "\ufffc", Rectangle{0, 0, 1, 1}}}},
{Known: true, Ranges: []TextRange{{0, 3, "a\nb", Rectangle{0, 0, 1, 1}}}},
{Known: true, Ranges: []TextRange{{0, 1, "x", Rectangle{0, 0, 1, 1}}, {0, 1, "x", Rectangle{0, 0, 1, 1}}}},
} {
node := textNode()
node.Text = text
if _, err := NewAccessibleTree(node.Snapshot.ID(), []TreeNode{node}); !errors.Is(err, ErrInvalidTree) {
t.Fatalf("accepted %#v: %v", text, err)
}
}
node := textNode()
node.Geometry = Geometry{}
if _, err := NewAccessibleTree(node.Snapshot.ID(), []TreeNode{node}); !errors.Is(err, ErrInvalidTree) {
t.Fatalf("text without geometry accepted: %v", err)
}
}
func TestTreeTextConcurrentInvalidation(t *testing.T) {
node := textNode()
tree, err := NewAccessibleTree(node.Snapshot.ID(), []TreeNode{node})
if err != nil {
t.Fatal(err)
}
ref, _ := tree.Root()
var readers sync.WaitGroup
for range 8 {
readers.Add(1)
go func() {
defer readers.Done()
for range 100 {
_, err := tree.VisibleText(ref)
if err != nil && !errors.Is(err, ErrStaleText) {
t.Errorf("read: %v", err)
}
}
}()
}
tree.InvalidateText(ref.ID())
readers.Wait()
}
func TestAdapterTextInvalidationPolicy(t *testing.T) {
for _, event := range []string{TextInsertedEvent, TextDeletedEvent + ":system", TextBoundsChangedEvent, TextAttributesChangedEvent, CaretEvent} {
tree := testTree(t)
adapter := New()
adapter.tree = tree
first, _ := tree.Reference(testID("/first"))
second, _ := tree.Reference(testID("/second"))
adapter.invalidateTreeEvent(event, 0, first.ID(), ObjectID{})
if _, err := tree.VisibleText(first); !errors.Is(err, ErrStaleText) {
t.Fatalf("%s: %v", event, err)
}
if _, err := tree.VisibleText(second); err != nil {
t.Fatalf("unrelated text: %v", err)
}
if _, err := tree.Resolve(first); err != nil {
t.Fatal(err)
}
}
}
func TestTreeTextTotalBudget(t *testing.T) {
root := testNode("/root", "Window")
nodes := []TreeNode{root}
for i := 0; i <= maxTreeTextBytes/(maxReviewCharacters*3); i++ {
node := textNode()
node.Snapshot.Path = "/n" + strings.Repeat("x", i+1)
node.Text.Ranges[0] = TextRange{0, maxReviewCharacters, strings.Repeat("猫", maxReviewCharacters), Rectangle{0, 0, 1, 1}}
nodes = append(nodes, node)
nodes[0].Children = append(nodes[0].Children, node.Snapshot.ID())
}
if _, err := NewAccessibleTree(root.Snapshot.ID(), nodes); !errors.Is(err, ErrInvalidTree) {
t.Fatalf("total budget: %v", err)
}
}
func TestTerminalCharacterCount(t *testing.T) {
for _, tc := range []struct{ reported, actual int }{{0, 0}, {49, 48}, {30, 10}, {10, 10}, {1000000, 700000}, {8, 0}} {
calls := 0
got, err := terminalCharacterCount(context.Background(), tc.reported, func(ctx context.Context, offset int) (bool, error) {
calls++
if offset < 0 || offset >= tc.reported {
t.Fatalf("offset out of bounds: %d", offset)
}
return offset < tc.actual, nil
})
if err != nil || got != tc.actual || calls > 22 {
t.Fatalf("%+v: count=%d calls=%d err=%v", tc, got, calls, err)
}
}
failure := errors.New("provider unavailable")
if _, err := terminalCharacterCount(context.Background(), 10, func(context.Context, int) (bool, error) { return false, failure }); !errors.Is(err, failure) {
t.Fatalf("failure = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := terminalCharacterCount(ctx, 10, func(ctx context.Context, _ int) (bool, error) { return false, ctx.Err() }); !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation = %v", err)
}
}
func TestCaptureVisibleTextRetainsBlankRows(t *testing.T) {
source := fakeReviewText{characters: 6, lines: map[int]TextRange{
0: {Start: 0, End: 2, Text: "a\n"},
2: {Start: 2, End: 3, Text: "\n"},
3: {Start: 3, End: 5, Text: "b\n"},
5: {Start: 5, End: 6, Text: "\n"},
}, rectangles: map[[2]int]Rectangle{
{0, 2}: {0, 0, 10, 10}, {2, 3}: {0, 10, 10, 10},
{3, 5}: {0, 20, 10, 10}, {5, 6}: {0, 30, 10, 10},
}}
clip := Rectangle{0, 0, 10, 40}
got, err := captureVisibleText(context.Background(), source, clip)
want := VisibleText{Known: true, Ranges: []TextRange{
{0, 1, "a", Rectangle{0, 0, 10, 10}},
{2, 3, "\n", Rectangle{0, 10, 10, 10}},
{3, 4, "b", Rectangle{0, 20, 10, 10}},
{5, 6, "\n", Rectangle{0, 30, 10, 10}},
}}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("capture=%+v err=%v want=%+v", got, err, want)
}
if !got.valid(Geometry{BoundsKnown: true, Bounds: clip}) {
t.Fatal("blank row capture rejected by tree validation")
}
}
func TestCaptureVisibleTextSplitsCombinedBlankRows(t *testing.T) {
source := fakeReviewText{characters: 4, lines: map[int]TextRange{
0: {Start: 0, End: 4, Text: "\n\r\n\n"},
}, rectangles: map[[2]int]Rectangle{
{0, 1}: {0, 0, 10, 10},
{1, 3}: {0, 10, 10, 10},
{3, 4}: {0, 20, 10, 10},
}}
got, err := captureVisibleText(context.Background(), source, Rectangle{0, 0, 10, 30})
want := VisibleText{Known: true, Ranges: []TextRange{
{0, 1, "\n", Rectangle{0, 0, 10, 10}},
{1, 3, "\r\n", Rectangle{0, 10, 10, 10}},
{3, 4, "\n", Rectangle{0, 20, 10, 10}},
}}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("capture = %#v %v, want %#v", got, err, want)
}
}