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:
@@ -15,13 +15,15 @@
|
||||
"build": "tsc -b",
|
||||
"dev": "tsc -b --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@parking/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "25.9.3",
|
||||
"typescript": "6.0.3"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -7,5 +7,6 @@
|
||||
"types": ["node"]
|
||||
},
|
||||
"references": [{ "path": "../shared" }],
|
||||
"include": ["src/**/*"]
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Device tests are pure byte-stream assertions over the ESC/POS renderers + the
|
||||
// printer-routing logic — no sockets, no hardware. Run from src (not dist).
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Generated
+3
@@ -186,6 +186,9 @@ importers:
|
||||
typescript:
|
||||
specifier: 6.0.3
|
||||
version: 6.0.3
|
||||
vitest:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))
|
||||
|
||||
packages/shared:
|
||||
devDependencies:
|
||||
|
||||
Reference in New Issue
Block a user