// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package ocr import ( "bufio" "bytes" "context" "errors" "fmt" "image" "image/png" "io" "os" "os/exec" "reflect" "strconv" "strings" "syscall" "testing" "time" ) func TestCatalogAndOrderedLanguages(t *testing.T) { catalog, err := parse_catalog("List of available languages in \"/data\" (4):\nfra\nosd\nscript/Latin\neng\n") if err != nil || catalog.DefaultLanguage != "eng" || !reflect.DeepEqual(catalog.Languages, []string{"eng", "fra", "script/Latin"}) { t.Fatalf("catalog: %+v, %v", catalog, err) } for _, expression := range []string{"", "fra+eng", "script/Latin"} { got, err := catalog.resolve_language(expression) want := expression if want == "" { want = "eng" } if err != nil || got != want { t.Fatalf("language expression %q: %q, %v", expression, got, err) } } for _, expression := range []string{"osd", "deu", "fra+missing", "eng+eng", "eng+", "+eng", "../eng", "-l eng", "eng;command", "fra\neng"} { if _, err := catalog.resolve_language(expression); !errors.Is(err, ErrUnavailable) { t.Fatalf("invalid/uninstalled expression %q accepted: %v", expression, err) } } other, err := parse_catalog("List of available languages in \"/data\" (2):\nfra\ndeu\n") if err != nil || other.DefaultLanguage != "deu" { t.Fatalf("default without English: %+v, %v", other, err) } } func TestMissingAndMalformedCatalogs(t *testing.T) { for _, output := range []string{ "", "Private fixture text", "List of available languages in \"/data\" (0):\n", "List of available languages in \"/data\" (1):\nosd\n", "List of available languages in \"/data\" (2):\neng\neng\n", "List of available languages in \"/data\" (1):\neng\nfra\n", "List of available languages in \"/data\" (1):\n../eng\n", } { _, err := parse_catalog(output) if !errors.Is(err, ErrUnavailable) || !strings.HasPrefix(err.Error(), "OCR not available") || strings.Contains(err.Error(), "Private") { t.Fatalf("unavailable message: %v", err) } } } func test_engine(t *testing.T, mode string) *Engine { t.Helper() executable, err := os.Executable() if err != nil { t.Fatal(err) } engine := NewEngine() engine.command = func(ctx context.Context, args ...string) *exec.Cmd { command := exec.CommandContext(ctx, executable, append([]string{"-test.run=^TestOCRChildProcess$", "--"}, args...)...) command.Env = append(os.Environ(), "CTHULHU_OCR_TEST_PROCESS=1", "CTHULHU_OCR_TEST_MODE="+mode) return command } return engine } func TestEngineRecognizesOneTSVInvocation(t *testing.T) { engine := test_engine(t, "success") original := engine.command var invocations [][]string engine.command = func(ctx context.Context, args ...string) *exec.Cmd { invocations = append(invocations, append([]string(nil), args...)) return original(ctx, args...) } // A nonzero source origin is encoded as a normalized recognition image. pixels := image.NewRGBA(image.Rect(-5, 10, 95, 110)) result, err := engine.Recognize(context.Background(), pixels, Options{Language: "fra+eng", Layout: SingleBlock}) if err != nil || result.Text != "é 中文" || result.Width != 100 || result.Height != 100 { t.Fatalf("recognition: %+v, %v", result, err) } want := [][]string{{"--list-langs"}, {"stdin", "stdout", "-l", "fra+eng", "--psm", "6", "tsv"}} if !reflect.DeepEqual(invocations, want) { t.Fatalf("invocations: %v, want %v", invocations, want) } if result.Words[1].Span != (Span{2, 4}) { t.Fatal("process result lost rune offsets") } if _, err := engine.Discover(context.Background()); err != nil { t.Fatalf("engine did not release process ownership: %v", err) } } func TestEngineMissingExecutableAndUnavailableLanguage(t *testing.T) { engine := NewEngine() // Constructing it never checks or loads OCR dependencies. engine.command = func(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "/nonexistent-ocr-test-engine", args...) } if _, err := engine.Discover(context.Background()); !errors.Is(err, ErrUnavailable) { t.Fatalf("missing executable: %v", err) } if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{}); !errors.Is(err, ErrUnavailable) { t.Fatalf("missing executable recognition: %v", err) } engine = test_engine(t, "success") original := engine.command var invocations int engine.command = func(ctx context.Context, args ...string) *exec.Cmd { invocations++ return original(ctx, args...) } if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{Language: "missing"}); !errors.Is(err, ErrUnavailable) || invocations != 1 { t.Fatalf("missing language was substituted or invoked: %v, %d", err, invocations) } } func TestDefaultEngineWithoutRuntimeDependencies(t *testing.T) { // Use the real constructor and default executable lookup, with no helper // injection. Even initialization and an attempted job need no installed OCR. t.Setenv("PATH", t.TempDir()) engine := NewEngine() if _, err := engine.Discover(context.Background()); !errors.Is(err, ErrUnavailable) || err.Error() != "OCR not available" { t.Fatalf("missing default backend: %v", err) } if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{}); !errors.Is(err, ErrUnavailable) { t.Fatalf("recognition without dependencies: %v", err) } } func TestEngineFailuresAndOutputBounds(t *testing.T) { for _, test := range []struct { mode string want error }{ {"failure", ErrRecognition}, {"invalid", ErrInvalidOutput}, {"partial-language", ErrUnavailable}, {"missing-data", ErrUnavailable}, {"no-languages", ErrUnavailable}, {"stdout-limit", ErrLimit}, {"stderr-limit", ErrLimit}, {"catalog-limit", ErrLimit}, } { t.Run(test.mode, func(t *testing.T) { engine := test_engine(t, test.mode) result, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{}) if !errors.Is(err, test.want) || result.Text != "" || len(result.Words) != 0 { t.Fatalf("result: %+v, error: %v; want %v", result, err, test.want) } if strings.Contains(err.Error(), "Private") { t.Fatal("native diagnostics leaked into error") } }) } } func TestEngineRejectsInvalidImageAndLayoutBeforeStartingChild(t *testing.T) { engine := NewEngine() engine.command = func(context.Context, ...string) *exec.Cmd { t.Fatal("child invoked for invalid request") return nil } for _, pixels := range []image.Image{nil, image.NewGray(image.Rectangle{}), oversized_image{}} { if _, err := engine.Recognize(context.Background(), pixels, Options{}); !errors.Is(err, ErrInvalidImage) { t.Fatalf("invalid image: %v", err) } } if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{Layout: 4}); !errors.Is(err, ErrRecognition) { t.Fatalf("invalid layout: %v", err) } } type oversized_image struct{ image.Image } func (oversized_image) Bounds() image.Rectangle { return image.Rect(0, 0, MaxPixels, 2) } func TestEngineCancellationReapsChildAndUnblocksWaitingRequest(t *testing.T) { t.Run("cancel", func(t *testing.T) { check_child_cancellation(t, false) }) t.Run("deadline", func(t *testing.T) { check_child_cancellation(t, true) }) } func check_child_cancellation(t *testing.T, deadline bool) { t.Helper() engine := test_engine(t, "wait") original := engine.command readyRead, readyWrite, err := os.Pipe() if err != nil { t.Fatal(err) } defer readyRead.Close() defer readyWrite.Close() children := make(chan *exec.Cmd, 2) engine.command = func(ctx context.Context, args ...string) *exec.Cmd { command := original(ctx, args...) if args[0] == "stdin" { command.ExtraFiles = []*os.File{readyWrite} children <- command } return command } ctx, cancel := context.WithCancel(context.Background()) if deadline { cancel() ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second) } defer cancel() done := make(chan error, 1) go func() { _, err := engine.Recognize(ctx, image.NewGray(image.Rect(0, 0, 100, 100)), Options{}) done <- err }() if err := readyRead.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { t.Fatal(err) } if _, err := io.ReadFull(readyRead, make([]byte, 1)); err != nil { t.Fatalf("child not ready: %v", err) } // A second call must not overlap recognition; it can cancel while waiting. waitCtx, waitCancel := context.WithTimeout(context.Background(), 40*time.Millisecond) defer waitCancel() if _, err := engine.Discover(waitCtx); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("waiting call failed to cancel: %v", err) } want := error(context.Canceled) if deadline { want = context.DeadlineExceeded } else { cancel() } select { case err := <-done: if !errors.Is(err, want) { t.Fatalf("cancel: %v", err) } case <-time.After(5 * time.Second): t.Fatal("canceled recognition did not join") } child := <-children if child.ProcessState == nil { t.Fatal("child Wait was not joined") } if _, err := syscall.Wait4(child.Process.Pid, nil, syscall.WNOHANG, nil); !errors.Is(err, syscall.ECHILD) { t.Fatalf("child was not reaped: %v", err) } if _, err := engine.Discover(context.Background()); err != nil { t.Fatalf("ownership not released after cancellation: %v", err) } } func TestEngineRetiresDescendantsHoldingPipes(t *testing.T) { engine := test_engine(t, "orphan") original := engine.command readyRead, readyWrite, err := os.Pipe() if err != nil { t.Fatal(err) } defer readyRead.Close() defer readyWrite.Close() engine.command = func(ctx context.Context, args ...string) *exec.Cmd { command := original(ctx, args...) if args[0] == "stdin" { command.ExtraFiles = []*os.File{readyWrite} } return command } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() done := make(chan error, 1) go func() { _, err := engine.Recognize(ctx, image.NewGray(image.Rect(0, 0, 100, 100)), Options{}) done <- err }() if err := readyRead.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { t.Fatal(err) } line, err := bufio.NewReader(readyRead).ReadString('\n') if err != nil { t.Fatal(err) } pid, err := strconv.Atoi(strings.TrimSpace(line)) if err != nil || pid <= 0 { t.Fatalf("invalid descendant PID: %q, %v", line, err) } select { case err := <-done: if !errors.Is(err, ErrRecognition) { t.Fatalf("inherited pipes did not fail within WaitDelay: %v", err) } case <-ctx.Done(): t.Fatal("inherited pipes stalled process cleanup") } // SIGKILL delivery and adoption by init are asynchronous. A zombie has exited // and holds no pipes; the parent engine cannot reap an adopted grandchild. deadline := time.NewTimer(time.Second) defer deadline.Stop() poll := time.NewTicker(10 * time.Millisecond) defer poll.Stop() for { status, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) if errors.Is(err, os.ErrNotExist) { break } if err != nil { t.Fatal(err) } fields := strings.Fields(string(status[strings.LastIndexByte(string(status), ')')+1:])) if len(fields) != 0 && fields[0] == "Z" { break } select { case <-deadline.C: t.Fatal("descendant survived process-group cleanup") case <-poll.C: } } } func TestEngineExpiredDeadlineStartsNoChild(t *testing.T) { engine := NewEngine() engine.command = func(context.Context, ...string) *exec.Cmd { t.Fatal("started child after expired deadline") return nil } ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) defer cancel() if _, err := engine.Discover(ctx); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("expired discovery: %v", err) } } func TestBoundedBufferCannotBypassWriteLimit(t *testing.T) { buffer := &bounded_buffer{limit: 5, ctx: context.Background()} if _, err := io.Copy(buffer, strings.NewReader("0123456789")); !errors.Is(err, ErrLimit) || buffer.Len() > 5 { t.Fatalf("writer limit bypassed: %d bytes, %v", buffer.Len(), err) } } // TestOCRChildProcess is an executable process fixture, not a mock of exec.Wait. func TestOCRChildProcess(t *testing.T) { if os.Getenv("CTHULHU_OCR_TEST_PROCESS") != "1" { return } args := os.Args for len(args) != 0 && args[0] != "--" { args = args[1:] } if len(args) < 2 { os.Exit(20) } args = args[1:] mode := os.Getenv("CTHULHU_OCR_TEST_MODE") if args[0] == "hold-pipes" { ready := os.NewFile(3, "ready") _, _ = fmt.Fprintln(ready, os.Getpid()) _ = ready.Close() time.Sleep(time.Hour) os.Exit(0) } if args[0] == "--list-langs" { switch mode { case "no-languages": fmt.Print("List of available languages in \"/data\" (1):\nosd\n") case "catalog-limit": fmt.Print(strings.Repeat("x", maxCatalogBytes+1)) default: fmt.Print("List of available languages in \"/data\" (3):\neng\nfra\nosd\n") } os.Exit(0) } if os.Getenv("OMP_THREAD_LIMIT") != "1" || os.Getenv("LC_ALL") != "C" { os.Exit(21) } encoded, err := io.ReadAll(io.LimitReader(os.Stdin, 1<<20)) if err != nil { os.Exit(22) } pixels, err := png.Decode(bytes.NewReader(encoded)) if err != nil || pixels.Bounds() != image.Rect(0, 0, 100, 100) { os.Exit(23) } switch mode { case "orphan": child := exec.Command(os.Args[0], "-test.run=^TestOCRChildProcess$", "--", "hold-pipes") child.Env = os.Environ() child.Stdout, child.Stderr = os.Stdout, os.Stderr child.ExtraFiles = []*os.File{os.NewFile(3, "ready")} if err := child.Start(); err != nil { os.Exit(24) } case "failure": fmt.Fprint(os.Stderr, "Private fixture diagnostics") os.Exit(1) case "missing-data": fmt.Fprint(os.Stderr, "Error opening data file /private/data/eng.traineddata\n") os.Exit(1) case "partial-language": fmt.Fprint(os.Stderr, "Failed loading language 'fra'\n") case "invalid": fmt.Print("Private malformed TSV") os.Exit(0) case "stdout-limit": fmt.Print(strings.Repeat("x", maxTSVBytes+1)) os.Exit(0) case "stderr-limit": fmt.Fprint(os.Stderr, strings.Repeat("x", maxStderrBytes+1)) os.Exit(0) case "wait": ready := os.NewFile(3, "ready") _, _ = ready.Write([]byte{1}) _ = ready.Close() time.Sleep(time.Hour) } fmt.Print(tsvHeader + "\n5\t1\t1\t1\t1\t1\t0\t0\t10\t10\t95\té\n5\t1\t1\t1\t1\t2\t20\t0\t10\t10\t90\t中文\n") os.Exit(0) }