Files
cthulhu/internal/review/document_navigation_test.go
hjozwiak 7eef6e8be6 Make web document navigation survive live-updating pages
Recreated-node caret recovery, tolerant caret validation, per-root retained document trees and cursors across tab switches, document warm-up, coalesced structural moves, and hung-provider fast-fail with an 'Application is not responding' announcement.
2026-11-03 10:34:55 -05:00

104 lines
4.5 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"errors"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
// Text and geometry staleness used to force a recapture before a caret move
// could commit onto the dirty target. On constantly ticking pages the
// target (and everything between caret and target) is re-invalidated faster
// than capture-and-retry can run, which trapped the caret outright. Caret
// moves read text and line bounds live, so the live read now wins for those
// two; semantic snapshot staleness (names are spoken from the snapshot)
// still refreshes first.
func TestDocumentCaretDefersUnrelatedUpdatesUntilTraversed(t *testing.T) {
for _, test := range []struct {
name string
invalidate func(*atspi.AccessibleTree)
want error
}{
{"text", func(tree *atspi.AccessibleTree) { tree.InvalidateText(webID("/next")) }, nil},
{"semantics", func(tree *atspi.AccessibleTree) { tree.InvalidateSnapshot(webID("/next")) }, atspi.ErrStaleSnapshot},
{"geometry", func(tree *atspi.AccessibleTree) { tree.InvalidateGeometry(webID("/next")) }, nil},
} {
t.Run(test.name, func(t *testing.T) {
reader := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"}
cursor := newCaretTextCursor(t, reader)
// The provider text moves on after capture; the live read must
// be what the move reports, not the captured length or text.
reader[webID("/next")] = "Next updated"
test.invalidate(cursor.tree)
if _, err := cursor.SyncFocus(context.Background(), webID("/text")); err != nil {
t.Fatal("unrelated update blocked focus synchronization", err)
}
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter)
if err != nil || !move.Moved || move.Text != "l" {
t.Fatalf("local character move: %+v %v", move, err)
}
move, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine)
if test.want == nil {
if err != nil || !move.Moved || move.Text != "Next updated" || move.Position.Object.ID() != webID("/next") {
t.Fatalf("live target read: %+v %v, want Next updated", move, err)
}
return
}
if !errors.Is(err, test.want) {
t.Fatalf("dirty target read: %v, want %v", err, test.want)
}
if place := cursor.places[0]; place.object.ID() != webID("/text") || place.offset != 1 {
t.Fatalf("failed move changed position: %+v", place)
}
})
}
}
// Crossing now follows captured child order with generation-checked
// endpoints and live reads; structural navigation still refreshes stale
// child order before promising the next target.
func TestDocumentCaretKeepsLocalTextThroughParentInsertion(t *testing.T) {
reader := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"}
cursor := newCaretTextCursor(t, reader)
cursor.tree.MarkChildrenChanged(webID("/doc"))
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter)
if err != nil || !move.Moved || move.Text != "l" {
t.Fatalf("parent insertion blocked live text: %+v %v", move, err)
}
move, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine)
if err != nil || !move.Moved || move.Text != "Next" || move.Position.Object.ID() != webID("/next") {
t.Fatalf("crossing changed child order: %+v %v", move, err)
}
}
func TestDocumentStructuralMoveDefersUnrelatedBranch(t *testing.T) {
nodes := []atspi.TreeNode{
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/one"), webID("/two"), webID("/later")}},
{Snapshot: atspi.Snapshot{BusName: webID("/one").BusName, Path: "/one", Role: "heading", Name: "First"}},
{Snapshot: atspi.Snapshot{BusName: webID("/two").BusName, Path: "/two", Role: "heading", Name: "Second"}},
{Snapshot: atspi.Snapshot{BusName: webID("/later").BusName, Path: "/later", Role: "static", Name: "Later"}},
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, _ := NewDocumentCursor(tree)
if _, err := cursor.SyncFocus(context.Background(), webID("/one")); err != nil {
t.Fatal(err)
}
tree.InvalidateText(webID("/later"))
move, err := cursor.Move(context.Background(), DocumentHeading, 0, false)
if err != nil || !move.Moved || move.Position.Object.ID() != webID("/two") {
t.Fatalf("unrelated update blocked adjacent heading: %+v %v", move, err)
}
if _, err := cursor.Move(context.Background(), DocumentHeading, 0, false); !errors.Is(err, atspi.ErrStaleText) {
t.Fatal("wrapped across dirty content", err)
}
}