import { beforeEach, describe, expect, it } from "vitest"; import { devices, siteConfig, ledgerEvents, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; import { createTestDb } from "@parking/db/testing"; import { registry, type PrinterDevice } from "@parking/devices"; import { EntryFlow } from "./entry-flow.js"; import { makeLog, silentLogger } from "./test-helpers.js"; // The PHYSICAL entry button's press gate (#suppressReason), layered (2026-07-04): // CAMERA — with an entry camera configured, a press is live only while the entry lane // camera confirms a vehicle (the button lamp's SOLID state). Blink (radar-only) prints // nothing. Camera-less sites skip this; the admin camera bypass drops it. // PRESENCE — one-car-one-ticket off the loop (unchanged). // COOLDOWN — now a BACKSTOP behind presence, not an alternative: a motion radar drops a // stationary car (no doppler return), spuriously re-arming the guard; the cooldown bounds // how fast that re-armed press can mint a second ticket for the same car. // A suppressed press is unsigned telemetry (entrySuppressed), never a ledger anomaly. let db: Db; let flow: EntryFlow; const CTL = "ctl-entry"; const BUTTON_INPUT = 1; const PRESENCE_INPUT = 2; // A no-op printer that always succeeds, so the happy path reaches the signed // vehicle_entry (the real drivers need hardware). Registered once (registry is global). const noopPrinter: PrinterDevice = { driverId: "test-printer-ok", connect: async () => {}, disconnect: async () => {}, healthCheck: async () => ({ status: "ready" as const }), printTicket: async () => {}, printReport: async () => {}, printSubscriptionCard: async () => {}, printReceipt: async () => {}, printWindowChargeNotice: async () => {}, }; if (!registry.get("test-printer-ok")) { registry.register({ id: "test-printer-ok", category: "printer", label: "Test printer", description: "always-succeeds stub for tests", transports: [], configFields: [], create: () => noopPrinter, }); } beforeEach(() => { ({ db } = createTestDb()); db.insert(devices).values({ id: CTL, category: "access", driverId: "stub-access", config: { relays: [{ relay: 1, direction: "entry" }], inputs: [ { input: BUTTON_INPUT, role: "button", relay: 1 }, { input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" }, ], }, enabled: true, }).run(); db.insert(devices).values({ id: "printer-entry", category: "printer", driverId: "test-printer-ok", config: { direction: "entry" }, enabled: true, }).run(); flow = new EntryFlow(db, makeLog(db), silentLogger()); }); /** Add an entry camera row. The driver never builds (unknown id) — only its EXISTENCE * matters to the press gate; snapshot capture failing is the normal fire-and-forget path. */ function addEntryCamera() { db.insert(devices).values({ id: "cam-entry", category: "camera", driverId: "no-such-camera-driver", config: { direction: "entry" }, enabled: true, }).run(); } function setCameraBypass(on: boolean) { db.insert(siteConfig) .values({ id: 1, bypassPresenceCamera: on }) .onConflictDoUpdate({ target: siteConfig.id, set: { bypassPresenceCamera: on } }) .run(); } async function edge(input: number, edge: "on" | "off") { await flow.onInput({ driverId: "stub-access", deviceId: CTL, input, edge, at: new Date().toISOString(), source: "poll", }); } const press = () => edge(BUTTON_INPUT, "on"); const radar = (present: boolean) => edge(PRESENCE_INPUT, present ? "on" : "off"); const entries = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "vehicle_entry"); const suppressed = () => db.select().from(deviceEventsTable).all() .map((r) => r.detail as { entrySuppressed?: boolean; reason?: string }) .filter((d) => d.entrySuppressed === true); describe("entry press gate — camera (blink vs solid)", () => { it("BLINK state (radar present, no camera confirmation) → press suppressed, nothing signed", async () => { addEntryCamera(); await radar(true); // lamp would blink: radar sees something, camera does not await press(); expect(entries()).toHaveLength(0); expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // no anomaly either — telemetry only expect(suppressed()).toHaveLength(1); expect(suppressed()[0].reason).toMatch(/camera/); }); it("SOLID state (radar present + camera busy) → press prints and signs a vehicle_entry", async () => { addEntryCamera(); await radar(true); flow.onLaneStatus({ entry: true, exit: false }); // camera confirms → SOLID await press(); expect(entries()).toHaveLength(1); expect(suppressed()).toHaveLength(0); }); it("camera-less site → the camera gate does not apply (radar-only, as before)", async () => { await radar(true); // no camera row; lane state irrelevant await press(); expect(entries()).toHaveLength(1); }); it("camera bypassed (faulty camera) → press prints without camera confirmation", async () => { addEntryCamera(); setCameraBypass(true); await radar(true); await press(); expect(entries()).toHaveLength(1); }); it("no car at all (radar clear too) → suppressed even with the camera bypassed", async () => { addEntryCamera(); setCameraBypass(true); await press(); // radar never went on expect(entries()).toHaveLength(0); expect(suppressed()[0].reason).toMatch(/presence loop clear/); }); }); describe("entry press gate — cooldown backstop behind presence", () => { /** Same lane but the button carries a cooldown, making it a backstop behind the loop. */ function setButtonCooldown(sec: number) { db.delete(devices).run(); db.insert(devices).values({ id: CTL, category: "access", driverId: "stub-access", config: { relays: [{ relay: 1, direction: "entry" }], inputs: [ { input: BUTTON_INPUT, role: "button", relay: 1, cooldownSec: sec }, { input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" }, ], }, enabled: true, }).run(); db.insert(devices).values({ id: "printer-entry", category: "printer", driverId: "test-printer-ok", config: { direction: "entry" }, enabled: true, }).run(); } it("radar dropout re-arm + quick re-press → caught by the cooldown (one ticket)", async () => { setButtonCooldown(60); await radar(true); await press(); // ticket 1 (no camera configured — radar-only site) expect(entries()).toHaveLength(1); // The motion radar loses the STATIONARY car and re-fires: off (re-arms!) then on. await radar(false); await radar(true); await press(); // presence gate says yes (present + re-armed) — the backstop must catch it expect(entries()).toHaveLength(1); expect(suppressed().some((d) => /cooldown/.test(d.reason ?? ""))).toBe(true); }); it("without a cooldown the dropout re-press mints a second ticket (the documented residual risk)", async () => { await radar(true); await press(); await radar(false); await radar(true); await press(); expect(entries()).toHaveLength(2); }); it("still-present car re-pressing (no dropout) stays suppressed by one-car-one-ticket", async () => { await radar(true); await press(); await press(); // car never left the loop → not re-armed expect(entries()).toHaveLength(1); expect(suppressed().some((d) => /already issued/.test(d.reason ?? ""))).toBe(true); }); });