feat(tags): QR-Aufkleber, Geraeteseite, Etikettendruck und Scanner
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Der Einstieg, fuer den die Anwendung gebaut ist: scannen, sehen was zu tun ist,
loslegen. `/t/<tagCode>` kennt drei Faelle — registriertes Geraet, vorgedruckter
Tag, unbekannter Code —, die Geraeteseite zeigt Zuweisungen, Schnellstart und
Historie, und beides ist am Handy bedienbar.

Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`: kein Name, keine
Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang sichtbar am Geraet
und wird von jedem Telefon gelesen; was darauf steht, ist veroeffentlicht. Die
Masse sind gemessen, nicht behauptet: ein echtes PDF liefert 62 x 29 mm und A4,
und ein bei 300 dpi gerastertes Etikett wird mit demselben Decoder
zurueckgelesen, den auch der Scanner benutzt.

Drei Defekte zeigte erst der gebaute Stand, alle an den Naehten zwischen den
parallel gebauten Teilen: „Starten“ setzte IN_ARBEIT und sprang nicht, „Vorlage
zuweisen“ bot Vorlagen an, die der Dienst danach ablehnte, und die Geraeteliste
verlinkte nichts. Der unabhaengige Review fand drei Blocker — darunter, dass der
Scanner nie die native BarcodeDetector-API benutzte und dass ein Unterpfad in
APP_URL aus dem gedruckten QR herausfiel.

Closes #14

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #28.
This commit is contained in:
2026-09-25 19:30:50 +00:00
co-authored by Claude Opus 5
parent c78a6ac1af
commit e7a2e2d5c7
58 changed files with 4828 additions and 54 deletions
+163
View File
@@ -0,0 +1,163 @@
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<string> {
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<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]),
};
}
test.afterAll(async () => {
await prisma.$disconnect();
});