146 lines
6.0 KiB
TypeScript
146 lines
6.0 KiB
TypeScript
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]),
|
||
};
|
||
}
|