Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
611
tests/e2e/challenger_m3_stress.ts
Normal file
611
tests/e2e/challenger_m3_stress.ts
Normal file
@@ -0,0 +1,611 @@
|
||||
/**
|
||||
* Milestone 3 (R3) Empirical Challenger Verification Harness
|
||||
* Comprehensive Adversarial Stress Testing of Table, Filter & Selection Engines
|
||||
*/
|
||||
|
||||
import { describe, test, it, expect } from "./runner";
|
||||
import { ProcessedReceipt, ReceiptCategory, DocumentType, PaymentMethod } from "../../src/lib/schema/receipt";
|
||||
import { resolveReceiptStatusTier, getStatusTierMeta, ReceiptStatusTier } from "../../src/components/dashboard/StatusBadge";
|
||||
import { recalculateReceipt, confirmReceiptReviewed } from "../../src/lib/ai/recalculate";
|
||||
import { generateDualSheetExcel } from "../../src/lib/export/excelGenerator";
|
||||
import { generateAccountingCsv } from "../../src/lib/export/csvGenerator";
|
||||
import { grossOf, netOf } from "../../src/components/dashboard/receiptFormat";
|
||||
|
||||
// Mock Receipt Factory
|
||||
function mockReceipt(id: string, overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
|
||||
return {
|
||||
id,
|
||||
imageHash: `hash-${id}`,
|
||||
originalFileName: `${id}.pdf`,
|
||||
fileSizeBytes: 50000,
|
||||
previewUrl: `blob:http://localhost/${id}.pdf`,
|
||||
createdAt: "2026-08-15T10:00:00.000Z",
|
||||
updatedAt: "2026-08-15T10:00:00.000Z",
|
||||
status: "ready",
|
||||
documentType: "KASSENBON",
|
||||
receiptNumber: `REC-${id}`,
|
||||
currency: "EUR",
|
||||
merchant: {
|
||||
name: `Merchant ${id}`,
|
||||
address: "Musterstr. 1, Berlin",
|
||||
taxId: "DE123456789",
|
||||
confidence: 0.99,
|
||||
},
|
||||
date: {
|
||||
isoDate: "2026-08-15",
|
||||
time: "10:30",
|
||||
confidence: 0.99,
|
||||
},
|
||||
totalAmount: {
|
||||
value: 100.0,
|
||||
confidence: 0.99,
|
||||
},
|
||||
netAmount: 84.03,
|
||||
taxBreakdown: [{ ratePercent: 19, taxAmount: 15.97, netAmount: 84.03 }],
|
||||
lineItems: [
|
||||
{ description: `Item for ${id}`, quantity: 1, price: 100.0, taxRate: 19 },
|
||||
],
|
||||
suggestedCategory: "Bürobedarf & IT",
|
||||
paymentMethod: "EC_KARTE",
|
||||
validation: {
|
||||
isMathValid: true,
|
||||
isDuplicateSuspected: false,
|
||||
needsUserReview: false,
|
||||
userConfirmed: false,
|
||||
reviewField: "none",
|
||||
reviewReason: null,
|
||||
issues: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Pure Filter Predicate implementation matching useReceiptFilters
|
||||
function applyFilters(
|
||||
receipts: ProcessedReceipt[],
|
||||
filters: {
|
||||
period?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
searchQuery?: string;
|
||||
amountRange?: { min?: number | null; max?: number | null };
|
||||
}
|
||||
): ProcessedReceipt[] {
|
||||
const {
|
||||
period = "all",
|
||||
status = "all",
|
||||
category = "all",
|
||||
searchQuery = "",
|
||||
amountRange = { min: null, max: null },
|
||||
} = filters;
|
||||
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
|
||||
return receipts.filter((receipt) => {
|
||||
// 1. Period filter
|
||||
if (period !== "all") {
|
||||
if (!receipt.date?.isoDate) return false;
|
||||
const date = new Date(receipt.date.isoDate);
|
||||
if (isNaN(date.getTime())) return false;
|
||||
|
||||
const now = new Date();
|
||||
const dateYear = date.getFullYear();
|
||||
const dateMonth = date.getMonth();
|
||||
const dateDay = date.getDate();
|
||||
|
||||
const nowYear = now.getFullYear();
|
||||
const nowMonth = now.getMonth();
|
||||
const nowDay = now.getDate();
|
||||
|
||||
if (period === "today") {
|
||||
if (!(dateYear === nowYear && dateMonth === nowMonth && dateDay === nowDay)) return false;
|
||||
} else if (period === "month") {
|
||||
if (!(dateYear === nowYear && dateMonth === nowMonth)) return false;
|
||||
} else if (period === "year") {
|
||||
if (dateYear !== nowYear) return false;
|
||||
} else if (typeof period === "string") {
|
||||
if (!receipt.date.isoDate.startsWith(period)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Status filter
|
||||
if (status !== "all") {
|
||||
const tier = resolveReceiptStatusTier(receipt);
|
||||
const norm = status.toLowerCase();
|
||||
if (norm === "pending" || norm === "pending_review" || norm === "pruefen") {
|
||||
if (tier !== "pending_review") return false;
|
||||
} else if (norm === "confirmed" || norm === "bestaetigt") {
|
||||
if (tier !== "confirmed") return false;
|
||||
} else if (norm === "scanned" || norm === "erfasst" || norm === "ready") {
|
||||
if (tier !== "scanned") return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Category filter
|
||||
if (category !== "all") {
|
||||
if (receipt.suggestedCategory !== category) return false;
|
||||
}
|
||||
|
||||
// 4. Amount Range filter
|
||||
const gross = grossOf(receipt);
|
||||
if (amountRange.min != null && !isNaN(amountRange.min)) {
|
||||
if (gross < amountRange.min) return false;
|
||||
}
|
||||
if (amountRange.max != null && !isNaN(amountRange.max)) {
|
||||
if (gross > amountRange.max) return false;
|
||||
}
|
||||
|
||||
// 5. Search query
|
||||
if (query) {
|
||||
const merchantName = (receipt.merchant?.name ?? "").toLowerCase();
|
||||
const merchantAddress = (receipt.merchant?.address ?? "").toLowerCase();
|
||||
const receiptNo = (receipt.receiptNumber ?? "").toLowerCase();
|
||||
const isoDate = (receipt.date?.isoDate ?? "").toLowerCase();
|
||||
const cat = (receipt.suggestedCategory ?? "").toLowerCase();
|
||||
const docType = (receipt.documentType ?? "").toLowerCase();
|
||||
const paymentMethod = (receipt.paymentMethod ?? "").toLowerCase();
|
||||
const grossStr = gross.toFixed(2);
|
||||
const grossStrDe = grossStr.replace(".", ",");
|
||||
|
||||
const lineItemsMatch = receipt.lineItems?.some((item) =>
|
||||
item?.description?.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
const matches =
|
||||
merchantName.includes(query) ||
|
||||
merchantAddress.includes(query) ||
|
||||
receiptNo.includes(query) ||
|
||||
isoDate.includes(query) ||
|
||||
cat.includes(query) ||
|
||||
docType.includes(query) ||
|
||||
paymentMethod.includes(query) ||
|
||||
grossStr.includes(query) ||
|
||||
grossStrDe.includes(query) ||
|
||||
lineItemsMatch;
|
||||
|
||||
if (!matches) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Pure Selection Engine state machine
|
||||
class SelectionStateMachine {
|
||||
selectedIds: string[] = [];
|
||||
|
||||
constructor(initial: string[] = []) {
|
||||
this.selectedIds = [...initial];
|
||||
}
|
||||
|
||||
get set(): Set<string> {
|
||||
return new Set(this.selectedIds);
|
||||
}
|
||||
|
||||
isSelected(id: string): boolean {
|
||||
return this.set.has(id);
|
||||
}
|
||||
|
||||
toggleSelect(id: string) {
|
||||
if (!id) return;
|
||||
this.selectedIds = this.selectedIds.includes(id)
|
||||
? this.selectedIds.filter((item) => item !== id)
|
||||
: [...this.selectedIds, id];
|
||||
}
|
||||
|
||||
selectAll(allIds: string[]) {
|
||||
this.selectedIds = Array.from(new Set(allIds.filter(Boolean)));
|
||||
}
|
||||
|
||||
toggleSelectAll(allIds: string[]) {
|
||||
if (!allIds || allIds.length === 0) {
|
||||
this.selectedIds = [];
|
||||
return;
|
||||
}
|
||||
const allSelected = allIds.every((id) => this.set.has(id));
|
||||
if (allSelected) {
|
||||
this.selectedIds = this.selectedIds.filter((id) => !allIds.includes(id));
|
||||
} else {
|
||||
this.selectedIds = Array.from(new Set([...this.selectedIds, ...allIds]));
|
||||
}
|
||||
}
|
||||
|
||||
selectRange(fromId: string, toId: string, allOrderedIds: string[]) {
|
||||
const fromIdx = allOrderedIds.indexOf(fromId);
|
||||
const toIdx = allOrderedIds.indexOf(toId);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
const start = Math.min(fromIdx, toIdx);
|
||||
const end = Math.max(fromIdx, toIdx);
|
||||
const rangeIds = allOrderedIds.slice(start, end + 1);
|
||||
this.selectedIds = Array.from(new Set([...this.selectedIds, ...rangeIds]));
|
||||
}
|
||||
|
||||
isAllSelected(allIds: string[]): boolean {
|
||||
if (!allIds || allIds.length === 0) return false;
|
||||
return allIds.every((id) => this.set.has(id));
|
||||
}
|
||||
|
||||
isPartiallySelected(allIds: string[]): boolean {
|
||||
if (!allIds || allIds.length === 0) return false;
|
||||
const some = allIds.some((id) => this.set.has(id));
|
||||
const all = allIds.every((id) => this.set.has(id));
|
||||
return some && !all;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.selectedIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
describe("Empirical Challenger M3: Adversarial Filter Engine Stress", () => {
|
||||
const corpus: ProcessedReceipt[] = [
|
||||
mockReceipt("rcpt-special-chars", {
|
||||
merchant: { name: 'Café "Kranzler" (GmbH & Co. KG) [Berlin]', address: "Kurfürstendamm 18/20", taxId: "DE999", confidence: 1.0 },
|
||||
receiptNumber: "INV-2026/08+99$#1",
|
||||
date: { isoDate: "2026-08-15", time: "09:00", confidence: 1.0 },
|
||||
totalAmount: { value: 12.50, confidence: 1.0 },
|
||||
suggestedCategory: "Bewirtung",
|
||||
paymentMethod: "BAR",
|
||||
lineItems: [{ description: "Kaffee & Croissant (Set *Special*)", quantity: 1, price: 12.50, taxRate: 19 }],
|
||||
}),
|
||||
mockReceipt("rcpt-zero-gross", {
|
||||
merchant: { name: "Gratis Probe Store", address: "Alexanderplatz 1", taxId: "DE000", confidence: 1.0 },
|
||||
receiptNumber: "ZERO-000",
|
||||
date: { isoDate: "2026-08-01", time: "10:00", confidence: 1.0 },
|
||||
totalAmount: { value: 0.00, confidence: 1.0 },
|
||||
netAmount: 0.00,
|
||||
taxBreakdown: [{ ratePercent: 19, taxAmount: 0.00, netAmount: 0.00 }],
|
||||
suggestedCategory: "Sonstiges",
|
||||
}),
|
||||
mockReceipt("rcpt-high-gross", {
|
||||
merchant: { name: "Apple Store Kurfürstendamm", address: "Berlin", taxId: "DE888", confidence: 1.0 },
|
||||
receiptNumber: "APPL-9988",
|
||||
date: { isoDate: "2026-07-25", time: "14:00", confidence: 1.0 },
|
||||
totalAmount: { value: 3499.00, confidence: 1.0 },
|
||||
suggestedCategory: "Bürobedarf & IT",
|
||||
paymentMethod: "APPLE_PAY",
|
||||
}),
|
||||
mockReceipt("rcpt-pending-review", {
|
||||
merchant: { name: "Unbekannter Beleg", address: "", taxId: "", confidence: 0.4 },
|
||||
receiptNumber: null,
|
||||
date: { isoDate: "2026-08-10", time: "12:00", confidence: 0.5 },
|
||||
totalAmount: { value: 50.00, confidence: 0.4 },
|
||||
suggestedCategory: "Sonstiges",
|
||||
validation: {
|
||||
isMathValid: false,
|
||||
isDuplicateSuspected: false,
|
||||
needsUserReview: true,
|
||||
userConfirmed: false,
|
||||
reviewField: "merchant",
|
||||
reviewReason: "Geringe Erkennungsgenauigkeit",
|
||||
issues: [{ field: "merchant", severity: "error", message: "Händler unsicher" }],
|
||||
},
|
||||
}),
|
||||
mockReceipt("rcpt-confirmed", {
|
||||
merchant: { name: "Tankstelle Jet", address: "Hamburg", taxId: "DE777", confidence: 0.9 },
|
||||
receiptNumber: "JET-4421",
|
||||
date: { isoDate: "2026-08-12", time: "18:00", confidence: 0.9 },
|
||||
totalAmount: { value: 85.40, confidence: 0.9 },
|
||||
suggestedCategory: "Tanken & KFZ",
|
||||
validation: {
|
||||
isMathValid: true,
|
||||
isDuplicateSuspected: false,
|
||||
needsUserReview: false,
|
||||
userConfirmed: true,
|
||||
reviewField: "none",
|
||||
reviewReason: null,
|
||||
issues: [],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
test("CHALLENGE-1.1: Complex regex meta-characters in search query do not throw", () => {
|
||||
const maliciousPatterns = [
|
||||
".*",
|
||||
"+",
|
||||
"?",
|
||||
"^",
|
||||
"$",
|
||||
"{1,3}",
|
||||
"()",
|
||||
"[]",
|
||||
"|",
|
||||
"\\d+",
|
||||
"[Berlin]",
|
||||
"*Special*",
|
||||
"$#1",
|
||||
'(GmbH & Co. KG)',
|
||||
'\\',
|
||||
'/.*+?^${}()|[]\\',
|
||||
];
|
||||
|
||||
for (const pat of maliciousPatterns) {
|
||||
const results = applyFilters(corpus, { searchQuery: pat });
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("CHALLENGE-1.2: German comma vs English dot search resolves correctly", () => {
|
||||
// 12,50 and 12.50 should both find rcpt-special-chars
|
||||
const resDe = applyFilters(corpus, { searchQuery: "12,50" });
|
||||
const resEn = applyFilters(corpus, { searchQuery: "12.50" });
|
||||
expect(resDe.map((r) => r.id)).toEqual(["rcpt-special-chars"]);
|
||||
expect(resEn.map((r) => r.id)).toEqual(["rcpt-special-chars"]);
|
||||
|
||||
// 85,40 and 85.40
|
||||
const resJetDe = applyFilters(corpus, { searchQuery: "85,40" });
|
||||
const resJetEn = applyFilters(corpus, { searchQuery: "85.40" });
|
||||
expect(resJetDe.map((r) => r.id)).toEqual(["rcpt-confirmed"]);
|
||||
expect(resJetEn.map((r) => r.id)).toEqual(["rcpt-confirmed"]);
|
||||
});
|
||||
|
||||
test("CHALLENGE-1.3: Substring search in line items descriptions", () => {
|
||||
const resLineItem = applyFilters(corpus, { searchQuery: "Croissant" });
|
||||
expect(resLineItem).toHaveLength(1);
|
||||
expect(resLineItem[0].id).toBe("rcpt-special-chars");
|
||||
});
|
||||
|
||||
test("CHALLENGE-1.4: Inverted amount range (min > max) returns clean empty array without error", () => {
|
||||
const inverted = applyFilters(corpus, {
|
||||
amountRange: { min: 500, max: 100 },
|
||||
});
|
||||
expect(inverted).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("CHALLENGE-1.5: Zero gross receipt filtering with min:0 max:0", () => {
|
||||
const zeroExact = applyFilters(corpus, {
|
||||
amountRange: { min: 0, max: 0 },
|
||||
});
|
||||
expect(zeroExact).toHaveLength(1);
|
||||
expect(zeroExact[0].id).toBe("rcpt-zero-gross");
|
||||
});
|
||||
|
||||
test("CHALLENGE-1.6: Boundary amount filtering (< 50, 50-200, > 200)", () => {
|
||||
const under50 = applyFilters(corpus, { amountRange: { min: 0, max: 50 } });
|
||||
// rcpt-special-chars (12.50), rcpt-zero-gross (0), rcpt-pending-review (50)
|
||||
expect(under50.map((r) => r.id)).toEqual(["rcpt-special-chars", "rcpt-zero-gross", "rcpt-pending-review"]);
|
||||
|
||||
const between50and200 = applyFilters(corpus, { amountRange: { min: 50, max: 200 } });
|
||||
// rcpt-pending-review (50), rcpt-confirmed (85.40)
|
||||
expect(between50and200.map((r) => r.id)).toEqual(["rcpt-pending-review", "rcpt-confirmed"]);
|
||||
|
||||
const over200 = applyFilters(corpus, { amountRange: { min: 200, max: null } });
|
||||
// rcpt-high-gross (3499)
|
||||
expect(over200.map((r) => r.id)).toEqual(["rcpt-high-gross"]);
|
||||
});
|
||||
|
||||
test("CHALLENGE-1.7: Status tier filtering accurately partitions dataset", () => {
|
||||
const scanned = applyFilters(corpus, { status: "scanned" });
|
||||
const pending = applyFilters(corpus, { status: "pending" });
|
||||
const confirmed = applyFilters(corpus, { status: "confirmed" });
|
||||
|
||||
expect(scanned.map((r) => r.id)).toEqual(["rcpt-special-chars", "rcpt-zero-gross", "rcpt-high-gross"]);
|
||||
expect(pending.map((r) => r.id)).toEqual(["rcpt-pending-review"]);
|
||||
expect(confirmed.map((r) => r.id)).toEqual(["rcpt-confirmed"]);
|
||||
expect(scanned.length + pending.length + confirmed.length).toBe(corpus.length);
|
||||
});
|
||||
|
||||
test("CHALLENGE-1.8: Category filtering with multi-criteria conjunction", () => {
|
||||
const bewirtung = applyFilters(corpus, {
|
||||
category: "Bewirtung",
|
||||
amountRange: { min: 10, max: 20 },
|
||||
searchQuery: "Berlin",
|
||||
});
|
||||
expect(bewirtung).toHaveLength(1);
|
||||
expect(bewirtung[0].id).toBe("rcpt-special-chars");
|
||||
|
||||
// Non-matching conjunction
|
||||
const emptyResult = applyFilters(corpus, {
|
||||
category: "Bewirtung",
|
||||
amountRange: { min: 50, max: 100 }, // rcpt-special-chars is 12.50
|
||||
});
|
||||
expect(emptyResult).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("CHALLENGE-1.9: Whitespace-only and empty search query does not filter out valid records", () => {
|
||||
expect(applyFilters(corpus, { searchQuery: " " })).toHaveLength(corpus.length);
|
||||
expect(applyFilters(corpus, { searchQuery: "" })).toHaveLength(corpus.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empirical Challenger M3: Selection Engine State & Invariant Stress", () => {
|
||||
const ids = Array.from({ length: 50 }, (_, i) => `item-${i}`);
|
||||
|
||||
test("CHALLENGE-2.1: 1,000 Rapid toggles maintains strict set consistency", () => {
|
||||
const sm = new SelectionStateMachine();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
sm.toggleSelect("rapid-id");
|
||||
}
|
||||
// 1000 toggles = even number = unselected
|
||||
expect(sm.selectedIds).toHaveLength(0);
|
||||
expect(sm.isSelected("rapid-id")).toBe(false);
|
||||
|
||||
sm.toggleSelect("rapid-id");
|
||||
expect(sm.selectedIds).toEqual(["rapid-id"]);
|
||||
expect(sm.isSelected("rapid-id")).toBe(true);
|
||||
});
|
||||
|
||||
test("CHALLENGE-2.2: selectAll deduplicates and ignores falsy values", () => {
|
||||
const sm = new SelectionStateMachine();
|
||||
sm.selectAll(["id-1", "id-1", "id-2", "", "id-3", "id-2"]);
|
||||
expect(sm.selectedIds).toEqual(["id-1", "id-2", "id-3"]);
|
||||
expect(sm.selectedIds.length).toBe(3);
|
||||
});
|
||||
|
||||
test("CHALLENGE-2.3: Forward, backward, and single range selections", () => {
|
||||
const sm = new SelectionStateMachine();
|
||||
|
||||
// Forward range 5 to 10
|
||||
sm.selectRange("item-5", "item-10", ids);
|
||||
expect(sm.selectedIds).toHaveLength(6);
|
||||
expect(sm.selectedIds).toEqual(["item-5", "item-6", "item-7", "item-8", "item-9", "item-10"]);
|
||||
|
||||
// Backward range 15 down to 12
|
||||
sm.selectRange("item-15", "item-12", ids);
|
||||
expect(sm.selectedIds).toHaveLength(10); // 6 + 4
|
||||
expect(sm.isSelected("item-12")).toBe(true);
|
||||
expect(sm.isSelected("item-15")).toBe(true);
|
||||
|
||||
// Single item range
|
||||
sm.selectRange("item-20", "item-20", ids);
|
||||
expect(sm.isSelected("item-20")).toBe(true);
|
||||
});
|
||||
|
||||
test("CHALLENGE-2.4: Range selection with invalid / unlisted boundary IDs fails safely without mutation", () => {
|
||||
const sm = new SelectionStateMachine(["item-1"]);
|
||||
sm.selectRange("missing-from", "item-5", ids);
|
||||
expect(sm.selectedIds).toEqual(["item-1"]);
|
||||
|
||||
sm.selectRange("item-5", "missing-to", ids);
|
||||
expect(sm.selectedIds).toEqual(["item-1"]);
|
||||
|
||||
sm.selectRange("missing-1", "missing-2", ids);
|
||||
expect(sm.selectedIds).toEqual(["item-1"]);
|
||||
|
||||
sm.selectRange("item-1", "item-2", []); // empty ordered list
|
||||
expect(sm.selectedIds).toEqual(["item-1"]);
|
||||
});
|
||||
|
||||
test("CHALLENGE-2.5: isAllSelected and isPartiallySelected state predicates", () => {
|
||||
const sm = new SelectionStateMachine();
|
||||
const testIds = ["a", "b", "c"];
|
||||
|
||||
expect(sm.isAllSelected(testIds)).toBe(false);
|
||||
expect(sm.isPartiallySelected(testIds)).toBe(false);
|
||||
|
||||
sm.toggleSelect("a");
|
||||
expect(sm.isAllSelected(testIds)).toBe(false);
|
||||
expect(sm.isPartiallySelected(testIds)).toBe(true);
|
||||
|
||||
sm.toggleSelect("b");
|
||||
sm.toggleSelect("c");
|
||||
expect(sm.isAllSelected(testIds)).toBe(true);
|
||||
expect(sm.isPartiallySelected(testIds)).toBe(false);
|
||||
|
||||
sm.toggleSelectAll(testIds); // all selected -> should deselect all
|
||||
expect(sm.selectedIds).toHaveLength(0);
|
||||
expect(sm.isAllSelected(testIds)).toBe(false);
|
||||
});
|
||||
|
||||
test("CHALLENGE-2.6: High-scale selection (1,000 items) executes in under 15ms", () => {
|
||||
const largeList = Array.from({ length: 1000 }, (_, i) => `large-${i}`);
|
||||
const sm = new SelectionStateMachine();
|
||||
|
||||
const start = performance.now();
|
||||
sm.selectAll(largeList);
|
||||
expect(sm.isAllSelected(largeList)).toBe(true);
|
||||
sm.toggleSelectAll(largeList);
|
||||
expect(sm.selectedIds).toHaveLength(0);
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empirical Challenger M3: Inline Recalculation & Financial Math Stress", () => {
|
||||
test("CHALLENGE-3.1: Gross amount change on 19% single-rate receipt", () => {
|
||||
const r = mockReceipt("single-rate-19", {
|
||||
totalAmount: { value: 119.0, confidence: 1.0 },
|
||||
netAmount: 100.0,
|
||||
taxBreakdown: [{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 }],
|
||||
});
|
||||
|
||||
const updated = {
|
||||
...r,
|
||||
totalAmount: { ...r.totalAmount, value: 238.0 },
|
||||
editedFields: { totalAmount: true },
|
||||
};
|
||||
|
||||
const recalculated = recalculateReceipt(updated, { editedField: "totalAmount" });
|
||||
expect(recalculated.totalAmount.value).toBe(238.0);
|
||||
expect(recalculated.netAmount).toBe(200.0);
|
||||
expect(recalculated.taxBreakdown[0].taxAmount).toBe(38.0);
|
||||
expect(recalculated.validation.isMathValid).toBe(true);
|
||||
});
|
||||
|
||||
test("CHALLENGE-3.2: Gross amount set to 0.00 € maintains zero math without NaN or Division by Zero", () => {
|
||||
const r = mockReceipt("zero-gross-test");
|
||||
const updated = {
|
||||
...r,
|
||||
totalAmount: { ...r.totalAmount, value: 0.0 },
|
||||
editedFields: { totalAmount: true },
|
||||
};
|
||||
|
||||
const recalculated = recalculateReceipt(updated, { editedField: "totalAmount" });
|
||||
expect(recalculated.totalAmount.value).toBe(0.0);
|
||||
expect(recalculated.netAmount).toBe(0.0);
|
||||
expect(recalculated.taxBreakdown[0].taxAmount).toBe(0.0);
|
||||
expect(recalculated.validation.isMathValid).toBe(true);
|
||||
});
|
||||
|
||||
test("CHALLENGE-3.3: Gross change on mixed 7% and 19% VAT rates redistributes proportionately", () => {
|
||||
const r = mockReceipt("mixed-rate-test", {
|
||||
totalAmount: { value: 100.0, confidence: 1.0 },
|
||||
netAmount: 88.0,
|
||||
taxBreakdown: [
|
||||
{ ratePercent: 7, taxAmount: 3.5, netAmount: 50.0 },
|
||||
{ ratePercent: 19, taxAmount: 8.5, netAmount: 38.0 },
|
||||
],
|
||||
});
|
||||
|
||||
const updated = {
|
||||
...r,
|
||||
totalAmount: { ...r.totalAmount, value: 200.0 },
|
||||
editedFields: { totalAmount: true },
|
||||
};
|
||||
|
||||
const recalculated = recalculateReceipt(updated, { editedField: "totalAmount" });
|
||||
expect(recalculated.totalAmount.value).toBe(200.0);
|
||||
expect(recalculated.validation.isMathValid).toBe(true);
|
||||
// Net + total tax equals gross
|
||||
const totalTax = recalculated.taxBreakdown.reduce((sum, item) => sum + (item.taxAmount || 0), 0);
|
||||
expect(Math.round(((recalculated.netAmount || 0) + totalTax) * 100) / 100).toBe(200.0);
|
||||
});
|
||||
|
||||
test("CHALLENGE-3.4: Editing non-financial fields (date, merchant) updates audit flag without corrupting math", () => {
|
||||
const r = mockReceipt("audit-flag-test");
|
||||
const updated = {
|
||||
...r,
|
||||
merchant: { ...r.merchant, name: "Neuer Bäcker" },
|
||||
date: { ...r.date, isoDate: "2026-08-01" },
|
||||
editedFields: { merchant: true, date: true },
|
||||
};
|
||||
|
||||
const recalculated = recalculateReceipt(updated, { editedField: "merchant" });
|
||||
expect(recalculated.merchant.name).toBe("Neuer Bäcker");
|
||||
expect(recalculated.date.isoDate).toBe("2026-08-01");
|
||||
expect(recalculated.totalAmount.value).toBe(100.0);
|
||||
expect(recalculated.netAmount).toBe(84.03);
|
||||
expect(recalculated.editedFields?.merchant).toBe(true);
|
||||
expect(recalculated.editedFields?.date).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empirical Challenger M3: Batch Export Generator Stress", () => {
|
||||
test("CHALLENGE-4.1: Dual-sheet Excel generation handles 50 receipts with varying tax configurations", async () => {
|
||||
const receipts = Array.from({ length: 50 }, (_, i) =>
|
||||
mockReceipt(`batch-xl-${i}`, {
|
||||
totalAmount: { value: 10.0 * (i + 1), confidence: 1.0 },
|
||||
suggestedCategory: i % 2 === 0 ? "Bewirtung" : "Reisekosten & Hotel",
|
||||
})
|
||||
);
|
||||
|
||||
const buffer = await generateDualSheetExcel(receipts);
|
||||
expect(buffer).toBeDefined();
|
||||
expect(buffer.length).toBeGreaterThan(10000);
|
||||
});
|
||||
|
||||
test("CHALLENGE-4.2: Accounting CSV generation escapes special CSV characters and enforces UTF-8 BOM", () => {
|
||||
const receipt = mockReceipt("csv-escape-test", {
|
||||
merchant: { name: 'Firma "Test;Semikolon & Neuer\nZeilenumbruch" GmbH', address: "Köln", taxId: "DE1", confidence: 1.0 },
|
||||
totalAmount: { value: 1234.56, confidence: 1.0 },
|
||||
suggestedCategory: "Bewirtung",
|
||||
});
|
||||
|
||||
const csv = generateAccountingCsv([receipt]);
|
||||
expect(csv).toBeDefined();
|
||||
expect(csv.charCodeAt(0)).toBe(0xfeff); // UTF-8 BOM
|
||||
expect(csv).toContain("1234,56");
|
||||
expect(csv).toContain("Firma");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user