547 lines
20 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|