Files
cthulhu/internal/review/flat_worker_test.go

266 lines
9.3 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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)
}
}