370 lines
13 KiB
Go
370 lines
13 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"
|
|
"sync"
|
|
"testing"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
func documentTestNodes() []TreeNode {
|
|
node := func(path, role string, children ...ObjectID) TreeNode {
|
|
n := testNode(path, path, children...)
|
|
n.Snapshot.Role = role
|
|
return n
|
|
}
|
|
return []TreeNode{
|
|
node("/root", "frame", testID("/chrome"), testID("/doc"), testID("/other")),
|
|
node("/chrome", "entry"),
|
|
node("/doc", "document web", testID("/heading"), testID("/inner")),
|
|
node("/heading", "heading"),
|
|
node("/inner", "document frame", testID("/link")),
|
|
node("/link", "link"),
|
|
node("/other", "document web"),
|
|
}
|
|
}
|
|
|
|
func TestDocumentTargetFocusPolicyForHeadings(t *testing.T) {
|
|
nodes := []TreeNode{
|
|
testNode("/doc", "/doc", testID("/ordinary"), testID("/linked"), testID("/button"), testID("/entry"), testID("/image"), testID("/imageLink")),
|
|
testNode("/ordinary", "/ordinary", testID("/text")),
|
|
testNode("/text", "/text"),
|
|
testNode("/linked", "/linked", testID("/link")),
|
|
testNode("/link", "/link"),
|
|
testNode("/button", "/button"),
|
|
testNode("/entry", "/entry"),
|
|
testNode("/image", "/image"),
|
|
testNode("/imageLink", "/imageLink", testID("/linkedImage")),
|
|
testNode("/linkedImage", "/linkedImage"),
|
|
}
|
|
nodes[0].Snapshot.Role = "document web"
|
|
for i, role := range map[int]string{1: "heading", 2: "static", 3: "heading", 4: "link", 5: "button", 6: "entry", 7: "image", 8: "link", 9: "image"} {
|
|
nodes[i].Snapshot.Role = role
|
|
nodes[i].Snapshot.Focusable = true
|
|
}
|
|
tree, err := NewAccessibleTree(testID("/doc"), nodes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tc := range []struct {
|
|
path string
|
|
want bool
|
|
}{
|
|
{"/ordinary", false},
|
|
{"/linked", true},
|
|
{"/button", true},
|
|
{"/entry", false},
|
|
{"/image", false},
|
|
{"/linkedImage", true},
|
|
} {
|
|
ref, err := tree.Reference(testID(tc.path))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snapshot, err := tree.Resolve(ref)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := documentTargetGrabsFocus(tree, ref, snapshot); got != tc.want {
|
|
t.Errorf("focus policy for %s = %v, want %v", tc.path, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocumentOwnershipAndOrder(t *testing.T) {
|
|
tree, err := NewAccessibleTree(testID("/root"), documentTestNodes())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref, _ := tree.Reference(testID("/link"))
|
|
doc, err := tree.Document(context.Background(), ref)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if doc.Context.Document.ID() != testID("/inner") || doc.Context.TopLevel.ID() != testID("/doc") {
|
|
t.Fatalf("wrong document ownership: %+v", doc.Context)
|
|
}
|
|
var paths []string
|
|
for _, node := range doc.Nodes {
|
|
paths = append(paths, node.Snapshot.Path)
|
|
}
|
|
if !reflect.DeepEqual(paths, []string{"/doc", "/heading", "/inner", "/link"}) {
|
|
t.Fatalf("document order: %v", paths)
|
|
}
|
|
var ancestors []string
|
|
for _, node := range doc.Context.Ancestors {
|
|
ancestors = append(ancestors, node.Path)
|
|
}
|
|
if !reflect.DeepEqual(ancestors, []string{"/link", "/inner", "/doc", "/root"}) {
|
|
t.Fatalf("ancestry: %v", ancestors)
|
|
}
|
|
doc.Nodes[0].Snapshot.Name = "mutated"
|
|
doc.Context.Ancestors[0].Name = "mutated"
|
|
again, err := tree.Document(context.Background(), ref)
|
|
if err != nil || again.Nodes[0].Snapshot.Name != "/doc" || again.Context.Ancestors[0].Name != "/link" {
|
|
t.Fatalf("snapshot aliasing: %+v %v", again, err)
|
|
}
|
|
chrome, _ := tree.Reference(testID("/chrome"))
|
|
if _, err := tree.DocumentContext(chrome); !errors.Is(err, ErrNotInDocument) {
|
|
t.Fatalf("browser chrome ownership: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDocumentRejectsStaleCapture(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
invalidate func(*AccessibleTree)
|
|
want error
|
|
}{
|
|
{"children", func(tree *AccessibleTree) { tree.InvalidateChildren(testID("/inner")) }, ErrIncompleteTree},
|
|
{"text", func(tree *AccessibleTree) { tree.InvalidateText(testID("/link")) }, ErrStaleText},
|
|
{"visibility", func(tree *AccessibleTree) { tree.InvalidateGeometry(testID("/inner")) }, ErrStaleGeometry},
|
|
{"document", func(tree *AccessibleTree) { tree.Invalidate(testID("/doc")) }, ErrStaleObject},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes())
|
|
ref, _ := tree.Reference(testID("/heading"))
|
|
tc.invalidate(tree)
|
|
doc, err := tree.Document(context.Background(), ref)
|
|
if !errors.Is(err, tc.want) || len(doc.Nodes) != 0 {
|
|
t.Fatalf("partial/stale document: %+v %v", doc, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocumentVisibilityAndCancellation(t *testing.T) {
|
|
nodes := documentTestNodes()
|
|
nodes[3].Geometry = Geometry{VisibilityKnown: true, Visible: true} // Offscreen, still navigable.
|
|
nodes[4].Geometry = Geometry{VisibilityKnown: true} // Hidden ancestor.
|
|
tree, _ := NewAccessibleTree(testID("/root"), nodes)
|
|
ref, _ := tree.Reference(testID("/heading"))
|
|
doc, err := tree.Document(context.Background(), ref)
|
|
if err != nil || doc.Nodes[1].Hidden || !doc.Nodes[2].Hidden || !doc.Nodes[3].Hidden {
|
|
t.Fatalf("visibility: %+v %v", doc, err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := tree.Document(ctx, ref); !errors.Is(err, context.Canceled) {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestDocumentLineAssemblesSameVisualRow(t *testing.T) {
|
|
nodes := documentTestNodes()
|
|
lineNode := func(index int, path, role, content string, bounds Rectangle, caret bool) {
|
|
snapshot := NewSnapshot(path, path, role, "", content)
|
|
snapshot.BusName = ":1.42"
|
|
nodes[index].Snapshot = snapshot
|
|
nodes[index].Geometry = Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
|
|
text := VisibleText{Known: true, Ranges: []TextRange{{
|
|
Start: 0, End: utf8.RuneCountInString(content), Text: content, Bounds: bounds,
|
|
}}}
|
|
if caret {
|
|
text.CaretKnown, text.CaretOffset = true, 1
|
|
}
|
|
nodes[index].Text = text
|
|
}
|
|
lineNode(3, "/heading", "heading", "Before", Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, true)
|
|
lineNode(5, "/link", "link", "link text", Rectangle{X: 100, Y: 100, Width: 80, Height: 20}, true)
|
|
lineNode(6, "/other", "paragraph", "Other row", Rectangle{X: 0, Y: 200, Width: 80, Height: 20}, false)
|
|
tree, err := NewAccessibleTree(testID("/root"), nodes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref, err := tree.Reference(testID("/link"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
line, err := tree.DocumentLineAtOffset(context.Background(), ref, -1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var contents []string
|
|
for _, content := range line.Contents {
|
|
contents = append(contents, content.Text)
|
|
}
|
|
if !reflect.DeepEqual(contents, []string{"Before", "link text"}) {
|
|
t.Fatalf("line contents = %q", contents)
|
|
}
|
|
if line.Document.ID() != testID("/doc") {
|
|
t.Fatalf("line document = %#v", line.Document)
|
|
}
|
|
}
|
|
|
|
func TestDocumentLineExcludesHiddenAndOtherRows(t *testing.T) {
|
|
nodes := documentTestNodes()
|
|
content := func(index int, text string, bounds Rectangle, caret bool) {
|
|
nodes[index].Snapshot.Role = "static"
|
|
nodes[index].Geometry = Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
|
|
nodes[index].Text = VisibleText{Known: true, Ranges: []TextRange{{
|
|
Start: 0, End: utf8.RuneCountInString(text), Text: text, Bounds: bounds,
|
|
}}}
|
|
if caret {
|
|
nodes[index].Text.CaretKnown, nodes[index].Text.CaretOffset = true, 3
|
|
}
|
|
}
|
|
content(3, "Visible", Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, true)
|
|
content(5, "Hidden", Rectangle{X: 100, Y: 100, Width: 50, Height: 20}, false)
|
|
content(6, "Other row", Rectangle{X: 0, Y: 200, Width: 80, Height: 20}, false)
|
|
nodes[4].Geometry = Geometry{VisibilityKnown: true, Visible: false}
|
|
tree, err := NewAccessibleTree(testID("/root"), nodes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref, err := tree.Reference(testID("/heading"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
line, err := tree.DocumentLineAtOffset(context.Background(), ref, -1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(line.Contents) != 1 || line.Contents[0].Text != "Visible" {
|
|
t.Fatalf("line contents = %#v", line.Contents)
|
|
}
|
|
emptyNodes := documentTestNodes()
|
|
withoutCaretTree, err := NewAccessibleTree(testID("/root"), emptyNodes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
withoutCaret, err := withoutCaretTree.Reference(testID("/heading"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := withoutCaretTree.DocumentLineAtOffset(context.Background(), withoutCaret, -1); !errors.Is(err, ErrNoDocumentCaret) {
|
|
t.Fatalf("missing caret error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDocumentLineAcceptsEndOfTextCaretOffset(t *testing.T) {
|
|
nodes := documentTestNodes()
|
|
content := "Visible"
|
|
nodes[3].Snapshot.Role = "static"
|
|
nodes[3].Geometry = Geometry{
|
|
Bounds: Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, BoundsKnown: true,
|
|
VisibilityKnown: true, Visible: true, Showing: true,
|
|
}
|
|
nodes[3].Text = VisibleText{Known: true, Ranges: []TextRange{{
|
|
Start: 0, End: utf8.RuneCountInString(content), Text: content,
|
|
Bounds: nodes[3].Geometry.Bounds,
|
|
}}}
|
|
tree, err := NewAccessibleTree(testID("/root"), nodes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reference, err := tree.Reference(testID("/heading"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
line, err := tree.DocumentLineAtOffset(context.Background(), reference, utf8.RuneCountInString(content))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(line.Contents) != 1 || line.Contents[0].Text != content {
|
|
t.Fatalf("line contents = %#v", line.Contents)
|
|
}
|
|
}
|
|
|
|
func TestDocumentLifecycleRetiresReusedIdentity(t *testing.T) {
|
|
for _, kind := range []string{DocumentReloadEvent, DocumentLoadCompleteEvent, DocumentLoadStoppedEvent} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
nodes := documentTestNodes()
|
|
tree, _ := NewAccessibleTree(testID("/root"), nodes)
|
|
adapter := New()
|
|
adapter.tree = tree
|
|
doc, _ := tree.Reference(testID("/doc"))
|
|
child, _ := tree.Reference(testID("/link"))
|
|
chrome, _ := tree.Reference(testID("/chrome"))
|
|
adapter.invalidateTreeEvent(kind, 0, doc.ID(), ObjectID{})
|
|
if err := tree.Replace(testID("/root"), nodes); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, ref := range []ObjectReference{doc, child} {
|
|
if _, err := tree.Resolve(ref); !errors.Is(err, ErrStaleObject) {
|
|
t.Fatalf("reused reference survived: %v", err)
|
|
}
|
|
}
|
|
if _, err := tree.Resolve(chrome); err != nil {
|
|
t.Fatalf("unrelated chrome: %v", err)
|
|
}
|
|
if adapter.TreeInvalidations() != 4 {
|
|
t.Fatalf("invalidation count: %d", adapter.TreeInvalidations())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocumentCaptureIsAtomicAcrossReplacement(t *testing.T) {
|
|
tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes())
|
|
ref, _ := tree.Reference(testID("/link"))
|
|
var workers sync.WaitGroup
|
|
workers.Add(1)
|
|
go func() {
|
|
defer workers.Done()
|
|
for i := 0; i < 100; i++ {
|
|
nodes := documentTestNodes()
|
|
for j := range nodes {
|
|
nodes[j].Snapshot.Name = "replacement"
|
|
}
|
|
if err := tree.Replace(testID("/root"), nodes); err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
for i := 0; i < 100; i++ {
|
|
doc, err := tree.Document(context.Background(), ref)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replaced := doc.Context.Ancestors[0].Name == "replacement"
|
|
for _, node := range doc.Nodes {
|
|
if (node.Snapshot.Name == "replacement") != replaced {
|
|
t.Fatal("mixed document captures")
|
|
}
|
|
}
|
|
}
|
|
workers.Wait()
|
|
}
|
|
|
|
func TestDocumentIgnoresCaretAndViewportInvalidation(t *testing.T) {
|
|
for _, event := range []string{CaretEvent, BoundsChangedEvent, TextBoundsChangedEvent, ShowingStateEvent, TextInsertedEvent, VisibleStateEvent} {
|
|
t.Run(event, func(t *testing.T) {
|
|
adapter := New()
|
|
adapter.documentTree, _ = NewAccessibleTree(testID("/root"), documentTestNodes())
|
|
adapter.tree, _ = NewAccessibleTree(testID("/root"), documentTestNodes())
|
|
ref, _ := adapter.documentTree.Reference(testID("/heading"))
|
|
adapter.invalidateTreeEvent(event, 1, ref.ID(), ObjectID{})
|
|
_, err := adapter.documentTree.Document(context.Background(), ref)
|
|
mustRefresh := event == TextInsertedEvent || event == VisibleStateEvent
|
|
if (err != nil) != mustRefresh {
|
|
t.Fatalf("document error = %v, refresh = %v", err, mustRefresh)
|
|
}
|
|
flatRef, _ := adapter.tree.Reference(testID("/heading"))
|
|
if _, err := adapter.tree.Document(context.Background(), flatRef); err == nil {
|
|
t.Fatal("flat review failed to invalidate")
|
|
}
|
|
})
|
|
}
|
|
}
|