Files

515 lines
19 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"
"fmt"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func webID(path string) atspi.ObjectID { return atspi.NewObjectID(":1.73", path) }
func TestLinkedHeadingNavigationAndActivation(t *testing.T) {
for _, tc := range []struct {
name, heading, link string
multiple bool
}{
{"heading contains link", "/heading", "/link", false},
{"link contains heading", "/heading", "/link", false},
{"ambiguous links", "/heading", "", true},
{"leading post link", "/heading", "/link1", false},
{"leading link without text offsets", "/heading", "/link1", false},
{"plain text before multiple links", "/heading", "", true},
{"plain text omitted from children", "/heading", "", true},
} {
t.Run(tc.name, func(t *testing.T) {
node := func(path, role string, children ...string) atspi.TreeNode {
s := atspi.NewSnapshot(path, "", role, "", "")
s.BusName = webID(path).BusName
if role == "heading" {
s.Name, s.HeadingLevel = "Linked heading", 2
}
if role == "link" {
s.Visited = true
}
n := atspi.TreeNode{Snapshot: s}
for _, child := range children {
n.Children = append(n.Children, webID(child))
}
return n
}
nodes := []atspi.TreeNode{node("/doc", "document web", "/start"), node("/start", "paragraph")}
switch tc.name {
case "link contains heading":
nodes[0].Children = append(nodes[0].Children, webID("/link"))
nodes = append(nodes, node("/link", "link", "/heading"), node("/heading", "heading"))
case "ambiguous links", "leading post link", "leading link without text offsets", "plain text before multiple links", "plain text omitted from children":
nodes[0].Children = append(nodes[0].Children, webID("/heading"))
nodes = append(nodes, node("/heading", "heading", "/link1", "/link2"), node("/link1", "link"), node("/link2", "link"))
nodes[2].Snapshot.SupportsClick = true
if tc.name != "ambiguous links" {
nodes[3].Children = []atspi.ObjectID{webID("/post_text")}
nodes[4].Children = []atspi.ObjectID{webID("/date_text")}
nodes = append(nodes, node("/post_text", "static"), node("/date_text", "static"))
nodes[5].Snapshot.Name, nodes[5].Snapshot.CharacterCount = "#1", 2
nodes[6].Snapshot.Name, nodes[6].Snapshot.CharacterCount = "Date", 4
}
if tc.name == "leading link without text offsets" || tc.name == "plain text omitted from children" {
nodes[2].Snapshot.Name = "#1 Date"
nodes[3].Snapshot.Name, nodes[4].Snapshot.Name = "#1", "Date"
nodes[5].Snapshot.CharacterCount, nodes[6].Snapshot.CharacterCount = 0, 0
}
if tc.name == "plain text omitted from children" {
nodes[2].Snapshot.Name = "Post #1 Date"
}
if tc.name == "plain text before multiple links" {
nodes[2].Children = append([]atspi.ObjectID{webID("/prefix")}, nodes[2].Children...)
nodes = append(nodes, node("/prefix", "static"))
nodes[len(nodes)-1].Snapshot.Name, nodes[len(nodes)-1].Snapshot.CharacterCount = "Post", 4
}
default:
nodes[0].Children = append(nodes[0].Children, webID("/heading"))
nodes = append(nodes, node("/heading", "heading", "/static"), node("/static", "static", "/link"), node("/link", "link"))
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, _ := NewDocumentCursor(tree)
if _, err := cursor.SyncFocus(context.Background(), webID("/start")); err != nil {
t.Fatal(err)
}
move, err := cursor.Move(context.Background(), DocumentHeading, 2, false)
if err != nil || !move.Moved || move.Position.Object.ID() != webID(tc.heading) {
t.Fatalf("heading move = %+v, %v", move, err)
}
target, err := cursor.ActivationTarget()
if tc.multiple {
if move.Position.LinkedHeading != nil || !errors.Is(err, ErrNoDocumentAction) {
t.Fatalf("ambiguous heading resolved: %+v, %v", move.Position.LinkedHeading, err)
}
} else if err != nil || target.ID() != webID(tc.link) || move.Position.LinkedHeading == nil || !move.Position.LinkedHeading.Visited {
t.Fatalf("linked heading = %+v, target %v, err %v", move.Position, target.ID(), err)
}
})
}
}
func TestDocumentFragmentSelectsPostPermalink(t *testing.T) {
node := func(path, name, role, id string, children ...string) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, name, role, "", "")
snapshot.BusName = webID(path).BusName
snapshot.ElementID = id
result := atspi.TreeNode{Snapshot: snapshot}
for _, child := range children {
result.Children = append(result.Children, webID(child))
}
return result
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
node("/doc", "Forum", "document web", "", "/first", "/second"),
node("/first", "", "section", "p1", "/first_link"),
node("/first_link", "#1", "link", ""),
node("/second", "", "section", "p2", "/heading"),
node("/heading", "#2 User", "heading", "", "/second_link"),
node("/second_link", "#2", "link", ""),
})
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil {
t.Fatal(err)
}
position, found, err := cursor.JumpToFragment(ctx, "p2")
if err != nil || !found || position.Object.ID() != webID("/second_link") {
t.Fatalf("fragment position = %+v, found %t, err %v", position, found, err)
}
if _, found, err := cursor.JumpToFragment(ctx, "absent"); err != nil || found {
t.Fatalf("missing fragment: found %t, err %v", found, err)
}
}
func webNodes() []atspi.TreeNode {
node := func(path, role string, children ...string) atspi.TreeNode {
s := atspi.NewSnapshot(path, path, role, "", "")
s.BusName = webID(path).BusName
if role == "heading" {
s.HeadingLevel = 2
}
n := atspi.TreeNode{Snapshot: s}
for _, child := range children {
n.Children = append(n.Children, webID(child))
}
return n
}
return []atspi.TreeNode{
node("/root", "frame", "/chrome", "/doc", "/other"),
node("/chrome", "entry"),
node("/doc", "document web", "/first", "/hidden", "/second", "/nested", "/button", "/checkbox", "/list", "/separator"),
node("/first", "heading"),
node("/hidden", "heading"),
node("/second", "heading"),
node("/nested", "document web", "/link"),
func() atspi.TreeNode {
link := node("/link", "link")
link.Snapshot.Focusable = true
return link
}(),
node("/other", "document web", "/otherHeading"),
node("/otherHeading", "heading"),
node("/button", "push button"),
node("/checkbox", "check box"),
node("/list", "list"),
node("/separator", "separator"),
}
}
func webCursor(t *testing.T) (*atspi.AccessibleTree, *DocumentCursor) {
t.Helper()
nodes := webNodes()
nodes[4].Geometry = atspi.Geometry{VisibilityKnown: true}
nodes[5].Geometry = atspi.Geometry{VisibilityKnown: true, Visible: true}
tree, err := atspi.NewAccessibleTree(webID("/root"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
return tree, cursor
}
func TestDocumentCursorNavigationAndChromeRoundTrip(t *testing.T) {
_, cursor := webCursor(t)
ctx := context.Background()
position, err := cursor.SyncFocus(ctx, webID("/first"))
if err != nil || position.Origin != NativeNavigation {
t.Fatalf("focus: %+v %v", position, err)
}
move, err := cursor.Move(ctx, DocumentHeading, 2, false)
if err != nil || !move.Moved || move.Position.Snapshot.Path != "/second" || move.Position.Origin != StructuralNavigation || move.Wrapped {
t.Fatalf("heading: %+v %v", move, err)
}
// Duplicate focus events and leaving/returning through chrome preserve
// a separately navigated position; a new native target overrides it.
for _, chrome := range []bool{false, true} {
if chrome {
if _, err := cursor.SyncFocus(ctx, webID("/chrome")); !errors.Is(err, atspi.ErrNotInDocument) {
t.Fatal(err)
}
if _, err := cursor.Move(ctx, DocumentHeading, 0, false); !errors.Is(err, ErrNoCurrent) {
t.Fatal(err)
}
}
position, err = cursor.SyncFocus(ctx, webID("/first"))
if err != nil || position.Snapshot.Path != "/second" {
t.Fatalf("lost position: %+v %v", position, err)
}
}
move, err = cursor.Move(ctx, DocumentLink, 0, false)
if err != nil || !move.Moved || move.Position.Snapshot.Path != "/link" || move.Position.Document.ID() != webID("/doc") || move.Wrapped {
t.Fatalf("embedded link: %+v %v", move, err)
}
move, err = cursor.Move(ctx, DocumentHeading, 0, false)
if err != nil || !move.Moved || !move.Wrapped || move.Position.Snapshot.Path != "/first" {
t.Fatalf("forward heading wrap: %+v %v", move, err)
}
move, err = cursor.Move(ctx, DocumentHeading, 0, true)
if err != nil || !move.Moved || !move.Wrapped || move.Position.Snapshot.Path != "/second" {
t.Fatalf("backward heading: %+v %v", move, err)
}
position, err = cursor.SyncFocus(ctx, webID("/link"))
if err != nil || position.Snapshot.Path != "/link" || position.Origin != NativeNavigation {
t.Fatalf("native focus: %+v %v", position, err)
}
}
func TestDocumentCursorDoesNotReanchorMatchingNestedTarget(t *testing.T) {
nodes := webNodes()
for i := range nodes {
if nodes[i].Snapshot.Path == "/list" {
nodes[i].Children = []atspi.ObjectID{webID("/inner_list")}
}
}
nodes = append(nodes, atspi.TreeNode{Snapshot: atspi.Snapshot{
BusName: webID("/inner_list").BusName, Path: "/inner_list", Role: "list",
}})
tree, err := atspi.NewAccessibleTree(webID("/root"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err := cursor.SyncFocus(ctx, webID("/inner_list")); err != nil {
t.Fatal(err)
}
move, err := cursor.Move(ctx, DocumentList, 0, false)
if err != nil || move.Position.Snapshot.Path != "/list" || !move.Wrapped {
t.Fatalf("next list should advance from the nested list and wrap: %+v, %v", move, err)
}
if _, err := cursor.SyncFocus(ctx, webID("/separator")); err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(ctx, webID("/inner_list")); err != nil {
t.Fatal(err)
}
move, err = cursor.Move(ctx, DocumentList, 0, true)
if err != nil || move.Position.Snapshot.Path != "/list" || move.Wrapped {
t.Fatalf("previous list should reach containing list without wrapping: %+v, %v", move, err)
}
}
func TestDocumentCursorAnchorsNavigationAtMatchingAncestor(t *testing.T) {
node := func(path, role string, children ...string) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, path, role, "", "")
snapshot.BusName = webID(path).BusName
result := atspi.TreeNode{Snapshot: snapshot}
for _, child := range children {
result.Children = append(result.Children, webID(child))
}
return result
}
first := node("/first", "link", "/first/text")
first.Snapshot.Focusable = true
second := node("/second", "link")
second.Snapshot.Focusable = true
tree, err := atspi.NewAccessibleTree(webID("/root"), []atspi.TreeNode{
node("/root", "frame", "/doc"),
node("/doc", "document web", "/first", "/second"),
first,
node("/first/text", "static"),
second,
})
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(context.Background(), webID("/first/text")); err != nil {
t.Fatal(err)
}
move, err := cursor.Move(context.Background(), DocumentLink, 0, true)
if err != nil || !move.Moved || !move.Wrapped || move.Position.Snapshot.Path != "/second" {
t.Fatalf("previous link from descendant = %+v, %v", move, err)
}
}
func TestDocumentCursorStructuralTargets(t *testing.T) {
_, cursor := webCursor(t)
ctx := context.Background()
cursor.SyncFocus(ctx, webID("/first"))
for _, tc := range []struct {
target DocumentTarget
path string
}{
{DocumentButton, "/button"},
{DocumentCheckBox, "/checkbox"},
{DocumentList, "/list"},
{DocumentSeparator, "/separator"},
} {
move, err := cursor.Move(ctx, tc.target, 0, false)
if err != nil || !move.Moved || move.Position.Snapshot.Path != tc.path || move.Position.Origin != StructuralNavigation || move.Wrapped {
t.Fatalf("target %v: %+v err=%v", tc.target, move, err)
}
move, err = cursor.Move(ctx, tc.target, 0, false)
if err != nil || !move.Moved || !move.Wrapped || move.Position.Snapshot.Path != tc.path {
t.Fatalf("target %v wrap: %+v err=%v", tc.target, move, err)
}
cursor.SyncFocus(ctx, webID("/first"))
}
if _, err := cursor.Move(ctx, DocumentHeading, 7, false); err == nil {
t.Fatal("invalid heading level accepted")
}
}
func TestDocumentCursorReferenceStructuralTargets(t *testing.T) {
snapshot := func(path, role string, configure func(*atspi.Snapshot)) atspi.TreeNode {
s := atspi.NewSnapshot(path, path, role, "", "")
s.BusName = webID(path).BusName
if configure != nil {
configure(&s)
}
return atspi.TreeNode{Snapshot: s}
}
nodes := []atspi.TreeNode{
snapshot("/doc", "document web", nil),
snapshot("/start", "section", nil),
snapshot("/blockquote", "block quote", nil),
snapshot("/combo", "combo box", nil),
snapshot("/entry", "entry", func(s *atspi.Snapshot) { s.Editable, s.Focusable = true, true }),
snapshot("/form", "push button", func(s *atspi.Snapshot) { s.Focusable = true }),
snapshot("/image", "image", nil),
snapshot("/landmark", "landmark", nil),
snapshot("/listitem", "list item", nil),
snapshot("/live", "section", func(s *atspi.Snapshot) { s.LiveRegion = true }),
snapshot("/paragraph", "paragraph", func(s *atspi.Snapshot) { s.CharacterCount = 4 }),
snapshot("/radio", "radio button", nil),
snapshot("/table", "table", func(s *atspi.Snapshot) { s.TableRowCount = 1 }),
snapshot("/tablecell", "table cell", nil),
snapshot("/unvisited", "link", func(s *atspi.Snapshot) { s.Focusable = true }),
snapshot("/visited", "link", func(s *atspi.Snapshot) { s.Focusable, s.Visited = true, true }),
snapshot("/clickable", "section", func(s *atspi.Snapshot) { s.SupportsClick = true }),
snapshot("/chunk", "paragraph", func(s *atspi.Snapshot) { s.CharacterCount, s.EmbeddedObjectCount = 80, 1 }),
snapshot("/heading", "heading", func(s *atspi.Snapshot) { s.HeadingLevel = 3 }),
}
nodes[0].Children = []atspi.ObjectID{
webID("/start"), webID("/blockquote"), webID("/combo"), webID("/entry"),
webID("/form"), webID("/image"), webID("/landmark"), webID("/listitem"),
webID("/live"), webID("/paragraph"), webID("/radio"), webID("/table"),
webID("/unvisited"), webID("/visited"), webID("/clickable"),
webID("/chunk"), webID("/heading"),
}
nodes[12].Children = []atspi.ObjectID{webID("/tablecell")}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err := cursor.SyncFocus(ctx, webID("/start")); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
target DocumentTarget
level int
path string
}{
{DocumentBlockQuote, 0, "/blockquote"},
{DocumentComboBox, 0, "/combo"},
{DocumentEntry, 0, "/entry"},
{DocumentFormField, 0, "/form"},
{DocumentImage, 0, "/image"},
{DocumentLandmark, 0, "/landmark"},
{DocumentListItem, 0, "/listitem"},
{DocumentLiveRegion, 0, "/live"},
{DocumentParagraph, 0, "/paragraph"},
{DocumentRadioButton, 0, "/radio"},
{DocumentTable, 0, "/table"},
{DocumentUnvisitedLink, 0, "/unvisited"},
{DocumentVisitedLink, 0, "/visited"},
{DocumentClickable, 0, "/clickable"},
{DocumentChunk, 0, "/chunk"},
{DocumentHeading, 3, "/heading"},
} {
move, err := cursor.Move(ctx, tc.target, tc.level, false)
if err != nil || !move.Moved || move.Position.Snapshot.Path != tc.path {
t.Fatalf("target %v level %d: %+v err=%v", tc.target, tc.level, move, err)
}
if _, err := cursor.SyncFocus(ctx, webID("/start")); err != nil {
t.Fatal(err)
}
}
}
func TestDocumentCursorTabsAndInvalidation(t *testing.T) {
tree, cursor := webCursor(t)
ctx := context.Background()
cursor.SyncFocus(ctx, webID("/first"))
cursor.Move(ctx, DocumentHeading, 0, false)
cursor.SyncFocus(ctx, webID("/otherHeading"))
position, err := cursor.SyncFocus(ctx, webID("/first"))
if err != nil || position.Snapshot.Path != "/second" {
t.Fatalf("tab restoration: %+v %v", position, err)
}
tree.Invalidate(webID("/doc"))
if _, err := cursor.Move(ctx, DocumentHeading, 0, false); !errors.Is(err, atspi.ErrStaleObject) {
t.Fatal(err)
}
if err := tree.Replace(webID("/root"), webNodes()); err != nil {
t.Fatal(err)
}
position, err = cursor.SyncFocus(ctx, webID("/first"))
if err != nil || position.Snapshot.Path != "/first" || position.Origin != NativeNavigation {
t.Fatalf("inherited stale position: %+v %v", position, err)
}
if _, err := cursor.SyncFocus(ctx, webID("/missing")); !errors.Is(err, atspi.ErrObjectNotFound) {
t.Fatal(err)
}
if _, err := cursor.Move(ctx, DocumentHeading, 0, false); !errors.Is(err, ErrNoCurrent) {
t.Fatal(err)
}
}
func TestDocumentCursorRejectsReparentedObject(t *testing.T) {
tree, cursor := webCursor(t)
ctx := context.Background()
cursor.SyncFocus(ctx, webID("/first"))
nodes := webNodes()
nodes[2].Children = nodes[2].Children[1:]
nodes[8].Children = append(nodes[8].Children, webID("/first"))
if err := tree.Replace(webID("/root"), nodes); err != nil {
t.Fatal(err)
}
if _, err := cursor.Move(ctx, DocumentHeading, 0, false); !errors.Is(err, atspi.ErrStaleObject) {
t.Fatal(err)
}
}
func TestDocumentCursorCanceledMovePreservesPosition(t *testing.T) {
_, cursor := webCursor(t)
cursor.SyncFocus(context.Background(), webID("/first"))
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := cursor.Move(ctx, DocumentHeading, 0, false); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
move, err := cursor.Move(context.Background(), DocumentHeading, 0, true)
if err != nil || !move.Moved || !move.Wrapped || move.Position.Snapshot.Path != "/second" {
t.Fatalf("position after canceled move: %+v %v", move, err)
}
}
func TestDocumentCursorBoundsRememberedDocuments(t *testing.T) {
_, cursor := webCursor(t)
ctx := context.Background()
for i := 0; i < 20; i++ {
nodes := webNodes()
// Simulate reused native paths after each page replacement.
cursor.tree.Invalidate(webID("/doc"))
nodes[3].Snapshot.Name = fmt.Sprintf("page %d", i)
if err := cursor.tree.Replace(webID("/root"), nodes); err != nil {
t.Fatal(err)
}
position, err := cursor.SyncFocus(ctx, webID("/first"))
if err != nil || position.Snapshot.Name != fmt.Sprintf("page %d", i) {
t.Fatalf("page %d position: %+v %v", i, position, err)
}
}
if len(cursor.places) != 16 {
t.Fatalf("remembered %d documents", len(cursor.places))
}
}