Files

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")
}
})
}
}