// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package review import ( "context" "errors" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) type freshFlatCapturer struct{ *fakeCapturer } func (*freshFlatCapturer) FreshReviewWindow(atspi.ObjectID) bool { return true } func TestFlatWorkerFreshProviderPreservesCursor(t *testing.T) { capturer := &freshFlatCapturer{&fakeCapturer{calls: make(chan captureCall, 1)}} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() defer worker.Stop(context.Background()) worker.SyncFocus(objectID("/a")) tree := flatTree(t, flatNode("/a", "alpha beta", 0, 0)) for i, step := range []int{0, 1, 0} { worker.ReadFlat(Word, step) var call captureCall select { case call = <-capturer.calls: case <-time.After(time.Second): t.Fatal("command reused cached text") } if i == 2 { fresh := flatTree(t, flatNode("/a", "omega zeta", 0, 0)) snapshot, _ := fresh.ReviewWindow(context.Background(), objectID("/a")) if err := tree.Replace(objectID("/window"), snapshot.Nodes); err != nil { t.Fatal(err) } } call.result <- captureResult{tree: tree} got := waitResult(t, worker) want := []string{"alpha ", "beta", "zeta"}[i] if got.Err != nil || got.FlatPosition.Text != want { t.Fatalf("cursor: %+v want=%s", got, want) } } } func TestFlatWorkerFreshProviderFailureDoesNotSpeakCachedText(t *testing.T) { capturer := &freshFlatCapturer{&fakeCapturer{calls: make(chan captureCall, 1)}} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() defer worker.Stop(context.Background()) worker.SyncFocus(objectID("/a")) worker.ReadFlat(Line, 0) call := <-capturer.calls call.result <- captureResult{tree: flatTree(t, flatNode("/a", "secret old text", 0, 0))} waitResult(t, worker) worker.ReadFlat(Line, 0) select { case call = <-capturer.calls: case <-time.After(time.Second): t.Fatal("failed freshness policy reused cached text") } call.result <- captureResult{err: context.DeadlineExceeded} result := waitResult(t, worker) if !errors.Is(result.Err, context.DeadlineExceeded) || result.FlatPosition.Text != "" { t.Fatalf("cached output: %+v", result) } } func TestFlatWorkerRecapturesOnceAndHonorsFocusDuringCapture(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() defer worker.Stop(context.Background()) worker.SyncFocus(objectID("/a")) if err := worker.ReadFlat(Line, 0); err != nil { t.Fatal(err) } call := <-capturer.calls if call.reviewFocus != objectID("/a") { t.Fatalf("review capture focus = %#v", call.reviewFocus) } worker.SyncFocus(objectID("/b")) tree := flatTree(t, flatNode("/a", "First", 0, 0), flatNode("/b", "Second", 0, 30)) call.result <- captureResult{tree: tree} if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "Second" { t.Fatalf("result=%#v", result) } worker.ReadFlat(Line, -1) if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "First" { t.Fatalf("result=%#v", result) } tree.InvalidateText(objectID("/a")) worker.ReadFlat(Line, 0) call = <-capturer.calls // A provider that remains stale after one recapture must fail, not loop. call.result <- captureResult{tree: tree} if result := waitResult(t, worker); !errors.Is(result.Err, atspi.ErrStaleText) || result.FlatPosition.Text != "" { t.Fatalf("result=%#v", result) } worker.ReadFlat(Line, 0) call = <-capturer.calls fresh := flatTree(t, flatNode("/a", "Refreshed", 0, 0), flatNode("/b", "Second", 0, 30)) snapshot, err := fresh.ReviewWindow(context.Background(), objectID("/a")) if err != nil { t.Fatal(err) } if err := tree.Replace(objectID("/window"), snapshot.Nodes); err != nil { t.Fatal(err) } call.result <- captureResult{tree: tree} if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "Refreshed" { t.Fatalf("independent position lost: %#v", result) } } func TestFlatWorkerPreservesQueuedSpeechStyles(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() defer worker.Stop(context.Background()) worker.SyncFocus(objectID("/a")) for _, style := range []FlatSpeech{FlatSpeak, FlatSpell, FlatPhonetic} { if err := worker.ReadFlatWithSpeech(Word, 0, style); err != nil { t.Fatal(err) } } call := <-capturer.calls call.result <- captureResult{tree: flatTree(t, flatNode("/a", "book", 0, 0))} for _, style := range []FlatSpeech{FlatSpeak, FlatSpell, FlatPhonetic} { result := waitResult(t, worker) if result.Err != nil || result.FlatSpeech != style || result.FlatPosition.Text != "book" { t.Fatalf("queued style=%d result=%#v", style, result) } } } func TestFlatWorkerStartsAtBrowsePositionThenMovesIndependently(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() defer worker.Stop(context.Background()) worker.SyncFocus(objectID("/window")) worker.SetFlatOrigin(objectID("/b"), 0) if err := worker.ReadFlat(Line, 0); err != nil { t.Fatal(err) } call := <-capturer.calls call.result <- captureResult{tree: flatTree(t, flatNode("/a", "First", 0, 0), flatNode("/b", "Second", 0, 30))} if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "Second" { t.Fatalf("initial browse position: %+v", result) } worker.SetFlatOrigin(objectID("/a"), 0) if err := worker.ReadFlat(Line, 0); err != nil { t.Fatal(err) } if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "Second" { t.Fatalf("flat cursor followed later browse movement: %+v", result) } } func TestFlatWorkerCaptureCancellationAndQueueBounds(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() worker.SyncFocus(objectID("/a")) worker.ReadFlat(Line, 0) call := <-capturer.calls for i := 0; i < requestCapacity; i++ { if err := worker.ReadFlat(Word, 1); err != nil { t.Fatal(err) } } if err := worker.ReadFlat(Character, 1); !errors.Is(err, ErrRequestQueueFull) { t.Fatal(err) } stopCtx, stopCancel := context.WithTimeout(context.Background(), time.Second) defer stopCancel() if err := worker.Stop(stopCtx); err != nil { t.Fatal(err) } if !errors.Is(call.ctx.Err(), context.Canceled) { t.Fatal("capture was not canceled") } } func TestFlatWorkerUsesSeparateCaptureDeadline(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, err := NewWorker(capturer, time.Second) if err != nil { t.Fatal(err) } if err := worker.SetFlatCaptureTimeout(10 * time.Second); err != nil { t.Fatal(err) } if err := worker.Start(context.Background()); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) if err := worker.SetFlatCaptureTimeout(time.Second); err == nil { t.Fatal("changed flat review deadline while worker was running") } worker.SyncFocus(objectID("/a")) if err := worker.ReadFlat(Line, 0); err != nil { t.Fatal(err) } call := <-capturer.calls deadline, ok := call.ctx.Deadline() if !ok || time.Until(deadline) < 8*time.Second { t.Fatalf("flat review capture deadline = %v, present = %t", deadline, ok) } call.result <- captureResult{tree: flatTree(t, flatNode("/a", "Visible", 0, 0))} if result := waitResult(t, worker); result.Err != nil || result.FlatPosition.Text != "Visible" { t.Fatalf("flat review = %+v", result) } } func TestFlatWorkerAcceptsVerticalAndLineEndUnits(t *testing.T) { capturer := &fakeCapturer{calls: make(chan captureCall, 1)} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() defer worker.Stop(context.Background()) worker.SyncFocus(objectID("/bottom")) for index, tc := range []struct { unit FlatUnit text string }{ {WordAbove, "first"}, {WordBelow, "last"}, {LineEnd, "t"}, } { if err := worker.ReadFlat(tc.unit, 0); err != nil { t.Fatalf("unit %d: %v", tc.unit, err) } if index == 0 { select { case call := <-capturer.calls: call.result <- captureResult{tree: flatTree(t, flatNode("/top", "first", 0, 0), flatNode("/bottom", "last", 0, 40))} case <-time.After(time.Second): t.Fatal("flat review capture did not start") } } if result := waitResult(t, worker); result.Err != nil || result.FlatUnit != tc.unit || result.FlatPosition.Text != tc.text || !result.Moved { t.Fatalf("unit %d: result=%+v", tc.unit, result) } } if err := worker.ReadFlat(LineEnd, 1); !errors.Is(err, ErrInvalidDirection) { t.Fatalf("line end step: %v", err) } }