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