Neun Kapitel unter docs/anleitung/ fuer Technik und Verwaltung, mit 35 Bildern aus `pnpm docs:screenshots` und einem Fehlerbehebungskapitel aus echten Meldungen. `pnpm docs:pdf` macht daraus 72 Seiten A4 — hell gedruckt, mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen, ohne neue Abhaengigkeit. Die Ablaeufe pruefen ihre Voraussetzungen, bevor der erste startet: antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der Server dieselbe Datenbank wie der Testprozess. Jeder Ablauf legt an, was er braucht, und raeumt es weg. Vier neue kommen dazu: Hauptablauf, Onboarding mit Vergleich der Zwischenablage, Rollen und 390 px auf achtzehn Seiten. Drei Fehler kamen dabei heraus. Das Projekt „handy" pruefte 412 px statt der verlangten 390. Ein angemeldeter Benutzer ohne Recht erfuhr nie, warum er abgewiesen wurde — die Meldung stand auf einer Seite, die ihn sofort weiterleitet; jetzt gibt es /kein-zugriff. Und der Verzeichnis-Ablauf schrieb die Attributzuordnung der ersten vorhandenen Quelle um, in einer echten Installation also der produktiven. 101 Ablaeufe in INTERN und 97 in DIENSTLEISTUNG, beide ohne Fehlschlag. Closes #18 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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]),
|
||
};
|
||
}
|