Files

432 lines
14 KiB
Go

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