Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,369 @@
|
||||
// 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user