/** * Sprint C: image quality — min width, contrast gate, crop box, deskew. */ import { describe, test, expect } from "./runner"; import { AI_IMAGE_LONG_EDGE_PX, AI_IMAGE_MIN_WIDTH_PX, computeReceiptResize, } from "../../src/lib/image/resize"; import { contentBoundingBox, estimateSkewDegrees, needsContrastBoost, } from "../../src/lib/image/enhance"; import { processReceiptImage } from "../../src/lib/image/processor"; describe("Sprint C — Mindestbreite", () => { test("Querformat bleibt am 1536-Long-Edge", () => { const out = computeReceiptResize(2000, 1400); expect(out.width).toBe(1536); expect(out.height).toBe(1075); }); test("quadratische kleine Fotos werden nicht hochskaliert", () => { expect(computeReceiptResize(400, 400)).toEqual({ width: 400, height: 400 }); }); test("hoher Thermobon wird breiter als bei reinem Long-Edge-Cap", () => { const srcW = 800; const srcH = 3500; const longEdgeOnlyW = Math.round(srcW * (AI_IMAGE_LONG_EDGE_PX / srcH)); const out = computeReceiptResize(srcW, srcH); expect(longEdgeOnlyW).toBeLessThan(400); expect(out.width).toBeGreaterThan(longEdgeOnlyW); expect(out.width).toBeGreaterThanOrEqual(650); expect(Math.max(out.width, out.height)).toBeLessThanOrEqual(4096); }); test("Min-Width-Konstante ist 1100", () => { expect(AI_IMAGE_MIN_WIDTH_PX).toBe(1100); }); }); describe("Sprint C — Kontrast", () => { test("verwaschenes Thermobild (niedrige Stdev) bekommt Boost", () => { expect( needsContrastBoost({ channels: [{ mean: 170, stdev: 12, min: 140, max: 200 }], }) ).toBe(true); }); test("farbstarkes Foto bekommt keinen Boost", () => { expect( needsContrastBoost({ channels: [ { mean: 90, stdev: 55, min: 10, max: 240 }, { mean: 100, stdev: 60, min: 8, max: 250 }, { mean: 80, stdev: 50, min: 5, max: 230 }, ], }) ).toBe(false); }); }); describe("Sprint C — Crop", () => { test("dunkles Rechteck auf weißem Grund wird als Inhalt erkannt", () => { const w = 80; const h = 80; const data = new Uint8Array(w * h).fill(250); for (let y = 18; y < 62; y++) { for (let x = 16; x < 64; x++) { data[y * w + x] = 20; } } const box = contentBoundingBox(data, w, h); expect(box).not.toBeNull(); expect(box!.left).toBeLessThanOrEqual(16); expect(box!.top).toBeLessThanOrEqual(18); expect(box!.left + box!.width).toBeGreaterThanOrEqual(64); expect(box!.top + box!.height).toBeGreaterThanOrEqual(62); }); test("einfarbiges Bild wird nicht beschnitten", () => { const data = new Uint8Array(40 * 40).fill(200); expect(contentBoundingBox(data, 40, 40)).toBeNull(); }); }); describe("Sprint C — Deskew", () => { test("waagerechte Textzeilen → Winkel ~ 0", () => { const w = 64; const h = 80; const data = new Uint8Array(w * h); for (let y = 0; y < h; y++) { const dark = y % 8 < 3; data.fill(dark ? 15 : 230, y * w, y * w + w); } expect(Math.abs(estimateSkewDegrees(data, w, h))).toBeLessThan(1); }); }); describe("Sprint C — Pipeline", () => { test("1×1-PNG überlebt Deskew/Crop/Resize", async () => { const png = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64" ); const result = await processReceiptImage(png, "image/png"); expect(result.mimeType).toBe("image/jpeg"); expect(result.width).toBeGreaterThan(0); expect(result.height).toBeGreaterThan(0); }); });