mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
951 lines
36 KiB
TypeScript
951 lines
36 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { performance } from "node:perf_hooks";
|
|
import { PassThrough } from "node:stream";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockSpawn = vi.hoisted(() => vi.fn());
|
|
const mockGetInstalledTesseractLanguages = vi.hoisted(() => vi.fn());
|
|
const mockGetCachedTesseractLanguages = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
|
|
vi.mock("../../../packages/ai/src/tesseract-languages.js", () => ({
|
|
getCachedTesseractLanguages: mockGetCachedTesseractLanguages,
|
|
getInstalledTesseractLanguages: mockGetInstalledTesseractLanguages,
|
|
}));
|
|
|
|
import {
|
|
getTesseractRuntimeMetadata,
|
|
resolveTesseractLanguage,
|
|
runAdaptiveTesseract,
|
|
runTesseract,
|
|
selectTesseractLanguageFamily,
|
|
selectTesseractLayout,
|
|
} from "../../../packages/ai/src/tesseract.js";
|
|
|
|
const TSV_HEADER =
|
|
"level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext";
|
|
|
|
function tsvWords(...words: Array<{ confidence: number; line?: number; text: string }>): string {
|
|
return [
|
|
TSV_HEADER,
|
|
...words.map(
|
|
(word, index) =>
|
|
`5\t1\t1\t1\t${word.line ?? 1}\t${index + 1}\t0\t0\t20\t10\t${word.confidence}\t${word.text}`,
|
|
),
|
|
].join("\n");
|
|
}
|
|
|
|
function createMockChild() {
|
|
const child = new EventEmitter() as EventEmitter & {
|
|
stdout: PassThrough;
|
|
stderr: PassThrough;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
};
|
|
child.stdout = new PassThrough();
|
|
child.stderr = new PassThrough();
|
|
child.kill = vi.fn(() => true);
|
|
return child;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const fullInventory = new Set(["eng", "deu", "fra", "spa", "chi_sim", "jpn", "osd"]);
|
|
mockGetInstalledTesseractLanguages.mockResolvedValue(fullInventory);
|
|
mockGetCachedTesseractLanguages.mockReturnValue(fullInventory);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("resolveTesseractLanguage", () => {
|
|
it.each([
|
|
["en", "eng"],
|
|
["de", "deu"],
|
|
["fr", "fra"],
|
|
["es", "spa"],
|
|
["zh", "chi_sim"],
|
|
["ja", "jpn"],
|
|
] as const)("maps %s to the installed Tesseract language %s", (language, expected) => {
|
|
expect(resolveTesseractLanguage(language)).toBe(expected);
|
|
});
|
|
|
|
it("uses every supported language for auto detection", () => {
|
|
expect(resolveTesseractLanguage("auto")).toBe("eng+deu+fra+spa+chi_sim+jpn");
|
|
});
|
|
|
|
it("rejects unsupported runtime language values", () => {
|
|
expect(() => resolveTesseractLanguage("it" as "en")).toThrow('Unsupported OCR language "it"');
|
|
});
|
|
});
|
|
|
|
describe("runTesseract", () => {
|
|
it("runs Tesseract without a shell and returns stdout with runtime metadata", async () => {
|
|
const child = createMockChild();
|
|
const onProgress = vi.fn();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input file.png", {
|
|
language: "ja",
|
|
onProgress,
|
|
tesseractPath: "/usr/local/bin/tesseract",
|
|
});
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
"/usr/local/bin/tesseract",
|
|
["/tmp/input file.png", "stdout", "-l", "jpn"],
|
|
{
|
|
shell: false,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
},
|
|
);
|
|
child.stdout.write("Recognized text\n");
|
|
child.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toEqual({
|
|
text: "Recognized text\n",
|
|
engine: "tesseract",
|
|
provider: "native",
|
|
device: "cpu",
|
|
});
|
|
expect(onProgress).toHaveBeenNthCalledWith(1, 0, "Starting Tesseract OCR");
|
|
expect(onProgress).toHaveBeenLastCalledWith(100, "Tesseract OCR complete");
|
|
});
|
|
|
|
it("defaults to all supported languages", async () => {
|
|
const child = createMockChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png");
|
|
expect(mockSpawn.mock.calls[0][1]).toEqual([
|
|
"/tmp/input.png",
|
|
"stdout",
|
|
"-l",
|
|
"eng+deu+fra+spa+chi_sim+jpn",
|
|
]);
|
|
child.emit("close", 0, null);
|
|
|
|
await resultPromise;
|
|
});
|
|
|
|
it.each(["Hangul", "kor"])(
|
|
"ignores legacy installed Korean model %s when resolving Fast auto languages",
|
|
async (koreanModel) => {
|
|
const child = createMockChild();
|
|
const inventory = new Set(["eng", koreanModel, "osd"]);
|
|
mockGetCachedTesseractLanguages.mockReturnValueOnce(inventory);
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/mixed.png", { language: "auto" });
|
|
const language = mockSpawn.mock.calls[0]?.[1]?.[3];
|
|
child.emit("close", 0, null);
|
|
|
|
await resultPromise;
|
|
expect(language).toBe("eng");
|
|
},
|
|
);
|
|
|
|
it("uses the installed supported subset for auto on a partial native host", async () => {
|
|
const child = createMockChild();
|
|
mockGetCachedTesseractLanguages.mockReturnValueOnce(new Set(["eng", "osd"]));
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png");
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
|
expect(mockSpawn.mock.calls[0][1]).toEqual(["/tmp/input.png", "stdout", "-l", "eng"]);
|
|
child.emit("close", 0, null);
|
|
|
|
await resultPromise;
|
|
});
|
|
|
|
it("fails an explicit missing language with cross-platform installation guidance", async () => {
|
|
mockGetCachedTesseractLanguages.mockReturnValueOnce(new Set(["eng", "osd"]));
|
|
|
|
await expect(runTesseract("/tmp/input.png", { language: "ja" })).rejects.toThrow(
|
|
'Tesseract language "ja" is unavailable: missing traineddata "jpn". Install Debian/Ubuntu package tesseract-ocr-jpn or the equivalent traineddata pack (Homebrew: brew install tesseract-lang), then restart SnapOtter.',
|
|
);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the real Debian package name for missing Simplified Chinese traineddata", async () => {
|
|
mockGetCachedTesseractLanguages.mockReturnValueOnce(new Set(["eng", "osd"]));
|
|
|
|
await expect(runTesseract("/tmp/input.png", { language: "zh" })).rejects.toThrow(
|
|
"Install Debian/Ubuntu package tesseract-ocr-chi-sim",
|
|
);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a raw Korean language value before spawning Tesseract", async () => {
|
|
mockGetCachedTesseractLanguages.mockReturnValueOnce(undefined);
|
|
|
|
await expect(runTesseract("/tmp/korean.png", { language: "ko" as never })).rejects.toThrow(
|
|
'Unsupported OCR language "ko"',
|
|
);
|
|
expect(mockGetInstalledTesseractLanguages).not.toHaveBeenCalled();
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["Hangul", "kor", "jpn+Hangul", "Hangul/../../eng", "Hangul+kor"])(
|
|
"rejects unsafe internal language set %s before spawning",
|
|
async (tesseractLanguages) => {
|
|
mockGetCachedTesseractLanguages.mockReturnValueOnce(new Set(["eng", "osd"]));
|
|
await expect(
|
|
runTesseract("/tmp/input.png", { language: "auto", tesseractLanguages }),
|
|
).rejects.toThrow("Unsupported internal Tesseract language set");
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("fails auto clearly when no supported traineddata is installed", async () => {
|
|
mockGetCachedTesseractLanguages.mockReturnValueOnce(new Set(["osd"]));
|
|
|
|
await expect(runTesseract("/tmp/input.png")).rejects.toThrow(
|
|
"Tesseract has no supported traineddata installed. Install at least tesseract-ocr-eng",
|
|
);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("includes stderr and the exit code when Tesseract fails", async () => {
|
|
const child = createMockChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png", { language: "en" });
|
|
child.stderr.write("Error opening data file");
|
|
child.emit("close", 1, null);
|
|
|
|
await expect(resultPromise).rejects.toThrow(
|
|
"Tesseract exited with code 1: Error opening data file",
|
|
);
|
|
});
|
|
|
|
it("preserves valid OCR output below the configured memory limit", async () => {
|
|
const child = createMockChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png", { maxOutputBytes: 32 });
|
|
child.stdout.write("Recognized ");
|
|
child.stdout.write("text\n");
|
|
child.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({ text: "Recognized text\n" });
|
|
});
|
|
|
|
it("terminates instead of retaining stdout beyond the configured memory limit", async () => {
|
|
const child = createMockChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png", { maxOutputBytes: 32 });
|
|
child.stdout.write(Buffer.alloc(33, 97));
|
|
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
child.emit("close", null, "SIGTERM");
|
|
await expect(resultPromise).rejects.toThrow("Tesseract stdout exceeded 32 bytes");
|
|
});
|
|
|
|
it("terminates instead of retaining stderr beyond the configured memory limit", async () => {
|
|
const child = createMockChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png", { maxOutputBytes: 32 });
|
|
child.stderr.write(Buffer.alloc(33, 97));
|
|
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
child.emit("close", null, "SIGTERM");
|
|
await expect(resultPromise).rejects.toThrow("Tesseract stderr exceeded 32 bytes");
|
|
});
|
|
|
|
it("supports an independent stdout allowance without shrinking stderr diagnostics", async () => {
|
|
const child = createMockChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png", {
|
|
maxStdoutBytes: 4,
|
|
maxStderrBytes: 32,
|
|
});
|
|
child.stderr.write("diagnostic");
|
|
child.stdout.write("12345");
|
|
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
child.emit("close", null, "SIGTERM");
|
|
await expect(resultPromise).rejects.toThrow("Tesseract stdout exceeded 4 bytes");
|
|
});
|
|
|
|
it("reports an actionable error when the Tesseract executable is missing", async () => {
|
|
const child = createMockChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png");
|
|
const error = Object.assign(new Error("spawn tesseract ENOENT"), { code: "ENOENT" });
|
|
child.emit("error", error);
|
|
|
|
await expect(resultPromise).rejects.toThrow(
|
|
"Tesseract executable not found. Install Tesseract or set TESSERACT_PATH.",
|
|
);
|
|
});
|
|
|
|
it("does not spawn when the request is already aborted", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png", { signal: controller.signal });
|
|
|
|
await expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("terminates an in-flight process when the request is aborted", async () => {
|
|
const child = createMockChild();
|
|
const controller = new AbortController();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png", { signal: controller.signal });
|
|
controller.abort();
|
|
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
child.emit("close", null, "SIGTERM");
|
|
await expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
|
});
|
|
|
|
it("does not miss cancellation that races with process startup", async () => {
|
|
const child = createMockChild();
|
|
const controller = new AbortController();
|
|
mockSpawn.mockImplementation(() => {
|
|
controller.abort();
|
|
return child;
|
|
});
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png", { signal: controller.signal });
|
|
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
child.emit("close", null, "SIGTERM");
|
|
await expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
|
});
|
|
|
|
it("terminates and rejects a process that exceeds its timeout", async () => {
|
|
vi.useFakeTimers();
|
|
const child = createMockChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png", { timeoutMs: 100 });
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
child.emit("close", null, "SIGTERM");
|
|
await expect(resultPromise).rejects.toThrow("Tesseract OCR timed out after 100ms");
|
|
});
|
|
|
|
it("force-kills but retains ownership until the process closes", async () => {
|
|
vi.useFakeTimers();
|
|
const child = createMockChild();
|
|
const controller = new AbortController();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseract("/tmp/input.png", { signal: controller.signal });
|
|
let rejected = false;
|
|
void resultPromise.catch(() => {
|
|
rejected = true;
|
|
});
|
|
const rejection = expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
|
controller.abort();
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
|
|
expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
|
|
expect(rejected).toBe(false);
|
|
child.emit("close", null, "SIGKILL");
|
|
await rejection;
|
|
});
|
|
});
|
|
|
|
describe("selectTesseractLayout", () => {
|
|
it("selects sparse layout when confidence-weighted coverage materially improves", () => {
|
|
const block = tsvWords({ confidence: 55, text: "fragment" });
|
|
const sparse = tsvWords(
|
|
{ confidence: 94, text: "complete" },
|
|
{ confidence: 93, line: 2, text: "receipt text" },
|
|
);
|
|
|
|
expect(selectTesseractLayout(block, sparse)).toEqual({
|
|
pageSegmentationMode: 11,
|
|
text: "complete\nreceipt text",
|
|
});
|
|
});
|
|
|
|
it("keeps block layout when the sparse score gain is only noise", () => {
|
|
const block = tsvWords({ confidence: 95, text: "invoice total" });
|
|
const sparse = tsvWords({ confidence: 96, text: "invoice total" });
|
|
|
|
expect(selectTesseractLayout(block, sparse)).toEqual({
|
|
pageSegmentationMode: 6,
|
|
text: "invoice total",
|
|
});
|
|
});
|
|
|
|
it("requires a conservative gain before sparse text volume can replace block layout", () => {
|
|
const block = tsvWords({ confidence: 95, text: "invoice total" });
|
|
const sparse = tsvWords(
|
|
{ confidence: 95, text: "invoice total" },
|
|
{ confidence: 85, line: 2, text: "tax" },
|
|
);
|
|
|
|
expect(selectTesseractLayout(block, sparse)).toEqual({
|
|
pageSegmentationMode: 6,
|
|
text: "invoice total",
|
|
});
|
|
});
|
|
|
|
it("rejects malformed TSV and non-finite word confidence", () => {
|
|
expect(() => selectTesseractLayout("not tsv", tsvWords())).toThrow("malformed TSV");
|
|
expect(() =>
|
|
selectTesseractLayout(tsvWords({ confidence: Number.NaN, text: "invoice" }), tsvWords()),
|
|
).toThrow("malformed TSV confidence");
|
|
});
|
|
});
|
|
|
|
describe("selectTesseractLanguageFamily", () => {
|
|
it("selects the CJK family only with material script evidence", () => {
|
|
const latin = tsvWords({ confidence: 80, text: "receipt 505" });
|
|
const cjk = tsvWords({ confidence: 85, text: "領収書 505" });
|
|
|
|
expect(selectTesseractLanguageFamily(latin, cjk)).toBe("jpn+chi_sim");
|
|
});
|
|
|
|
it("keeps the Latin family when a candidate contains one CJK hallucination", () => {
|
|
const latin = tsvWords({ confidence: 90, text: "invoice total" });
|
|
const cjk = tsvWords({ confidence: 92, text: "invoice 合 total" });
|
|
|
|
expect(selectTesseractLanguageFamily(latin, cjk)).toBe("eng+deu+fra+spa");
|
|
});
|
|
|
|
it("keeps the Latin family when a noisy candidate has weak CJK density", () => {
|
|
const latin = tsvWords({ confidence: 90, text: "invoice total 505" });
|
|
const cjk = tsvWords({ confidence: 50, text: "合計 invoice" });
|
|
|
|
expect(selectTesseractLanguageFamily(latin, cjk)).toBe("eng+deu+fra+spa");
|
|
});
|
|
|
|
it("selects a mixed CJK address when comparative evidence is stronger", () => {
|
|
const latin = tsvWords({ confidence: 65, text: "Tokyo Chiyoda Railway 1234567890" });
|
|
const cjk = tsvWords({
|
|
confidence: 95,
|
|
text: "東京都千代田 Chiyoda Railway 1234567890",
|
|
});
|
|
|
|
expect(selectTesseractLanguageFamily(latin, cjk)).toBe("jpn+chi_sim");
|
|
});
|
|
});
|
|
|
|
describe("runAdaptiveTesseract", () => {
|
|
it("rejects a raw Korean language value before inventory preflight or recognition", async () => {
|
|
await expect(
|
|
runAdaptiveTesseract("/tmp/korean.png", { language: "ko" as never }),
|
|
).rejects.toThrow('Unsupported OCR language "ko"');
|
|
|
|
expect(mockGetInstalledTesseractLanguages).not.toHaveBeenCalled();
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("runs an English-only auto host without probing unavailable CJK packs", async () => {
|
|
const block = createMockChild();
|
|
const sparse = createMockChild();
|
|
mockGetInstalledTesseractLanguages.mockResolvedValue(new Set(["eng", "osd"]));
|
|
mockGetCachedTesseractLanguages.mockReturnValue(new Set(["eng", "osd"]));
|
|
mockSpawn.mockReturnValueOnce(block).mockReturnValueOnce(sparse);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/english.png", {
|
|
language: "auto",
|
|
timeoutMs: 5_000,
|
|
});
|
|
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
|
expect(mockSpawn.mock.calls[0]?.[1]).toContain("eng");
|
|
block.stdout.write(tsvWords({ confidence: 90, text: "English text" }));
|
|
block.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
expect(mockSpawn.mock.calls[1]?.[1]).toContain("eng");
|
|
expect(mockSpawn.mock.calls.flatMap((call) => call[1])).not.toContain("jpn+chi_sim");
|
|
sparse.stdout.write(tsvWords({ confidence: 80, text: "English text" }));
|
|
sparse.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({ text: "English text" });
|
|
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("probes only installed members when both auto families remain viable", async () => {
|
|
const latinBlock = createMockChild();
|
|
const cjkBlock = createMockChild();
|
|
const cjkSparse = createMockChild();
|
|
mockGetInstalledTesseractLanguages.mockResolvedValue(new Set(["eng", "jpn", "osd"]));
|
|
mockGetCachedTesseractLanguages.mockReturnValue(new Set(["eng", "jpn", "osd"]));
|
|
mockSpawn
|
|
.mockReturnValueOnce(latinBlock)
|
|
.mockReturnValueOnce(cjkBlock)
|
|
.mockReturnValueOnce(cjkSparse);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/mixed.png", {
|
|
language: "auto",
|
|
timeoutMs: 5_000,
|
|
});
|
|
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
|
expect(mockSpawn.mock.calls[0]?.[1]).toContain("eng");
|
|
latinBlock.stdout.write(tsvWords({ confidence: 60, text: "receipt" }));
|
|
latinBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
expect(mockSpawn.mock.calls[1]?.[1]).toContain("jpn");
|
|
cjkBlock.stdout.write(tsvWords({ confidence: 95, text: "日本語文字列" }));
|
|
cjkBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
|
expect(mockSpawn.mock.calls[2]?.[1]).toContain("jpn");
|
|
cjkSparse.stdout.write(tsvWords({ confidence: 80, text: "日本語文字列" }));
|
|
cjkSparse.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({ text: "日本語文字列" });
|
|
const languageArgs = mockSpawn.mock.calls.map((call) => call[1][3]);
|
|
expect(languageArgs).toEqual(["eng", "jpn", "jpn"]);
|
|
});
|
|
|
|
it("runs bounded block and sparse TSV candidates without a shell", async () => {
|
|
const block = createMockChild();
|
|
const sparse = createMockChild();
|
|
mockSpawn.mockReturnValueOnce(block).mockReturnValueOnce(sparse);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/receipt.png", {
|
|
language: "ja",
|
|
timeoutMs: 5_000,
|
|
maxStdoutBytes: 1_000_000,
|
|
});
|
|
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
|
expect(mockSpawn.mock.calls[0]?.[1]).toEqual([
|
|
"/tmp/receipt.png",
|
|
"stdout",
|
|
"-l",
|
|
"jpn",
|
|
"--psm",
|
|
"6",
|
|
"tsv",
|
|
]);
|
|
block.stdout.write(tsvWords({ confidence: 55, text: "fragment" }));
|
|
block.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
expect(mockSpawn.mock.calls[1]?.[1]).toEqual([
|
|
"/tmp/receipt.png",
|
|
"stdout",
|
|
"-l",
|
|
"jpn",
|
|
"--psm",
|
|
"11",
|
|
"tsv",
|
|
]);
|
|
sparse.stdout.write(
|
|
tsvWords(
|
|
{ confidence: 94, text: "complete" },
|
|
{ confidence: 93, line: 2, text: "receipt text" },
|
|
),
|
|
);
|
|
sparse.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toEqual({
|
|
text: "complete\nreceipt text",
|
|
engine: "tesseract",
|
|
provider: "native",
|
|
device: "cpu",
|
|
});
|
|
});
|
|
|
|
it("recovers weak CJK scene text through bounded horizontal tile fallbacks", async () => {
|
|
const primaryBlock = createMockChild();
|
|
const primarySparse = createMockChild();
|
|
const upperBlock = createMockChild();
|
|
const upperSparse = createMockChild();
|
|
const lowerBlock = createMockChild();
|
|
const lowerSparse = createMockChild();
|
|
mockSpawn
|
|
.mockReturnValueOnce(primaryBlock)
|
|
.mockReturnValueOnce(primarySparse)
|
|
.mockReturnValueOnce(upperBlock)
|
|
.mockReturnValueOnce(upperSparse)
|
|
.mockReturnValueOnce(lowerBlock)
|
|
.mockReturnValueOnce(lowerSparse);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/board.png", {
|
|
fallbackInputPaths: ["/tmp/board-upper.png", "/tmp/board-lower.png"],
|
|
language: "ja",
|
|
timeoutMs: 10_000,
|
|
});
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
|
primaryBlock.stdout.write(tsvWords({ confidence: 40, text: "僅" }));
|
|
primaryBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
primarySparse.stdout.write(tsvWords({ confidence: 35, text: "断" }));
|
|
primarySparse.emit("close", 0, null);
|
|
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
|
expect(mockSpawn.mock.calls[2]?.[1]).toEqual([
|
|
"/tmp/board-upper.png",
|
|
"stdout",
|
|
"-l",
|
|
"jpn",
|
|
"--psm",
|
|
"6",
|
|
"tsv",
|
|
]);
|
|
upperBlock.stdout.write(
|
|
tsvWords({
|
|
confidence: 94,
|
|
text: "上段仕様表日本語文字列一二三四五六七八九十上段仕様表日本語文字列一二三四五六七八九十",
|
|
}),
|
|
);
|
|
upperBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(4));
|
|
upperSparse.stdout.write(tsvWords({ confidence: 50, text: "上段" }));
|
|
upperSparse.emit("close", 0, null);
|
|
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(5));
|
|
lowerBlock.stdout.write(
|
|
tsvWords({
|
|
confidence: 93,
|
|
text: "下段仕様表日本語文字列十一十二十三十四十五下段仕様表日本語文字列十一十二十三十四十五",
|
|
}),
|
|
);
|
|
lowerBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(6));
|
|
lowerSparse.stdout.write(tsvWords({ confidence: 45, text: "下段" }));
|
|
lowerSparse.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({
|
|
text: "上段仕様表日本語文字列一二三四五六七八九十上段仕様表日本語文字列一二三四五六七八九十\n下段仕様表日本語文字列十一十二十三十四十五下段仕様表日本語文字列十一十二十三十四十五",
|
|
});
|
|
expect(mockSpawn).toHaveBeenCalledTimes(6);
|
|
});
|
|
|
|
it("does not spend tile fallback work when the primary CJK result is strong", async () => {
|
|
const primaryBlock = createMockChild();
|
|
const primarySparse = createMockChild();
|
|
mockSpawn.mockReturnValueOnce(primaryBlock).mockReturnValueOnce(primarySparse);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/board.png", {
|
|
fallbackInputPaths: ["/tmp/board-upper.png", "/tmp/board-lower.png"],
|
|
language: "ja",
|
|
timeoutMs: 10_000,
|
|
});
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
|
primaryBlock.stdout.write(
|
|
tsvWords({
|
|
confidence: 95,
|
|
text: "十分な日本語文字列を含む通常の認識結果です一二三四五六七八九十",
|
|
}),
|
|
);
|
|
primaryBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
primarySparse.stdout.write(tsvWords({ confidence: 50, text: "断片" }));
|
|
primarySparse.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({
|
|
text: "十分な日本語文字列を含む通常の認識結果です一二三四五六七八九十",
|
|
});
|
|
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("recovers a dense CJK board through one enhanced block candidate", async () => {
|
|
const primaryBlock = createMockChild();
|
|
const primarySparse = createMockChild();
|
|
const enhancedBlock = createMockChild();
|
|
const denseCjkInputProvider = vi.fn().mockResolvedValue("/tmp/board-dense.png");
|
|
mockSpawn
|
|
.mockReturnValueOnce(primaryBlock)
|
|
.mockReturnValueOnce(primarySparse)
|
|
.mockReturnValueOnce(enhancedBlock);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/board.png", {
|
|
denseCjkInputProvider,
|
|
language: "ja",
|
|
timeoutMs: 10_000,
|
|
});
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
|
primaryBlock.stdout.write(tsvWords({ confidence: 45, text: "日本語の短い断片" }));
|
|
primaryBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
primarySparse.stdout.write(
|
|
tsvWords({ confidence: 60, text: "日本語の中程度の断片一二三四五六七八九十" }),
|
|
);
|
|
primarySparse.emit("close", 0, null);
|
|
|
|
await vi.waitFor(() => expect(denseCjkInputProvider).toHaveBeenCalledTimes(1));
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
|
expect(mockSpawn.mock.calls[2]?.[1]).toEqual([
|
|
"/tmp/board-dense.png",
|
|
"stdout",
|
|
"-l",
|
|
"jpn",
|
|
"--psm",
|
|
"6",
|
|
"tsv",
|
|
]);
|
|
const recovered =
|
|
"高信頼の日本語仕様表一二三四五六七八九十高信頼の日本語仕様表一二三四五六七八九十" +
|
|
"高信頼の日本語仕様表一二三四五六七八九十高信頼の日本語仕様表一二三四五六七八九十" +
|
|
"高信頼の日本語仕様表一二三四五六七八九十";
|
|
enhancedBlock.stdout.write(
|
|
tsvWords(
|
|
{ confidence: 90, text: "|" },
|
|
{ confidence: 82, text: recovered },
|
|
{ confidence: 91, text: "||" },
|
|
),
|
|
);
|
|
enhancedBlock.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({ text: recovered });
|
|
expect(mockSpawn).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("does not preprocess a strong CJK primary result", async () => {
|
|
const primaryBlock = createMockChild();
|
|
const primarySparse = createMockChild();
|
|
const denseCjkInputProvider = vi.fn().mockResolvedValue("/tmp/board-dense.png");
|
|
mockSpawn.mockReturnValueOnce(primaryBlock).mockReturnValueOnce(primarySparse);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/board.png", {
|
|
denseCjkInputProvider,
|
|
language: "ja",
|
|
timeoutMs: 10_000,
|
|
});
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
|
primaryBlock.stdout.write(
|
|
tsvWords({
|
|
confidence: 95,
|
|
text: "十分な日本語文字列を含む通常の認識結果です一二三四五六七八九十",
|
|
}),
|
|
);
|
|
primaryBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
primarySparse.stdout.write(tsvWords({ confidence: 50, text: "断片" }));
|
|
primarySparse.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({
|
|
text: "十分な日本語文字列を含む通常の認識結果です一二三四五六七八九十",
|
|
});
|
|
expect(denseCjkInputProvider).not.toHaveBeenCalled();
|
|
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("probes bounded language families for auto without seven-language dilution", async () => {
|
|
const latinBlock = createMockChild();
|
|
const cjkBlock = createMockChild();
|
|
const cjkSparse = createMockChild();
|
|
mockSpawn
|
|
.mockReturnValueOnce(latinBlock)
|
|
.mockReturnValueOnce(cjkBlock)
|
|
.mockReturnValueOnce(cjkSparse);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/mixed.png", {
|
|
language: "auto",
|
|
timeoutMs: 5_000,
|
|
});
|
|
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
|
expect(mockSpawn.mock.calls[0]?.[1]).toContain("eng+deu+fra+spa");
|
|
latinBlock.stdout.write(tsvWords({ confidence: 80, text: "receipt 505" }));
|
|
latinBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
expect(mockSpawn.mock.calls[1]?.[1]).toContain("jpn+chi_sim");
|
|
cjkBlock.stdout.write(tsvWords({ confidence: 90, text: "領収書 505" }));
|
|
cjkBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
|
expect(mockSpawn.mock.calls[2]?.[1]).toEqual([
|
|
"/tmp/mixed.png",
|
|
"stdout",
|
|
"-l",
|
|
"jpn+chi_sim",
|
|
"--psm",
|
|
"11",
|
|
"tsv",
|
|
]);
|
|
cjkSparse.stdout.write(tsvWords({ confidence: 70, text: "領収書" }));
|
|
cjkSparse.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({ text: "領収書 505" });
|
|
expect(mockSpawn.mock.calls.flatMap((call) => call[1] as string[])).not.toContain(
|
|
"eng+deu+fra+spa+chi_sim+jpn",
|
|
);
|
|
});
|
|
|
|
it("probes auto script on the original before recognizing a preprocessed image", async () => {
|
|
const latinProbe = createMockChild();
|
|
const cjkProbe = createMockChild();
|
|
const processedBlock = createMockChild();
|
|
const processedSparse = createMockChild();
|
|
mockSpawn
|
|
.mockReturnValueOnce(latinProbe)
|
|
.mockReturnValueOnce(cjkProbe)
|
|
.mockReturnValueOnce(processedBlock)
|
|
.mockReturnValueOnce(processedSparse);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/original.png", {
|
|
language: "auto",
|
|
recognitionInputPath: "/tmp/low-contrast.png",
|
|
timeoutMs: 5_000,
|
|
});
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
|
expect(mockSpawn.mock.calls[0]?.[1]).toEqual([
|
|
"/tmp/original.png",
|
|
"stdout",
|
|
"-l",
|
|
"eng+deu+fra+spa",
|
|
"--psm",
|
|
"6",
|
|
"tsv",
|
|
]);
|
|
latinProbe.stdout.write(tsvWords({ confidence: 90, text: "invoice total 505" }));
|
|
latinProbe.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
expect(mockSpawn.mock.calls[1]?.[1]?.[0]).toBe("/tmp/original.png");
|
|
cjkProbe.stdout.write(tsvWords({ confidence: 50, text: "invoice 合 total" }));
|
|
cjkProbe.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
|
expect(mockSpawn.mock.calls[2]?.[1]).toEqual([
|
|
"/tmp/low-contrast.png",
|
|
"stdout",
|
|
"-l",
|
|
"eng+deu+fra+spa",
|
|
"--psm",
|
|
"6",
|
|
"tsv",
|
|
]);
|
|
processedBlock.stdout.write(tsvWords({ confidence: 95, text: "invoice total 505" }));
|
|
processedBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(4));
|
|
expect(mockSpawn.mock.calls[3]?.[1]?.[0]).toBe("/tmp/low-contrast.png");
|
|
processedSparse.stdout.write(tsvWords({ confidence: 70, text: "invoice" }));
|
|
processedSparse.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({ text: "invoice total 505" });
|
|
});
|
|
|
|
it("can retain block layout for calibrated low-contrast recognition", async () => {
|
|
const latinProbe = createMockChild();
|
|
const cjkProbe = createMockChild();
|
|
const processedBlock = createMockChild();
|
|
mockSpawn
|
|
.mockReturnValueOnce(latinProbe)
|
|
.mockReturnValueOnce(cjkProbe)
|
|
.mockReturnValueOnce(processedBlock);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/original.png", {
|
|
blockLayoutOnly: true,
|
|
language: "auto",
|
|
recognitionInputPath: "/tmp/low-contrast.png",
|
|
});
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
|
latinProbe.stdout.write(tsvWords({ confidence: 90, text: "invoice total 505" }));
|
|
latinProbe.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
cjkProbe.stdout.write(tsvWords({ confidence: 50, text: "invoice 合 total" }));
|
|
cjkProbe.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
|
processedBlock.stdout.write(tsvWords({ confidence: 95, text: "invoice total 505" }));
|
|
processedBlock.emit("close", 0, null);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({ text: "invoice total 505" });
|
|
expect(mockSpawn).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("shares one deadline across sequential layout candidates", async () => {
|
|
vi.useFakeTimers();
|
|
const monotonicNow = vi.spyOn(performance, "now").mockReturnValue(0);
|
|
const block = createMockChild();
|
|
const sparse = createMockChild();
|
|
mockSpawn.mockReturnValueOnce(block).mockReturnValueOnce(sparse);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/form.png", {
|
|
language: "en",
|
|
timeoutMs: 100,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(40);
|
|
monotonicNow.mockReturnValue(40);
|
|
block.stdout.write(tsvWords({ confidence: 95, text: "form" }));
|
|
block.emit("close", 0, null);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
|
await vi.advanceTimersByTimeAsync(35);
|
|
|
|
expect(sparse.kill).toHaveBeenCalledWith("SIGTERM");
|
|
sparse.emit("close", null, "SIGTERM");
|
|
await expect(resultPromise).rejects.toThrow("timed out");
|
|
});
|
|
|
|
it("reserves bounded termination grace across all three auto candidates", async () => {
|
|
vi.useFakeTimers();
|
|
const monotonicNow = vi.spyOn(performance, "now").mockReturnValue(0);
|
|
const latinBlock = createMockChild();
|
|
const cjkBlock = createMockChild();
|
|
const cjkSparse = createMockChild();
|
|
mockSpawn
|
|
.mockReturnValueOnce(latinBlock)
|
|
.mockReturnValueOnce(cjkBlock)
|
|
.mockReturnValueOnce(cjkSparse);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/mixed.png", {
|
|
language: "auto",
|
|
timeoutMs: 200,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
monotonicNow.mockReturnValue(20);
|
|
latinBlock.stdout.write(tsvWords({ confidence: 80, text: "receipt 505" }));
|
|
latinBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
monotonicNow.mockReturnValue(40);
|
|
cjkBlock.stdout.write(tsvWords({ confidence: 90, text: "領収書 505" }));
|
|
cjkBlock.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
|
|
|
await vi.advanceTimersByTimeAsync(110);
|
|
expect(cjkSparse.kill).toHaveBeenCalledWith("SIGTERM");
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
expect(cjkSparse.kill).toHaveBeenCalledWith("SIGKILL");
|
|
cjkSparse.emit("close", null, "SIGKILL");
|
|
await expect(resultPromise).rejects.toThrow("timed out");
|
|
});
|
|
|
|
it("uses a monotonic aggregate deadline across wall-clock jumps", async () => {
|
|
vi.useFakeTimers();
|
|
const monotonicNow = vi.spyOn(performance, "now").mockReturnValue(0);
|
|
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
|
|
const block = createMockChild();
|
|
const sparse = createMockChild();
|
|
mockSpawn.mockReturnValueOnce(block).mockReturnValueOnce(sparse);
|
|
|
|
const resultPromise = runAdaptiveTesseract("/tmp/form.png", {
|
|
language: "en",
|
|
timeoutMs: 100,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
monotonicNow.mockReturnValue(20);
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
block.stdout.write(tsvWords({ confidence: 95, text: "form" }));
|
|
block.emit("close", 0, null);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
|
|
await vi.advanceTimersByTimeAsync(55);
|
|
expect(sparse.kill).toHaveBeenCalledWith("SIGTERM");
|
|
sparse.emit("close", null, "SIGTERM");
|
|
await expect(resultPromise).rejects.toThrow("timed out");
|
|
});
|
|
});
|
|
|
|
describe("getTesseractRuntimeMetadata", () => {
|
|
it("reports the native CPU provider on every host", () => {
|
|
expect(getTesseractRuntimeMetadata()).toEqual({
|
|
engine: "tesseract",
|
|
provider: "native",
|
|
device: "cpu",
|
|
});
|
|
});
|
|
});
|