Files

547 lines
20 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 review
import (
"context"
"errors"
"strings"
"testing"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func flatNode(path, text string, x, y int32) atspi.TreeNode {
n := node(path, "")
n.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{X: x, Y: y, Width: 200, Height: 20}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
n.Text = atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{Text: text, Start: 0, End: utf8.RuneCountInString(text), Bounds: n.Geometry.Bounds}}}
return n
}
func flatTree(t *testing.T, nodes ...atspi.TreeNode) *atspi.AccessibleTree {
t.Helper()
root := node("/window", "Example Window")
root.Snapshot.Role = "frame"
root.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{X: -500, Y: -500, Width: 2000, Height: 2000}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
for _, n := range nodes {
root.Children = append(root.Children, n.Snapshot.ID())
}
tree, err := atspi.NewAccessibleTree(objectID("/window"), append([]atspi.TreeNode{root}, nodes...))
if err != nil {
t.Fatal(err)
}
return tree
}
func TestFlatReviewSpeaksSharedHeadingTextOnce(t *testing.T) {
heading := flatNode("/heading", "Reading section", 0, 0)
heading.Snapshot.Role = "heading"
heading.Children = []atspi.ObjectID{objectID("/text")}
child := flatNode("/text", "Reading section", 0, 0)
child.Snapshot.Role = "static"
root := node("/window", "")
root.Snapshot.Role = "frame"
root.Geometry = heading.Geometry
root.Children = []atspi.ObjectID{objectID("/heading")}
tree, err := atspi.NewAccessibleTree(objectID("/window"), []atspi.TreeNode{root, heading, child})
if err != nil {
t.Fatal(err)
}
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), tree, objectID("/heading")); err != nil {
t.Fatal(err)
}
position, _, err := cursor.Read(Line, 0)
if err != nil || position.Text != "Reading section" {
t.Fatalf("repeated heading text: position=%+v err=%v", position, err)
}
}
func TestFlatReviewPageTabHeaderDoesNotClipPageContent(t *testing.T) {
frame := node("/window", "")
frame.Snapshot.Role = "frame"
frame.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{Width: 300, Height: 200}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
frame.Children = []atspi.ObjectID{objectID("/tab"), objectID("/hidden_tab")}
tab := node("/tab", "")
tab.Snapshot.Role = "page tab"
tab.Geometry = atspi.Geometry{Bounds: atspi.Rectangle{Width: 100, Height: 20}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
tab.Children = []atspi.ObjectID{objectID("/editor")}
editor := flatNode("/editor", "Alpha beta", 0, 40)
editor.Snapshot.Role = "text"
hiddenTab := node("/hidden_tab", "")
hiddenTab.Snapshot.Role = "page tab"
hiddenTab.Geometry = atspi.Geometry{VisibilityKnown: true, Visible: true, Showing: false}
hiddenTab.Children = []atspi.ObjectID{objectID("/hidden_editor")}
hiddenEditor := flatNode("/hidden_editor", "Should stay hidden", 0, 80)
tree, err := atspi.NewAccessibleTree(objectID("/window"), []atspi.TreeNode{frame, tab, editor, hiddenTab, hiddenEditor})
if err != nil {
t.Fatal(err)
}
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), tree, objectID("/editor")); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
unit FlatUnit
step int
text string
moved bool
}{
{Word, 0, "Alpha ", false},
{Character, 1, "l", true},
{Word, 1, "beta", true},
} {
position, moved, err := cursor.Read(tc.unit, tc.step)
if err != nil || position.Object != objectID("/editor") || position.Text != tc.text || moved != tc.moved {
t.Fatalf("unit=%v step=%d: position=%+v moved=%v err=%v", tc.unit, tc.step, position, moved, err)
}
}
if position, moved, err := cursor.Read(Line, 1); err != nil || moved || position.Object != objectID("/editor") {
t.Fatalf("hidden page entered review: position=%+v moved=%v err=%v", position, moved, err)
}
}
func TestFlatReadingOrderAndUnicodeNavigation(t *testing.T) {
// Tree order deliberately differs from the screen order.
tree := flatTree(t, flatNode("/bottom", "next", 0, 40), flatNode("/right", "right", 250, 2), flatNode("/left", "é🙂 one", -100, 0))
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), tree, objectID("/left")); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
unit FlatUnit
step int
text string
offset int
moved bool
}{
{Line, 0, "é🙂 one right", 0, false},
{Character, 1, "🙂", 1, true},
{Character, 1, " ", 2, true},
{Word, 1, "one", 3, true},
{Word, 1, "right", 0, true},
{Word, 1, "next", 0, true},
{Line, 1, "next", 0, false},
{Character, -1, "t", 4, true},
{Line, -1, "é🙂 one right", 4, false},
} {
got, moved, err := cursor.Read(tc.unit, tc.step)
if err != nil || got.Text != tc.text || got.Offset != tc.offset || moved != tc.moved || !got.HasOffset {
t.Fatalf("unit=%d step=%d: %#v moved=%v err=%v", tc.unit, tc.step, got, moved, err)
}
}
}
func TestFlatWindowStartAndEnd(t *testing.T) {
tree := flatTree(t, flatNode("/first", "first", 0, 0), flatNode("/last", "last", 0, 40))
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), tree, objectID("/last")); err != nil {
t.Fatal(err)
}
if got, moved, err := cursor.Read(WindowStart, 0); err != nil || !moved || got.Text != "first" {
t.Fatalf("start: %#v moved=%v err=%v", got, moved, err)
}
if got, moved, err := cursor.Read(WindowEnd, 0); err != nil || !moved || got.Text != "last" {
t.Fatalf("end: %#v moved=%v err=%v", got, moved, err)
}
if got, moved, err := cursor.Read(Character, 0); err != nil || moved || got.Text != "t" {
t.Fatalf("end character: %#v moved=%v err=%v", got, moved, err)
}
}
func TestFlatVerticalWordsAndLineEnd(t *testing.T) {
bottom := flatNode("/bottom", "four", 100, 40)
firstTop := flatNode("/first", "one", 0, 0)
firstTop.Geometry.Bounds.Width = 100
firstTop.Text.Ranges[0].Bounds.Width = 100
secondTop := flatNode("/second", "two", 100, 0)
tree := flatTree(t, firstTop, secondTop, bottom)
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), tree, objectID("/bottom")); err != nil {
t.Fatal(err)
}
if _, moved, err := cursor.Read(Character, 1); err != nil || !moved {
t.Fatalf("position character: moved=%v err=%v", moved, err)
}
got, moved, err := cursor.Read(WordAbove, 0)
if err != nil || !moved || got.Object != objectID("/second") || got.Text != "two" || got.Offset != 0 {
t.Fatalf("above: got=%+v moved=%v err=%v", got, moved, err)
}
got, moved, err = cursor.Read(WordBelow, 0)
if err != nil || !moved || got.Object != objectID("/bottom") || got.Text != "four" || got.Offset != 0 {
t.Fatalf("below: got=%+v moved=%v err=%v", got, moved, err)
}
got, moved, err = cursor.Read(LineEnd, 0)
if err != nil || !moved || got.Object != objectID("/bottom") || got.Text != "r" || got.Offset != 3 {
t.Fatalf("line end: got=%+v moved=%v err=%v", got, moved, err)
}
got, moved, err = cursor.Read(LineEnd, 0)
if err != nil || moved || got.Text != "r" || got.Offset != 3 {
t.Fatalf("line end repeat: got=%+v moved=%v err=%v", got, moved, err)
}
if _, _, err := cursor.Read(WindowStart, 0); err != nil {
t.Fatal(err)
}
got, moved, err = cursor.Read(WordAbove, 0)
if err != nil || moved || got.Text != "one" {
t.Fatalf("top boundary: got=%+v moved=%v err=%v", got, moved, err)
}
}
func TestFlatVerticalMovementPreservesHorizontalTarget(t *testing.T) {
top := flatNode("/top", "abcd", 0, 0)
middle := flatNode("/middle", "ab", 0, 40)
bottom := flatNode("/bottom", "abcd", 0, 80)
for _, node := range []*atspi.TreeNode{&top, &middle, &bottom} {
node.Geometry.Bounds.Width = 100
node.Text.Ranges[0].Bounds.Width = 100
}
tree := flatTree(t, top, middle, bottom)
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), tree, objectID("/top")); err != nil {
t.Fatal(err)
}
if _, moved, err := cursor.Read(Character, 1); err != nil || !moved {
t.Fatalf("position character: moved=%v err=%v", moved, err)
}
if _, moved, err := cursor.Read(WordBelow, 0); err != nil || !moved {
t.Fatalf("first below: moved=%v err=%v", moved, err)
}
// The worker refreshes the cursor before every request. The original
// horizontal target must survive that refresh for consecutive moves.
if err := cursor.Refresh(context.Background(), tree, objectID("/top")); err != nil {
t.Fatal(err)
}
if _, moved, err := cursor.Read(WordBelow, 0); err != nil || !moved {
t.Fatalf("second below: moved=%v err=%v", moved, err)
}
got, _, err := cursor.Read(Character, 0)
if err != nil || got.Object != objectID("/bottom") || got.Text != "b" || got.Offset != 1 {
t.Fatalf("preserved target: got=%+v err=%v", got, err)
}
for _, tc := range []struct {
unit FlatUnit
step int
object string
character string
}{
{WordAbove, 0, "/middle", "a"},
{WordAbove, 0, "/top", "b"},
{LineEnd, 0, "/top", "d"},
{WordBelow, 0, "/middle", "b"},
{WordBelow, 0, "/bottom", "d"},
{Character, -1, "/bottom", "c"},
{WordAbove, 0, "/middle", "b"},
{WordAbove, 0, "/top", "c"},
} {
if _, _, err := cursor.Read(tc.unit, tc.step); err != nil {
t.Fatal(err)
}
got, _, err := cursor.Read(Character, 0)
if err != nil || got.Object != objectID(tc.object) || got.Text != tc.character {
t.Fatalf("unit %d: got=%+v err=%v", tc.unit, got, err)
}
}
if err := cursor.Refresh(context.Background(), tree, objectID("/bottom")); err != nil {
t.Fatal(err)
}
if _, _, err := cursor.Read(WordAbove, 0); err != nil {
t.Fatal(err)
}
got, _, err = cursor.Read(Character, 0)
if err != nil || got.Text != "a" {
t.Fatalf("changed focus retained old target: got=%+v err=%v", got, err)
}
}
func TestFlatRefreshPreservesPositionAndHonorsChangedFocus(t *testing.T) {
tree := flatTree(t, flatNode("/a", "a", 0, 0), flatNode("/b", "b🙂", 0, 30))
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil {
t.Fatal(err)
}
cursor.Read(Line, 1)
cursor.Read(Character, 1)
if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil {
t.Fatal(err)
}
got, _, _ := cursor.Read(Character, 0)
if got.Object != objectID("/b") || got.Text != "🙂" || got.Offset != 1 {
t.Fatalf("lost independent position: %#v", got)
}
tree.InvalidateText(objectID("/b"))
if err := cursor.Refresh(context.Background(), tree, objectID("/a")); !errors.Is(err, atspi.ErrStaleText) {
t.Fatal(err)
}
got, _, _ = cursor.Read(Character, 0)
if got.Text != "🙂" {
t.Fatal("failed refresh mutated position")
}
tree = flatTree(t, flatNode("/a", "a", 0, 0), flatNode("/b", "b🙂", 0, 30), flatNode("/c", "c", 0, 60))
if err := cursor.Refresh(context.Background(), tree, objectID("/c")); err != nil {
t.Fatal(err)
}
got, _, _ = cursor.Read(Character, 0)
if got.Text != "c" {
t.Fatalf("changed focus ignored: %#v", got)
}
}
func TestFlatSyntheticNamesHiddenAndPasswordText(t *testing.T) {
password := flatNode("/password", "secret", 0, 30)
password.Snapshot.Role = "password text"
password.Snapshot.Name = "Password"
hidden := node("/hidden", "Hidden content")
button := flatNode("/button", "unused", 0, 0)
button.Text = atspi.VisibleText{}
button.Snapshot.Name = "Save"
tree := flatTree(t, button, password, hidden)
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), tree, objectID("/button")); err != nil {
t.Fatal(err)
}
got, _, _ := cursor.Read(Word, 0)
if got.Text != "Save" || got.HasOffset {
t.Fatalf("synthetic name: %#v", got)
}
got, _, _ = cursor.Read(Line, 1)
if got.Text != "Password" || got.HasOffset {
t.Fatalf("password: %#v", got)
}
if len(cursor.lines) != 2 {
t.Fatalf("hidden/container content leaked: %#v", cursor.lines)
}
}
func TestFlatClusteringDoesNotChainRowsAndKeepsRTLLogicalText(t *testing.T) {
nodes := []atspi.TreeNode{flatNode("/a", "אבג", 0, 0), flatNode("/b", "b", 250, 5), flatNode("/c", "c", 500, 10)}
lines, err := buildFlatLines(context.Background(), nodes)
if err != nil || len(lines) != 2 || len(lines[0].zones) != 2 || lines[0].zones[0].text != "אבג" {
t.Fatalf("lines=%#v err=%v", lines, err)
}
}
func TestFlatRejectsLimitedEmptyAndInvalidRequests(t *testing.T) {
limited := flatNode("/large", "x", 0, 0)
limited.Text = atspi.VisibleText{Limited: true}
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), flatTree(t, limited), objectID("/large")); !errors.Is(err, ErrLimitedReview) {
t.Fatal(err)
}
if _, _, err := cursor.Read(Line, 0); !errors.Is(err, ErrNoReviewContent) {
t.Fatal(err)
}
if _, _, err := cursor.Read(0, 0); !errors.Is(err, ErrInvalidDirection) {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := buildFlatLines(ctx, []atspi.TreeNode{limited}); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
}
func TestFlatWordWhitespaceAndCharacterBoundaries(t *testing.T) {
tree := flatTree(t, flatNode("/a", "one two", 0, 0), flatNode("/space", " ", 0, 30), flatNode("/b", "三", 0, 60))
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil {
t.Fatal(err)
}
got, moved, _ := cursor.Read(Character, -1)
if moved || got.Text != "o" {
t.Fatalf("start boundary: %#v %v", got, moved)
}
for _, text := range []string{"two", "三"} {
got, moved, err := cursor.Read(Word, 1)
if err != nil || !moved || strings.TrimSpace(got.Text) != text {
t.Fatalf("word: %#v %v %v", got, moved, err)
}
}
got, moved, _ = cursor.Read(Word, 1)
if moved || got.Text != "三" {
t.Fatalf("end boundary: %#v %v", got, moved)
}
got, moved, _ = cursor.Read(Word, -1)
if !moved || got.Text != "two" {
t.Fatalf("previous word: %#v %v", got, moved)
}
}
func TestFlatClippedCharacterRunsKeepWordsAndOffsetGaps(t *testing.T) {
n := flatNode("/a", "x", 0, 0)
n.Text.Ranges = []atspi.TextRange{
{Start: 2, End: 3, Text: "猫", Bounds: atspi.Rectangle{X: 0, Y: 0, Width: 12, Height: 20}},
{Start: 3, End: 4, Text: "a", Bounds: atspi.Rectangle{X: 12, Y: 0, Width: 10, Height: 20}},
{Start: 7, End: 8, Text: "z", Bounds: atspi.Rectangle{X: 80, Y: 0, Width: 10, Height: 20}},
}
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), flatTree(t, n), objectID("/a")); err != nil {
t.Fatal(err)
}
got, _, _ := cursor.Read(Line, 0)
if got.Text != "猫a z" || got.Offset != 2 {
t.Fatalf("fragmented line: %#v", got)
}
got, _, _ = cursor.Read(Word, 0)
if got.Text != "猫a" {
t.Fatalf("fragmented word: %#v", got)
}
got, _, _ = cursor.Read(Word, 1)
if got.Text != "z" || got.Offset != 7 {
t.Fatalf("offset gap lost: %#v", got)
}
}
func TestFlatClipsSyntheticNamesAndSuppressesHiddenDescendants(t *testing.T) {
parent := flatNode("/parent", "unused", 0, 0)
parent.Text = atspi.VisibleText{}
parent.Snapshot.Name = "Container"
child := flatNode("/child", "unused", 150, 0)
child.Text = atspi.VisibleText{}
child.Snapshot.Name = "Button"
parent.Children = []atspi.ObjectID{child.Snapshot.ID()}
lines, err := buildFlatLines(context.Background(), []atspi.TreeNode{parent, child})
if err != nil || len(lines) != 1 || len(lines[0].zones) != 1 || lines[0].zones[0].bounds.Width != 50 {
t.Fatalf("clipping: %#v %v", lines, err)
}
parent.Geometry = atspi.Geometry{VisibilityKnown: true}
if _, err := buildFlatLines(context.Background(), []atspi.TreeNode{parent, child}); !errors.Is(err, ErrNoReviewContent) {
t.Fatalf("hidden descendant: %v", err)
}
}
func TestFlatCaretAndSyntheticCharacterPosition(t *testing.T) {
n := flatNode("/a", "ab🙂", 0, 0)
n.Text.CaretKnown, n.Text.CaretOffset = true, 2
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), flatTree(t, n), objectID("/a")); err != nil {
t.Fatal(err)
}
got, _, _ := cursor.Read(Character, 0)
if got.Text != "🙂" || got.Offset != 2 {
t.Fatalf("caret: %#v", got)
}
cursor = &FlatCursor{}
n.Text.CaretOffset = 3
if err := cursor.Refresh(context.Background(), flatTree(t, n), objectID("/a")); err != nil {
t.Fatal(err)
}
got, _, _ = cursor.Read(Character, 0)
if got.Text != "" || got.Offset != 3 {
t.Fatalf("end caret: %#v", got)
}
got, moved, _ := cursor.Read(Character, -1)
if !moved || got.Text != "🙂" {
t.Fatalf("before end caret: %#v", got)
}
n.Text = atspi.VisibleText{}
n.Snapshot.Name = "Save"
tree := flatTree(t, n)
cursor = &FlatCursor{}
for _, want := range []string{"a", "v", "e"} {
if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil {
t.Fatal(err)
}
got, moved, err := cursor.Read(Character, 1)
if err != nil || !moved || got.Text != want {
t.Fatalf("synthetic position: %#v %v", got, err)
}
}
}
func TestFlatModelBoundsSyntheticText(t *testing.T) {
var nodes []atspi.TreeNode
for i := 0; i < 300; i++ {
n := flatNode("/a", "x", 0, int32(i*20))
n.Text = atspi.VisibleText{}
n.Snapshot.Name = strings.Repeat("a", 4096)
nodes = append(nodes, n)
}
if _, err := buildFlatLines(context.Background(), nodes); !errors.Is(err, ErrLimitedReview) {
t.Fatal(err)
}
}
func TestFlatDoesNotRestoreReusedObjectIdentity(t *testing.T) {
tree := flatTree(t, flatNode("/a", "Focus", 0, 0), flatNode("/b", "Original", 0, 30))
cursor := &FlatCursor{}
if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil {
t.Fatal(err)
}
cursor.Read(Line, 1)
tree.Invalidate(objectID("/b"))
fresh := flatTree(t, flatNode("/a", "Focus", 0, 0), flatNode("/b", "Replacement", 0, 30))
snapshot, err := fresh.ReviewWindow(context.Background(), objectID("/a"))
if err != nil {
t.Fatal(err)
}
if err := tree.Replace(objectID("/window"), snapshot.Nodes); err != nil {
t.Fatal(err)
}
if err := cursor.Refresh(context.Background(), tree, objectID("/a")); err != nil {
t.Fatal(err)
}
got, _, _ := cursor.Read(Line, 0)
if got.Text != "Focus" {
t.Fatalf("reused identity inherited position: %#v", got)
}
}
func TestFlatBlankRowsAcrossReviewUnits(t *testing.T) {
n := flatNode("/terminal", "a", 0, 0)
n.Geometry.Bounds.Height = 80
n.Text.Ranges = []atspi.TextRange{
{Start: 0, End: 1, Text: "a", Bounds: atspi.Rectangle{Width: 20, Height: 20}},
{Start: 2, End: 3, Text: "\n", Bounds: atspi.Rectangle{Y: 20, Width: 20, Height: 20}},
{Start: 3, End: 4, Text: "b", Bounds: atspi.Rectangle{Y: 40, Width: 20, Height: 20}},
{Start: 5, End: 6, Text: "\n", Bounds: atspi.Rectangle{Y: 60, Width: 20, Height: 20}},
}
tree := flatTree(t, n)
cursor := &FlatCursor{}
for _, tc := range []struct {
unit FlatUnit
step int
text string
offset int
moved bool
}{
{Line, 1, "\n", 2, true},
{Character, 0, "\n", 2, false},
{Line, 0, "\n", 2, false},
{Line, 1, "b", 3, true},
{Word, -1, "\n", 2, true},
{Word, -1, "a", 0, true},
{Word, 1, "\n", 2, true},
{Word, 1, "b", 3, true},
{WindowEnd, 0, "\n", 5, true},
{Line, 1, "\n", 5, false},
{WindowStart, 0, "a", 0, true},
{Character, 1, "\n", 2, true},
{Character, 1, "b", 3, true},
} {
if err := cursor.Refresh(context.Background(), tree, n.Snapshot.ID()); err != nil {
t.Fatal(err)
}
got, moved, err := cursor.Read(tc.unit, tc.step)
if err != nil || got.Text != tc.text || got.Offset != tc.offset || moved != tc.moved {
t.Fatalf("%+v: got=%+v moved=%v err=%v", tc, got, moved, err)
}
}
}