test(devices): Phase 2 — ESC/POS byte stream + printer routing

Pins the device-layer bugs we kept hand-verifying, as pure byte-stream assertions
(no sockets, no hardware):

- printer-escpos.test.ts (12): CP852 codepage select; the ë→0x89 / Ë→0xD3 mapping
  and the em-dash/⚠ ASCII fallbacks (never a stray 0x3f "?"); and the Code128 MODULE
  WIDTH contract — a short ticket id at width 3, but the ~20-char out-of-window
  occurrence id at width 2 so it fits the 80mm head (width 3 overflows ~576 dots and
  the firmware silently aborts the barcode). Plus the QR-and-Code128 dual encoding and
  the Albanian stamp() format.
- printer-routing.test.ts (6): the failover order (booth printer is a fallback for
  entry tickets; a receipt never prints on the outside dispenser), rank-then-id
  tiebreak, and printWithFailover walking the order + NoPrinterAvailableError.

Wires Vitest into @parking/devices. devices 18/18 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-21 16:17:51 +02:00
parent 5e9be16f65
commit 352c643009
6 changed files with 212 additions and 3 deletions
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import {
orderForRole,
printWithFailover,
NoPrinterAvailableError,
type PrinterInstance,
} from "./printer-routing.js";
import type { PrinterDevice } from "./interfaces.js";
// Printer routing is pure selection over (config, health): which printer prints a job,
// best-first, with failover. The key business rules: the booth printer is a FALLBACK for
// entry tickets but a receipt NEVER prints on the outside dispenser; rank then id break
// ties deterministically; printWithFailover walks the order and surfaces all failures.
function inst(id: string, role: PrinterInstance["role"], failoverRank = 0, device?: PrinterDevice): PrinterInstance {
return { id, role, failoverRank, device: device ?? ({} as PrinterDevice) };
}
describe("orderForRole", () => {
it("entry-dispenser job: dispensers first, booth-receipt as fallback", () => {
const printers = [inst("booth", "booth-receipt"), inst("disp", "entry-dispenser")];
expect(orderForRole(printers, "entry-dispenser").map((p) => p.id)).toEqual(["disp", "booth"]);
});
it("booth-receipt job: NEVER falls back to the outside dispenser", () => {
const printers = [inst("disp", "entry-dispenser"), inst("booth", "booth-receipt")];
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
});
it("breaks ties by failoverRank (higher first), then id", () => {
const printers = [
inst("b", "entry-dispenser", 1),
inst("a", "entry-dispenser", 1),
inst("c", "entry-dispenser", 5),
];
expect(orderForRole(printers, "entry-dispenser").map((p) => p.id)).toEqual(["c", "a", "b"]);
});
it("excludes printers of no relevant role", () => {
const printers = [inst("booth", "booth-receipt")];
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
// For a receipt job, an entry dispenser is excluded entirely.
expect(orderForRole([inst("disp", "entry-dispenser")], "booth-receipt")).toEqual([]);
});
});
describe("printWithFailover", () => {
function device(behavior: "ok" | "fail"): PrinterDevice {
return {
printTicket: vi.fn(behavior === "ok" ? async () => {} : async () => { throw new Error("offline"); }),
} as unknown as PrinterDevice;
}
it("prints on the first healthy candidate and returns its id", async () => {
const printers = [inst("disp", "entry-dispenser", 0, device("ok")), inst("booth", "booth-receipt", 0, device("ok"))];
const job = vi.fn(async (d: PrinterDevice) => d.printTicket({} as never));
const used = await printWithFailover(printers, "entry-dispenser", job);
expect(used).toBe("disp");
expect(job).toHaveBeenCalledTimes(1);
});
it("fails over to the booth printer when the dispenser throws", async () => {
const printers = [inst("disp", "entry-dispenser", 0, device("fail")), inst("booth", "booth-receipt", 0, device("ok"))];
const used = await printWithFailover(printers, "entry-dispenser", (d) => d.printTicket({} as never));
expect(used).toBe("booth");
});
it("throws NoPrinterAvailableError listing every failed attempt", async () => {
const printers = [inst("disp", "entry-dispenser", 0, device("fail")), inst("booth", "booth-receipt", 0, device("fail"))];
await expect(printWithFailover(printers, "entry-dispenser", (d) => d.printTicket({} as never)))
.rejects.toBeInstanceOf(NoPrinterAvailableError);
});
it("throws when no printer is configured for the role", async () => {
await expect(printWithFailover([], "entry-dispenser", async () => {}))
.rejects.toBeInstanceOf(NoPrinterAvailableError);
});
});