// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi import ( "context" "errors" "sync" "testing" ) func TestNavigationDocumentConcurrentReplacementKeepsViewsImmutable(t *testing.T) { tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes()) ref, _ := tree.Reference(testID("/heading")) var workers sync.WaitGroup workers.Add(1) go func() { defer workers.Done() for range 100 { nodes := documentTestNodes() for index := range nodes { nodes[index].Snapshot.Name = "Replacement" } if err := tree.Replace(testID("/root"), nodes); err != nil { t.Error(err) return } } }() defer workers.Wait() for range 100 { view, err := tree.NavigationDocument(context.Background(), ref) if err != nil { t.Fatal(err) } replaced := view.Context.Ancestors[0].Name == "Replacement" for _, node := range view.Nodes { if (node.Snapshot.Name == "Replacement") != replaced { t.Fatal("mixed replacement data in shared view") } } if err := tree.ValidateDocumentRange(context.Background(), view, 0, len(view.Nodes)-1); err != nil && !errors.Is(err, ErrStaleObject) { t.Fatal(err) } } } func TestNavigationDocumentSharesOnlyImmutableData(t *testing.T) { tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes()) ref, _ := tree.Reference(testID("/heading")) first, err := tree.NavigationDocument(context.Background(), ref) if err != nil { t.Fatal(err) } second, err := tree.NavigationDocument(context.Background(), ref) if err != nil || !first.SameContent(second) || &first.Nodes[0] != &second.Nodes[0] { t.Fatal("unchanged document data was rebuilt", err) } copy, err := tree.Document(context.Background(), ref) if err != nil { t.Fatal(err) } copy.Nodes[0].Snapshot.Name = "Changed copy" if first.Nodes[0].Snapshot.Name == "Changed copy" { t.Fatal("owned snapshot modified shared navigation data") } for index, node := range first.Nodes { if got, ok := first.Index(node.Reference.ID()); !ok || got != index { t.Fatalf("index for %v = %d, %v", node.Reference.ID(), got, ok) } if end := first.SubtreeEnd(index); end < index || end >= len(first.Nodes) { t.Fatalf("invalid subtree end %d", end) } } } func TestNavigationDocumentValidatesOnlyTraversedBranches(t *testing.T) { for _, test := range []struct { name string invalidate func(*AccessibleTree) want error }{ {"text", func(tree *AccessibleTree) { tree.InvalidateText(testID("/link")) }, ErrStaleText}, {"semantics", func(tree *AccessibleTree) { tree.InvalidateSnapshot(testID("/link")) }, ErrStaleSnapshot}, {"geometry", func(tree *AccessibleTree) { tree.InvalidateGeometry(testID("/inner")) }, ErrStaleGeometry}, {"children", func(tree *AccessibleTree) { tree.MarkChildrenChanged(testID("/inner")) }, ErrIncompleteTree}, } { t.Run(test.name, func(t *testing.T) { tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes()) ref, _ := tree.Reference(testID("/heading")) view, err := tree.NavigationDocument(context.Background(), ref) if err != nil { t.Fatal(err) } test.invalidate(tree) current, err := tree.NavigationDocument(context.Background(), ref) if err != nil || !current.SameContent(view) { t.Fatal("unrelated dirty branch rebuilt document", err) } heading, _ := view.Index(ref.ID()) if err := tree.ValidateDocumentRange(context.Background(), view, heading, heading); err != nil { t.Fatal("unrelated update blocked current heading", err) } if err := tree.ValidateDocumentRange(context.Background(), view, heading, len(view.Nodes)-1); !errors.Is(err, test.want) { t.Fatalf("traversed stale branch: %v, want %v", err, test.want) } }) } } func TestNavigationDocumentRejectsRetiredViewsAndCanceledMoves(t *testing.T) { tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes()) ref, _ := tree.Reference(testID("/heading")) view, _ := tree.NavigationDocument(context.Background(), ref) ctx, cancel := context.WithCancel(context.Background()) cancel() if err := tree.ValidateDocumentRange(ctx, view, 0, 0); !errors.Is(err, context.Canceled) { t.Fatal(err) } if err := tree.Replace(testID("/root"), documentTestNodes()); err != nil { t.Fatal(err) } if err := tree.ValidateDocumentRange(context.Background(), view, 0, 0); !errors.Is(err, ErrStaleObject) { t.Fatal("replacement retained old view", err) } fresh, _ := tree.NavigationDocument(context.Background(), ref) if fresh.SameContent(view) { t.Fatal("replacement reused navigation data") } tree.Invalidate(testID("/link")) if err := tree.ValidateDocumentRange(context.Background(), fresh, 0, 0); !errors.Is(err, ErrStaleObject) { t.Fatal("removal retained old topology", err) } } func TestNavigationDocumentValidatesDerivedRowDependencies(t *testing.T) { node := func(path, role string, children ...ObjectID) TreeNode { n := testNode(path, path, children...) n.Snapshot.Role = role return n } nodes := []TreeNode{node("/doc", "document web", testID("/table")), node("/table", "table", testID("/first"), testID("/second")), node("/first", "table cell", testID("/text")), node("/text", "static"), node("/second", "table cell")} for _, index := range []int{2, 4} { nodes[index].Snapshot.TablePositionKnown = true nodes[index].Snapshot.TableColumnIndex = index/2 - 1 } tree, err := NewAccessibleTree(testID("/doc"), nodes) if err != nil { t.Fatal(err) } ref, _ := tree.Reference(testID("/text")) view, err := tree.NavigationDocument(context.Background(), ref) if err != nil { t.Fatal(err) } index, _ := view.Index(ref.ID()) tree.InvalidateText(testID("/second")) if err := tree.ValidateDocumentRange(context.Background(), view, index, index); !errors.Is(err, ErrStaleText) { t.Fatal("cached full row ignored a changed sibling cell", err) } }