test(web): Phase 4 — booth formatters + focus-independent scanner hook
Closes the standing "no automated frontend tests" gap for the pure, testable logic: - format.test.ts (12): the booth display formatters — formatMoney (minor units → currency, malformed-code fallback), formatDuration (m / h+m / 0m / em-dash on negative-invalid), formatTime, and formatRelativeDateTime (today/yesterday words + catalog month names, no Intl dependence). - use-scanner.test.ts (5): the 2026-06-21 focus-independent hardware scan — a fast burst+Enter on <body> fires onScan; slow human typing (gap > 50ms) does not; paused (modal open) no-ops; keystrokes into an editable field are ignored; a lone Enter / too-short burst is ignored. Wires Vitest (jsdom + @testing-library/react) into @parking/web. web 17/17. Full workspace green: shared 87, devices 18, server 75, web 17 (= 197) + build/lint 14/14. (apps/vision still has its 2 pre-existing failures — next.) Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -8,7 +8,8 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
@@ -30,9 +31,12 @@
|
|||||||
"@tanstack/react-router-devtools": "^1.167.0",
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@types/react": "19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.16"
|
"vite": "8.0.16",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatMoney, formatDuration, formatTime, formatRelativeDateTime, type TFn } from "./format.js";
|
||||||
|
|
||||||
|
// The booth's display formatters. Money is integer MINOR units (never a float, matching
|
||||||
|
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
|
||||||
|
// log/history rows. These are the numbers an operator reads off the screen.
|
||||||
|
|
||||||
|
describe("formatMoney", () => {
|
||||||
|
it("renders minor units as a major-unit currency string", () => {
|
||||||
|
// 20000 minor = 200.00; the exact glyph/locale varies, but the number must show.
|
||||||
|
expect(formatMoney(20000, "ALL")).toContain("200");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to '<n> <code>' for a malformed currency code", () => {
|
||||||
|
// Intl requires a 3-letter ISO code; a malformed one throws RangeError → fallback.
|
||||||
|
// (Note: an unknown-but-well-formed code like "ZZZ" does NOT throw — Intl renders it.)
|
||||||
|
expect(formatMoney(12345, "X")).toBe("123.45 X");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDuration", () => {
|
||||||
|
const base = "2026-06-21T10:00:00.000Z";
|
||||||
|
it("shows minutes under an hour", () => {
|
||||||
|
expect(formatDuration(base, "2026-06-21T10:47:00.000Z")).toBe("47m");
|
||||||
|
});
|
||||||
|
it("shows hours and minutes past an hour", () => {
|
||||||
|
expect(formatDuration(base, "2026-06-21T12:14:00.000Z")).toBe("2h 14m");
|
||||||
|
});
|
||||||
|
it("renders 0m for a sub-minute span", () => {
|
||||||
|
expect(formatDuration(base, "2026-06-21T10:00:30.000Z")).toBe("0m");
|
||||||
|
});
|
||||||
|
it("returns an em dash for a negative or invalid span", () => {
|
||||||
|
expect(formatDuration("2026-06-21T10:00:00.000Z", "2026-06-21T09:00:00.000Z")).toBe("—");
|
||||||
|
expect(formatDuration("bad", "also-bad")).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTime", () => {
|
||||||
|
it("returns an em dash for null/invalid", () => {
|
||||||
|
expect(formatTime(null)).toBe("—");
|
||||||
|
expect(formatTime("not-a-date")).toBe("—");
|
||||||
|
});
|
||||||
|
it("renders HH:MM:SS local time", () => {
|
||||||
|
expect(formatTime("2026-06-21T10:48:25.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatRelativeDateTime", () => {
|
||||||
|
// A tiny fake t(): today/yesterday words + the month-name array.
|
||||||
|
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
|
||||||
|
const t = ((key: string, opts?: { returnObjects: true }) => {
|
||||||
|
if (key === "common.today") return "Sot";
|
||||||
|
if (key === "common.yesterday") return "Dje";
|
||||||
|
if (key === "common.months" && opts?.returnObjects) return months;
|
||||||
|
return key;
|
||||||
|
}) as TFn;
|
||||||
|
|
||||||
|
it("labels today with the localized word + HH:MM", () => {
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(10, 48, 0, 0);
|
||||||
|
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels yesterday with the localized word", () => {
|
||||||
|
const y = new Date();
|
||||||
|
y.setDate(y.getDate() - 1);
|
||||||
|
y.setHours(17, 33, 0, 0);
|
||||||
|
expect(formatRelativeDateTime(y.toISOString(), t)).toMatch(/^Dje \d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the catalog month name for an older date (no Intl dependence)", () => {
|
||||||
|
// A fixed older date in the same year as 'now' would risk year drift; use an
|
||||||
|
// explicit past date and just assert a catalog month name appears.
|
||||||
|
const out = formatRelativeDateTime("2020-03-05T08:15:00.000Z", t);
|
||||||
|
expect(out).toContain("Mars");
|
||||||
|
expect(out).toContain("2020"); // different year → year shown
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an em dash for null/invalid", () => {
|
||||||
|
expect(formatRelativeDateTime(null, t)).toBe("—");
|
||||||
|
expect(formatRelativeDateTime("nope", t)).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useScanner } from "./use-scanner.js";
|
||||||
|
|
||||||
|
// The global hardware-scanner hook: a fast keystroke burst ended by Enter fires onScan,
|
||||||
|
// regardless of focus, WITHOUT hijacking human typing or editable fields, and pauses
|
||||||
|
// while a modal is open. This pins the 2026-06-21 focus-independent scan behaviour
|
||||||
|
// (otherwise only verifiable in Playwright).
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
/** Dispatch a keydown on the document with a controllable timeStamp (the hook measures
|
||||||
|
* inter-key gaps off e.timeStamp). jsdom sets timeStamp to 0, so we override it. */
|
||||||
|
function key(char: string, timeStamp: number, target: EventTarget = document.body) {
|
||||||
|
const e = new KeyboardEvent("keydown", { key: char, bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(e, "timeStamp", { value: timeStamp });
|
||||||
|
Object.defineProperty(e, "target", { value: target });
|
||||||
|
document.dispatchEvent(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type a code as a fast burst (5ms apart) ending in Enter, from a start time. */
|
||||||
|
function scan(code: string, start = 1000, gap = 5) {
|
||||||
|
let t = start;
|
||||||
|
for (const ch of code) { key(ch, t); t += gap; }
|
||||||
|
key("Enter", t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useScanner", () => {
|
||||||
|
it("fires onScan with the code on a fast burst + Enter (focus on body)", () => {
|
||||||
|
const onScan = vi.fn();
|
||||||
|
renderHook(() => useScanner({ onScan }));
|
||||||
|
scan("12345678901");
|
||||||
|
expect(onScan).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onScan).toHaveBeenCalledWith("12345678901");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores slow, human-paced typing (gap > 50ms resets the buffer)", () => {
|
||||||
|
const onScan = vi.fn();
|
||||||
|
renderHook(() => useScanner({ onScan }));
|
||||||
|
// 120ms between keys — a person, not a scanner. Each gap resets the buffer, so by
|
||||||
|
// Enter only the last char remains (< MIN_LENGTH) → no scan.
|
||||||
|
scan("123", 1000, 120);
|
||||||
|
expect(onScan).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire while paused (a modal is open)", () => {
|
||||||
|
const onScan = vi.fn();
|
||||||
|
renderHook(() => useScanner({ onScan, paused: true }));
|
||||||
|
scan("12345678901");
|
||||||
|
expect(onScan).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores keystrokes into an editable field (manual typing unaffected)", () => {
|
||||||
|
const onScan = vi.fn();
|
||||||
|
renderHook(() => useScanner({ onScan }));
|
||||||
|
const input = document.createElement("input");
|
||||||
|
document.body.appendChild(input);
|
||||||
|
scanInto("12345678901", input);
|
||||||
|
expect(onScan).not.toHaveBeenCalled();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a lone Enter / too-short burst", () => {
|
||||||
|
const onScan = vi.fn();
|
||||||
|
renderHook(() => useScanner({ onScan }));
|
||||||
|
key("Enter", 1000);
|
||||||
|
expect(onScan).not.toHaveBeenCalled();
|
||||||
|
scan("ab"); // length 2 < MIN_LENGTH 3
|
||||||
|
expect(onScan).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Burst with the event target set to an editable element. */
|
||||||
|
function scanInto(code: string, target: EventTarget, start = 1000, gap = 5) {
|
||||||
|
let t = start;
|
||||||
|
for (const ch of code) { key(ch, t, target); t += gap; }
|
||||||
|
key("Enter", t, target);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// Web unit tests: pure formatters (no DOM) + the global hardware-scanner hook (needs a
|
||||||
|
// document, so jsdom). Kept minimal — the booth/live-feed/modal flows are still verified
|
||||||
|
// manually (Playwright); this pins the testable pure logic + the focus-independent scan.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+601
-4
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user