diff --git a/packages/devices/package.json b/packages/devices/package.json index 1e09792..750a9fd 100644 --- a/packages/devices/package.json +++ b/packages/devices/package.json @@ -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" } } diff --git a/packages/devices/src/drivers/printer-escpos.test.ts b/packages/devices/src/drivers/printer-escpos.test.ts new file mode 100644 index 0000000..351c7f3 --- /dev/null +++ b/packages/devices/src/drivers/printer-escpos.test.ts @@ -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[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("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"); + }); +}); diff --git a/packages/devices/src/printer-routing.test.ts b/packages/devices/src/printer-routing.test.ts new file mode 100644 index 0000000..89f2b0c --- /dev/null +++ b/packages/devices/src/printer-routing.test.ts @@ -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); + }); +}); diff --git a/packages/devices/tsconfig.json b/packages/devices/tsconfig.json index 4999a19..7be994f 100644 --- a/packages/devices/tsconfig.json +++ b/packages/devices/tsconfig.json @@ -7,5 +7,6 @@ "types": ["node"] }, "references": [{ "path": "../shared" }], - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] } diff --git a/packages/devices/vitest.config.ts b/packages/devices/vitest.config.ts new file mode 100644 index 0000000..3a19563 --- /dev/null +++ b/packages/devices/vitest.config.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"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 635801e..d76a4b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: