/** * Sprint B: scanner speed — client prepare, vision page pick, reasoning * retry policy, provider timeout constant, PDF raster target. */ import { describe, test, expect } from "./runner"; import { AI_IMAGE_LONG_EDGE_PX, computeReceiptTileRegions, MAX_RECEIPT_DETAIL_TILES, MAX_VISION_PAGES, selectVisionPages, } from "../../src/lib/image/processor"; import { prepareUploadFile, CLIENT_UPLOAD_SKIP_BELOW_BYTES } from "../../src/lib/image/prepareUpload"; import { PROVIDER_TIMEOUT_MS, isRateLimitError, shouldRetryWithReasoning, shouldVerifyLineItems, selectLineItemViewsForVerification, } from "../../src/lib/ai/extractor"; import { PENDING_VALIDATION, ReceiptData } from "../../src/lib/schema/receipt"; function receipt(mathValid: boolean): ReceiptData { return { merchant: { name: "REWE", address: null, taxId: null, confidence: 0.97 }, date: { isoDate: "2026-08-12", time: null, confidence: 0.95 }, documentType: "KASSENBON", receiptNumber: null, currency: "EUR", totalAmount: { value: 11.9, confidence: 0.98 }, netAmount: 10.0, taxBreakdown: [{ ratePercent: 19, taxAmount: 1.9, netAmount: 10.0 }], lineItems: [], suggestedCategory: "Sonstiges", validation: { ...PENDING_VALIDATION, isMathValid: mathValid }, }; } describe("Sprint B — Vision-Seiten", () => { test("kurze Dokumente behalten alle Seiten", () => { expect(selectVisionPages([1, 2, 3])).toEqual([1, 2, 3]); expect(selectVisionPages([1, 2, 3, 4, 5, 6, 7, 8])).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); }); test("lange Dokumente: erste 6 + letzte 2, ohne Duplikate", () => { const pages = Array.from({ length: 20 }, (_, i) => i + 1); expect(selectVisionPages(pages)).toEqual([1, 2, 3, 4, 5, 6, 19, 20]); expect(selectVisionPages(pages).length).toBeLessThanOrEqual(MAX_VISION_PAGES); }); test("7 Seiten überlappen sich nicht doppelt", () => { expect(selectVisionPages([1, 2, 3, 4, 5, 6, 7])).toEqual([1, 2, 3, 4, 5, 6, 7]); }); test("langer Thermobon wird in überlappende Detailansichten geteilt", () => { const regions = computeReceiptTileRegions(1000, 3600); expect(regions.length).toBeGreaterThan(1); expect(regions[0].top).toBe(0); expect(regions[regions.length - 1].top + regions[regions.length - 1].height).toBe(3600); expect(regions[1].top).toBeLessThan(regions[0].top + regions[0].height); }); test("normales Dokument wird nicht unnötig gekachelt", () => { expect(computeReceiptTileRegions(1200, 1600)).toEqual([]); }); for (const aspect of [8, 12, 20]) { test(`${aspect}:1-Thermobon bleibt vollständig und lückenlos`, () => { const width = 1000; const height = width * aspect; const regions = computeReceiptTileRegions(width, height); expect(regions.length).toBeGreaterThan(5); expect(regions.length).toBeLessThanOrEqual(MAX_RECEIPT_DETAIL_TILES); expect(regions[0].top).toBe(0); expect(regions[regions.length - 1].top + regions[regions.length - 1].height).toBe(height); for (let index = 1; index < regions.length; index++) { expect(regions[index].top).toBeLessThanOrEqual( regions[index - 1].top + regions[index - 1].height ); } }); } }); describe("Sprint B — Reasoning on-demand", () => { test("Math-Fehler löst Reasoning-Retry aus", () => { expect(shouldRetryWithReasoning(receipt(false))).toBe(true); }); test("Positionswarnung löst trotz valider Steuerrechnung einen Retry aus", () => { const value = receipt(true); value.validation = { ...value.validation, needsUserReview: true, issues: [{ field: "lineItems", severity: "warning", message: "Sum mismatch" }], }; expect(shouldRetryWithReasoning(value)).toBe(true); expect(shouldVerifyLineItems(value)).toBe(true); }); test("echter Netto-/Steuerfehler bleibt ein Reasoning-Fall", () => { const value = receipt(false); value.validation = { ...value.validation, needsUserReview: true, issues: [{ field: "taxBreakdown", severity: "error", message: "Tax mismatch" }], }; expect(shouldVerifyLineItems(value)).toBe(false); }); test("valide Math braucht kein Reasoning", () => { expect(shouldRetryWithReasoning(receipt(true))).toBe(false); }); test("extremer oder schwieriger Bon erzwingt fokussierte Zeilen-OCR", () => { expect(shouldVerifyLineItems(receipt(true), true)).toBe(true); }); test("niedrige Zeilen-Confidence löst gezielte Prüfung aus", () => { const value = receipt(true); value.lineItems = [ { description: "Schwer lesbar", quantity: 1, price: 11.9, unitPrice: null, taxRate: 19, confidence: 0.55, }, ]; expect(shouldVerifyLineItems(value)).toBe(true); }); test("nur unsicherer Ausschnitt plus direkte Nachbarn werden erneut gelesen", () => { const value = receipt(true); value.lineItems = [ { description: "Artikel", quantity: 1, price: 11.9, unitPrice: null, taxRate: 19, confidence: 0.92, sourceView: 3, rowOrder: 1, }, ]; const views = Array.from({ length: 6 }, (_, index) => ({ buffer: Buffer.from([index]), sourceView: index + 1, qualityScore: index === 3 ? 0.2 : 0.9, difficult: index === 3, })); expect( selectLineItemViewsForVerification(value, views, "detail_views").map( (view) => view.sourceView ) ).toEqual([3, 4, 5]); }); test("Extrembon prüft trotz lokaler Auswahl alle lückenlosen Ausschnitte", () => { const value = receipt(true); value.lineItems = [ { description: "Artikel", quantity: 1, price: 11.9, unitPrice: null, taxRate: 19, confidence: 0.9, sourceView: 1, rowOrder: 1, }, ]; const views = Array.from({ length: 8 }, (_, index) => ({ buffer: Buffer.from([index]), sourceView: index + 1, qualityScore: 0.9, difficult: false, })); expect(selectLineItemViewsForVerification(value, views, "long_receipt_summary")).toHaveLength(8); }); }); describe("Sprint B — Rate-Limit & Timeout", () => { test("429 wird als retryable erkannt", () => { expect(isRateLimitError({ status: 429, message: "Too Many Requests" })).toBe(true); expect(isRateLimitError(new Error("HTTP 429 rate_limit"))).toBe(true); expect(isRateLimitError(new Error("timeout"))).toBe(false); }); test("Provider-Timeout lässt Detailansichten genug Zeit", () => { expect(PROVIDER_TIMEOUT_MS).toBe(20_000); }); }); describe("Sprint B — Bildpipeline", () => { test("PDF-Raster und AI-JPEG teilen die 1536px-Kante", () => { expect(AI_IMAGE_LONG_EDGE_PX).toBe(1536); }); }); describe("Sprint B — Client-Prepare", () => { test("PDF bleibt unverändert", async () => { const pdf = new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "invoice.pdf", { type: "application/pdf", }); const out = await prepareUploadFile(pdf); expect(out).toBe(pdf); }); test("kleine JPEGs unter dem Skip-Limit bleiben unverändert", async () => { const bytes = new Uint8Array(Math.min(1024, CLIENT_UPLOAD_SKIP_BELOW_BYTES)); const file = new File([bytes], "tiny.jpg", { type: "image/jpeg" }); Object.defineProperty(file, "size", { value: 12_000 }); const out = await prepareUploadFile(file); expect(out).toBe(file); }); });