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