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.
104 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|