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,116 @@
import { describe, expect, it } from "vitest";
import {
renderTicket,
renderReceipt,
renderWindowChargeNotice,
renderSubscriptionCard,
stamp,
} from "./printer-escpos.js";
// The ESC/POS renderers are pure (data → Buffer). These tests pin the byte-level
// invariants that caused real misprints: the CP852 codepage select, the Albanian/
// punctuation character mapping (no stray "?"), and the Code128 module width — a
// ~20-char id at width 3 overflows the 80mm head and the firmware silently aborts the
// barcode, so the out-of-window slip MUST use width 2.
// Command-byte markers (see printer-escpos.ts).
const SELECT_CP852 = Buffer.from([0x1b, 0x74, 0x12]); // ESC t 18
const CODE128_PREFIX = [0x1d, 0x6b, 0x49]; // GS k 73 (function B, Code128)
const GS_W = (w: number) => [0x1d, 0x77, w]; // GS w n — module width
const QR_PRINT = [0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]; // fn 181
function indexOfSeq(buf: Buffer, seq: number[]): number {
return buf.indexOf(Buffer.from(seq));
}
function hasSeq(buf: Buffer, seq: number[]): boolean {
return indexOfSeq(buf, seq) >= 0;
}
describe("renderTicket", () => {
const out = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
it("selects the CP852 codepage in the preamble", () => {
expect(out.includes(SELECT_CP852)).toBe(true);
});
it("emits a Code128 barcode of the ticket id", () => {
expect(hasSeq(out, CODE128_PREFIX)).toBe(true);
// The id appears as both barcode payload (prefixed {B) and large text.
expect(out.includes(Buffer.from("12345678901", "ascii"))).toBe(true);
});
it("uses module width 3 for a short (11-char) ticket id", () => {
expect(hasSeq(out, GS_W(3))).toBe(true);
});
});
describe("renderWindowChargeNotice — the scannable out-of-window slip", () => {
const out = renderWindowChargeNotice({
occurrenceId: "SUBSESS-abcdef0123456789",
holderName: "Taras Bulba",
at: "2026-06-21T13:21:00.000Z",
edge: "entry",
windowOpensMin: 20 * 60, // 20:00
});
it("uses module width 2 so the ~20-char occurrence id fits the 80mm head", () => {
// This is the fix for the silent no-print: width 3 would overflow ~576 dots.
expect(hasSeq(out, GS_W(2))).toBe(true);
expect(hasSeq(out, GS_W(3))).toBe(false);
});
it("emits BOTH a Code128 and a QR of the occurrence id (scan two ways)", () => {
expect(hasSeq(out, CODE128_PREFIX)).toBe(true);
expect(hasSeq(out, QR_PRINT)).toBe(true);
expect(out.includes(Buffer.from("SUBSESS-abcdef0123456789", "ascii"))).toBe(true);
});
it("does not emit a literal '?' for the warning sign or em dash (CP852 fallback)", () => {
// The title is "PARKIM - JASHTË ORARIT" (ASCII dash) and the pending notice uses
// "!" not ⚠. The Ë must map to its CP852 byte 0xD3, never 0x3f.
expect(out.includes(0xd3)).toBe(true); // Ë → CP852 0xD3
});
});
describe("CP852 character mapping (the misprint fixes)", () => {
it("maps ë to its CP852 byte, not '?'", () => {
// A receipt's "Kohëzgjatja" / "Mënyra" lines carry ë.
const out = renderReceipt({
ticketId: "12345678901",
header: { parkName: "Parking Ë" },
enteredAt: "2026-06-21T08:00:00.000Z",
paidAt: "2026-06-21T10:00:00.000Z",
amountMinor: 20000,
currency: "ALL",
tender: "cash",
voucher: false,
} as Parameters<typeof renderReceipt>[0]);
expect(out.includes(0x89)).toBe(true); // ë → CP852 0x89
});
it("transliterates an em dash to ASCII '-' (no '?') in the validity line", () => {
// No validFrom/validTo → the card uses an em dash placeholder "—" which must
// degrade to '-'. Count of '?' (0x3f) stays 0 across the buffer.
const out = renderSubscriptionCard({
code: "SUB-1",
header: { parkName: "P" },
holderName: "Test",
validFrom: null,
validTo: null,
} as Parameters<typeof renderSubscriptionCard>[0]);
// The em dash is replaced by '-' (0x2d); there must be no '?' fallback byte.
expect(out.includes(0x3f)).toBe(false);
});
});
describe("stamp (Albanian date format)", () => {
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
// Local-time dependent, so assert the structure + the Albanian month name.
const s = stamp("2026-06-21T10:48:25.000Z");
expect(s).toMatch(/Qershor 2026 \d{2}:\d{2}:\d{2}$/);
});
it("passes through an invalid date unchanged", () => {
expect(stamp("not-a-date")).toBe("not-a-date");
});
});