// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package review import ( "context" "errors" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) type readAllCapturer struct { *fakeCapturer caretTextReader tree *atspi.AccessibleTree nativePlacements int } func (capturer *readAllCapturer) CaptureDocument(ctx context.Context, _ atspi.ObjectID) (*atspi.AccessibleTree, error) { return capturer.tree, ctx.Err() } type sequenceReadAllCapturer struct { *fakeCapturer caretTextReader trees []*atspi.AccessibleTree index int invalidateTree *atspi.AccessibleTree staleOnce bool staleAt int reads int invalidateAfter bool } func (capturer *sequenceReadAllCapturer) CaptureDocument(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error) { if capturer.index >= len(capturer.trees) { return nil, errors.New("no more trees") } tree := capturer.trees[capturer.index] capturer.index++ return tree, nil } func (capturer *sequenceReadAllCapturer) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { capturer.reads++ if capturer.staleOnce || capturer.staleAt == capturer.reads { capturer.staleOnce = false capturer.invalidateTree.InvalidateSnapshot(reference.ID()) return atspi.DocumentCaretText{}, atspi.ErrStaleSnapshot } result, err := capturer.caretTextReader.ReadDocumentCaret(ctx, reference, offset, unit) if capturer.invalidateAfter { capturer.invalidateAfter = false capturer.invalidateTree.InvalidateSnapshot(reference.ID()) } return result, err } func TestReadAllProgressRebindsEarlierLineAfterRecapture(t *testing.T) { text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"} cursor := newCaretTextCursor(t, text) fresh := newCaretTextCursor(t, text).tree // Give the recaptured text a new generation, as happens when a provider // removes and recreates the object while keeping its accessible path. fresh.Invalidate(webID("/text")) nodes := make([]atspi.TreeNode, 0, 3) for _, path := range []string{"/doc", "/text", "/next"} { snapshot := atspi.NewSnapshot(path, "", "static", "", "") snapshot.BusName = webID(path).BusName snapshot.CharacterCount = len([]rune(text[webID(path)])) node := atspi.TreeNode{Snapshot: snapshot} if path == "/doc" { node.Snapshot.Role = "document web" node.Children = []atspi.ObjectID{webID("/text"), webID("/next")} } nodes = append(nodes, node) } if err := fresh.Replace(webID("/doc"), nodes); err != nil { t.Fatal(err) } capturer := &sequenceReadAllCapturer{ caretTextReader: text, trees: []*atspi.AccessibleTree{fresh}, invalidateTree: cursor.tree, staleAt: 2, } worker, _ := NewWorker(capturer, time.Second) worker.SyncFocus(webID("/text")) result := worker.readAll(context.Background(), cursor) if result.Err != nil || len(result.ReadAllLines) < 2 { t.Fatalf("read all after recapture: %+v", result) } result.ReadAllProgress(0) request := <-worker.requests if request.readTree != fresh { t.Fatal("progress did not use recaptured tree") } followedCursor, followed := worker.followReadAll(context.Background(), cursor, request) if !followed || followedCursor.places[len(followedCursor.places)-1].object != request.readPosition.Object { t.Fatalf("first spoken line did not move the browse cursor after recapture: %+v", request.readPosition) } current, err := fresh.Reference(webID("/text")) if err != nil || request.readPosition.Object != current { t.Fatalf("spoken line did not rebind to the current object: %v, %v", request.readPosition.Object, err) } } func TestReadAllRetriesSecondStaleSnapshotCapture(t *testing.T) { text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"} stale := newCaretTextCursor(t, text).tree stale.InvalidateSnapshot(webID("/text")) fresh := newCaretTextCursor(t, text).tree capturer := &sequenceReadAllCapturer{caretTextReader: text, trees: []*atspi.AccessibleTree{stale, fresh}} worker, _ := NewWorker(capturer, time.Second) worker.SyncFocus(webID("/text")) cursor := newCaretTextCursor(t, text) cursor.tree.InvalidateSnapshot(webID("/text")) result := worker.readAll(context.Background(), cursor) if result.Err != nil || len(result.ReadAllLines) == 0 { t.Fatalf("read all after stale snapshot retry: %+v", result) } } func TestReadAllRetriesStaleLineMovementWithoutSkipping(t *testing.T) { text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"} cursor := newCaretTextCursor(t, text) fresh := newCaretTextCursor(t, text).tree secondFresh := newCaretTextCursor(t, text).tree thirdFresh := newCaretTextCursor(t, text).tree fourthFresh := newCaretTextCursor(t, text).tree capturer := &sequenceReadAllCapturer{ caretTextReader: text, trees: []*atspi.AccessibleTree{fresh, secondFresh, thirdFresh, fourthFresh}, invalidateTree: cursor.tree, staleOnce: true, } worker, _ := NewWorker(capturer, time.Second) worker.SyncFocus(webID("/text")) result := worker.readAll(context.Background(), cursor) if result.Err != nil || len(result.ReadAllLines) < 2 { t.Fatalf("read all stale line movement retry: %+v", result) } } func TestReadAllContinuesWhenAncestorContextBecomesStale(t *testing.T) { text := caretTextReader{webID("/text"): "First\nSecond\n", webID("/next"): "Last"} cursor := newCaretTextCursor(t, text) fresh := newCaretTextCursor(t, text).tree capturer := &sequenceReadAllCapturer{ caretTextReader: text, trees: []*atspi.AccessibleTree{fresh, fresh}, invalidateTree: cursor.tree, invalidateAfter: true, } worker, _ := NewWorker(capturer, time.Second) worker.SyncFocus(webID("/text")) result := worker.readAll(context.Background(), cursor) if result.Err != nil || len(result.ReadAllLines) < 2 { t.Fatalf("read all stale ancestor context: %+v", result) } } func (capturer *readAllCapturer) SetDocumentCaretPosition(context.Context, atspi.ObjectReference, atspi.ObjectID, int) error { capturer.nativePlacements++ return nil } func TestReadAllStartsAtUnicodeOffsetAndPreservesCursorUntilSpeech(t *testing.T) { text := caretTextReader{webID("/text"): "Café 猫\nSecond line\n", webID("/next"): "Last line"} cursor := newCaretTextCursor(t, text) cursor.places[0].offset = 5 reader := &readAllCapturer{caretTextReader: text, tree: cursor.tree} worker, _ := NewWorker(reader, time.Second) worker.SyncFocus(webID("/text")) result := worker.readAll(context.Background(), cursor) if result.Err != nil { t.Fatal(result.Err) } var lines []string for _, line := range result.ReadAllLines { lines = append(lines, strings.TrimSpace(line.Contents[0].Text)) } if got := strings.Join(lines, "|"); got != "猫|Second line|Last line" { t.Fatalf("read all = %q", got) } if cursor.places[0].offset != 5 || cursor.places[0].object.ID() != webID("/text") { t.Fatal("capture advanced user's cursor before speech") } result.ReadAllProgress(1) request := <-worker.requests cursor, _ = worker.followReadAll(context.Background(), cursor, request) if cursor.places[0].offset != 7 { t.Fatalf("speech progress offset = %d", cursor.places[0].offset) } if reader.nativePlacements != 0 { t.Fatalf("read-all progress moved the native caret %d times", reader.nativePlacements) } resumed := worker.readAll(context.Background(), cursor) if resumed.Err != nil || strings.TrimSpace(resumed.ReadAllLines[0].Contents[0].Text) != "Second line" { t.Fatalf("resumed = %+v", resumed) } } func TestReadAllCancellation(t *testing.T) { text := caretTextReader{webID("/text"): "First", webID("/next"): "Last"} cursor := newCaretTextCursor(t, text) worker, _ := NewWorker(&readAllCapturer{caretTextReader: text, tree: cursor.tree}, time.Second) worker.SyncFocus(webID("/text")) ctx, cancel := context.WithCancel(context.Background()) cancel() result := worker.readAll(ctx, cursor) if !errors.Is(result.Err, context.Canceled) || len(result.ReadAllLines) != 0 { t.Fatalf("canceled read all = %+v", result) } }