/** * Milestone 2 (R2): Side-by-Side Receipt Inspector & Split Review Modal — Adversarial Stress Suite * Empirical Challenger Verification * * Stress-tests: * 1. Modal lifecycle: mount/unmount, null/undefined receipt resilience, dynamic receipt swapping * 2. Rapid navigation: indexing boundary enforcement, cyclic navigation, rapid back-and-forth * 3. Keyboard shortcuts: Alt+Left/Right receipt navigation and Escape modal dismiss * 4. Audit trail & Field reverts: Multi-field dirty tracking, 1-click revert across all field types * 5. Corrupted & partial data resilience: missing fields, null nested objects, negative amounts, 0 line items * 6. Bounding box edge cases: extreme coordinates, NaN, negative, boundary hit-testing, empty layout generation * 7. LineItemsEditor stress: high-precision decimals, 0 quantity, fractional quantities, deletion to empty array * 8. Responsive mobile tab switching: state synchronization and view toggle */ import { describe, test, it, expect, beforeEach } from "./runner"; import { ProcessedReceipt, ReceiptData, PaymentMethodSchema, BoundingBoxRectSchema, ReceiptBoundingBoxesSchema, ReceiptExtractionSchema, } from "../../src/lib/schema/receipt"; import { clampPercent, createBoundingBoxRect, pixelRectToPercent, isPointInsideBox, generateDefaultBoundingBoxes, getFieldBoundingBox, getFieldLabel, findFieldAtCoordinates, } from "../../src/lib/utils/boundingBoxes"; import { recalculateReceipt, confirmReceiptReviewed, receiptNeedsAttention } from "../../src/lib/ai/recalculate"; function createAdversarialReceipt(overrides: Partial = {}): ProcessedReceipt { const base: ProcessedReceipt = { id: "adv-rcpt-001", imageHash: "adv-hash-999", originalFileName: "stress_test_receipt.png", fileSizeBytes: 180000, previewUrl: "blob:http://localhost/adv-receipt.png", createdAt: "2026-08-15T12:00:00.000Z", updatedAt: "2026-08-15T12:00:00.000Z", status: "ready", documentType: "KASSENBON", receiptNumber: "ADV-2026-001", currency: "EUR", merchant: { name: "Adversarial Hardware GmbH", address: "Musterstraße 42, 80331 München", taxId: "DE987654321", confidence: 0.95, }, date: { isoDate: "2026-08-15", time: "15:45", confidence: 0.99, }, totalAmount: { value: 119.00, confidence: 0.98, }, netAmount: 100.00, taxBreakdown: [ { ratePercent: 19, taxAmount: 19.00, netAmount: 100.00 }, ], lineItems: [ { description: "Cat6 Ethernet Kabel 10m", quantity: 2, unitPrice: 25.00, price: 50.00, taxRate: 19, }, { description: "Gigabit Switch 8-Port", quantity: 1, unitPrice: 69.00, price: 69.00, taxRate: 19, }, ], suggestedCategory: "Bürobedarf & IT", paymentMethod: "EC_KARTE", validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, reviewField: "none", reviewReason: null, issues: [], userConfirmed: false, }, originalExtraction: { merchant: { name: "Adversarial Hardware GmbH", address: "Musterstraße 42, 80331 München", taxId: "DE987654321", confidence: 0.95, }, date: { isoDate: "2026-08-15", time: "15:45", confidence: 0.99, }, totalAmount: { value: 119.00, confidence: 0.98, }, netAmount: 100.00, taxBreakdown: [ { ratePercent: 19, taxAmount: 19.00, netAmount: 100.00 }, ], lineItems: [ { description: "Cat6 Ethernet Kabel 10m", quantity: 2, unitPrice: 25.00, price: 50.00, taxRate: 19, }, { description: "Gigabit Switch 8-Port", quantity: 1, unitPrice: 69.00, price: 69.00, taxRate: 19, }, ], suggestedCategory: "Bürobedarf & IT", documentType: "KASSENBON", receiptNumber: "ADV-2026-001", }, }; return { ...base, ...overrides }; } describe("Milestone 2: Adversarial Lifecycle, Navigation & Keyboard Event Stress", () => { test("ADV-M2.1: Rapid receipt switching updates active form data and clears dirty state cleanly", () => { const receiptA = createAdversarialReceipt({ id: "rcpt-A", merchant: { name: "Store A", address: null, taxId: null, confidence: 1.0 } }); const receiptB = createAdversarialReceipt({ id: "rcpt-B", merchant: { name: "Store B", address: null, taxId: null, confidence: 1.0 } }); // Simulate modal state manager let currentReceipt: ProcessedReceipt | null = receiptA; let editedFields = new Set(); // User edits merchant in receipt A editedFields.add("merchant"); expect(editedFields.has("merchant")).toBe(true); // Rapid navigation to receipt B -> should sync to receipt B and reset edited fields currentReceipt = receiptB; editedFields = new Set(); expect(currentReceipt.id).toBe("rcpt-B"); expect(currentReceipt.merchant.name).toBe("Store B"); expect(editedFields.size).toBe(0); }); test("ADV-M2.2: Keyboard shortcut dispatcher processes Alt+ArrowLeft, Alt+ArrowRight and Escape correctly", () => { let navigatedDirection: "prev" | "next" | null = null; let closed = false; const handleKeyDown = (event: { key: string; altKey?: boolean; preventDefault: () => void }) => { if (event.key === "Escape") { closed = true; } else if (event.altKey && event.key === "ArrowLeft") { event.preventDefault(); navigatedDirection = "prev"; } else if (event.altKey && event.key === "ArrowRight") { event.preventDefault(); navigatedDirection = "next"; } }; let prevented = false; const fakePreventDefault = () => { prevented = true; }; // Test Alt + Left prevented = false; handleKeyDown({ key: "ArrowLeft", altKey: true, preventDefault: fakePreventDefault }); expect(navigatedDirection).toBe("prev"); expect(prevented).toBe(true); // Test Alt + Right prevented = false; handleKeyDown({ key: "ArrowRight", altKey: true, preventDefault: fakePreventDefault }); expect(navigatedDirection).toBe("next"); expect(prevented).toBe(true); // Test Escape (Dismiss Modal) handleKeyDown({ key: "Escape", preventDefault: fakePreventDefault }); expect(closed).toBe(true); // Test Unrelated key (no action) navigatedDirection = null; prevented = false; handleKeyDown({ key: "ArrowLeft", altKey: false, preventDefault: fakePreventDefault }); expect(navigatedDirection).toBeNull(); expect(prevented).toBe(false); }); test("ADV-M2.3: Boundary navigation clamping prevents out-of-bounds index overflow or underflow", () => { const totalReceipts = 3; let currentIndex = 0; const navigate = (direction: "prev" | "next") => { if (direction === "prev") { currentIndex = Math.max(0, currentIndex - 1); } else { currentIndex = Math.min(totalReceipts - 1, currentIndex + 1); } }; // Attempt to navigate backwards at lower bound (index 0) navigate("prev"); expect(currentIndex).toBe(0); navigate("prev"); expect(currentIndex).toBe(0); // Navigate to upper bound navigate("next"); expect(currentIndex).toBe(1); navigate("next"); expect(currentIndex).toBe(2); // Attempt to navigate forward past upper bound navigate("next"); expect(currentIndex).toBe(2); navigate("next"); expect(currentIndex).toBe(2); }); test("ADV-M2.4: Debounced auto-save timer handles rapid unmount without memory leaks or race conditions", async () => { let savedReceipt: ProcessedReceipt | null = null; let saveCount = 0; let timer: any = null; const triggerEdit = (newReceipt: ProcessedReceipt) => { if (timer) clearTimeout(timer); timer = setTimeout(() => { savedReceipt = newReceipt; saveCount++; }, 50); }; // Trigger 5 rapid edits within 20ms const r1 = createAdversarialReceipt({ id: "edit-1" }); const r2 = createAdversarialReceipt({ id: "edit-2" }); const r3 = createAdversarialReceipt({ id: "edit-3" }); triggerEdit(r1); triggerEdit(r2); triggerEdit(r3); // Wait 100ms for debounce timer to settle await new Promise((res) => setTimeout(res, 100)); // Only the final edit should have been committed expect(saveCount).toBe(1); expect((savedReceipt as ProcessedReceipt | null)?.id).toBe("edit-3"); }); }); describe("Milestone 2: Adversarial Field Audit Trail & Revert Capabilities", () => { let receipt: ProcessedReceipt; beforeEach(() => { receipt = createAdversarialReceipt(); }); test("ADV-M2.5: Comprehensive 1-Click Revert restores each individual field without affecting other edits", () => { const editedFields = new Set(); // 1. Edit Merchant receipt.merchant = { ...receipt.merchant, name: "Modified Merchant Name" }; editedFields.add("merchant"); // 2. Edit Date receipt.date = { ...receipt.date, isoDate: "2020-01-01" }; editedFields.add("date"); // 3. Edit Receipt Number receipt.receiptNumber = "MODIFIED-NR-999"; editedFields.add("receiptNumber"); // 4. Edit Category receipt.suggestedCategory = "Bewirtung"; editedFields.add("suggestedCategory"); // 5. Edit Document Type receipt.documentType = "RECHNUNG"; editedFields.add("documentType"); expect(editedFields.size).toBe(5); // Revert only Merchant receipt.merchant = { ...receipt.originalExtraction!.merchant! }; editedFields.delete("merchant"); expect(receipt.merchant.name).toBe("Adversarial Hardware GmbH"); expect(editedFields.has("merchant")).toBe(false); expect(editedFields.has("date")).toBe(true); expect(receipt.date.isoDate).toBe("2020-01-01"); // Date remains modified // Revert Date receipt.date = { ...receipt.originalExtraction!.date! }; editedFields.delete("date"); expect(receipt.date.isoDate).toBe("2026-08-15"); expect(editedFields.has("date")).toBe(false); // Revert Receipt Number receipt.receiptNumber = receipt.originalExtraction!.receiptNumber ?? null; editedFields.delete("receiptNumber"); expect(receipt.receiptNumber).toBe("ADV-2026-001"); // Revert Category & DocType receipt.suggestedCategory = receipt.originalExtraction!.suggestedCategory!; editedFields.delete("suggestedCategory"); receipt.documentType = receipt.originalExtraction!.documentType!; editedFields.delete("documentType"); expect(editedFields.size).toBe(0); expect(receipt.suggestedCategory).toBe("Bürobedarf & IT"); expect(receipt.documentType).toBe("KASSENBON"); }); test("ADV-M2.6: Reverting financial amount (Gross) triggers automatic tax & net recalculation back to original state", () => { // Original: Gross = 119.00, Net = 100.00, MwSt 19% = 19.00 expect(receipt.totalAmount.value).toBe(119.00); // Modify Gross to 357.00 € (MwSt 19% = 57.00, Net = 300.00) const modified = recalculateReceipt( { ...receipt, totalAmount: { ...receipt.totalAmount, value: 357.00 }, }, { editedField: "totalAmount" } ); expect(modified.totalAmount.value).toBe(357.00); expect(modified.netAmount).toBe(300.00); expect(modified.taxBreakdown?.[0].taxAmount).toBe(57.00); // Revert Gross to original 119.00 € const reverted = recalculateReceipt( { ...modified, totalAmount: { ...modified.totalAmount, value: receipt.originalExtraction!.totalAmount!.value }, }, { editedField: "totalAmount" } ); expect(reverted.totalAmount.value).toBe(119.00); expect(reverted.netAmount).toBe(100.00); expect(reverted.taxBreakdown?.[0].taxAmount).toBe(19.00); expect(reverted.validation.isMathValid).toBe(true); }); test("ADV-M2.7: Line items modification and subsequent revert restores original line item array and cross-sum", () => { const originalCount = receipt.originalExtraction?.lineItems?.length || 2; expect(receipt.lineItems).toHaveLength(originalCount); // Mutate line items (add new items and change prices) receipt.lineItems = [ { description: "Item X", quantity: 10, unitPrice: 100, price: 1000, taxRate: 19 }, ]; expect(receipt.lineItems).toHaveLength(1); expect(receipt.lineItems[0].price).toBe(1000); // Revert line items receipt.lineItems = [...receipt.originalExtraction!.lineItems!]; expect(receipt.lineItems).toHaveLength(2); expect(receipt.lineItems[0].description).toBe("Cat6 Ethernet Kabel 10m"); expect(receipt.lineItems[1].description).toBe("Gigabit Switch 8-Port"); }); }); describe("Milestone 2: Adversarial Partial, Corrupted & Extreme Data Resilience", () => { test("ADV-M2.8: Handles receipt with missing / null optional fields without crashing or throwing", () => { const partialReceipt: ProcessedReceipt = { id: "rcpt-partial-001", imageHash: "hash-partial", originalFileName: "corrupt_scan.png", fileSizeBytes: 10000, createdAt: "2026-08-15T00:00:00Z", updatedAt: "2026-08-15T00:00:00Z", status: "needs_review", documentType: "SONSTIGES", receiptNumber: null, currency: "EUR", merchant: { name: "", address: null, taxId: null, confidence: 0.1 }, date: { isoDate: "", time: null, confidence: 0.1 }, totalAmount: { value: 0, confidence: 0.1 }, netAmount: null, taxBreakdown: [], lineItems: [], suggestedCategory: "Sonstiges", validation: { isMathValid: false, isDuplicateSuspected: false, needsUserReview: true, reviewField: "totalAmount", reviewReason: "Bruttobetrag fehlt", }, }; // Ensure generateDefaultBoundingBoxes works on empty receipt without crashing const boxes = generateDefaultBoundingBoxes(partialReceipt); expect(boxes.merchant).toBeDefined(); expect(boxes.date).toBeDefined(); expect(boxes.totalAmount).toBeDefined(); expect(boxes.lineItems).toBeUndefined(); expect(boxes.taxBreakdown).toBeUndefined(); // Recalculation on empty receipt const recalculated = recalculateReceipt(partialReceipt); expect(recalculated.validation.needsUserReview).toBe(true); }); test("ADV-M2.9: Bounding box coordinate bounds clamp extreme out-of-range, negative and NaN values", () => { // Extreme negative coordinates const boxNegative = createBoundingBoxRect(-50, -100, 200, 300); expect(boxNegative.x).toBe(0); expect(boxNegative.y).toBe(0); expect(boxNegative.width).toBe(100); expect(boxNegative.height).toBe(100); // Overflow coordinates (x = 80, width = 50 -> clamped width should be 20) const boxOverflow = createBoundingBoxRect(80, 70, 50, 50); expect(boxOverflow.x).toBe(80); expect(boxOverflow.width).toBe(20); expect(boxOverflow.y).toBe(70); expect(boxOverflow.height).toBe(30); // NaN coordinates const boxNaN = createBoundingBoxRect(NaN, NaN, NaN, NaN); expect(boxNaN.x).toBe(0); expect(boxNaN.y).toBe(0); expect(boxNaN.width).toBe(0); expect(boxNaN.height).toBe(0); }); test("ADV-M2.10: Zero-pixel image dimensions in pixelRectToPercent safely fallback to 100% box", () => { const pixelRect = { x: 50, y: 50, width: 200, height: 200 }; const box = pixelRectToPercent(pixelRect, 0, 0); expect(box.x).toBe(0); expect(box.y).toBe(0); expect(box.width).toBe(100); expect(box.height).toBe(100); }); test("ADV-M2.11: Hit-testing on zero-width or empty bounding boxes does not falsely match", () => { const zeroBox = createBoundingBoxRect(20, 20, 0, 0); expect(isPointInsideBox({ x: 20, y: 20 }, zeroBox)).toBe(true); // Exact point expect(isPointInsideBox({ x: 20.1, y: 20 }, zeroBox)).toBe(false); expect(isPointInsideBox({ x: 19.9, y: 20 }, zeroBox)).toBe(false); }); }); describe("Milestone 2: Adversarial Line Items Calculations & Numerical Precision", () => { test("ADV-M2.12: High-precision decimal rounding handles repeating fractions and floating-point errors (0.1 + 0.2)", () => { // 3 items @ 0.33 € each = 0.99 € const qty = 3; const unitPrice = 0.33; const computed = Math.round(qty * unitPrice * 100) / 100; expect(computed).toBe(0.99); // Floating-point edge: 7 items @ 0.70 € = 4.90 € (without 4.8999999999999995 bug) const qty2 = 7; const unitPrice2 = 0.70; const computed2 = Math.round(qty2 * unitPrice2 * 100) / 100; expect(computed2).toBe(4.90); }); test("ADV-M2.13: Fractional quantities (e.g. 1.345 kg of fruit or 45.2 liters of fuel)", () => { const fuelLiters = 45.28; const fuelPricePerLiter = 1.749; // Fuel prices have 3 decimal places in Germany const totalPrice = Math.round(fuelLiters * fuelPricePerLiter * 100) / 100; expect(totalPrice).toBe(79.19); // 45.28 * 1.749 = 79.19472 -> 79.19 € }); test("ADV-M2.14: Line items cross-sum discrepancy tolerance window (0.02 € threshold)", () => { const receiptGross = 100.00; // Diff 0.01 € -> within tolerance (e.g. rounding difference) const sum1 = 100.01; const diff1 = Math.abs(Math.round((sum1 - receiptGross) * 100) / 100); expect(diff1 <= 0.02).toBe(true); // Diff 0.02 € -> within tolerance const sum2 = 99.98; const diff2 = Math.abs(Math.round((sum2 - receiptGross) * 100) / 100); expect(diff2 <= 0.02).toBe(true); // Diff 0.03 € -> discrepancy triggered! const sum3 = 100.03; const diff3 = Math.abs(Math.round((sum3 - receiptGross) * 100) / 100); expect(diff3 > 0.02).toBe(true); }); test("ADV-M2.15: Deleting all line item rows transitions gracefully to empty state without throwing", () => { let items = [ { description: "Item 1", quantity: 1, price: 10, taxRate: 19 }, { description: "Item 2", quantity: 1, price: 20, taxRate: 19 }, ]; // Delete item 0 items = items.filter((_, idx) => idx !== 0); expect(items).toHaveLength(1); // Delete remaining item items = items.filter((_, idx) => idx !== 0); expect(items).toHaveLength(0); const sum = Math.round(items.reduce((acc, curr) => acc + (curr?.price ?? 0), 0) * 100) / 100; expect(sum).toBe(0); }); });