// 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: } }