Files

406 lines
12 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"
"reflect"
"sync"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
type captureCall struct {
ctx context.Context
reviewFocus atspi.ObjectID
result chan captureResult
}
type captureResult struct {
tree *atspi.AccessibleTree
err error
}
type fakeCapturer struct {
calls chan captureCall
}
func (capturer *fakeCapturer) CaptureDesktop(ctx context.Context) (*atspi.AccessibleTree, error) {
return capturer.capture(ctx, atspi.ObjectID{})
}
func (capturer *fakeCapturer) CaptureReviewWindow(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
return capturer.capture(ctx, focus)
}
func (capturer *fakeCapturer) CaptureDocument(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
return capturer.capture(ctx, focus)
}
func (capturer *fakeCapturer) CaptureDocumentText(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
return capturer.capture(ctx, focus)
}
func (capturer *fakeCapturer) capture(ctx context.Context, reviewFocus atspi.ObjectID) (*atspi.AccessibleTree, error) {
call := captureCall{ctx: ctx, reviewFocus: reviewFocus, result: make(chan captureResult, 1)}
capturer.calls <- call
select {
case result := <-call.result:
return result.tree, result.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
func startWorker(t *testing.T, capturer TreeCapturer, timeout time.Duration) (*Worker, context.CancelFunc) {
t.Helper()
worker, err := NewWorker(capturer, timeout)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := worker.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
return worker, cancel
}
func waitResult(t *testing.T, worker *Worker) Result {
t.Helper()
select {
case result := <-worker.Results():
return result
case <-time.After(time.Second):
t.Fatal("timed out waiting for object review result")
return Result{}
}
}
func TestWorkerCaptureTimeoutDoesNotBlockCallers(t *testing.T) {
capturer := &fakeCapturer{calls: make(chan captureCall, 1)}
worker, cancel := startWorker(t, capturer, 20*time.Millisecond)
defer cancel()
worker.SyncFocus(objectID("/first"))
if err := worker.Move(NextSibling); err != nil {
t.Fatal(err)
}
call := <-capturer.calls
select {
case <-call.ctx.Done():
case <-time.After(time.Second):
t.Fatal("capture context did not time out")
}
result := waitResult(t, worker)
if !errors.Is(result.Err, context.DeadlineExceeded) {
t.Fatalf("result error = %v, want deadline exceeded", result.Err)
}
}
func TestWorkerReadsDocumentLineFromTextCapture(t *testing.T) {
capturer := &fakeCapturer{calls: make(chan captureCall, 1)}
worker, cancel := startWorker(t, capturer, time.Second)
defer cancel()
focus := objectID("/link")
request, err := worker.ReadDocumentLine(focus, 6)
if err != nil {
t.Fatal(err)
}
call := <-capturer.calls
if call.reviewFocus != focus {
t.Fatalf("capture focus = %#v", call.reviewFocus)
}
call.result <- captureResult{tree: documentLineTree(t)}
result := waitResult(t, worker)
if result.Err != nil || !result.DocumentLine || !result.Moved {
t.Fatalf("line result = %#v", result)
}
if result.DocumentLineFocus != focus {
t.Fatalf("result focus = %#v", result.DocumentLineFocus)
}
if result.DocumentLineRequest != request {
t.Fatalf("result request = %d, want %d", result.DocumentLineRequest, request)
}
var contents []string
for _, content := range result.DocumentLineData.Contents {
contents = append(contents, content.Text)
}
if !reflect.DeepEqual(contents, []string{"Read ", "the link"}) {
t.Fatalf("line contents = %#v", contents)
}
}
func TestWorkerReusesDocumentTextCaptureForCaretLines(t *testing.T) {
capturer := &fakeCapturer{calls: make(chan captureCall, 2)}
worker, cancel := startWorker(t, capturer, time.Second)
defer cancel()
focus := objectID("/link")
if _, err := worker.ReadDocumentLine(focus, 1); err != nil {
t.Fatal(err)
}
call := <-capturer.calls
call.result <- captureResult{tree: documentLineTree(t)}
if result := waitResult(t, worker); result.Err != nil {
t.Fatalf("first line result = %#v", result)
}
if _, err := worker.ReadDocumentLine(focus, 2); err != nil {
t.Fatal(err)
}
if result := waitResult(t, worker); result.Err != nil {
t.Fatalf("cached line result = %#v", result)
}
select {
case call := <-capturer.calls:
t.Fatalf("unexpected repeated document capture for %#v", call.reviewFocus)
case <-time.After(20 * time.Millisecond):
}
}
func TestWorkerUsesNewestFocusAfterConcurrentCapture(t *testing.T) {
capturer := &fakeCapturer{calls: make(chan captureCall, 1)}
worker, cancel := startWorker(t, capturer, time.Second)
defer cancel()
worker.SyncFocus(objectID("/first"))
if err := worker.Move(NextSibling); err != nil {
t.Fatal(err)
}
call := <-capturer.calls
worker.SyncFocus(objectID("/second"))
call.result <- captureResult{tree: threeNodeTree(t)}
result := waitResult(t, worker)
if result.Err != nil || !result.Moved || result.Position.Current.Name != "Third" {
t.Fatalf("result = %#v, want move from newest focus to Third", result)
}
}
func TestWorkerRecapturesAndRepairsStaleCursor(t *testing.T) {
capturer := &fakeCapturer{calls: make(chan captureCall, 2)}
worker, cancel := startWorker(t, capturer, time.Second)
defer cancel()
firstTree := threeNodeTree(t)
worker.SyncFocus(objectID("/first"))
if err := worker.Move(NextSibling); err != nil {
t.Fatal(err)
}
firstCall := <-capturer.calls
firstCall.result <- captureResult{tree: firstTree}
if result := waitResult(t, worker); result.Err != nil || result.Position.Current.Name != "Second" {
t.Fatalf("initial result = %#v", result)
}
firstTree.Invalidate(objectID("/second"))
if err := worker.Move(NextSibling); err != nil {
t.Fatal(err)
}
secondCall := <-capturer.calls
secondCall.result <- captureResult{tree: threeNodeTree(t)}
result := waitResult(t, worker)
if result.Err != nil || !result.Moved || result.Position.Current.Name != "Second" {
t.Fatalf("recovered result = %#v, want Second", result)
}
}
func TestWorkerStopsCleanlyDuringCapture(t *testing.T) {
capturer := &fakeCapturer{calls: make(chan captureCall, 1)}
worker, cancel := startWorker(t, capturer, time.Minute)
worker.SyncFocus(objectID("/first"))
if err := worker.Move(NextSibling); err != nil {
t.Fatal(err)
}
call := <-capturer.calls
cancel()
stopContext, stopCancel := context.WithTimeout(context.Background(), time.Second)
defer stopCancel()
if err := worker.Stop(stopContext); err != nil {
t.Fatal(err)
}
select {
case <-call.ctx.Done():
default:
t.Fatal("shutdown did not cancel in-flight capture")
}
}
func documentLineTree(t *testing.T) *atspi.AccessibleTree {
t.Helper()
baseNode := func(path, name, role string, children ...atspi.ObjectID) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, name, role, "", "")
snapshot.BusName = ":1.42"
return atspi.TreeNode{Snapshot: snapshot, Children: children}
}
lineText := func(path, role, text string, bounds atspi.Rectangle, caret bool) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, path, role, "", text)
snapshot.BusName = ":1.42"
geometry := atspi.Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
visible := atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{
Start: 0, End: len(text), Text: text, Bounds: bounds,
}}}
if caret {
visible.CaretKnown, visible.CaretOffset = true, 1
}
return atspi.TreeNode{Snapshot: snapshot, Geometry: geometry, Text: visible}
}
tree, err := atspi.NewAccessibleTree(objectID("/root"), []atspi.TreeNode{
baseNode("/root", "Window", "frame", objectID("/doc")),
baseNode("/doc", "Document", "document web", objectID("/text"), objectID("/link")),
lineText("/text", "static", "Read ", atspi.Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, false),
lineText("/link", "link", "the link", atspi.Rectangle{X: 100, Y: 100, Width: 80, Height: 20}, false),
})
if err != nil {
t.Fatal(err)
}
return tree
}
func TestWorkerAcceptsConcurrentFocusUpdates(t *testing.T) {
capturer := &fakeCapturer{calls: make(chan captureCall, 1)}
worker, cancel := startWorker(t, capturer, time.Second)
defer cancel()
var wait sync.WaitGroup
for index := 0; index < 20; index++ {
wait.Add(1)
go func() {
defer wait.Done()
worker.SyncFocus(objectID("/second"))
}()
}
wait.Wait()
if err := worker.Move(NextSibling); err != nil {
t.Fatal(err)
}
call := <-capturer.calls
call.result <- captureResult{tree: threeNodeTree(t)}
result := waitResult(t, worker)
if result.Err != nil || result.Position.Current.Name != "Third" {
t.Fatalf("result = %#v", result)
}
}
func threeNodeTree(t *testing.T) *atspi.AccessibleTree {
t.Helper()
tree, err := atspi.NewAccessibleTree(objectID("/root"), []atspi.TreeNode{
node("/root", "Window", objectID("/first"), objectID("/second"), objectID("/third")),
node("/first", "First"),
node("/second", "Second"),
node("/third", "Third"),
})
if err != nil {
t.Fatal(err)
}
return tree
}
func TestWorkerRecapturesIncompleteChildrenAtIndependentPosition(t *testing.T) {
for _, outcome := range []string{"added", "empty", "failure", "focus-changed", "invalidated-again"} {
t.Run(outcome, func(t *testing.T) {
tree := threeNodeTree(t)
cursor, err := NewCursor(tree)
if err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(objectID("/first")); err != nil {
t.Fatal(err)
}
if _, _, err := cursor.Move(NextSibling); err != nil {
t.Fatal(err)
}
tree.InvalidateChildren(objectID("/second"))
capturer := &fakeCapturer{calls: make(chan captureCall, 1)}
worker, err := NewWorker(capturer, time.Second)
if err != nil {
t.Fatal(err)
}
worker.SyncFocus(objectID("/first"))
results := make(chan moveResult, 1)
go func() { results <- worker.move(context.Background(), cursor, FirstChild) }()
var call captureCall
select {
case call = <-capturer.calls:
case result := <-results:
t.Fatalf("navigation did not recapture: %#v", result)
case <-time.After(time.Second):
t.Fatal("capture did not start")
}
captureErr := errors.New("provider unavailable")
if outcome == "failure" {
call.result <- captureResult{err: captureErr}
} else {
nodes := []atspi.TreeNode{
node("/root", "Window", objectID("/first"), objectID("/second")),
node("/first", "First"),
node("/second", "Second"),
}
if outcome != "empty" {
nodes[2].Children = []atspi.ObjectID{objectID("/added")}
nodes = append(nodes, node("/added", "Added"))
}
if err := tree.Replace(objectID("/root"), nodes); err != nil {
t.Fatal(err)
}
if outcome == "focus-changed" {
worker.SyncFocus(objectID("/root"))
}
if outcome == "invalidated-again" {
tree.InvalidateChildren(objectID("/second"))
}
call.result <- captureResult{tree: tree}
}
var result moveResult
select {
case result = <-results:
case <-time.After(2 * time.Second):
t.Fatal("navigation did not finish after one recapture")
}
switch outcome {
case "failure":
if !errors.Is(result.Err, captureErr) || result.cursor != cursor {
t.Fatalf("failed recovery = %#v", result)
}
position, err := cursor.Current()
if err != nil || position.Current.Name != "Second" {
t.Fatalf("lost review position: %#v %v", position, err)
}
case "invalidated-again":
if !errors.Is(result.Err, atspi.ErrIncompleteTree) {
t.Fatalf("repeated invalidation = %#v", result)
}
default:
want, moved := "Added", true
if outcome == "empty" {
want, moved = "Second", false
}
if outcome == "focus-changed" {
want = "First"
}
if result.Err != nil || result.Moved != moved || result.Position.Current.Name != want {
t.Fatalf("result = %#v, want %s moved=%v", result, want, moved)
}
}
})
}
}