Files
cthulhu/internal/app/ocr_session_test.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:
}
}