Files
florian.wilde fbbb634e56
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
2026-09-26 09:04:15 +00:00

146 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from "@playwright/test";
import "dotenv/config";
import { anmelden, SEED_TAG } from "./helfer";
/**
* 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.
*
* Gedruckt wird `SEED_TAG` und nicht irgendein Code aus der Datenbank: dass es
* ihn gibt, prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf. Ein
* `findFirst` haette gegen eine frische Installation „kein Geraet“ gemeldet und
* gegen eine benutzte einen beliebigen Aufkleber gedruckt (#018).
*/
const DPI = 300;
const PX_JE_MM = 96 / 25.4;
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);
await page.goto(`/drucken/etiketten?layout=etikett&codes=${SEED_TAG}`);
/*
* `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.");
/*
* 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=${SEED_TAG}`);
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/${SEED_TAG}`);
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<string[]> {
const { readFile } = await import("node:fs/promises");
const { BarcodeDetector, setZXingModuleOverrides } = await import("barcode-detector/ponyfill");
const global = globalThis as unknown as Record<string, unknown>;
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]),
};
}