720 lines
25 KiB
Go
720 lines
25 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"log/slog"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/braille"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/ocr"
|
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
)
|
|
|
|
type ocrTestKeys struct {
|
|
calls chan string
|
|
enter func(context.Context) error
|
|
releaseErr error
|
|
release func() error
|
|
}
|
|
|
|
func (keys *ocrTestKeys) SetOCRReview(ctx context.Context, enabled bool) error {
|
|
if enabled {
|
|
panic("entry must supply foreground")
|
|
}
|
|
keys.calls <- "released"
|
|
if keys.release != nil {
|
|
return keys.release()
|
|
}
|
|
return keys.releaseErr
|
|
}
|
|
func (keys *ocrTestKeys) SetOCRContext(ctx context.Context, foreground uint64, pending bool) error {
|
|
if foreground != 42 {
|
|
return ocr.ErrSourceChanged
|
|
}
|
|
if pending {
|
|
keys.calls <- "pending"
|
|
return ctx.Err()
|
|
}
|
|
keys.calls <- "review"
|
|
if keys.enter != nil {
|
|
return keys.enter(ctx)
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
|
|
type ocrTestForeground struct {
|
|
mu sync.Mutex
|
|
state atspi.ForegroundState
|
|
}
|
|
|
|
func (monitor *ocrTestForeground) Foreground() atspi.ForegroundState {
|
|
monitor.mu.Lock()
|
|
defer monitor.mu.Unlock()
|
|
return monitor.state
|
|
}
|
|
func (*ocrTestForeground) ForegroundChanges() <-chan struct{} { return nil }
|
|
|
|
type ocrTestGeometry struct {
|
|
monitor *ocrTestForeground
|
|
calls chan atspi.ObjectID
|
|
err error
|
|
}
|
|
|
|
func (source *ocrTestGeometry) CaptureOCRGeometry(ctx context.Context, id atspi.ObjectID, zone *atspi.Rectangle) (atspi.OCRGeometry, error) {
|
|
source.calls <- id
|
|
if ctx.Err() != nil {
|
|
return atspi.OCRGeometry{}, ctx.Err()
|
|
}
|
|
return atspi.OCRGeometry{Object: id, Foreground: source.monitor.Foreground(), ScreenBounds: image.Rect(10, 20, 110, 70)}, source.err
|
|
}
|
|
|
|
type ocrTestInterest struct{ interest review.Interest }
|
|
|
|
func (source ocrTestInterest) ObjectOfInterest(context.Context) (review.Interest, error) {
|
|
return source.interest, nil
|
|
}
|
|
|
|
type ocrTestCapture struct {
|
|
calls chan ocr.CaptureRequest
|
|
run func(context.Context) error
|
|
produce func(context.Context, ocr.CaptureRequest) (ocr.Capture, error)
|
|
}
|
|
|
|
func (source *ocrTestCapture) Capture(ctx context.Context, request ocr.CaptureRequest) (ocr.Capture, error) {
|
|
source.calls <- request
|
|
if source.produce != nil {
|
|
return source.produce(ctx, request)
|
|
}
|
|
if source.run != nil {
|
|
if err := source.run(ctx); err != nil {
|
|
return ocr.Capture{}, err
|
|
}
|
|
}
|
|
return ocr.Capture{CaptureHeader: ocr.CaptureHeader{Request: request}, Pixels: image.NewRGBA(image.Rect(0, 0, 100, 50))}, ctx.Err()
|
|
}
|
|
|
|
type ocrTestEngine struct {
|
|
calls chan context.Context
|
|
results chan ocr.Result
|
|
err error
|
|
}
|
|
|
|
func (engine *ocrTestEngine) Recognize(ctx context.Context, _ image.Image, _ ocr.Options) (ocr.Result, error) {
|
|
engine.calls <- ctx
|
|
// Deliberately finish after cancellation to verify the publication guard.
|
|
return <-engine.results, engine.err
|
|
}
|
|
|
|
type ocrTestOutput struct {
|
|
speech chan speechRequest
|
|
frames chan braille.Frame
|
|
}
|
|
|
|
type ocrTestCopier struct{ copied chan string }
|
|
|
|
func (copier *ocrTestCopier) Copy(ctx context.Context, value string) error {
|
|
copier.copied <- value
|
|
return ctx.Err()
|
|
}
|
|
|
|
type ocrTestReadAll struct{ timestamp uint32 }
|
|
|
|
func (reader *ocrTestReadAll) BeginReadAll(ctx context.Context, timestamp uint32) context.Context {
|
|
reader.timestamp = timestamp
|
|
return ctx
|
|
}
|
|
|
|
func (output *ocrTestOutput) Enqueue(request speechRequest) { output.speech <- request }
|
|
func (*ocrTestOutput) Interrupt() {}
|
|
func (output *ocrTestOutput) EnqueueFrame(frame braille.Frame) { output.frames <- frame }
|
|
|
|
func ocr_test_receive[T any](t *testing.T, channel <-chan T) T {
|
|
t.Helper()
|
|
select {
|
|
case result := <-channel:
|
|
return result
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("OCR worker did not respond")
|
|
}
|
|
var empty T
|
|
return empty
|
|
}
|
|
func ocr_test_focus(path, role string) atspi.Event {
|
|
return atspi.Event{Type: atspi.FocusEvent, Source: atspi.Snapshot{BusName: ":1.42", Path: path, Role: role}, Window: atspi.NewObjectID(":1.42", "/window")}
|
|
}
|
|
func ocr_test_session(t *testing.T) (*ocrSession, *ocrTestKeys, *ocrTestCapture, *ocrTestEngine, *ocrTestOutput) {
|
|
t.Helper()
|
|
keys := &ocrTestKeys{calls: make(chan string, 32)}
|
|
monitor := &ocrTestForeground{state: atspi.ForegroundState{Epoch: 1, Client: xorg.Foreground{Known: true, Window: 42, PID: 123, Width: 100, Height: 50}}}
|
|
capture := &ocrTestCapture{calls: make(chan ocr.CaptureRequest, 16)}
|
|
engine := &ocrTestEngine{calls: make(chan context.Context, 16), results: make(chan ocr.Result, 16)}
|
|
output := &ocrTestOutput{speech: make(chan speechRequest, 32), frames: make(chan braille.Frame, 32)}
|
|
session := &ocrSession{logger: slog.New(slog.DiscardHandler), keys: keys, geometry: &ocrTestGeometry{monitor: monitor, calls: make(chan atspi.ObjectID, 32)},
|
|
interest: ocrTestInterest{review.Interest{Object: atspi.NewObjectID(":1.42", "/image")}}, foreground: monitor, capture: capture, engine: engine,
|
|
speech: output, display: output, gate: newFocusGate(nil), wake: make(chan struct{}, 1), failures: make(chan error, 1),
|
|
copyRequests: make(chan ocrCopyRequest, 1), activateRequests: make(chan ocrActivateRequest, 1), held: make(map[string]bool), selectionAnchor: -1}
|
|
if err := session.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session.Observe(ocr_test_focus("/focus", "image"))
|
|
t.Cleanup(func() {
|
|
engine.results <- ocr.Result{}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := session.Stop(ctx); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
return session, keys, capture, engine, output
|
|
}
|
|
|
|
func TestOCRSessionCommandsOwnSourceAndRunePresentation(t *testing.T) {
|
|
session, keys, capture, engine, output := ocr_test_session(t)
|
|
if !session.Handle(input.CommandEvent{Command: input.OCRObject, Timestamp: 100}) {
|
|
t.Fatal("OCR command not claimed")
|
|
}
|
|
if got := ocr_test_receive(t, keys.calls); got != "pending" {
|
|
t.Fatal(got)
|
|
}
|
|
request := ocr_test_receive(t, capture.calls)
|
|
if request.Kind != ocr.ObjectSource || request.Foreground != 42 || request.Bounds != image.Rect(10, 20, 110, 70) || request.Generation == 0 {
|
|
t.Fatalf("capture: %+v", request)
|
|
}
|
|
ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "é one\ntwo", Words: []ocr.Word{{Text: "é", Span: ocr.Span{Start: 0, End: 1}}, {Text: "one", Span: ocr.Span{Start: 2, End: 5}}, {Text: "two", Span: ocr.Span{Start: 6, End: 9}}}}
|
|
if got := ocr_test_receive(t, keys.calls); got != "review" {
|
|
t.Fatal(got)
|
|
}
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "é one" {
|
|
t.Fatal(got)
|
|
}
|
|
if got := ocr_test_receive(t, output.frames); got.Text != "é one" || got.Cursor != 0 || len(got.Routes) != 0 {
|
|
t.Fatalf("braille: %+v", got)
|
|
}
|
|
session.Input(input.NewKeyEvent(1, true, "Right"))
|
|
session.Handle(input.CommandEvent{Command: input.OCRNextCharacter, Timestamp: 101})
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "space" || !got.Character {
|
|
t.Fatal(got)
|
|
}
|
|
if got := ocr_test_receive(t, output.frames); got.Cursor != 1 {
|
|
t.Fatal(got)
|
|
}
|
|
session.Handle(input.CommandEvent{Command: input.OCRNextWord, Timestamp: 102})
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "one" {
|
|
t.Fatal(got)
|
|
}
|
|
if got := ocr_test_receive(t, output.frames); got.Cursor != 2 {
|
|
t.Fatal(got)
|
|
}
|
|
// A repeated activation of the same top-level is not a focus change.
|
|
for _, tc := range []struct {
|
|
command input.Command
|
|
parts []string
|
|
}{
|
|
{input.ReviewSpellWord, []string{"o", "n", "e"}},
|
|
{input.ReviewPhoneticWord, []string{"oscar", "november", "echo"}},
|
|
{input.ReviewPhoneticCharacter, []string{"oscar"}},
|
|
} {
|
|
if !session.Handle(input.CommandEvent{Command: tc.command}) {
|
|
t.Fatal("OCR did not claim spelling")
|
|
}
|
|
if got := ocr_test_receive(t, output.speech); !reflect.DeepEqual(got.Spelling, tc.parts) {
|
|
t.Fatalf("OCR spelling: %+v", got)
|
|
}
|
|
if got := ocr_test_receive(t, output.frames); got.Text != "é one" || got.Cursor != 2 {
|
|
t.Fatalf("spelling moved OCR braille cursor: %+v", got)
|
|
}
|
|
}
|
|
event := ocr_test_focus("/window", "frame")
|
|
event.Type = atspi.WindowActivatedEvent
|
|
if !session.Observe(event) {
|
|
t.Fatal("duplicate activation discarded OCR")
|
|
}
|
|
event.Type = atspi.WindowDeactivatedEvent
|
|
session.Observe(event)
|
|
session.Handle(input.CommandEvent{Command: input.OCRNextLine, Timestamp: 103})
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "two" {
|
|
t.Fatal(got)
|
|
}
|
|
if got := ocr_test_receive(t, output.frames); got.Text != "two" || got.Cursor != 2 {
|
|
t.Fatal(got)
|
|
}
|
|
session.Handle(input.CommandEvent{Command: input.OCRDismiss, Timestamp: 104})
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "OCR dismissed" {
|
|
t.Fatal(got)
|
|
}
|
|
if got := ocr_test_receive(t, keys.calls); got != "released" {
|
|
t.Fatal(got)
|
|
}
|
|
}
|
|
|
|
func TestOCRSessionSupersedesAndJoinsBeforeNewJob(t *testing.T) {
|
|
session, keys, capture, engine, output := ocr_test_session(t)
|
|
session.Handle(input.CommandEvent{Command: input.OCRWindow, Timestamp: 100})
|
|
first := ocr_test_receive(t, capture.calls)
|
|
firstContext := ocr_test_receive(t, engine.calls)
|
|
session.Handle(input.CommandEvent{Command: input.OCRClipboard, Timestamp: 101})
|
|
if firstContext.Err() != context.Canceled {
|
|
t.Fatal("replacement did not cancel old job")
|
|
}
|
|
select {
|
|
case <-capture.calls:
|
|
t.Fatal("new capture overlapped old recognition")
|
|
default:
|
|
}
|
|
engine.results <- ocr.Result{Text: "stale result"}
|
|
second := ocr_test_receive(t, capture.calls)
|
|
if first.Kind != ocr.WindowSource || second.Kind != ocr.ClipboardSource || second.Generation <= first.Generation {
|
|
t.Fatalf("generations: %+v %+v", first, second)
|
|
}
|
|
ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "current result"}
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "current result" {
|
|
t.Fatal(got)
|
|
}
|
|
// Source options/pixels are owned until dismissal, even for clipboard OCR.
|
|
session.mu.Lock()
|
|
kind := session.pixels.Request.Kind
|
|
session.mu.Unlock()
|
|
if kind != ocr.ClipboardSource {
|
|
t.Fatal(kind)
|
|
}
|
|
_ = keys
|
|
}
|
|
|
|
func TestOCRSessionRetiresEveryJobBoundary(t *testing.T) {
|
|
for _, boundary := range []string{"input", "same-client-focus", "password", "source-destroyed", "foreground", "lock", "terminal", "dialog", "shutdown"} {
|
|
t.Run(boundary, func(t *testing.T) {
|
|
session, keys, _, engine, output := ocr_test_session(t)
|
|
session.Handle(input.CommandEvent{Command: input.OCRObject, Timestamp: 100})
|
|
jobContext := ocr_test_receive(t, engine.calls)
|
|
switch boundary {
|
|
case "input":
|
|
event := input.NewKeyEvent(1, true, "a")
|
|
event.Timestamp = 101
|
|
session.Input(event)
|
|
case "same-client-focus":
|
|
session.Observe(ocr_test_focus("/other", "image"))
|
|
case "password":
|
|
session.Observe(ocr_test_focus("/password", "password text"))
|
|
case "source-destroyed":
|
|
event := ocr_test_focus("/image", "image")
|
|
event.Type = atspi.DefunctStateEvent
|
|
session.Observe(event)
|
|
case "foreground":
|
|
monitor := session.foreground.(*ocrTestForeground)
|
|
monitor.mu.Lock()
|
|
monitor.state.Epoch++
|
|
monitor.mu.Unlock()
|
|
session.ForegroundChanged()
|
|
case "lock":
|
|
session.gate.ocr = session
|
|
session.gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{})
|
|
case "terminal":
|
|
session.gate.ocr = session
|
|
session.gate.SetSuspended(true)
|
|
case "dialog":
|
|
if session.Handle(input.CommandEvent{Command: input.Preferences}) {
|
|
t.Fatal("preferences swallowed")
|
|
}
|
|
case "shutdown":
|
|
session.stop()
|
|
}
|
|
select {
|
|
case <-jobContext.Done():
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("job not canceled")
|
|
}
|
|
engine.results <- ocr.Result{Text: "late private text"}
|
|
for ocr_test_receive(t, keys.calls) != "released" {
|
|
}
|
|
select {
|
|
case got := <-output.speech:
|
|
t.Fatalf("late output: %+v", got)
|
|
default:
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOCRSessionPasswordFocusAllowsVisibleCapture(t *testing.T) {
|
|
for _, command := range []input.Command{input.OCRObject, input.OCRWindow, input.OCRDesktop, input.OCRClipboard} {
|
|
t.Run(string(command), func(t *testing.T) {
|
|
session, _, capture, engine, output := ocr_test_session(t)
|
|
session.Observe(ocr_test_focus("/protected", "password text"))
|
|
session.Handle(input.CommandEvent{Command: command})
|
|
ocr_test_receive(t, capture.calls)
|
|
ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "Visible label"}
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "Visible label" {
|
|
t.Fatal(got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOCRSessionScreenSourcesWithoutAccessibility(t *testing.T) {
|
|
for _, command := range []input.Command{input.OCRWindow, input.OCRDesktop, input.OCRClipboard} {
|
|
for _, staleFocus := range []bool{false, true} {
|
|
t.Run(string(command)+fmt.Sprint("/stale=", staleFocus), func(t *testing.T) {
|
|
session, _, capture, engine, output := ocr_test_session(t)
|
|
session.geometry, session.interest = nil, nil
|
|
if staleFocus {
|
|
session.focusForeground = 99
|
|
} else {
|
|
session.focus, session.window = atspi.ObjectID{}, atspi.ObjectID{}
|
|
}
|
|
session.Handle(input.CommandEvent{Command: command, Timestamp: 100})
|
|
request := ocr_test_receive(t, capture.calls)
|
|
if request.Foreground != 42 || request.Bounds != (image.Rectangle{}) {
|
|
t.Fatalf("non-object capture: %+v", request)
|
|
}
|
|
ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "Visible text"}
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "Visible text" {
|
|
t.Fatal(got)
|
|
}
|
|
ocr_test_receive(t, output.frames)
|
|
session.Handle(input.CommandEvent{Command: input.OCRRefresh, Timestamp: 101})
|
|
if command != input.OCRClipboard {
|
|
ocr_test_receive(t, capture.calls)
|
|
}
|
|
ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "Updated text"}
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "Updated text" {
|
|
t.Fatal(got)
|
|
}
|
|
ocr_test_receive(t, output.frames)
|
|
session.Handle(input.CommandEvent{Command: input.OCRDismiss})
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "OCR dismissed" {
|
|
t.Fatal(got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOCRSessionObjectStillRequiresAccessibleFocus(t *testing.T) {
|
|
for _, staleFocus := range []bool{false, true} {
|
|
session, _, capture, _, output := ocr_test_session(t)
|
|
if staleFocus {
|
|
session.focusForeground = 99
|
|
} else {
|
|
session.focus = atspi.ObjectID{}
|
|
}
|
|
session.Handle(input.CommandEvent{Command: input.OCRObject})
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "OCR source unavailable" {
|
|
t.Fatal(got)
|
|
}
|
|
select {
|
|
case <-capture.calls:
|
|
t.Fatal("object OCR captured without a current accessible focus")
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOCRSessionLockRetiresInaccessibleWindowResult(t *testing.T) {
|
|
session, keys, _, engine, output := ocr_test_session(t)
|
|
session.focus, session.window = atspi.ObjectID{}, atspi.ObjectID{}
|
|
session.geometry = nil
|
|
session.Handle(input.CommandEvent{Command: input.OCRWindow})
|
|
ocr_test_receive(t, keys.calls)
|
|
jobContext := ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "Visible text"}
|
|
ocr_test_receive(t, keys.calls)
|
|
ocr_test_receive(t, output.speech)
|
|
ocr_test_receive(t, output.frames)
|
|
session.gate.ocr = session
|
|
session.gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{})
|
|
if jobContext.Err() != context.Canceled || session.Reviewing() {
|
|
t.Fatal("screen lock retained OCR result")
|
|
}
|
|
if got := ocr_test_receive(t, output.frames); got.Text != "" {
|
|
t.Fatal("screen lock retained OCR braille text")
|
|
}
|
|
for ocr_test_receive(t, keys.calls) != "released" {
|
|
}
|
|
for _, command := range []input.Command{input.OCRObject, input.OCRWindow, input.OCRDesktop, input.OCRClipboard} {
|
|
session.Handle(input.CommandEvent{Command: command})
|
|
}
|
|
select {
|
|
case <-engine.calls:
|
|
t.Fatal("recognition started while locked")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestOCRSessionSourceAndDependencyErrors(t *testing.T) {
|
|
for _, failure := range []error{ocr.ErrUnavailable, nil, atspi.ErrOCRGeometry} {
|
|
session, keys, capture, engine, output := ocr_test_session(t)
|
|
engine.err = failure
|
|
if errors.Is(failure, atspi.ErrOCRGeometry) {
|
|
session.geometry.(*ocrTestGeometry).err = failure
|
|
}
|
|
command := input.OCRWindow
|
|
if errors.Is(failure, atspi.ErrOCRGeometry) {
|
|
command = input.OCRObject
|
|
}
|
|
session.Handle(input.CommandEvent{Command: command})
|
|
if !errors.Is(failure, atspi.ErrOCRGeometry) {
|
|
ocr_test_receive(t, capture.calls)
|
|
ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{}
|
|
}
|
|
want := "No text recognized"
|
|
if failure == ocr.ErrUnavailable {
|
|
want = "OCR not available"
|
|
}
|
|
if failure == atspi.ErrOCRGeometry {
|
|
want = "OCR source unavailable"
|
|
}
|
|
if got := ocr_test_receive(t, output.speech); got.Text != want {
|
|
t.Fatal(got)
|
|
}
|
|
for ocr_test_receive(t, keys.calls) != "released" {
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOCRSessionDelayedInitiatingInputAndChordRetirement(t *testing.T) {
|
|
session, _, _, engine, output := ocr_test_session(t)
|
|
session.Handle(input.CommandEvent{Command: input.OCRWindow, Timestamp: 100})
|
|
jobContext := ocr_test_receive(t, engine.calls)
|
|
event := input.NewKeyEvent(1, true, "o")
|
|
event.Timestamp = 100
|
|
session.Input(event)
|
|
if jobContext.Err() != nil {
|
|
t.Fatal("delayed command observation canceled job")
|
|
}
|
|
engine.results <- ocr.Result{Text: "result"}
|
|
ocr_test_receive(t, output.speech)
|
|
session.Input(input.NewKeyEvent(2, true, "Control_L"))
|
|
session.Input(input.NewKeyEvent(3, true, "Right"))
|
|
if jobContext.Err() != nil {
|
|
t.Fatal("claimed Ctrl+Right canceled result")
|
|
}
|
|
session.Input(input.NewKeyEvent(3, false, "Right"))
|
|
session.Input(input.NewKeyEvent(4, true, "g"))
|
|
if jobContext.Err() != context.Canceled {
|
|
t.Fatal("unbound Ctrl+G did not retire")
|
|
}
|
|
}
|
|
|
|
func TestOCRSessionCancellationDuringCaptureAndInputEntry(t *testing.T) {
|
|
for _, phase := range []string{"capture", "input-entry"} {
|
|
t.Run(phase, func(t *testing.T) {
|
|
session, keys, capture, engine, output := ocr_test_session(t)
|
|
started := make(chan context.Context, 1)
|
|
block := func(ctx context.Context) error { started <- ctx; <-ctx.Done(); return ctx.Err() }
|
|
if phase == "capture" {
|
|
capture.run = block
|
|
} else {
|
|
keys.enter = block
|
|
}
|
|
session.Handle(input.CommandEvent{Command: input.OCRWindow})
|
|
if phase == "input-entry" {
|
|
ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "ready"}
|
|
}
|
|
jobContext := ocr_test_receive(t, started)
|
|
session.Retire()
|
|
if jobContext.Err() != context.Canceled {
|
|
t.Fatal("native operation not canceled")
|
|
}
|
|
for ocr_test_receive(t, keys.calls) != "released" {
|
|
}
|
|
select {
|
|
case got := <-output.speech:
|
|
t.Fatalf("canceled operation published: %+v", got)
|
|
default:
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOCRSessionReleaseFailureIsReported(t *testing.T) {
|
|
session, keys, _, engine, output := ocr_test_session(t)
|
|
keys.releaseErr = errors.New("release failed")
|
|
session.Handle(input.CommandEvent{Command: input.OCRWindow})
|
|
ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "ready"}
|
|
ocr_test_receive(t, output.speech)
|
|
session.Retire()
|
|
if err := ocr_test_receive(t, session.failures); err != keys.releaseErr {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestOCRSessionWaitForPhysicalReleaseRetries(t *testing.T) {
|
|
session, keys, _, engine, output := ocr_test_session(t)
|
|
count := 0
|
|
keys.release = func() error {
|
|
count++
|
|
if count == 1 {
|
|
return context.DeadlineExceeded
|
|
}
|
|
return nil
|
|
}
|
|
session.Handle(input.CommandEvent{Command: input.OCRWindow})
|
|
ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "ready"}
|
|
ocr_test_receive(t, output.speech)
|
|
session.Retire()
|
|
releases := 0
|
|
for releases < 2 {
|
|
if ocr_test_receive(t, keys.calls) == "released" {
|
|
releases++
|
|
}
|
|
}
|
|
select {
|
|
case err := <-session.failures:
|
|
t.Fatalf("held physical key stopped reader: %v", err)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestOCRSessionRecognizesCthulhuAndNativeKeypadNames(t *testing.T) {
|
|
for _, name := range []string{"KP_Insert", "Caps_Lock", "KP_Next", "KP_Prior"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
session, _, _, engine, output := ocr_test_session(t)
|
|
settings := session.settings()
|
|
settings.CthulhuModifierKeys = []string{"Insert", "KP_Insert", "Caps_Lock"}
|
|
session.live = &liveSettings{}
|
|
session.live.Store(settings)
|
|
session.Handle(input.CommandEvent{Command: input.OCRWindow})
|
|
jobContext := ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "ready"}
|
|
ocr_test_receive(t, output.speech)
|
|
session.Input(input.NewKeyEvent(1, true, name))
|
|
if jobContext.Err() != nil {
|
|
t.Fatalf("claimed native key %s retired OCR", name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOCRBrailleKeepsCursorInLongUnicodeLine(t *testing.T) {
|
|
line := strings.Repeat("界", 5000)
|
|
frame := ocr_braille_frame(ocr.Position{Line: line, Cursor: 4999})
|
|
if len(frame.Text) > 10000 || frame.Cursor != 1999 || len([]rune(frame.Text)) != 2000 || []rune(frame.Text)[frame.Cursor] != '界' {
|
|
t.Fatalf("long-line cursor lost: bytes=%d cursor=%d", len(frame.Text), frame.Cursor)
|
|
}
|
|
}
|
|
|
|
func TestOCRSelectionCopySayAllAndBrailleRouting(t *testing.T) {
|
|
session, _, _, engine, output := ocr_test_session(t)
|
|
copier := &ocrTestCopier{copied: make(chan string, 2)}
|
|
reader := &ocrTestReadAll{}
|
|
session.copier = copier
|
|
session.readAll = reader
|
|
session.Handle(input.CommandEvent{Command: input.OCRWindow, Timestamp: 100})
|
|
ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "é one\n猫 two"}
|
|
ocr_test_receive(t, output.speech)
|
|
initial := ocr_test_receive(t, output.frames)
|
|
if initial.Virtual == nil || initial.Virtual.Generation == 0 || initial.Virtual.SourceStart != 0 {
|
|
t.Fatalf("missing OCR route: %+v", initial)
|
|
}
|
|
session.Handle(input.CommandEvent{Command: input.OCRNextCharacter, Chord: input.CommandChord{Modifiers: 1}, Timestamp: 101})
|
|
ocr_test_receive(t, output.speech)
|
|
selected := ocr_test_receive(t, output.frames)
|
|
if selected.Cursor != 1 || len(selected.Dots) != 5 || selected.Dots[0] == 0 || selected.Dots[1] != 0 {
|
|
t.Fatalf("selection frame: %+v", selected)
|
|
}
|
|
session.Handle(input.CommandEvent{Command: input.OCRCopySelection, Timestamp: 102})
|
|
if got := ocr_test_receive(t, copier.copied); got != "é" {
|
|
t.Fatalf("copied %q", got)
|
|
}
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "OCR text copied to clipboard" {
|
|
t.Fatal(got)
|
|
}
|
|
ocr_test_receive(t, output.frames) // copy status
|
|
session.Handle(input.CommandEvent{Command: input.OCRSelectAll, Timestamp: 103})
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "OCR text selected" {
|
|
t.Fatal(got)
|
|
}
|
|
ocr_test_receive(t, output.frames)
|
|
session.Handle(input.CommandEvent{Command: input.OCRCopySelection, Timestamp: 104})
|
|
if got := ocr_test_receive(t, copier.copied); got != "é one\n猫 two" {
|
|
t.Fatalf("copied all %q", got)
|
|
}
|
|
ocr_test_receive(t, output.speech)
|
|
ocr_test_receive(t, output.frames)
|
|
session.RouteBraille(context.Background(), initial.Virtual.Generation, 6)
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "猫" {
|
|
t.Fatal(got)
|
|
}
|
|
routed := ocr_test_receive(t, output.frames)
|
|
if routed.Text != "猫 two" || routed.Cursor != 0 || routed.Virtual.SourceStart != 6 {
|
|
t.Fatalf("routed frame: %+v", routed)
|
|
}
|
|
session.Handle(input.CommandEvent{Command: input.ReadAll, Timestamp: 105})
|
|
request := ocr_test_receive(t, output.speech)
|
|
if reader.timestamp != 105 || len(request.ReadAll) != 1 || request.ReadAll[0].Text != "猫 two" {
|
|
t.Fatalf("Say All request: %+v", request)
|
|
}
|
|
request.ReadAll[0].Began()
|
|
if got := ocr_test_receive(t, output.frames); got.Cursor != 0 || got.Virtual == nil {
|
|
t.Fatalf("Say All braille: %+v", got)
|
|
}
|
|
session.Retire()
|
|
request.ReadAll[0].Began()
|
|
select {
|
|
case got := <-output.frames:
|
|
if got.Text != "" {
|
|
t.Fatalf("late Say All frame: %+v", got)
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestOCRBrailleWorkerRoutesOnlyCurrentVirtualFrame(t *testing.T) {
|
|
session, _, _, engine, output := ocr_test_session(t)
|
|
session.Handle(input.CommandEvent{Command: input.OCRWindow})
|
|
ocr_test_receive(t, engine.calls)
|
|
engine.results <- ocr.Result{Text: "é猫"}
|
|
ocr_test_receive(t, output.speech)
|
|
frame := ocr_test_receive(t, output.frames)
|
|
native := &recordingBrailleRouter{calls: make(chan atspi.BrailleRoute, 1)}
|
|
worker := &brailleWorker{ocr: session, router: native, logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), cancel: func() {}}
|
|
worker.EnqueueFrame(frame)
|
|
id := worker.current().ID
|
|
worker.route(context.Background(), braille.RoutingEvent{ID: id + 1, Offset: 1})
|
|
select {
|
|
case <-output.speech:
|
|
t.Fatal("stale frame moved OCR cursor")
|
|
default:
|
|
}
|
|
worker.route(context.Background(), braille.RoutingEvent{ID: id, Offset: 1})
|
|
if got := ocr_test_receive(t, output.speech); got.Text != "猫" {
|
|
t.Fatal(got)
|
|
}
|
|
if len(native.calls) != 0 {
|
|
t.Fatal("OCR routing moved the native caret")
|
|
}
|
|
session.Retire()
|
|
ocr_test_receive(t, output.frames) // routed presentation
|
|
ocr_test_receive(t, output.frames) // retired blank frame
|
|
worker.route(context.Background(), braille.RoutingEvent{ID: id, Offset: 0})
|
|
select {
|
|
case <-output.speech:
|
|
t.Fatal("retired OCR route spoke")
|
|
default:
|
|
}
|
|
}
|