fix(entry): enforce the camera press-gate + duplicate-ticket defenses
Field report (park-buzi): a BLINKING entry button still printed — the lamp encoded blink-vs-solid (radar-only vs radar+camera) but #suppressReason only checked the radar, so a radar false-positive (rain, pedestrian) minted a real signed ticket. Three layered fixes: 1. CAMERA gate on the physical press: with an entry camera configured, a press is live only in the lamp's SOLID state (LaneStatus.entry busy, mirrored into EntryFlow via onLaneStatus). Suppress-only — the camera stays advisory (never opens, never traps). Camera-less sites keep the radar-only gate; a faulty camera is dropped via the existing bypassPresenceCamera admin toggle. 2. Cooldown as a REAL backstop behind presence: the presence branch returned early, so entryCooldownSec was dead wherever a loop was wired. Now it bounds the stationary-car double-ticket (a motion radar drops a motionless car → spurious loop-clear re-arms one-car-one-ticket → same car reprints). 3. Post-hoc duplicate-plate anomaly (entry-side twin of plateSwapSuspected): when entry ANPR recognizes a plate already OPEN under another session entered within ENTRY_DUP_PLATE_WINDOW_MIN (default 15 min), sign ONE entry.duplicatePlate anomaly naming both tickets for the operator to void. ANPR stays non-blocking (rides the post-open snapshot as before). REJECTED: camera-vetoed re-arm (defer re-arm until the lane flips free). The camera has no leave events — "free" is a ~30s silence timeout that never lapses inside a queue, so every queued car after the first would be suppressed until an operator intervened. Blocking legit entry at peak beats nothing; the proper preventive fix is a pass-through sensor (passedInput) — recorded as open in wiki/concepts/entry-double-press.md. Also: setup.relayTest reason was missing from both web catalogs (parity is only enforced sq<->en, so the build passed) — added. Tests: entry-press-gate.test.ts (blink suppresses / solid prints / camera-less unaffected / bypass honored / cooldown catches the dropout re-press / residual risk documented / still-present re-press stays suppressed) + entry-duplicate-plate.test.ts (flags open dup, ignores closed/stale/self/other plates). Suite 258 green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { deviceEvents as deviceEventsTable, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { flagDuplicateEntryPlate } from "./snapshot.js";
|
||||
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// Entry-side duplicate-plate reconciliation (2026-07-04): when ANPR recognizes a plate on
|
||||
// a fresh transient entry and that plate is already OPEN under another RECENT session,
|
||||
// the same car most likely minted a second ticket (a motion radar dropped the stationary
|
||||
// car → the button re-armed). We sign ONE entry.duplicatePlate anomaly for the operator
|
||||
// to void. Post-hoc + advisory: recognition never gates the (already-open) barrier —
|
||||
// exactly the non-blocking role the plate can play here.
|
||||
|
||||
let db: Db;
|
||||
let log: EventLog;
|
||||
|
||||
const PLATE = "AA111BB";
|
||||
const OLD = "11111111111";
|
||||
const NEW = "22222222222";
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
log = makeLog(db);
|
||||
});
|
||||
|
||||
/** Seed the prior entry's unsigned plate-read telemetry (what recognizePlate records). */
|
||||
function seedPriorRead(opts: { identity?: string; plate?: string; direction?: string; agoMs?: number } = {}) {
|
||||
db.insert(deviceEventsTable).values({
|
||||
id: randomUUID(),
|
||||
deviceId: "cam-entry",
|
||||
category: "camera",
|
||||
kind: "read",
|
||||
detail: {
|
||||
identity: opts.identity ?? OLD,
|
||||
direction: opts.direction ?? "entry",
|
||||
plate: opts.plate ?? PLATE,
|
||||
snapshotId: "snap-old",
|
||||
source: "entry-exit-snapshot",
|
||||
},
|
||||
occurredAt: new Date(Date.now() - (opts.agoMs ?? 60_000)).toISOString(),
|
||||
}).run();
|
||||
}
|
||||
|
||||
function seedSession(id: string, state: "open" | "closed") {
|
||||
db.insert(sessions).values({
|
||||
id,
|
||||
identity: id,
|
||||
source: "ticket",
|
||||
enteredAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
state,
|
||||
}).run();
|
||||
}
|
||||
|
||||
const flag = () =>
|
||||
flagDuplicateEntryPlate({ db, log, identity: NEW, plate: PLATE, snapshotId: "snap-new", logger: silentLogger() });
|
||||
|
||||
const anomalies = () =>
|
||||
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||
|
||||
describe("flagDuplicateEntryPlate", () => {
|
||||
it("same plate OPEN under another recent session → signs ONE entry.duplicatePlate anomaly", async () => {
|
||||
seedPriorRead();
|
||||
seedSession(OLD, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(1);
|
||||
const a = anomalies()[0];
|
||||
expect(a.identity).toBe(NEW); // keyed to the NEW (suspect) ticket
|
||||
expect(a.payload).toMatchObject({
|
||||
reasonCode: "entry.duplicatePlate",
|
||||
duplicateEntrySuspected: true,
|
||||
plate: PLATE,
|
||||
otherIdentity: OLD,
|
||||
snapshotId: "snap-new",
|
||||
});
|
||||
});
|
||||
|
||||
it("prior session already CLOSED → no anomaly (that car drove off; a re-visit is legit)", async () => {
|
||||
seedPriorRead();
|
||||
seedSession(OLD, "closed");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("prior read outside the window → no anomaly (stale coincidence, not a double press)", async () => {
|
||||
seedPriorRead({ agoMs: 30 * 60_000 }); // beyond the 15-min default window
|
||||
seedSession(OLD, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("own read (same identity) never flags itself", async () => {
|
||||
seedPriorRead({ identity: NEW });
|
||||
seedSession(NEW, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("different plate / exit-side reads are ignored", async () => {
|
||||
seedPriorRead({ plate: "ZZ999ZZ" });
|
||||
seedPriorRead({ direction: "exit" });
|
||||
seedSession(OLD, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user