515 lines
19 KiB
Go
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))
|
|
}
|
|
}
|