import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { randomUUID } from "node:crypto"; import { devices, type Db } from "@parking/db"; import { createTestDb } from "@parking/db/testing"; import { LaneStatus } from "./lane-status.js"; import { deviceEvents, type LaneStatusEvent } from "./device-events.js"; import { silentLogger } from "./test-helpers.js"; // LaneStatus: a camera's vehicle detection marks its bound lane busy, then auto-clears // after a timeout (this camera class sends no leave signal). Advisory; emits a // lane-status change only when the busy/free state actually flips. let db: Db; beforeEach(() => { ({ db } = createTestDb()); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); /** Seed a controller (relay 1=entry, 2=exit, 3=both) + a camera bound to the relay * whose direction we want, so directionOf resolves from the real bound relay. */ function seedCamera(direction: "entry" | "exit" | "both"): 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" }, { relay: 2, direction: "exit" }, { relay: 3, direction: "both" }, ], }, enabled: true, }).run(); const relay = direction === "entry" ? 1 : direction === "exit" ? 2 : 3; const camId = randomUUID(); db.insert(devices).values({ id: camId, category: "camera", driverId: "hikvision", config: { host: "10.0.0.9", controllerId, relay }, enabled: true, }).run(); return camId; } /** Capture lane-status events emitted during `fn`. */ function captureEmits(fn: () => void): LaneStatusEvent[] { const got: LaneStatusEvent[] = []; const off = deviceEvents.onLaneStatus((e) => got.push(e)); try { fn(); } finally { off(); } return got; } describe("LaneStatus", () => { it("marks the camera's bound lane busy on a vehicle detection, free until then", () => { const cam = seedCamera("entry"); const lane = new LaneStatus(db, silentLogger(), 90_000); expect(lane.snapshot()).toEqual({ entry: false, exit: false }); const emits = captureEmits(() => lane.vehicleDetected(cam)); expect(lane.snapshot()).toEqual({ entry: true, exit: false }); expect(emits).toEqual([{ entry: true, exit: false }]); // emitted on the flip }); it("auto-clears to free after the TTL (no leave signal from the camera)", () => { const cam = seedCamera("entry"); const lane = new LaneStatus(db, silentLogger(), 90_000); lane.vehicleDetected(cam); expect(lane.snapshot().entry).toBe(true); const emits = captureEmits(() => vi.advanceTimersByTime(90_001)); expect(lane.snapshot().entry).toBe(false); expect(emits).toEqual([{ entry: false, exit: false }]); }); it("re-arms the timer on each detection (a parked car keeps the lane busy)", () => { const cam = seedCamera("entry"); const lane = new LaneStatus(db, silentLogger(), 90_000); lane.vehicleDetected(cam); // Re-fire just before the TTL — should NOT clear, and should push the clear out. vi.advanceTimersByTime(80_000); lane.vehicleDetected(cam); vi.advanceTimersByTime(80_000); // 160s total, but only 80s since the last detect expect(lane.snapshot().entry).toBe(true); // Now let it lapse fully. vi.advanceTimersByTime(90_001); expect(lane.snapshot().entry).toBe(false); }); it("does NOT re-emit on a repeat detection while already busy (only state flips)", () => { const cam = seedCamera("entry"); const lane = new LaneStatus(db, silentLogger(), 90_000); lane.vehicleDetected(cam); // flip -> emits const emits = captureEmits(() => { lane.vehicleDetected(cam); // already busy -> no emit lane.vehicleDetected(cam); }); expect(emits).toEqual([]); }); it("a 'both'-direction camera marks BOTH lanes busy", () => { const cam = seedCamera("both"); const lane = new LaneStatus(db, silentLogger(), 90_000); lane.vehicleDetected(cam); expect(lane.snapshot()).toEqual({ entry: true, exit: true }); }); it("exit camera marks only the exit lane", () => { const cam = seedCamera("exit"); const lane = new LaneStatus(db, silentLogger(), 90_000); lane.vehicleDetected(cam); expect(lane.snapshot()).toEqual({ entry: false, exit: true }); }); it("ignores an unknown device id", () => { const lane = new LaneStatus(db, silentLogger(), 90_000); lane.vehicleDetected("nope"); expect(lane.snapshot()).toEqual({ entry: false, exit: false }); }); });