Files
parking_solution/apps/server/src/button-light.test.ts
T
julian 4418594af0
Build desktop / desktop (push) Successful in 4m16s
Build & push images / images (push) Successful in 2m43s
CI / check (push) Successful in 38s
refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
The controller new/edit modal hardcoded both its outputs and its inputs, so an
operator could neither add a generic event-driven relay nor a free-standing input
(e.g. a second radar at the exit). This unifies both into symmetric, first-class
lists. Behaviour for existing booths is unchanged (back-compat, no DB migration).

Outputs — one event→action relays[] list:
- A relay is "when EVENT X happens, do its action": entry/exit/both pulse a
  barrier; a new `radarAlert` event drives a non-barrier alert lamp (blink while
  its trigger input is active, SOLID once the camera confirms a car).
- Dropped the separate config.buttonLight block — the lamp is just a relays[] row
  with direction:"radarAlert" (triggerInput + blink cadence). `alertRelaysOf()`
  replaces `buttonLightOf()`; ButtonLightController keeps its proven 3-state
  machine (serialized UDP, fail-OFF, hot-reload), now keyed per controllerId:relay
  so several alert lamps on one controller run independently. Every barrier
  resolver skips radarAlert rows (no auto-open; barrier-not-a-door intact).

Inputs — one first-class config.inputs[] list (the twin of relays[]):
- Each row is { input, role, relay?, kind?, activeLow?, cooldownSec? } with a
  "+ Add input" button. role ∈ button | presence | alertTrigger; button/presence
  name the relay they serve. An exit radar is just another presence row.
- Keystone `inputsOf(row)`: returns config.inputs[] or SYNTHESIZES it from the
  legacy relays[].button/presenceInput/... fields, so relayForButton /
  relayForPresence resolve identically from either shape — zero-downtime, no
  migration. entry-flow.ts is unchanged (resolves through the same functions).
- Fixed a latent bug this exposed: the alert lamp's camera lock was hardcoded to
  the ENTRY camera. Added relays[].lockLane ("entry"|"exit", default entry); the
  lamp now locks on its own lane's camera, so an exit radar's lamp tracks the exit
  camera. button-light tracks both #entryBusy/#exitBusy.
- Driver: extracted activeLowFrom(config) — merges inputs[] activeLow, legacy
  relays[].presenceActiveLow, and the inputActiveLow escape hatch.

UI: the relay dropdown gained a "Radar alert" option (reveals trigger/lock/blink
inputs); InputEditor is rewritten to a generic list (role select folds loop/radar);
i18n sq+en kept at type-parity.

Tests: new device-resolve.test.ts (inputs[] resolution + legacy fallback identical
+ exit-radar resolves to the exit relay); button-light gains a two-independent-
alert-relays case and an exit-lamp lockLane case; access-dingtian gains
activeLowFrom cases. Full workspace build/lint/test green (i18n parity included).

Wiki + memory updated (button-light-indicator, entry-double-press, dingtian-relay).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 11:23:15 +02:00

