import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { chromium } from "@playwright/test"; import { ETIKETT_QR_FLAECHE, ETIKETT_RAHMEN } from "@/components/tags/etikett"; import { QR_ERSATZ_HERKUNFT, QR_FEHLERKORREKTUR, QrFehler, qrSvg, tagAdresse } from "@/lib/qr"; /** * QR-Codes und Etiketten (#014). * * Zwei Zusagen aus dem Auftrag stehen hier fest: * * 1. Im QR steht **genau** `{APP_URL}/t/{tagCode}` und nichts weiter — kein * Hostname, kein Besitzer, keine Seriennummer. * 2. Ein bei 300 dpi gerastertes 62 × 29-mm-Etikett wird von einem Decoder wieder gelesen. * Das ist der Test, der zaehlt: ein QR, der auf dem Bildschirm huebsch * aussieht und am Etikettendrucker nicht mehr lesbar ist, waere genau der * Fehler, den niemand vor dem ersten Klebeband merkt. */ const APP_URL = "https://laufzettel.example.com"; const TAGCODE = "LZ-7K3F9Q"; let vorherigeAppUrl: string | undefined; beforeEach(() => { vorherigeAppUrl = process.env["APP_URL"]; process.env["APP_URL"] = APP_URL; }); afterEach(() => { if (vorherigeAppUrl === undefined) delete process.env["APP_URL"]; else process.env["APP_URL"] = vorherigeAppUrl; }); describe("Adresse im QR", () => { it("ist genau {APP_URL}/t/{tagCode}", () => { expect(tagAdresse(TAGCODE)).toBe(`${APP_URL}/t/${TAGCODE}`); }); it("verschluckt den Unterpfad aus APP_URL nicht", () => { /* * Der Reviewbefund zu #014, und der teuerste von allen: steht die Anwendung * unter `https://firma.de/laufzettel`, dann zeigte der gedruckte Aufkleber * auf `https://firma.de/t/…` — und dort steht nichts. Ein Etikett laesst * sich nicht nachtraeglich berichtigen; es klebt. */ process.env["APP_URL"] = "https://firma.de/laufzettel"; expect(tagAdresse(TAGCODE)).toBe(`https://firma.de/laufzettel/t/${TAGCODE}`); // Mit Schrägstrich am Ende derselbe Weg, dasselbe Ergebnis. process.env["APP_URL"] = "https://firma.de/laufzettel/"; expect(tagAdresse(TAGCODE)).toBe(`https://firma.de/laufzettel/t/${TAGCODE}`); process.env["APP_URL"] = "https://intern.example.com/"; expect(tagAdresse(TAGCODE)).toBe(`https://intern.example.com/t/${TAGCODE}`); }); it("fällt ohne APP_URL auf die mitgegebene Ersatzherkunft zurück", () => { delete process.env["APP_URL"]; expect(tagAdresse(TAGCODE, "http://127.0.0.1:3300")).toBe(`http://127.0.0.1:3300/t/${TAGCODE}`); expect(tagAdresse(TAGCODE)).toBe(`${QR_ERSATZ_HERKUNFT}/t/${TAGCODE}`); }); it("weist alles ab, was nicht die Form eines Tag-Codes hat", () => { // Der Code landet in der Adresse und damit im QR jedes gedruckten // Aufklebers. Ein ungeprueftes Zeichen waere dort nicht mehr einzufangen. expect(() => tagAdresse("LZ-0O1I")).toThrow(QrFehler); expect(() => tagAdresse("../../etc/passwd")).toThrow(QrFehler); expect(() => tagAdresse("lz-7k3f9q")).toThrow(QrFehler); }); }); describe("QR-SVG", () => { it("enthält nur Geometrie – keine Personendaten, keine Schrift, kein Skript", async () => { const svg = await qrSvg(TAGCODE); expect(svg).toMatch(/^ { /* * Geprueft an der Kantenlaenge in Modulen: dieselbe Nutzlast braucht mit * hoeherer Fehlerkorrektur eine groessere Version. 29 Module (Version 3) plus * zweimal die Ruhezone von 4 ergeben 37 — mit "Q" oder "H" waeren es mehr, * mit "L" weniger. * * Die Adresse ist hier 42 Zeichen lang und damit **genau** die Grenze von * Version 3 bei M. Eine laengere APP_URL ist deshalb kein Fehler, macht den * Code aber eine Version groesser und die Module kleiner — bei 300 dpi bleibt * selbst Version 6 noch weit ueber der Lesbarkeitsgrenze (siehe unten). */ expect(QR_FEHLERKORREKTUR).toBe("M"); expect(`${APP_URL}/t/${TAGCODE}`).toHaveLength(42); const svg = await qrSvg(TAGCODE); expect(svg).toContain('viewBox="0 0 37 37"'); }); it("trägt eine feste Kantenlänge nur, wenn eine verlangt ist", async () => { // Ohne Angabe fuellt das SVG seinen Platz – genau das braucht ein Etikett, // das in Millimetern gesetzt ist. expect(await qrSvg(TAGCODE)).not.toContain("width="); expect(await qrSvg(TAGCODE, { breite: 256 })).toContain('width="256" height="256"'); }); }); /* * Die Rasterung, ausgerechnet. * * CSS rechnet 1 mm = 96 / 25.4 px. Ein Etikett von 62 × 29 mm ist im Browser * also 62 · 96 / 25.4 = 234,33 × 109,61 CSS-Pixel gross. * * 300 dpi bedeutet 300 / 96 = 3,125 Geraetepixel je CSS-Pixel. Mit diesem * `deviceScaleFactor` kommt der Screenshot mit * 62 / 25,4 · 300 = 732,28 × 342,52 Geraetepixeln heraus — also genau das * Bild, das ein 300-dpi-Etikettendrucker aufs Papier bringt. * * Der QR selbst ist 25 mm breit, das sind 295,3 Geraetepixel fuer 37 Module, * also knapp 8 Pixel je Modul. Kein Decoder hat damit Muehe — und genau * deshalb steht die Zahl hier: faellt sie unter drei Pixel je Modul, wird es * knapp, und dieser Test ist die Stelle, an der das auffaellt. */ const MM_JE_ZOLL = 25.4; const CSS_PX_JE_ZOLL = 96; const DPI = 300; /** Liest `62` aus `w-[62mm]` – die Masse stehen nur in `etikett.tsx`. */ function mmAus(klassen: string, praefix: string): number { const treffer = klassen.match(new RegExp(`(?:^| )${praefix}-\\[([0-9.]+)mm\\]`)); if (!treffer?.[1]) throw new Error(`In „${klassen}“ steht kein ${praefix}-[…mm].`); return Number(treffer[1]); } describe("Gerastertes Etikett", () => { it("wird bei 300 dpi im Format 62 × 29 mm von einem Decoder zurückgelesen", async () => { const breiteMm = mmAus(ETIKETT_RAHMEN.etikett, "w"); const hoeheMm = mmAus(ETIKETT_RAHMEN.etikett, "h"); const randMm = mmAus(ETIKETT_RAHMEN.etikett, "p"); const qrMm = mmAus(ETIKETT_QR_FLAECHE.etikett, "size"); expect([breiteMm, hoeheMm]).toEqual([62, 29]); const svg = await qrSvg(TAGCODE); const skalierung = DPI / CSS_PX_JE_ZOLL; const browser = await chromium.launch(); try { const seite = await browser.newPage({ viewport: { width: Math.ceil((breiteMm / MM_JE_ZOLL) * CSS_PX_JE_ZOLL), height: Math.ceil((hoeheMm / MM_JE_ZOLL) * CSS_PX_JE_ZOLL), }, deviceScaleFactor: skalierung, }); /* * Nachgebaut und nicht die Komponente gerendert: `Etikett` traegt seine * Masse in Tailwind-Klassen, und ohne den Build gibt es kein Stylesheet * dazu. Die Millimeter kommen deshalb aus denselben Klassen * (`mmAus` oben) — schrumpft das Etikett dort, schrumpft es hier mit. */ await seite.setContent(`
${svg}
${TAGCODE}
`); const etikett = seite.locator("#etikett"); const bild = await etikett.screenshot({ type: "png" }); // Die Rechnung von oben, gegen das tatsaechliche Bild geprueft. const erwarteteBreite = Math.round((breiteMm / MM_JE_ZOLL) * DPI); const masse = await seite.evaluate( (datenAdresse) => new Promise<{ breite: number; hoehe: number }>((fertig, gescheitert) => { const bildElement = new Image(); bildElement.onload = () => fertig({ breite: bildElement.naturalWidth, hoehe: bildElement.naturalHeight }); bildElement.onerror = () => gescheitert(new Error("Das Bild ließ sich nicht laden.")); bildElement.src = datenAdresse; }), `data:image/png;base64,${bild.toString("base64")}`, ); // Ein Pixel Spiel: der Screenshot rundet auf ganze Geraetepixel. expect(Math.abs(masse.breite - erwarteteBreite)).toBeLessThanOrEqual(2); /* * Zurueckgelesen mit `barcode-detector` im Seitenkontext. Das WASM kommt * als `wasmBinary` aus `node_modules` mit — ohne diese Uebergabe holte * es sich das Modul von einem CDN, und ein Test, der das Netz braucht, * ist kein Test. */ const require = createRequire(import.meta.url); const ponyfill = require.resolve("barcode-detector/ponyfill"); const wasm = readFileSync( require.resolve("zxing-wasm/reader/zxing_reader.wasm", { paths: [ponyfill] }), ); await seite.addScriptTag({ path: ponyfill.replace(/dist[/\\](es|cjs)[/\\]ponyfill\.js$/, "dist/iife/ponyfill.js"), }); const gelesen = await seite.evaluate( async ({ pngBase64, wasmBase64 }) => { const bytes = (text: string): Uint8Array => Uint8Array.from(atob(text), (zeichen) => zeichen.charCodeAt(0)); const api = (globalThis as unknown as { BarcodeDetectionAPI: BarcodeApi }).BarcodeDetectionAPI; api.prepareZXingModule({ overrides: { wasmBinary: bytes(wasmBase64) } }); const decoder = new api.BarcodeDetector({ formats: ["qr_code"] }); const treffer = await decoder.detect( new Blob([bytes(pngBase64) as BlobPart], { type: "image/png" }), ); return treffer.map((eintrag) => eintrag.rawValue); }, { pngBase64: bild.toString("base64"), wasmBase64: wasm.toString("base64") }, ); // Genau ein Code, und genau die Adresse – nichts sonst steht darin. expect(gelesen).toEqual([`${APP_URL}/t/${TAGCODE}`]); } finally { await browser.close(); } }, 120_000); }); /** Nur das, was der Test vom Ponyfill im Seitenkontext braucht. */ type BarcodeApi = { prepareZXingModule: (optionen: { overrides: { wasmBinary: Uint8Array } }) => void; BarcodeDetector: new (optionen?: { formats?: readonly string[] }) => { detect: (bild: Blob) => Promise; }; };