Files
parking_solution/apps/server/src/device-resolve.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

92 lines
3.8 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { inputsOf, relayForButton, relayForPresence } from "./device-resolve.js";
// device-resolve: the input resolution layer. Inputs live in config.inputs[] (the first-class
// model); a pre-inputs[] controller is back-compat-synthesized from the legacy per-relay
// button/presenceInput fields. relayForButton/relayForPresence must resolve IDENTICALLY from
// either shape, so an exit radar = just another presence row.
let db: Db;
const CTL = "ctl-1";
function seed(config: Record<string, unknown>): void {
({ db } = createTestDb());
db.insert(devices).values({ id: CTL, category: "access", driverId: "dingtian", config, enabled: true }).run();
}
describe("inputsOf back-compat synth", () => {
it("synthesizes inputs[] from legacy relay button/presence fields", () => {
seed({
relays: [
{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "radar", presenceActiveLow: true },
{ relay: 2, direction: "exit" },
],
});
const row = db.select().from(devices).get()!;
const inputs = inputsOf(row);
expect(inputs).toEqual([
{ input: 1, role: "button", relay: 1, cooldownSec: undefined },
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
]);
});
it("prefers an explicit inputs[] over the legacy fields", () => {
seed({
relays: [{ relay: 1, direction: "entry", button: 9 /* legacy ignored */ }],
inputs: [{ input: 1, role: "button", relay: 1 }],
});
const row = db.select().from(devices).get()!;
expect(inputsOf(row)).toEqual([{ input: 1, role: "button", relay: 1 }]);
});
});
describe("relayForButton / relayForPresence", () => {
it("resolves a button + presence from inputs[]", () => {
seed({
relays: [{ relay: 1, direction: "entry" }],
inputs: [
{ input: 1, role: "button", relay: 1 },
{ input: 2, role: "presence", relay: 1, kind: "radar" },
],
});
const byBtn = relayForButton(db, CTL, 1);
expect(byBtn).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2, presenceKind: "radar" });
const byPres = relayForPresence(db, CTL, 2);
expect(byPres).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2 });
});
it("resolves IDENTICALLY from the legacy shape (no inputs[])", () => {
seed({ relays: [{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "loop" }] });
expect(relayForButton(db, CTL, 1)).toMatchObject({ relay: 1, presenceInput: 2, presenceKind: "loop" });
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1, presenceInput: 2 });
});
it("resolves an EXIT presence row to the exit relay (the exit radar)", () => {
seed({
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
],
inputs: [
{ input: 2, role: "presence", relay: 1, kind: "radar" }, // entry radar
{ input: 5, role: "presence", relay: 2, kind: "radar" }, // exit radar
],
});
// NOTE: relayForPresence only gates entry/both relays (transient entry). The exit radar
// resolves to null HERE (the exit barrier has no entry gate) — but it's still a valid
// inputs[] row the lamp can trigger on. The entry radar resolves to relay 1.
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1 });
expect(relayForPresence(db, CTL, 5)).toBeNull(); // exit relay isn't a transient-entry gate
});
it("a button on an exit-only relay is not a transient-entry trigger", () => {
seed({
relays: [{ relay: 2, direction: "exit" }],
inputs: [{ input: 1, role: "button", relay: 2 }],
});
expect(relayForButton(db, CTL, 1)).toBeNull();
});
});