Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,405 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user