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