feat(tags): QR-Aufkleber, Geraeteseite, Etikettendruck und Scanner
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) 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 is contained in:
Florian Wilde
2026-09-25 21:30:12 +02:00
co-authored by Claude Opus 5
parent c78a6ac1af
commit 5f1b0d1435
58 changed files with 4828 additions and 54 deletions
+240
View File
@@ -0,0 +1,240 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { chromium } from "@playwright/test";
import { ETIKETT_QR_FLAECHE, ETIKETT_RAHMEN } from "@/components/tags/etikett";
import { QR_ERSATZ_HERKUNFT, QR_FEHLERKORREKTUR, QrFehler, qrSvg, tagAdresse } from "@/lib/qr";
/**
* QR-Codes und Etiketten (#014).
*
* Zwei Zusagen aus dem Auftrag stehen hier fest:
*
* 1. Im QR steht **genau** `{APP_URL}/t/{tagCode}` und nichts weiter — kein
* Hostname, kein Besitzer, keine Seriennummer.
* 2. Ein bei 300 dpi gerastertes 62 × 29-mm-Etikett wird von einem Decoder wieder gelesen.
* Das ist der Test, der zaehlt: ein QR, der auf dem Bildschirm huebsch
* aussieht und am Etikettendrucker nicht mehr lesbar ist, waere genau der
* Fehler, den niemand vor dem ersten Klebeband merkt.
*/
const APP_URL = "https://laufzettel.example.com";
const TAGCODE = "LZ-7K3F9Q";
let vorherigeAppUrl: string | undefined;
beforeEach(() => {
vorherigeAppUrl = process.env["APP_URL"];
process.env["APP_URL"] = APP_URL;
});
afterEach(() => {
if (vorherigeAppUrl === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = vorherigeAppUrl;
});
describe("Adresse im QR", () => {
it("ist genau {APP_URL}/t/{tagCode}", () => {
expect(tagAdresse(TAGCODE)).toBe(`${APP_URL}/t/${TAGCODE}`);
});
it("verschluckt den Unterpfad aus APP_URL nicht", () => {
/*
* Der Reviewbefund zu #014, und der teuerste von allen: steht die Anwendung
* unter `https://firma.de/laufzettel`, dann zeigte der gedruckte Aufkleber
* auf `https://firma.de/t/…` — und dort steht nichts. Ein Etikett laesst
* sich nicht nachtraeglich berichtigen; es klebt.
*/
process.env["APP_URL"] = "https://firma.de/laufzettel";
expect(tagAdresse(TAGCODE)).toBe(`https://firma.de/laufzettel/t/${TAGCODE}`);
// Mit Schrägstrich am Ende derselbe Weg, dasselbe Ergebnis.
process.env["APP_URL"] = "https://firma.de/laufzettel/";
expect(tagAdresse(TAGCODE)).toBe(`https://firma.de/laufzettel/t/${TAGCODE}`);
process.env["APP_URL"] = "https://intern.example.com/";
expect(tagAdresse(TAGCODE)).toBe(`https://intern.example.com/t/${TAGCODE}`);
});
it("fällt ohne APP_URL auf die mitgegebene Ersatzherkunft zurück", () => {
delete process.env["APP_URL"];
expect(tagAdresse(TAGCODE, "http://127.0.0.1:3300")).toBe(`http://127.0.0.1:3300/t/${TAGCODE}`);
expect(tagAdresse(TAGCODE)).toBe(`${QR_ERSATZ_HERKUNFT}/t/${TAGCODE}`);
});
it("weist alles ab, was nicht die Form eines Tag-Codes hat", () => {
// Der Code landet in der Adresse und damit im QR jedes gedruckten
// Aufklebers. Ein ungeprueftes Zeichen waere dort nicht mehr einzufangen.
expect(() => tagAdresse("LZ-0O1I")).toThrow(QrFehler);
expect(() => tagAdresse("../../etc/passwd")).toThrow(QrFehler);
expect(() => tagAdresse("lz-7k3f9q")).toThrow(QrFehler);
});
});
describe("QR-SVG", () => {
it("enthält nur Geometrie – keine Personendaten, keine Schrift, kein Skript", async () => {
const svg = await qrSvg(TAGCODE);
expect(svg).toMatch(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" /);
// Ein QR besteht aus zwei Pfaden: weisser Grund und schwarze Module.
expect(svg.match(/<path /g)).toHaveLength(2);
expect(svg).not.toContain("<text");
expect(svg).not.toContain("<script");
expect(svg).not.toContain("<image");
// Der Klartext steht nirgends im SVG – auch nicht der tagCode selbst.
expect(svg).not.toContain(TAGCODE);
expect(svg).not.toContain(APP_URL);
});
it("nutzt Fehlerkorrektur M", async () => {
/*
* Geprueft an der Kantenlaenge in Modulen: dieselbe Nutzlast braucht mit
* hoeherer Fehlerkorrektur eine groessere Version. 29 Module (Version 3) plus
* zweimal die Ruhezone von 4 ergeben 37 — mit "Q" oder "H" waeren es mehr,
* mit "L" weniger.
*
* Die Adresse ist hier 42 Zeichen lang und damit **genau** die Grenze von
* Version 3 bei M. Eine laengere APP_URL ist deshalb kein Fehler, macht den
* Code aber eine Version groesser und die Module kleiner — bei 300 dpi bleibt
* selbst Version 6 noch weit ueber der Lesbarkeitsgrenze (siehe unten).
*/
expect(QR_FEHLERKORREKTUR).toBe("M");
expect(`${APP_URL}/t/${TAGCODE}`).toHaveLength(42);
const svg = await qrSvg(TAGCODE);
expect(svg).toContain('viewBox="0 0 37 37"');
});
it("trägt eine feste Kantenlänge nur, wenn eine verlangt ist", async () => {
// Ohne Angabe fuellt das SVG seinen Platz – genau das braucht ein Etikett,
// das in Millimetern gesetzt ist.
expect(await qrSvg(TAGCODE)).not.toContain("width=");
expect(await qrSvg(TAGCODE, { breite: 256 })).toContain('width="256" height="256"');
});
});
/*
* Die Rasterung, ausgerechnet.
*
* CSS rechnet 1 mm = 96 / 25.4 px. Ein Etikett von 62 × 29 mm ist im Browser
* also 62 · 96 / 25.4 = 234,33 × 109,61 CSS-Pixel gross.
*
* 300 dpi bedeutet 300 / 96 = 3,125 Geraetepixel je CSS-Pixel. Mit diesem
* `deviceScaleFactor` kommt der Screenshot mit
* 62 / 25,4 · 300 = 732,28 × 342,52 Geraetepixeln heraus — also genau das
* Bild, das ein 300-dpi-Etikettendrucker aufs Papier bringt.
*
* Der QR selbst ist 25 mm breit, das sind 295,3 Geraetepixel fuer 37 Module,
* also knapp 8 Pixel je Modul. Kein Decoder hat damit Muehe — und genau
* deshalb steht die Zahl hier: faellt sie unter drei Pixel je Modul, wird es
* knapp, und dieser Test ist die Stelle, an der das auffaellt.
*/
const MM_JE_ZOLL = 25.4;
const CSS_PX_JE_ZOLL = 96;
const DPI = 300;
/** Liest `62` aus `w-[62mm]` – die Masse stehen nur in `etikett.tsx`. */
function mmAus(klassen: string, praefix: string): number {
const treffer = klassen.match(new RegExp(`(?:^| )${praefix}-\\[([0-9.]+)mm\\]`));
if (!treffer?.[1]) throw new Error(`In „${klassen}“ steht kein ${praefix}-[…mm].`);
return Number(treffer[1]);
}
describe("Gerastertes Etikett", () => {
it("wird bei 300 dpi im Format 62 × 29 mm von einem Decoder zurückgelesen", async () => {
const breiteMm = mmAus(ETIKETT_RAHMEN.etikett, "w");
const hoeheMm = mmAus(ETIKETT_RAHMEN.etikett, "h");
const randMm = mmAus(ETIKETT_RAHMEN.etikett, "p");
const qrMm = mmAus(ETIKETT_QR_FLAECHE.etikett, "size");
expect([breiteMm, hoeheMm]).toEqual([62, 29]);
const svg = await qrSvg(TAGCODE);
const skalierung = DPI / CSS_PX_JE_ZOLL;
const browser = await chromium.launch();
try {
const seite = await browser.newPage({
viewport: {
width: Math.ceil((breiteMm / MM_JE_ZOLL) * CSS_PX_JE_ZOLL),
height: Math.ceil((hoeheMm / MM_JE_ZOLL) * CSS_PX_JE_ZOLL),
},
deviceScaleFactor: skalierung,
});
/*
* Nachgebaut und nicht die Komponente gerendert: `Etikett` traegt seine
* Masse in Tailwind-Klassen, und ohne den Build gibt es kein Stylesheet
* dazu. Die Millimeter kommen deshalb aus denselben Klassen
* (`mmAus` oben) — schrumpft das Etikett dort, schrumpft es hier mit.
*/
await seite.setContent(`<!doctype html>
<html><body style="margin:0;background:#ffffff">
<div id="etikett" style="box-sizing:border-box;width:${breiteMm}mm;height:${hoeheMm}mm;
padding:${randMm}mm;background:#ffffff;display:flex;align-items:center;gap:2mm">
<div style="width:${qrMm}mm;height:${qrMm}mm;flex:none">${svg}</div>
<div style="font-family:monospace;font-size:3.4mm;font-weight:700">${TAGCODE}</div>
</div>
</body></html>`);
const etikett = seite.locator("#etikett");
const bild = await etikett.screenshot({ type: "png" });
// Die Rechnung von oben, gegen das tatsaechliche Bild geprueft.
const erwarteteBreite = Math.round((breiteMm / MM_JE_ZOLL) * DPI);
const masse = await seite.evaluate(
(datenAdresse) =>
new Promise<{ breite: number; hoehe: number }>((fertig, gescheitert) => {
const bildElement = new Image();
bildElement.onload = () =>
fertig({ breite: bildElement.naturalWidth, hoehe: bildElement.naturalHeight });
bildElement.onerror = () => gescheitert(new Error("Das Bild ließ sich nicht laden."));
bildElement.src = datenAdresse;
}),
`data:image/png;base64,${bild.toString("base64")}`,
);
// Ein Pixel Spiel: der Screenshot rundet auf ganze Geraetepixel.
expect(Math.abs(masse.breite - erwarteteBreite)).toBeLessThanOrEqual(2);
/*
* Zurueckgelesen mit `barcode-detector` im Seitenkontext. Das WASM kommt
* als `wasmBinary` aus `node_modules` mit — ohne diese Uebergabe holte
* es sich das Modul von einem CDN, und ein Test, der das Netz braucht,
* ist kein Test.
*/
const require = createRequire(import.meta.url);
const ponyfill = require.resolve("barcode-detector/ponyfill");
const wasm = readFileSync(
require.resolve("zxing-wasm/reader/zxing_reader.wasm", { paths: [ponyfill] }),
);
await seite.addScriptTag({
path: ponyfill.replace(/dist[/\\](es|cjs)[/\\]ponyfill\.js$/, "dist/iife/ponyfill.js"),
});
const gelesen = await seite.evaluate(
async ({ pngBase64, wasmBase64 }) => {
const bytes = (text: string): Uint8Array =>
Uint8Array.from(atob(text), (zeichen) => zeichen.charCodeAt(0));
const api = (globalThis as unknown as { BarcodeDetectionAPI: BarcodeApi }).BarcodeDetectionAPI;
api.prepareZXingModule({ overrides: { wasmBinary: bytes(wasmBase64) } });
const decoder = new api.BarcodeDetector({ formats: ["qr_code"] });
const treffer = await decoder.detect(
new Blob([bytes(pngBase64) as BlobPart], { type: "image/png" }),
);
return treffer.map((eintrag) => eintrag.rawValue);
},
{ pngBase64: bild.toString("base64"), wasmBase64: wasm.toString("base64") },
);
// Genau ein Code, und genau die Adresse – nichts sonst steht darin.
expect(gelesen).toEqual([`${APP_URL}/t/${TAGCODE}`]);
} finally {
await browser.close();
}
}, 120_000);
});
/** Nur das, was der Test vom Ponyfill im Seitenkontext braucht. */
type BarcodeApi = {
prepareZXingModule: (optionen: { overrides: { wasmBinary: Uint8Array } }) => void;
BarcodeDetector: new (optionen?: { formats?: readonly string[] }) => {
detect: (bild: Blob) => Promise<readonly { rawValue: string }[]>;
};
};