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,265 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user