feat: add iOS support and harden receipt scanning
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
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";
|
||||
@@ -14,6 +16,8 @@ import {
|
||||
PROVIDER_TIMEOUT_MS,
|
||||
isRateLimitError,
|
||||
shouldRetryWithReasoning,
|
||||
shouldVerifyLineItems,
|
||||
selectLineItemViewsForVerification,
|
||||
} from "../../src/lib/ai/extractor";
|
||||
import { PENDING_VALIDATION, ReceiptData } from "../../src/lib/schema/receipt";
|
||||
|
||||
@@ -48,6 +52,35 @@ describe("Sprint B — Vision-Seiten", () => {
|
||||
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", () => {
|
||||
@@ -55,9 +88,99 @@ describe("Sprint B — Reasoning on-demand", () => {
|
||||
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", () => {
|
||||
@@ -67,8 +190,8 @@ describe("Sprint B — Rate-Limit & Timeout", () => {
|
||||
expect(isRateLimitError(new Error("timeout"))).toBe(false);
|
||||
});
|
||||
|
||||
test("Provider-Timeout ist 12s", () => {
|
||||
expect(PROVIDER_TIMEOUT_MS).toBe(12_000);
|
||||
test("Provider-Timeout lässt Detailansichten genug Zeit", () => {
|
||||
expect(PROVIDER_TIMEOUT_MS).toBe(20_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user