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