7366ad19cb
The ESC/POS printer drivers were TCP-only — every path went through sendRaw/probe to a raw socket on port 9100. Add a USB transport behind the existing render layer without touching a single render*() function. - printer-escpos.ts: sendRawUsb/probeUsb write the same ESC/POS bytes to a kernel usblp char device (/dev/usb/lp0) via a plain fs write — no libusb/CUPS/native dep (keeps MIT-only + minimal-deps appliance). A discriminated Transport + transportFromConfig/sendTo/probeTo dispatch the wire; anything not transport:"usb" is TCP, so existing host-only configs need no migration. Shared transportField/devicePathField config fields. - cashino + rongta resolve a Transport once; both are reachability-only over USB, and the Rongta's HTTP status page degrades to the open-the-node probe over USB (no guessed paper/cover — the standing honesty rule). host/port made not-required so a USB printer needs neither. - Tests: printer-escpos.test.ts (USB writes the exact rendered bytes; probe present/absent; transportFromConfig TCP back-compat) + printer-cashino.test.ts (USB-configured driver prints to the node, ready/offline). USB itself is unverified on hardware (the on-site printers are networked); the appliance-side usblp + udev provisioning is tracked as open-questions #14. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
186 lines
7.1 KiB
TypeScript
186 lines
7.1 KiB
TypeScript
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<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("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 '<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");
|
|
});
|
|
});
|