358 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { eq, devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import type { AuxOutputDevice } from "@parking/devices";
import { ButtonLightController } from "./button-light.js";
import { deviceEvents } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
// trigger active + lane busy -> SOLID on
// trigger active + lane free -> BLINK (~1 Hz)
// otherwise -> OFF
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may
// carry several alert relays (each its own row + trigger input), keyed independently.
let db: Db;
const CONTROLLER = "ctl-1";
const RADAR_INPUT = 2; // I2
const LAMP_RELAY = 3; // spare relay R3
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
return {
async setAux(channel: number, on: boolean): Promise<void> {
if (throwOnce.v) {
throwOnce.v = false;
throw new Error("UDP down");
}
record.push({ ch: channel, on });
},
};
}
beforeEach(() => {
({ db } = createTestDb());
vi.useFakeTimers();
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
db.insert(devices).values({
id: CONTROLLER,
category: "access",
driverId: "dingtian",
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
{ relay: 2, direction: "exit" },
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
],
},
enabled: true,
}).run();
});
afterEach(() => {
vi.useRealTimers();
});
/** Emit a radar (presence input) edge for the controller. */
function radar(present: boolean): void {
deviceEvents.emitInput({
driverId: "dingtian",
deviceId: CONTROLLER,
input: RADAR_INPUT,
edge: present ? "on" : "off",
at: new Date().toISOString(),
source: "poll",
});
}
/** Emit a lane status (entry busy/free). */
function lane(entryBusy: boolean): void {
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
}
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
async function flush(): Promise<void> {
for (let i = 0; i < 6; i++) await Promise.resolve();
}
describe("ButtonLightController truth table", () => {
it("OFF at start (no radar, no car)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
ctl.start();
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("off");
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
// device is confirmed OFF and at most one call was made.
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
ctl.stop();
});
it("radar present + lane busy -> SOLID on", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
// Solid = no blinking: advancing time produces no further sends.
const n = calls.length;
vi.advanceTimersByTime(2000);
await flush();
expect(calls.length).toBe(n);
ctl.stop();
});
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
radar(true); // lane still free
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
vi.advanceTimersByTime(500);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
vi.advanceTimersByTime(500);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
ctl.stop();
});
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
radar(true); // blink
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
lane(true); // camera confirms
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
// No more toggles (blink torn down) — the device stays ON over time.
vi.advanceTimersByTime(2000);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
it("radar clears -> OFF", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true); // solid
await flush();
radar(false); // car gone
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("off");
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
ctl.stop();
});
it("de-dupes redundant writes (no spam on repeat events)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true); // solid, on
await flush();
const n = calls.length;
radar(true); // same state — no new edge (present unchanged)
lane(true); // same lane — no change
await flush();
expect(calls.length).toBe(n);
ctl.stop();
});
it("fails OFF: a setAux error does not throw or escalate", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const throwOnce = { v: true };
const aux = fakeAux(calls, throwOnce);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
// First write (initial off) throws — must be swallowed.
expect(() => ctl.start()).not.toThrow();
await flush();
// Subsequent writes work; driving to solid still converges to ON.
lane(true);
radar(true);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
it("ignores controllers without an alert relay", () => {
// A second controller, no alert relay.
db.insert(devices).values({
id: "ctl-2",
category: "access",
driverId: "dingtian",
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
enabled: true,
}).run();
const calls: Array<{ ch: number; on: boolean }> = [];
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
ctl.start();
expect(ctl.stateOf("ctl-2")).toBeNull();
ctl.stop();
});
it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
// Fresh controller with a radar input but NO alert relay yet.
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
// Replace the seeded controller with one that has the radar but no lamp.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
ctl.start();
await flush();
// No lamp configured → an input does nothing.
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBeNull();
expect(calls.length).toBe(0);
radar(false);
await flush();
// Admin saves an alert relay (relay 3, trigger I2) — without restarting the server.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" },
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
// The very next radar edge reconciles + blinks (lane still free).
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
it("drives two alert relays on one controller independently", async () => {
const R3 = 3;
const R4 = 4;
const I2 = 2;
const I3 = 3;
// Controller with two alert lamps, each on its own trigger input.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" },
{ relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 },
{ relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
// I2 active → only R3 blinks; R4 stays off (different trigger).
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
// I3 active → R4 blinks too, independently.
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// Camera confirms a car → BOTH lock solid (lane-busy is site-wide).
lane(true);
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
// I2 clears → R3 off, R4 still solid (its trigger still active).
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
ctl.stop();
});
it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => {
const R4 = 4;
const I5 = 5; // exit radar
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
// Exit alert lamp: triggers on the exit radar, locks on the EXIT camera.
{ relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
const aux = fakeAux([]);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
// Exit radar active → blink.
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// ENTRY camera busy must NOT lock this exit lamp — it still blinks.
deviceEvents.emitLaneStatus({ entry: true, exit: false });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// EXIT camera busy → SOLID.
deviceEvents.emitLaneStatus({ entry: true, exit: true });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
ctl.stop();
});
});