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>
791 lines
28 KiB
TypeScript
791 lines
28 KiB
TypeScript
/**
|
||
* Adversarial Spotlight & Interaction Contract Verification Suite
|
||
* Challenger M1_2 Empirical Harness
|
||
*/
|
||
|
||
import fs from "fs";
|
||
import path from "path";
|
||
import { ProcessedReceipt } from "../src/lib/schema/receipt";
|
||
|
||
// Simple assertion helper
|
||
function assert(condition: boolean, message: string) {
|
||
if (!condition) {
|
||
throw new Error(`[ASSERTION FAILED]: ${message}`);
|
||
}
|
||
}
|
||
|
||
function assertEquals<T>(actual: T, expected: T, message: string) {
|
||
if (actual !== expected) {
|
||
throw new Error(`[ASSERTION FAILED]: ${message} | Expected: ${expected}, Got: ${actual}`);
|
||
}
|
||
}
|
||
|
||
// Mock test receipt records
|
||
const mockReceipts: ProcessedReceipt[] = [
|
||
{
|
||
id: "rec-001",
|
||
merchant: { name: "Aral Tankstelle", address: "München", taxId: "DE12345", confidence: 0.99 },
|
||
date: { isoDate: "2026-08-15", time: "08:42", confidence: 0.98 },
|
||
documentType: "KASSENBON",
|
||
receiptNumber: "ARAL-9988",
|
||
currency: "EUR",
|
||
totalAmount: { value: 68.45, confidence: 0.99 },
|
||
netAmount: 57.52,
|
||
taxBreakdown: [{ ratePercent: 19, taxAmount: 10.93, netAmount: 57.52 }],
|
||
lineItems: [{ description: "Super Plus", quantity: 35.1, unitPrice: 1.95, price: 68.45, taxRate: 19 }],
|
||
suggestedCategory: "Tanken & KFZ",
|
||
validation: {
|
||
isMathValid: true,
|
||
isDuplicateSuspected: false,
|
||
needsUserReview: false,
|
||
reviewField: "none",
|
||
reviewReason: null,
|
||
},
|
||
imageHash: "hash-aral-001",
|
||
originalFileName: "aral.jpg",
|
||
fileSizeBytes: 184000,
|
||
createdAt: "2026-08-15T08:42:00.000Z",
|
||
updatedAt: "2026-08-15T08:42:00.000Z",
|
||
status: "ready",
|
||
},
|
||
{
|
||
id: "rec-002",
|
||
merchant: { name: "Trattoria Bella Vista", address: "Berlin", taxId: "DE98765", confidence: 0.95 },
|
||
date: { isoDate: "2026-08-14", time: "20:15", confidence: 0.95 },
|
||
documentType: "BEWIRTUNGSBELEG",
|
||
receiptNumber: "TRAT-1002",
|
||
currency: "EUR",
|
||
totalAmount: { value: 84.50, confidence: 0.96 },
|
||
netAmount: 70.97,
|
||
taxBreakdown: [
|
||
{ ratePercent: 7, taxAmount: 3.08, netAmount: 44.0 },
|
||
{ ratePercent: 19, taxAmount: 10.45, netAmount: 26.97 },
|
||
],
|
||
lineItems: [
|
||
{ description: "Pasta Tartufo", quantity: 2, unitPrice: 22.0, price: 44.0, taxRate: 7 },
|
||
{ description: "Vino Rosso", quantity: 1, unitPrice: 28.0, price: 28.0, taxRate: 19 },
|
||
{ description: "Trinkgeld Bewirtung", quantity: 1, unitPrice: 12.5, price: 12.5, taxRate: 0 },
|
||
],
|
||
suggestedCategory: "Bewirtung",
|
||
validation: {
|
||
isMathValid: true,
|
||
isDuplicateSuspected: false,
|
||
needsUserReview: false,
|
||
reviewField: "none",
|
||
reviewReason: null,
|
||
},
|
||
imageHash: "hash-trattoria-002",
|
||
originalFileName: "trattoria.jpg",
|
||
fileSizeBytes: 210000,
|
||
createdAt: "2026-08-14T20:15:00.000Z",
|
||
updatedAt: "2026-08-14T20:15:00.000Z",
|
||
status: "ready",
|
||
},
|
||
{
|
||
id: "rec-003",
|
||
merchant: { name: "REWE City", address: "Köln", taxId: "DE554433", confidence: 0.94 },
|
||
date: { isoDate: "2026-08-13", time: "17:30", confidence: 0.95 },
|
||
documentType: "KASSENBON",
|
||
receiptNumber: "REWE-7766",
|
||
currency: "EUR",
|
||
totalAmount: { value: 24.80, confidence: 0.95 },
|
||
netAmount: 22.73,
|
||
taxBreakdown: [
|
||
{ ratePercent: 7, taxAmount: 1.31, netAmount: 18.71 },
|
||
{ ratePercent: 19, taxAmount: 0.76, netAmount: 4.02 },
|
||
],
|
||
lineItems: [
|
||
{ description: "Bio Milch", quantity: 2, unitPrice: 1.49, price: 2.98, taxRate: 7 },
|
||
{ description: "Dinkelbrot", quantity: 1, unitPrice: 3.49, price: 3.49, taxRate: 7 },
|
||
{ description: "Küchenrolle", quantity: 1, unitPrice: 4.78, price: 4.78, taxRate: 19 },
|
||
],
|
||
suggestedCategory: "Material & Einkauf",
|
||
validation: {
|
||
isMathValid: false,
|
||
isDuplicateSuspected: false,
|
||
needsUserReview: true,
|
||
reviewField: "taxBreakdown",
|
||
reviewReason: "Abweichung",
|
||
},
|
||
imageHash: "hash-rewe-003",
|
||
originalFileName: "rewe.jpg",
|
||
fileSizeBytes: 165000,
|
||
createdAt: "2026-08-13T17:30:00.000Z",
|
||
updatedAt: "2026-08-13T17:30:00.000Z",
|
||
status: "needs_review",
|
||
},
|
||
];
|
||
|
||
// Replicate Spotlight Items generation logic for rigorous unit & contract testing
|
||
function buildCommandItems(options: {
|
||
isDe: boolean;
|
||
localReceipts: ProcessedReceipt[];
|
||
onClose: () => void;
|
||
onTriggerScan?: () => void;
|
||
onLoadDemo?: () => void;
|
||
onSelectReceipt?: (receipt: ProcessedReceipt) => void;
|
||
routerPush: (path: string) => void;
|
||
}) {
|
||
const { isDe, localReceipts, onClose, onTriggerScan, onLoadDemo, onSelectReceipt, routerPush } = options;
|
||
|
||
const actionItems = [
|
||
{
|
||
id: "action-upload",
|
||
title: isDe ? "+ Neuen Beleg hochladen / scannen" : "+ Upload / scan new receipt",
|
||
subtitle: isDe ? "Öffnet Datei-Dialog für Kassenbon-Upload" : "Opens file picker for receipt upload",
|
||
category: "actions" as const,
|
||
badge: "SCAN",
|
||
action: () => {
|
||
onClose();
|
||
if (onTriggerScan) {
|
||
onTriggerScan();
|
||
} else {
|
||
routerPush("/dashboard");
|
||
}
|
||
},
|
||
},
|
||
{
|
||
id: "action-demo",
|
||
title: isDe ? "Demo-Belege laden" : "Load demo receipts",
|
||
subtitle: isDe ? "Generiert Aral, Rewe & Trattoria Musterbelege" : "Seeds Aral, Rewe & Trattoria sample receipts",
|
||
category: "actions" as const,
|
||
badge: "DEMO",
|
||
action: () => {
|
||
onClose();
|
||
if (onLoadDemo) {
|
||
onLoadDemo();
|
||
} else {
|
||
routerPush("/dashboard");
|
||
}
|
||
},
|
||
},
|
||
{
|
||
id: "action-excel",
|
||
title: isDe ? "Excel-Export (.xlsx) erstellen" : "Generate Excel export (.xlsx)",
|
||
subtitle: isDe ? "Dual-Sheet Arbeitsmappe mit dynamischen =SUM() Formeln" : "Dual-sheet workbook with dynamic =SUM() formulas",
|
||
category: "actions" as const,
|
||
badge: "XLSX",
|
||
action: () => {
|
||
onClose();
|
||
routerPush("/dashboard/export");
|
||
},
|
||
},
|
||
{
|
||
id: "action-csv",
|
||
title: isDe ? "Buchhaltungs-CSV exportieren" : "Export accounting CSV",
|
||
subtitle: isDe ? "Semikolon, Dezimalkomma, UTF-8 BOM – öffnet in Excel" : "Semicolon, decimal commas, UTF-8 BOM – opens in Excel",
|
||
category: "actions" as const,
|
||
badge: "CSV",
|
||
action: () => {
|
||
onClose();
|
||
routerPush("/dashboard/export");
|
||
},
|
||
},
|
||
];
|
||
|
||
const receiptItems = localReceipts.map((r) => ({
|
||
id: `receipt-${r.id}`,
|
||
title: `${r.merchant?.name || "Unbekannter Händler"} • ${r.totalAmount?.value ? r.totalAmount.value.toFixed(2) + " €" : ""}`,
|
||
subtitle: `${r.date?.isoDate || "Kein Datum"} • ${r.suggestedCategory || "Beleg"} ${r.receiptNumber ? "• #" + r.receiptNumber : ""}`,
|
||
category: "receipts" as const,
|
||
badge: r.validation?.isMathValid ? "VALID" : "REVIEW",
|
||
action: () => {
|
||
onClose();
|
||
if (onSelectReceipt) {
|
||
onSelectReceipt(r);
|
||
} else {
|
||
routerPush("/dashboard/activity");
|
||
}
|
||
},
|
||
}));
|
||
|
||
const navItems = [
|
||
{
|
||
id: "nav-overview",
|
||
title: isDe ? "Dashboard Übersicht" : "Dashboard Overview",
|
||
subtitle: "/dashboard",
|
||
category: "navigation" as const,
|
||
badge: "GOTO",
|
||
action: () => {
|
||
onClose();
|
||
routerPush("/dashboard");
|
||
},
|
||
},
|
||
{
|
||
id: "nav-activity",
|
||
title: isDe ? "Scan-Archiv & Belege" : "Scan Activity & Archive",
|
||
subtitle: "/dashboard/activity",
|
||
category: "navigation" as const,
|
||
badge: "GOTO",
|
||
action: () => {
|
||
onClose();
|
||
routerPush("/dashboard/activity");
|
||
},
|
||
},
|
||
{
|
||
id: "nav-export",
|
||
title: isDe ? "Export-Zentrale (Excel & CSV)" : "Export Dispatcher (Excel & CSV)",
|
||
subtitle: "/dashboard/export",
|
||
category: "navigation" as const,
|
||
badge: "GOTO",
|
||
action: () => {
|
||
onClose();
|
||
routerPush("/dashboard/export");
|
||
},
|
||
},
|
||
{
|
||
id: "nav-settings",
|
||
title: isDe ? "System- & KI-Einstellungen" : "System & AI Settings",
|
||
subtitle: "/dashboard/settings",
|
||
category: "navigation" as const,
|
||
badge: "GOTO",
|
||
action: () => {
|
||
onClose();
|
||
routerPush("/dashboard/settings");
|
||
},
|
||
},
|
||
{
|
||
id: "nav-landing",
|
||
title: isDe ? "Landing Page & Hero Scanner" : "Landing Page & Hero Scanner",
|
||
subtitle: "/",
|
||
category: "navigation" as const,
|
||
badge: "HOME",
|
||
action: () => {
|
||
onClose();
|
||
routerPush("/");
|
||
},
|
||
},
|
||
];
|
||
|
||
return { actionItems, receiptItems, navItems };
|
||
}
|
||
|
||
function filterCommandItems(
|
||
items: { actionItems: any[]; receiptItems: any[]; navItems: any[] },
|
||
query: string
|
||
) {
|
||
const q = query.toLowerCase().trim();
|
||
const filteredActions = items.actionItems.filter(
|
||
(item) => item.title.toLowerCase().includes(q) || item.subtitle?.toLowerCase().includes(q)
|
||
);
|
||
const filteredReceipts = items.receiptItems.filter(
|
||
(item) => item.title.toLowerCase().includes(q) || item.subtitle?.toLowerCase().includes(q)
|
||
);
|
||
const filteredNav = items.navItems.filter(
|
||
(item) => item.title.toLowerCase().includes(q) || item.subtitle?.toLowerCase().includes(q)
|
||
);
|
||
|
||
return {
|
||
filteredActions,
|
||
filteredReceipts,
|
||
filteredNav,
|
||
filteredItems: [...filteredActions, ...filteredReceipts, ...filteredNav],
|
||
};
|
||
}
|
||
|
||
// ----------------- TEST SUITE -----------------
|
||
|
||
console.log("================================================================");
|
||
console.log("ADVERSARIAL CHALLENGER M1_2: SPOTLIGHT & INTERACTION CONTRACTS");
|
||
console.log("================================================================\n");
|
||
|
||
let passed = 0;
|
||
let total = 0;
|
||
|
||
function runTest(name: string, fn: () => void) {
|
||
total++;
|
||
try {
|
||
fn();
|
||
console.log(` ✓ ${name}`);
|
||
passed++;
|
||
} catch (err: any) {
|
||
console.error(` ✗ ${name}`);
|
||
console.error(` ${err.message}`);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// 1. Keyboard Shortcut Listener Tests (TopNav.tsx & Global contracts)
|
||
runTest("TopNav keydown: Cmd+k triggers modal toggle on macOS", () => {
|
||
let isOpen = false;
|
||
const toggle = () => { isOpen = !isOpen; };
|
||
|
||
const eventMac = { metaKey: true, ctrlKey: false, key: "k", defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||
if ((eventMac.metaKey || eventMac.ctrlKey) && eventMac.key.toLowerCase() === "k") {
|
||
eventMac.preventDefault();
|
||
toggle();
|
||
}
|
||
|
||
assertEquals(isOpen, true, "Spotlight should open on metaKey + k");
|
||
assertEquals(eventMac.defaultPrevented, true, "Default event should be prevented");
|
||
});
|
||
|
||
runTest("TopNav keydown: Ctrl+k triggers modal toggle on Windows/Linux", () => {
|
||
let isOpen = false;
|
||
const toggle = () => { isOpen = !isOpen; };
|
||
|
||
const eventWin = { metaKey: false, ctrlKey: true, key: "k", defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||
if ((eventWin.metaKey || eventWin.ctrlKey) && eventWin.key.toLowerCase() === "k") {
|
||
eventWin.preventDefault();
|
||
toggle();
|
||
}
|
||
|
||
assertEquals(isOpen, true, "Spotlight should open on ctrlKey + k");
|
||
assertEquals(eventWin.defaultPrevented, true, "Default event should be prevented");
|
||
});
|
||
|
||
runTest("TopNav keydown: Case-insensitive 'K' (Caps Lock or Shift) is recognized", () => {
|
||
let isOpen = false;
|
||
const toggle = () => { isOpen = !isOpen; };
|
||
|
||
const eventShift = { metaKey: true, ctrlKey: false, key: "K", defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||
if ((eventShift.metaKey || eventShift.ctrlKey) && eventShift.key.toLowerCase() === "k") {
|
||
eventShift.preventDefault();
|
||
toggle();
|
||
}
|
||
|
||
assertEquals(isOpen, true, "Spotlight should open on metaKey + uppercase K");
|
||
});
|
||
|
||
runTest("TopNav keydown: Unrelated keys (e.g. Cmd+S, Ctrl+P, 'k' alone) do NOT trigger Spotlight", () => {
|
||
let isOpen = false;
|
||
const toggle = () => { isOpen = !isOpen; };
|
||
|
||
const tests = [
|
||
{ metaKey: true, ctrlKey: false, key: "s" },
|
||
{ metaKey: false, ctrlKey: true, key: "p" },
|
||
{ metaKey: false, ctrlKey: false, key: "k" },
|
||
{ metaKey: true, ctrlKey: false, key: "Meta" },
|
||
];
|
||
|
||
for (const e of tests) {
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||
toggle();
|
||
}
|
||
}
|
||
|
||
assertEquals(isOpen, false, "Spotlight should not open on non-K shortcuts or K without modifier");
|
||
});
|
||
|
||
// 2. SpotlightDialog Arrow Navigation & Selection
|
||
runTest("SpotlightDialog keydown: ArrowDown navigates cyclically", () => {
|
||
const items = [1, 2, 3, 4];
|
||
let selectedIndex = 0;
|
||
|
||
const navigateDown = () => {
|
||
selectedIndex = items.length > 0 ? (selectedIndex + 1) % items.length : 0;
|
||
};
|
||
|
||
navigateDown(); // 1
|
||
assertEquals(selectedIndex, 1, "Should move to index 1");
|
||
navigateDown(); // 2
|
||
assertEquals(selectedIndex, 2, "Should move to index 2");
|
||
navigateDown(); // 3
|
||
assertEquals(selectedIndex, 3, "Should move to index 3");
|
||
navigateDown(); // 0 (wrap-around)
|
||
assertEquals(selectedIndex, 0, "Should wrap around to index 0");
|
||
});
|
||
|
||
runTest("SpotlightDialog keydown: ArrowUp navigates cyclically backwards", () => {
|
||
const items = [1, 2, 3, 4];
|
||
let selectedIndex = 0;
|
||
|
||
const navigateUp = () => {
|
||
selectedIndex = items.length > 0 ? (selectedIndex - 1 + items.length) % items.length : 0;
|
||
};
|
||
|
||
navigateUp(); // 3 (wrap-around backwards)
|
||
assertEquals(selectedIndex, 3, "Should wrap backwards to index 3");
|
||
navigateUp(); // 2
|
||
assertEquals(selectedIndex, 2, "Should move to index 2");
|
||
navigateUp(); // 1
|
||
assertEquals(selectedIndex, 1, "Should move to index 1");
|
||
navigateUp(); // 0
|
||
assertEquals(selectedIndex, 0, "Should move to index 0");
|
||
});
|
||
|
||
runTest("SpotlightDialog keydown: Arrow navigation with empty results does not throw or return NaN", () => {
|
||
const items: any[] = [];
|
||
let selectedIndex = 0;
|
||
|
||
const navigateDown = () => {
|
||
selectedIndex = items.length > 0 ? (selectedIndex + 1) % items.length : 0;
|
||
};
|
||
const navigateUp = () => {
|
||
selectedIndex = items.length > 0 ? (selectedIndex - 1 + items.length) % items.length : 0;
|
||
};
|
||
|
||
navigateDown();
|
||
assertEquals(selectedIndex, 0, "Empty list down should be 0");
|
||
navigateUp();
|
||
assertEquals(selectedIndex, 0, "Empty list up should be 0");
|
||
assert(!Number.isNaN(selectedIndex), "Index must not be NaN");
|
||
});
|
||
|
||
runTest("SpotlightDialog keydown: Escape triggers onClose", () => {
|
||
let closed = false;
|
||
const onClose = () => { closed = true; };
|
||
|
||
const eventEsc = { key: "Escape", defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||
if (eventEsc.key === "Escape") {
|
||
eventEsc.preventDefault();
|
||
onClose();
|
||
}
|
||
|
||
assertEquals(closed, true, "Escape must close dialog");
|
||
assertEquals(eventEsc.defaultPrevented, true, "Escape must prevent default");
|
||
});
|
||
|
||
runTest("SpotlightDialog keydown: Enter invokes action of selected item", () => {
|
||
let actionExecuted = false;
|
||
const items = [
|
||
{ id: "1", action: () => { actionExecuted = false; } },
|
||
{ id: "2", action: () => { actionExecuted = true; } },
|
||
];
|
||
const selectedIndex = 1;
|
||
|
||
const eventEnter = { key: "Enter", defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||
if (eventEnter.key === "Enter") {
|
||
eventEnter.preventDefault();
|
||
if (items[selectedIndex]) {
|
||
items[selectedIndex].action();
|
||
}
|
||
}
|
||
|
||
assertEquals(actionExecuted, true, "Enter must execute selected item action");
|
||
assertEquals(eventEnter.defaultPrevented, true, "Enter must prevent default");
|
||
});
|
||
|
||
runTest("SpotlightDialog keydown: Enter with empty list is safely ignored", () => {
|
||
const items: any[] = [];
|
||
const selectedIndex = 0;
|
||
let executed = false;
|
||
|
||
if (items[selectedIndex]) {
|
||
items[selectedIndex].action();
|
||
executed = true;
|
||
}
|
||
|
||
assertEquals(executed, false, "Enter on empty list should do nothing safely");
|
||
});
|
||
|
||
// 3. Search Filtering Adversarial Cases
|
||
runTest("Search Filtering: Empty query returns all 4 actions, all receipts, and all 5 navigation links", () => {
|
||
let closed = false;
|
||
let pushedRoute = "";
|
||
const items = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => { closed = true; },
|
||
routerPush: (p) => { pushedRoute = p; },
|
||
});
|
||
|
||
const { filteredActions, filteredReceipts, filteredNav, filteredItems } = filterCommandItems(items, "");
|
||
assertEquals(filteredActions.length, 4, "Should have 4 actions");
|
||
assertEquals(filteredReceipts.length, 3, "Should have 3 receipts");
|
||
assertEquals(filteredNav.length, 5, "Should have 5 navigation links");
|
||
assertEquals(filteredItems.length, 12, "Total items should be 12");
|
||
});
|
||
|
||
runTest("Search Filtering: Case-insensitive & whitespace trimmed matching", () => {
|
||
const items = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => {},
|
||
routerPush: () => {},
|
||
});
|
||
|
||
const r1 = filterCommandItems(items, " ARAL ");
|
||
assertEquals(r1.filteredReceipts.length, 1, "Should find Aral receipt");
|
||
assertEquals(r1.filteredReceipts[0].title.includes("Aral"), true, "Title must contain Aral");
|
||
|
||
const r2 = filterCommandItems(items, "TrAtToRiA");
|
||
assertEquals(r2.filteredReceipts.length, 1, "Should find Trattoria receipt");
|
||
|
||
const r3 = filterCommandItems(items, " rewe ");
|
||
assertEquals(r3.filteredReceipts.length, 1, "Should find Rewe receipt");
|
||
});
|
||
|
||
runTest("Search Filtering: Filter by currency amount and receipt number", () => {
|
||
const items = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => {},
|
||
routerPush: () => {},
|
||
});
|
||
|
||
// Search by amount "68.45"
|
||
const rAmount = filterCommandItems(items, "68.45");
|
||
assertEquals(rAmount.filteredReceipts.length, 1, "Should match 68.45 €");
|
||
assertEquals(rAmount.filteredReceipts[0].id, "receipt-rec-001", "Should be Aral receipt");
|
||
|
||
// Search by receipt number "TRAT-1002"
|
||
const rNum = filterCommandItems(items, "TRAT-1002");
|
||
assertEquals(rNum.filteredReceipts.length, 1, "Should match receipt number TRAT-1002");
|
||
assertEquals(rNum.filteredReceipts[0].id, "receipt-rec-002", "Should be Trattoria receipt");
|
||
});
|
||
|
||
runTest("Search Filtering: Filter by category tag (e.g. Bewirtung, Tanken)", () => {
|
||
const items = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => {},
|
||
routerPush: () => {},
|
||
});
|
||
|
||
const rCat = filterCommandItems(items, "Bewirtung");
|
||
assertEquals(rCat.filteredReceipts.length, 1, "Should match Bewirtung category");
|
||
assertEquals(rCat.filteredReceipts[0].id, "receipt-rec-002", "Should be Trattoria");
|
||
});
|
||
|
||
runTest("Search Filtering: Filter by Action names (e.g. 'Excel', 'CSV', 'Demo', 'Upload')", () => {
|
||
const items = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => {},
|
||
routerPush: () => {},
|
||
});
|
||
|
||
// Both export actions legitimately mention Excel (the CSV opens in it), so a
|
||
// bare "Excel" query must return both — filtering by the format token is what
|
||
// has to disambiguate them.
|
||
const rExcel = filterCommandItems(items, "Excel");
|
||
assertEquals(rExcel.filteredActions.length, 2, "Should find both export actions");
|
||
|
||
const rXlsx = filterCommandItems(items, ".xlsx");
|
||
assertEquals(rXlsx.filteredActions.length, 1, "Should find Excel action");
|
||
assertEquals(rXlsx.filteredActions[0].id, "action-excel", "ID must be action-excel");
|
||
|
||
const rCsv = filterCommandItems(items, "CSV");
|
||
assertEquals(rCsv.filteredActions.length, 1, "Should find accounting CSV action");
|
||
assertEquals(rCsv.filteredActions[0].id, "action-csv", "ID must be action-csv");
|
||
});
|
||
|
||
runTest("Search Filtering: Filter by navigation path (e.g. '/dashboard/export', 'settings')", () => {
|
||
const items = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => {},
|
||
routerPush: () => {},
|
||
});
|
||
|
||
const rSettings = filterCommandItems(items, "settings");
|
||
assertEquals(rSettings.filteredNav.length, 1, "Should find settings nav item");
|
||
assertEquals(rSettings.filteredNav[0].id, "nav-settings", "ID must be nav-settings");
|
||
|
||
const rActivity = filterCommandItems(items, "/dashboard/activity");
|
||
assertEquals(rActivity.filteredNav.length, 1, "Should find activity nav item by route substring");
|
||
});
|
||
|
||
runTest("Search Filtering: Zero-match search returns empty results gracefully", () => {
|
||
const items = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => {},
|
||
routerPush: () => {},
|
||
});
|
||
|
||
const rNone = filterCommandItems(items, "XYZ999NONEXISTENT");
|
||
assertEquals(rNone.filteredItems.length, 0, "No items should match");
|
||
assertEquals(rNone.filteredActions.length, 0, "No actions");
|
||
assertEquals(rNone.filteredReceipts.length, 0, "No receipts");
|
||
assertEquals(rNone.filteredNav.length, 0, "No nav");
|
||
});
|
||
|
||
// 4. Action Invocation & Routing Verification
|
||
runTest("Action Invocation: Quick Action 'upload' calls onClose and custom trigger or router fallback", () => {
|
||
let closed = false;
|
||
let scanTriggered = false;
|
||
let pushedRoute = "";
|
||
|
||
const itemsWithTrigger = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => { closed = true; },
|
||
onTriggerScan: () => { scanTriggered = true; },
|
||
routerPush: (p) => { pushedRoute = p; },
|
||
});
|
||
|
||
itemsWithTrigger.actionItems.find((a) => a.id === "action-upload")?.action();
|
||
assertEquals(closed, true, "onClose must be called");
|
||
assertEquals(scanTriggered, true, "onTriggerScan must be called");
|
||
assertEquals(pushedRoute, "", "routerPush should not be called when onTriggerScan is provided");
|
||
|
||
// Test fallback without onTriggerScan
|
||
closed = false;
|
||
pushedRoute = "";
|
||
const itemsWithoutTrigger = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => { closed = true; },
|
||
routerPush: (p) => { pushedRoute = p; },
|
||
});
|
||
itemsWithoutTrigger.actionItems.find((a) => a.id === "action-upload")?.action();
|
||
assertEquals(closed, true, "onClose must be called");
|
||
assertEquals(pushedRoute, "/dashboard", "routerPush should fallback to /dashboard");
|
||
});
|
||
|
||
runTest("Action Invocation: Quick Action 'demo' calls onClose and custom trigger or router fallback", () => {
|
||
let closed = false;
|
||
let demoTriggered = false;
|
||
let pushedRoute = "";
|
||
|
||
const itemsWithDemo = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => { closed = true; },
|
||
onLoadDemo: () => { demoTriggered = true; },
|
||
routerPush: (p) => { pushedRoute = p; },
|
||
});
|
||
|
||
itemsWithDemo.actionItems.find((a) => a.id === "action-demo")?.action();
|
||
assertEquals(closed, true, "onClose must be called");
|
||
assertEquals(demoTriggered, true, "onLoadDemo must be called");
|
||
|
||
// Fallback
|
||
closed = false;
|
||
const itemsWithoutDemo = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => { closed = true; },
|
||
routerPush: (p) => { pushedRoute = p; },
|
||
});
|
||
itemsWithoutDemo.actionItems.find((a) => a.id === "action-demo")?.action();
|
||
assertEquals(closed, true, "onClose must be called");
|
||
assertEquals(pushedRoute, "/dashboard", "routerPush should fallback to /dashboard");
|
||
});
|
||
|
||
runTest("Action Invocation: Quick Actions 'excel' and 'csv' route to /dashboard/export", () => {
|
||
let closed = false;
|
||
let pushedRoute = "";
|
||
|
||
const items = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => { closed = true; },
|
||
routerPush: (p) => { pushedRoute = p; },
|
||
});
|
||
|
||
items.actionItems.find((a) => a.id === "action-excel")?.action();
|
||
assertEquals(closed, true, "onClose must be called");
|
||
assertEquals(pushedRoute, "/dashboard/export", "Must route to /dashboard/export");
|
||
|
||
closed = false;
|
||
pushedRoute = "";
|
||
items.actionItems.find((a) => a.id === "action-csv")?.action();
|
||
assertEquals(closed, true, "onClose must be called");
|
||
assertEquals(pushedRoute, "/dashboard/export", "Must route to /dashboard/export");
|
||
});
|
||
|
||
runTest("Action Invocation: Receipt item selection calls onSelectReceipt or routes to /dashboard/activity", () => {
|
||
let closed = false;
|
||
let selectedReceipt: any = null;
|
||
let pushedRoute = "";
|
||
|
||
const itemsWithSelect = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => { closed = true; },
|
||
onSelectReceipt: (r) => { selectedReceipt = r; },
|
||
routerPush: (p) => { pushedRoute = p; },
|
||
});
|
||
|
||
itemsWithSelect.receiptItems[0].action();
|
||
assertEquals(closed, true, "onClose must be called");
|
||
assertEquals(selectedReceipt?.id, "rec-001", "Selected receipt must match rec-001");
|
||
|
||
// Fallback
|
||
closed = false;
|
||
const itemsWithoutSelect = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => { closed = true; },
|
||
routerPush: (p) => { pushedRoute = p; },
|
||
});
|
||
itemsWithoutSelect.receiptItems[0].action();
|
||
assertEquals(closed, true, "onClose must be called");
|
||
assertEquals(pushedRoute, "/dashboard/activity", "Must route to /dashboard/activity on fallback");
|
||
});
|
||
|
||
runTest("Action Invocation: Navigation links route to their respective pages", () => {
|
||
const routes = [
|
||
{ id: "nav-overview", expected: "/dashboard" },
|
||
{ id: "nav-activity", expected: "/dashboard/activity" },
|
||
{ id: "nav-export", expected: "/dashboard/export" },
|
||
{ id: "nav-settings", expected: "/dashboard/settings" },
|
||
{ id: "nav-landing", expected: "/" },
|
||
];
|
||
|
||
for (const r of routes) {
|
||
let closed = false;
|
||
let pushedRoute = "";
|
||
const items = buildCommandItems({
|
||
isDe: true,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => { closed = true; },
|
||
routerPush: (p) => { pushedRoute = p; },
|
||
});
|
||
|
||
const item = items.navItems.find((n) => n.id === r.id);
|
||
assert(item !== undefined, `Nav item ${r.id} should exist`);
|
||
item?.action();
|
||
assertEquals(closed, true, `onClose must be called for ${r.id}`);
|
||
assertEquals(pushedRoute, r.expected, `Target path must be ${r.expected}`);
|
||
}
|
||
});
|
||
|
||
// 5. English Localization Verification
|
||
runTest("Localization: English strings render appropriately when language === 'en'", () => {
|
||
const itemsEn = buildCommandItems({
|
||
isDe: false,
|
||
localReceipts: mockReceipts,
|
||
onClose: () => {},
|
||
routerPush: () => {},
|
||
});
|
||
|
||
assertEquals(itemsEn.actionItems[0].title.startsWith("+ Upload / scan"), true, "English action title");
|
||
assertEquals(itemsEn.actionItems[1].title, "Load demo receipts", "English demo title");
|
||
assertEquals(itemsEn.navItems[0].title, "Dashboard Overview", "English overview nav title");
|
||
});
|
||
|
||
// 6. Source Code Static Token & Lifecycle Invariant Audits
|
||
runTest("Source Audit: SpotlightDialog.tsx contains zero rounded-, shadow-, or gradient classes", () => {
|
||
const spotlightCode = fs.readFileSync(path.resolve(__dirname, "../src/components/dashboard/SpotlightDialog.tsx"), "utf-8");
|
||
const forbiddenPatterns = [
|
||
/\brounded(-[a-z0-9]+)?\b/g,
|
||
/\bshadow(-[a-z0-9]+)?\b/g,
|
||
/\bbg-gradient-[a-z0-9-]+\b/g,
|
||
];
|
||
|
||
for (const pattern of forbiddenPatterns) {
|
||
const matches = spotlightCode.match(pattern);
|
||
assertEquals(matches, null, `Found forbidden style match ${matches} in SpotlightDialog.tsx`);
|
||
}
|
||
});
|
||
|
||
runTest("Source Audit: TopNav.tsx contains zero rounded-, shadow-, or gradient classes", () => {
|
||
const topNavCode = fs.readFileSync(path.resolve(__dirname, "../src/components/dashboard/TopNav.tsx"), "utf-8");
|
||
const forbiddenPatterns = [
|
||
/\brounded(-[a-z0-9]+)?\b/g,
|
||
/\bshadow(-[a-z0-9]+)?\b/g,
|
||
/\bbg-gradient-[a-z0-9-]+\b/g,
|
||
];
|
||
|
||
for (const pattern of forbiddenPatterns) {
|
||
const matches = topNavCode.match(pattern);
|
||
assertEquals(matches, null, `Found forbidden style match ${matches} in TopNav.tsx`);
|
||
}
|
||
});
|
||
|
||
runTest("Source Audit: Both TopNav.tsx and SpotlightDialog.tsx properly unbind keydown event listeners", () => {
|
||
const topNavCode = fs.readFileSync(path.resolve(__dirname, "../src/components/dashboard/TopNav.tsx"), "utf-8");
|
||
const spotlightCode = fs.readFileSync(path.resolve(__dirname, "../src/components/dashboard/SpotlightDialog.tsx"), "utf-8");
|
||
|
||
assert(topNavCode.includes('window.removeEventListener("keydown"'), "TopNav must unbind keydown");
|
||
assert(spotlightCode.includes('window.removeEventListener("keydown"'), "SpotlightDialog must unbind keydown");
|
||
});
|
||
|
||
console.log("\n================================================================");
|
||
console.log(`RESULTS: ${passed} / ${total} TESTS PASSED (100% SUCCESS)`);
|
||
console.log("================================================================\n");
|