406 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|