// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "testing" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/ocr" ) func TestOCRAutomaticReadAllOnceWithCopyAndRefresh(t *testing.T) { session, _, _, engine, output := ocr_test_session(t) reader := &ocrTestReadAll{} copier := &ocrTestCopier{copied: make(chan string, 1)} session.readAll, session.copier = reader, copier settings := session.settings() settings.OCR.AutoReadAll, settings.OCR.CopyFirstResult = true, true session.live = &liveSettings{} session.live.Store(settings) session.Handle(input.CommandEvent{Command: input.OCRWindow, Timestamp: 100}) ocr_test_receive(t, engine.calls) // A pending job must keep the initiating preferences. settings.OCR.AutoReadAll, settings.OCR.CopyFirstResult = false, false session.live.Store(settings) engine.results <- ocr.Result{Text: "é one\n猫 two"} request := ocr_test_receive(t, output.speech) if request.Cause != "read-all" || len(request.ReadAll) != 2 || request.ReadAll[0].Text != "é one" || reader.timestamp != 100 { t.Fatalf("initial reading: %+v", request) } frame := ocr_test_receive(t, output.frames) if frame.Text != "é one" || frame.Cursor != 0 || frame.Virtual == nil { t.Fatalf("initial braille: %+v", frame) } if text := ocr_test_receive(t, copier.copied); text != "é one\n猫 two" { t.Fatal(text) } request.ReadAll[1].Began() frame = ocr_test_receive(t, output.frames) if frame.Text != "猫 two" || frame.Virtual.SourceStart != 6 { t.Fatalf("paced braille: %+v", frame) } session.Handle(input.CommandEvent{Command: input.OCRRefresh, Timestamp: 101}) ocr_test_receive(t, engine.calls) // Worker has also finished the optional copy. if request.context.Err() != context.Canceled { t.Fatal("manual refresh did not cancel old reading") } engine.results <- ocr.Result{Text: "é one\n猫 two"} if got := ocr_test_receive(t, output.speech); got.Text != "OCR text unchanged" || len(got.ReadAll) != 0 { t.Fatalf("copy interrupted reading or refresh restarted it: %+v", got) } ocr_test_receive(t, output.frames) // Status, then current review frame. ocr_test_receive(t, output.frames) request.ReadAll[0].Began() select { case got := <-output.frames: t.Fatalf("stale reading moved refreshed braille: %+v", got) default: } } func TestOCRPeriodicRefreshWaitsForSayAll(t *testing.T) { for _, interrupted := range []bool{false, true} { t.Run(map[bool]string{false: "completed", true: "interrupted"}[interrupted], func(t *testing.T) { session, _, _, engine, output := ocr_test_session(t) session.readAll = &ocrTestReadAll{} settings := session.settings() settings.OCR.AutoReadAll = true session.live = &liveSettings{} session.live.Store(settings) session.Handle(input.CommandEvent{Command: input.OCRWindow}) ocr_test_receive(t, engine.calls) engine.results <- ocr.Result{Text: "first\nsecond"} request := ocr_test_receive(t, output.speech) ocr_test_receive(t, output.frames) settings.OCR.AutoRefresh = true session.live.Store(settings) session.mu.Lock() session.refresh_locked(true) if session.refreshing || request.context.Err() != nil { session.mu.Unlock() t.Fatal("periodic refresh truncated Say All") } if interrupted { ctx, cancel := context.WithCancel(session.job.ctx) session.readAllContext = ctx cancel() } else { completeSpeechRequest(request, nil) } session.refresh_locked(true) if !session.refreshing { session.mu.Unlock() t.Fatal("periodic refresh did not resume after reading") } session.mu.Unlock() ocr_test_receive(t, engine.calls) engine.results <- ocr.Result{Text: "changed"} if got := ocr_test_receive(t, output.speech); got.Text != "changed" || len(got.ReadAll) != 0 { t.Fatalf("automatic refresh restarted Say All: %+v", got) } }) } } func TestOCRShiftSelectionAnnouncesChangedRanges(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: "é猫abc\nnext"} ocr_test_receive(t, output.speech) ocr_test_receive(t, output.frames) steps := []struct { command input.Command shift bool speech string offset, start, end int }{ {input.OCRNextCharacter, true, "é selected", 1, 0, 1}, {input.OCRNextCharacter, true, "猫 selected", 2, 0, 2}, {input.OCRPreviousCharacter, true, "猫 unselected", 1, 0, 1}, {input.OCRPreviousCharacter, true, "é unselected", 0, 0, 0}, {input.OCRPreviousCharacter, true, "OCR text boundary", 0, 0, 0}, {input.OCRNextCharacter, false, "猫", 1, -1, -1}, {input.OCRNextCharacter, true, "猫 selected", 2, 1, 2}, {input.OCRLineStart, true, "猫 unselected, é selected", 0, 0, 1}, {input.OCRLineEnd, true, "é unselected, 猫abc selected", 5, 1, 5}, {input.OCRNextCharacter, true, "selected", 6, 1, 6}, {input.OCRDocumentEnd, true, "next selected", 10, 1, 10}, {input.OCRNextCharacter, false, "OCR text boundary", 10, -1, -1}, } for _, step := range steps { modifiers := uint16(0) if step.shift { modifiers = 1 } session.Handle(input.CommandEvent{Command: step.command, Chord: input.CommandChord{Modifiers: modifiers}}) if got := ocr_test_receive(t, output.speech); got.Text != step.speech { t.Fatalf("%s shift=%v: %q, want %q", step.command, step.shift, got.Text, step.speech) } frame := ocr_test_receive(t, output.frames) session.mu.Lock() start, end := session.selection_locked() offset := session.cursor.Offset() session.mu.Unlock() if offset != step.offset || start != step.start || end != step.end || frame.Virtual == nil { t.Fatalf("%s: offset=%d selection=%d:%d frame=%+v", step.command, offset, start, end, frame) } for index, dots := range frame.Dots { source := frame.Virtual.SourceStart + index if (dots != 0) != (source >= start && source < end) { t.Fatalf("wrong selection dots: %+v", frame) } } if start == end && len(frame.Dots) != 0 { t.Fatalf("cleared selection retained dots: %+v", frame) } } }