import { expect, test, type Page } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; /** * Der Etikettendruck am gebauten Stand (#014). * * Diese Pruefungen gibt es nur hier, weil sie ohne Browser nicht ehrlich zu * fuehren sind: ob Tailwind die Millimeterklassen wirklich erzeugt, ob `@page` * das Blatt auf 62 × 29 mm stellt und ob der gedruckte QR **zurueckgelesen** * werden kann, entscheidet sich erst im gerenderten Dokument. `tests/qr.test.ts` * prueft die Geometrie rechnerisch; hier steht das Ergebnis. * * Die Aufloesung: 1 mm sind 96/25,4 px, ein `deviceScaleFactor` von 300/96 = * 3,125 macht daraus 300 dpi. 62 mm werden so zu 62 · 96/25,4 · 3,125 ≈ 732 * Geraetepixeln — genau die Aufloesung, mit der ein Etikettendrucker arbeitet. */ const DPI = 300; const PX_JE_MM = 96 / 25.4; async function anmelden(seite: Page) { const email = process.env["SEED_ADMIN_EMAIL"]; const passwort = process.env["SEED_ADMIN_PASSWORD"]; if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); await seite.goto("/anmelden"); await seite.fill('input[name="email"]', email); await seite.fill('input[name="passwort"]', passwort); await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); } /** Irgendein Tag-Code, den es in dieser Datenbank wirklich gibt. */ async function einTagCode(): Promise { const geraet = await prisma.geraet.findFirst({ select: { tagCode: true } }); if (!geraet) throw new Error("Kein Gerät in der Datenbank — erst pnpm db:seed."); return geraet.tagCode; } test.describe("Etikettendruck", () => { test("druckt ein Einzeletikett auf 62 × 29 mm", async ({ page }) => { test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät."); await anmelden(page); const code = await einTagCode(); await page.goto(`/drucken/etiketten?layout=etikett&codes=${code}`); /* * `page.pdf()` gilt in Chromium und beachtet `@page`. Gemessen wird die * Seitengroesse in Punkten (1 pt = 1/72 Zoll): 62 mm sind 175,7 pt, 29 mm * sind 82,2 pt. Ein Etikettendrucker schneidet, was darueber hinausgeht — * eine falsche Seitengroesse faellt deshalb erst auf der Rolle auf. */ const pdf = await page.pdf({ preferCSSPageSize: true }); const masse = seitenmasseAusPdf(pdf); expect(masse.breite).toBeCloseTo((62 / 25.4) * 72, 0); expect(masse.hoehe).toBeCloseTo((29 / 25.4) * 72, 0); }); test("druckt den A4-Bogen auf A4", async ({ page }) => { test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät."); await anmelden(page); await page.goto("/drucken/etiketten?layout=bogen"); const pdf = await page.pdf({ preferCSSPageSize: true }); const masse = seitenmasseAusPdf(pdf); // A4 sind 210 × 297 mm, in Punkten 595 × 842. expect(masse.breite).toBeCloseTo(595, 0); expect(masse.hoehe).toBeCloseTo(842, 0); }); test("ein bei 300 dpi gerastertes Etikett lässt sich zurücklesen", async ({ browser }) => { test.skip(test.info().project.name !== "desktop", "Der Decoder hängt nicht am Gerät."); const code = await einTagCode(); /* * Eigener Kontext: der `deviceScaleFactor` ist die Aufloesung, und die * gehoert zu diesem einen Test. Die Breite entspricht dem Etikett, damit * gerastert wird, was auch gedruckt wird. */ const kontext = await browser.newContext({ baseURL: process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000", viewport: { width: Math.round(62 * PX_JE_MM), height: Math.round(29 * PX_JE_MM) }, deviceScaleFactor: DPI / 96, }); try { const tab = await kontext.newPage(); await anmelden(tab); await tab.goto(`/drucken/etiketten?layout=etikett&codes=${code}`); const etikett = tab.locator("[data-etikett]").first(); await expect(etikett).toBeVisible(); const bild = await etikett.screenshot(); const gelesen = await zurueckgelesen(bild); expect(gelesen).toHaveLength(1); /* * Der ganze Inhalt, nicht nur das Ende: „enthaelt keinen Hostnamen“ waere * keine Pruefung — dieser Aufkleber koennte zu einem Geraet ohne Hostname * gehoeren, und der Test waere gruen, ohne etwas zu wissen. Ein Vergleich * auf Gleichheit schliesst alles andere aus, was jemand spaeter * dazulegen koennte. */ const adresse = new URL(gelesen[0] ?? ""); expect(adresse.pathname).toBe(`/t/${code}`); expect(adresse.search).toBe(""); expect(adresse.hash).toBe(""); } finally { await kontext.close(); } }); }); /** * Liest die QR-Codes aus einem PNG zurueck. * * Mit **demselben** Decoder, den der Scanner im Browser benutzt * (`barcode-detector` ueber `zxing-wasm`): ein anderer Leser bewiese etwas * anderes als „unsere Kamera erkennt das“. Das WebAssembly kommt aus `public/`, * wohin `scripts/wasm-bereitstellen.mjs` es legt — nicht aus dem Netz. * * Der kleine Behelf ist noetig, weil die Ponyfill ihre Treffer in DOM-Typen * verpackt, die es in Node nicht gibt. Gelesen wird trotzdem im WebAssembly, * also genau wie im Browser; ersetzt wird nur die Huelle um das Ergebnis. */ async function zurueckgelesen(png: Buffer): Promise { const { readFile } = await import("node:fs/promises"); const { BarcodeDetector, setZXingModuleOverrides } = await import("barcode-detector/ponyfill"); const global = globalThis as unknown as Record; global["DOMRectReadOnly"] ??= class { constructor( readonly x = 0, readonly y = 0, readonly width = 0, readonly height = 0, ) {} }; setZXingModuleOverrides({ wasmBinary: await readFile("public/zxing/zxing_reader.wasm") }); const funde = await new BarcodeDetector({ formats: ["qr_code"] }).detect( new Blob([new Uint8Array(png)], { type: "image/png" }), ); return funde.map((fund) => fund.rawValue); } /** * Breite und Hoehe der ersten Seite eines PDF, in Punkten. * * Reicht fuer diesen Zweck und spart eine Abhaengigkeit: das `/MediaBox`-Feld * steht als Klartext im Dokument, auch wenn die Inhalte komprimiert sind. */ function seitenmasseAusPdf(pdf: Buffer): { breite: number; hoehe: number } { const text = pdf.toString("latin1"); const treffer = /\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/u.exec(text); if (!treffer) throw new Error("Im PDF steht keine MediaBox — das Dokument ist unbrauchbar."); return { breite: Number(treffer[3]) - Number(treffer[1]), hoehe: Number(treffer[4]) - Number(treffer[2]), }; } test.afterAll(async () => { await prisma.$disconnect(); });