5e1395db18
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the open half (no bundled recognizer emits body_type yet, so the desk shows nothing until phase A lands in the vision service). - Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT, reason code carwash.categoryDowngrade; settings/order/lookup views carry the read. - Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node client normalises to the vocabulary and drops the rest. - Record: snapshot.ts stores the read in the plate's device_events row (or its own when the plate was unreadable); vehicleForIdentity() resolves it like the plate. - Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"), carwash_config.vision_threshold (signed config_change when it moves), four vision columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId. - Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup offers class chips per category and the threshold. Operator decides. - Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no reason prompt (user, 2026-09-06). Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
331 lines
15 KiB
TypeScript
331 lines
15 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { randomUUID } from "node:crypto";
|
|
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db } from "@parking/db";
|
|
import { createTestDb } from "@parking/db/testing";
|
|
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
|
import { silentLogger } from "./test-helpers.js";
|
|
import type { VisionClient, VisionResult } from "./vision-client.js";
|
|
import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js";
|
|
|
|
// The ANPR bridge: a camera vehicle detection → (opt-in) snapshot → plate → MATCH a
|
|
// subscriber → emit a plate read. We mock the camera build (buildCamera) so no real
|
|
// snapshot HTTP is made, and pass fake Vision/Subscription so the test is the bridge's
|
|
// own logic only. See anpr-entry.ts.
|
|
|
|
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
|
|
// (no registry, no network). The factory returns a fresh shot each call.
|
|
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
|
// The bridge now goes through captureSnapshotShared (the dedup wrapper, exercised in
|
|
// snapshot.test.ts); here it just delegates to the fake camera's captureSnapshot so this
|
|
// suite stays focused on the bridge's own match/debounce/emit logic.
|
|
vi.mock("./snapshot.js", () => ({
|
|
buildCamera: () => ({ captureSnapshot }),
|
|
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
|
|
camera.captureSnapshot(ctx as never),
|
|
}));
|
|
|
|
// Import AFTER the mock is registered.
|
|
const { AnprBridge } = await import("./anpr-entry.js");
|
|
|
|
let db: Db;
|
|
beforeEach(() => {
|
|
({ db } = createTestDb());
|
|
captureSnapshot.mockClear();
|
|
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
|
delete process.env.ANPR_DEBOUNCE_MS;
|
|
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
|
|
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
|
|
// bridge reads these in its constructor, so set them before `new AnprBridge`.
|
|
process.env.ANPR_POLL_MS = "1";
|
|
process.env.ANPR_POLL_WINDOW_MS = "5";
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env.ANPR_POLL_MS;
|
|
delete process.env.ANPR_POLL_WINDOW_MS;
|
|
delete process.env.ANPR_POLL_MAX_MS;
|
|
});
|
|
|
|
/** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
|
|
* per-camera auto-open gate (absent ⇒ defaults on). */
|
|
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
|
|
const controllerId = randomUUID();
|
|
db.insert(devices).values({
|
|
id: controllerId,
|
|
category: "access",
|
|
driverId: "dingtian",
|
|
config: { host: "10.0.0.5", relays: [{ relay: 1, direction: "entry" }] },
|
|
enabled: true,
|
|
}).run();
|
|
const camId = randomUUID();
|
|
db.insert(devices).values({
|
|
id: camId,
|
|
category: "camera",
|
|
driverId: "hikvision",
|
|
config: {
|
|
host: "10.0.0.9",
|
|
controllerId,
|
|
relay: 1,
|
|
...(opts.anpr ? { anpr: true } : {}),
|
|
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
|
|
},
|
|
enabled: true,
|
|
}).run();
|
|
return camId;
|
|
}
|
|
|
|
/** A fake VisionClient: enabled, returning a chosen plate/confidence (or null). */
|
|
function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: number } = {}): VisionClient {
|
|
const enabled = opts.enabled ?? true;
|
|
const result: VisionResult | null =
|
|
opts.plate == null
|
|
? null
|
|
: {
|
|
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
|
|
plates: [],
|
|
lowConfidence: false,
|
|
vehicle: null,
|
|
modelVersion: "test",
|
|
tookMs: 1,
|
|
};
|
|
return {
|
|
enabled,
|
|
analyze: vi.fn(async () => (enabled ? result : null)),
|
|
} as unknown as VisionClient;
|
|
}
|
|
|
|
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
|
function fakeSubFlow(
|
|
match: SubscriptionMatch | null,
|
|
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
|
|
// credential closing an occurrence mid-poll → count changes).
|
|
openCounts: number | number[] = 1,
|
|
): SubscriptionFlow {
|
|
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
|
|
return {
|
|
match: vi.fn(() => match),
|
|
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
|
|
} as unknown as SubscriptionFlow;
|
|
}
|
|
|
|
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
|
|
|
/** Capture read events emitted during `fn` (async). */
|
|
async function captureReads(fn: () => Promise<void>): Promise<DeviceReadEvent[]> {
|
|
const got: DeviceReadEvent[] = [];
|
|
const off = deviceEvents.onRead((e) => got.push(e));
|
|
try {
|
|
await fn();
|
|
} finally {
|
|
off();
|
|
}
|
|
return got;
|
|
}
|
|
|
|
describe("AnprBridge", () => {
|
|
it("does nothing for an opt-OUT camera (no anpr flag) — no analyze, no read", async () => {
|
|
const cam = seedCamera({ anpr: false });
|
|
const vision = fakeVision({ plate: "AA111BB" });
|
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
|
|
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
|
expect(reads).toEqual([]);
|
|
expect(vision.analyze).not.toHaveBeenCalled();
|
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
|
|
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
|
|
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
|
|
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
|
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
|
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
|
|
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
|
expect(reads).toEqual([]);
|
|
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
|
|
});
|
|
|
|
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
|
const cam = seedCamera({ anpr: true });
|
|
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
|
|
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
|
expect(reads).toHaveLength(1);
|
|
expect(reads[0]).toMatchObject({ deviceId: cam, value: "AA111BB", kind: "plate", driverId: "hikvision" });
|
|
});
|
|
|
|
it("ignores a plate below the entry confidence floor", async () => {
|
|
const cam = seedCamera({ anpr: true });
|
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.6 }); // < default 0.85
|
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
|
|
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
|
expect(reads).toEqual([]);
|
|
});
|
|
|
|
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
|
|
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
|
|
// must re-pull until one frame clears the floor, not give up on the first bad frame.
|
|
const cam = seedCamera({ anpr: true });
|
|
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
|
|
const confs = [0.2, 0.2, 0.97];
|
|
let i = 0;
|
|
const vision = {
|
|
enabled: true,
|
|
analyze: vi.fn(async () => ({
|
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
|
plates: [],
|
|
lowConfidence: false,
|
|
vehicle: null,
|
|
modelVersion: "test",
|
|
tookMs: 1,
|
|
})),
|
|
} as unknown as VisionClient;
|
|
// Generous window so all 3 escalation attempts run deterministically under suite load
|
|
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
|
|
process.env.ANPR_POLL_MS = "1";
|
|
process.env.ANPR_POLL_WINDOW_MS = "2000";
|
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
|
|
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
|
expect(reads).toHaveLength(1);
|
|
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
|
|
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
|
|
});
|
|
|
|
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
|
|
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
|
|
// extends the deadline, so the loop keeps polling and reads the car that settles at the
|
|
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
|
|
// the start bumps the deadline so attempt 5's confident read still lands.
|
|
const cam = seedCamera({ anpr: true });
|
|
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
|
|
let i = 0;
|
|
const vision = {
|
|
enabled: true,
|
|
analyze: vi.fn(async () => ({
|
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
|
plates: [],
|
|
lowConfidence: false,
|
|
vehicle: null,
|
|
modelVersion: "test",
|
|
tookMs: 1,
|
|
})),
|
|
} as unknown as VisionClient;
|
|
process.env.ANPR_POLL_MS = "5";
|
|
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
|
|
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
|
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
|
|
|
const reads = await captureReads(async () => {
|
|
const loop = bridge.onVehicleDetected(cam); // starts the loop
|
|
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
|
|
for (let k = 0; k < 5; k++) {
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
|
|
}
|
|
await loop;
|
|
});
|
|
expect(reads).toHaveLength(1);
|
|
expect(reads[0]).toMatchObject({ value: "AA111BB" });
|
|
});
|
|
|
|
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
|
|
// The car's plate is read (identity known) but stays below the floor; meanwhile the
|
|
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
|
|
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
|
|
const cam = seedCamera({ anpr: true });
|
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
|
|
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
|
|
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
|
|
const bridge = new AnprBridge(db, vision, sub, silentLogger());
|
|
|
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
|
expect(reads).toEqual([]); // aborted — the credential already handled it
|
|
});
|
|
|
|
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
|
const cam = seedCamera({ anpr: true });
|
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
|
|
|
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
|
expect(reads).toEqual([]);
|
|
|
|
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
|
|
expect(skips).toHaveLength(1);
|
|
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
|
|
});
|
|
|
|
it("analyzes AT LEAST ONE frame even if the poll window already elapsed (loaded host)", async () => {
|
|
// Regression for a CI flake (2026-07-04): with a plain `while`, a window that lapsed
|
|
// between deadline-set and loop-entry (slow runner; here forced with a 0ms window)
|
|
// meant ZERO analyze attempts — the detection was silently dropped ("gave up") and no
|
|
// skip was recorded. The do-while guarantees one frame per detection regardless of load.
|
|
process.env.ANPR_POLL_WINDOW_MS = "0";
|
|
const cam = seedCamera({ anpr: true });
|
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
|
|
|
|
await captureReads(() => bridge.onVehicleDetected(cam));
|
|
expect(captureSnapshot).toHaveBeenCalledTimes(1); // the guaranteed first attempt
|
|
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
|
|
expect(skips).toHaveLength(1);
|
|
});
|
|
|
|
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
|
|
const cam = seedCamera({ anpr: true });
|
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
|
|
|
const reads = await captureReads(async () => {
|
|
await bridge.onVehicleDetected(cam);
|
|
await bridge.onVehicleDetected(cam); // within the 12s window → suppressed
|
|
});
|
|
expect(reads).toHaveLength(1);
|
|
expect(captureSnapshot).toHaveBeenCalledTimes(1); // 2nd was gated before the snapshot
|
|
});
|
|
|
|
it("is a no-op (no throw) when vision is disabled or reads nothing", async () => {
|
|
const cam = seedCamera({ anpr: true });
|
|
const disabled = new AnprBridge(db, fakeVision({ enabled: false, plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
|
|
const noPlate = new AnprBridge(db, fakeVision({ plate: undefined }), fakeSubFlow(SUB_MATCH), silentLogger());
|
|
|
|
const reads = await captureReads(async () => {
|
|
await disabled.onVehicleDetected(cam);
|
|
await noPlate.onVehicleDetected(cam);
|
|
});
|
|
expect(reads).toEqual([]);
|
|
});
|
|
|
|
it("never throws on an unknown device id", async () => {
|
|
const bridge = new AnprBridge(db, fakeVision({ plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
|
|
await expect(bridge.onVehicleDetected("nope")).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("does NOTHING when the admin has disabled the bridge (site_config.anprEntryEnabled = false)", async () => {
|
|
const cam = seedCamera({ anpr: true });
|
|
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: false }).run();
|
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
|
|
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
|
expect(reads).toEqual([]);
|
|
// The flag is checked FIRST — no snapshot, no analyze, no match attempt.
|
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
|
expect(vision.analyze).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still emits when the bridge is explicitly enabled (anprEntryEnabled = true)", async () => {
|
|
const cam = seedCamera({ anpr: true });
|
|
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: true }).run();
|
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
|
|
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
|
expect(reads).toHaveLength(1);
|
|
});
|
|
});
|