import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { renderTicket, renderReceipt, renderWindowChargeNotice, renderSubscriptionCard, probeUsb, sendRawUsb, transportFromConfig, 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[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[0]); // The em dash is replaced by '-' (0x2d); there must be no '?' fallback byte. expect(out.includes(0x3f)).toBe(false); }); }); describe("USB transport (sendRawUsb / probeUsb / transportFromConfig)", () => { // A regular file stands in for the usblp character device: open(O_WRONLY) + write // is the same syscall path. This proves the transport is byte-blind — the EXACT // ESC/POS stream renderTicket produces lands at the device path, with no transport // touching a rendered byte (the whole point of the seam). let dir: string; let devicePath: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "escpos-usb-")); devicePath = join(dir, "lp0"); // A real usblp node already EXISTS (created by the kernel on enumeration); we open // it O_WRONLY without O_CREAT, never create it. Pre-create the stand-in file so the // test mirrors that — opening an ABSENT path means "printer not present" (offline). writeFileSync(devicePath, ""); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); it("writes the exact rendered ESC/POS bytes to the device path", async () => { const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" }); await sendRawUsb(devicePath, payload, 1000); const written = readFileSync(devicePath); expect(written.equals(payload)).toBe(true); }); it("rejects when the device path can't be opened (printer not present)", async () => { await expect( sendRawUsb(join(dir, "absent-lp0"), Buffer.from([0x1b, 0x40]), 1000), ).rejects.toThrow(); }); it("probeUsb resolves for an existing node, rejects for a missing one", async () => { await expect(probeUsb(devicePath, 1000)).resolves.toBeUndefined(); await expect(probeUsb(join(dir, "nope"), 1000)).rejects.toThrow(); }); it("transportFromConfig: transport=usb selects the char device (default /dev/usb/lp0)", () => { expect(transportFromConfig({ transport: "usb", devicePath: "/dev/usb/lp1" })).toEqual({ kind: "usb", devicePath: "/dev/usb/lp1", }); expect(transportFromConfig({ transport: "usb" })).toEqual({ kind: "usb", devicePath: "/dev/usb/lp0", }); }); it("transportFromConfig: anything else is TCP (back-compat with host-only configs)", () => { expect(transportFromConfig({ host: "10.0.0.9" })).toEqual({ kind: "tcp", host: "10.0.0.9", port: 9100, }); expect(transportFromConfig({ host: "10.0.0.9", port: 9101 })).toEqual({ kind: "tcp", host: "10.0.0.9", port: 9101, }); }); }); describe("stamp (Albanian date format)", () => { it("formats an ISO time as ' 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"); }); });