feat(tags): QR-Aufkleber, Geraeteseite, Etikettendruck und Scanner
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:
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Der Weg vom Aufkleber zur Arbeit (#014).
|
||||
*
|
||||
* Das ist der Ablauf, fuer den die Anwendung gebaut ist: scannen, sehen was zu
|
||||
* tun ist, loslegen. Geprueft wird er am **gebauten** Stand, weil genau hier die
|
||||
* Teile zusammenkommen, die einzeln schon funktionierten — die Weiche unter
|
||||
* `/t/<code>`, das Registrieren, die Geraeteseite und der Lauf.
|
||||
*/
|
||||
|
||||
const STEMPEL = Date.now();
|
||||
|
||||
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"]')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine schlichte Vorlage **ohne** Pflichtangaben.
|
||||
*
|
||||
* Die Beispielvorlage verlangt Betriebssystem und Einbindung; „Vorlage zuweisen“
|
||||
* fragt beides nicht ab und fuehrt dafuer in den Start-Dialog. Dieser Test will
|
||||
* aber den kurzen Weg pruefen, also bringt er seine eigene Vorlage mit.
|
||||
*/
|
||||
async function vorlageAnlegen(): Promise<string> {
|
||||
const name = `ZZ E2E Geraetelauf ${STEMPEL}`;
|
||||
const vorlage = await prisma.vorlage.create({ data: { name, kategorie: "ZZ Test" } });
|
||||
const version = await prisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
|
||||
});
|
||||
const abschnitt = await prisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Prüfen" },
|
||||
});
|
||||
await prisma.vorlagenSchritt.create({
|
||||
data: { abschnittId: abschnitt.id, titel: "Gerät angesehen", pflicht: true },
|
||||
});
|
||||
await prisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Die Tags, die dieser Lauf angelegt hat – nur die raeumt er hinterher weg. */
|
||||
const eigeneTags: string[] = [];
|
||||
|
||||
/** Legt einen vorgedruckten Tag an – ueber den Dienst, nicht ueber die Oberflaeche. */
|
||||
async function tagAnlegen(): Promise<string> {
|
||||
const { tagsErzeugen } = await import("@/server/services/tags");
|
||||
const admin = await prisma.benutzer.findFirstOrThrow({
|
||||
where: { rolle: "ADMIN" },
|
||||
select: { id: true, rolle: true },
|
||||
});
|
||||
const stapel = await tagsErzeugen(1, null, { id: admin.id, rolle: admin.rolle });
|
||||
const code = stapel.tagCodes[0];
|
||||
if (!code) throw new Error("Der Stapel kam leer zurück.");
|
||||
eigeneTags.push(code);
|
||||
return code;
|
||||
}
|
||||
|
||||
test.describe("Vom Aufkleber zur Arbeit", () => {
|
||||
test("führt einen unbekannten Code nicht ins Leere", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/t/LZ-ZZZZZZ");
|
||||
await expect(page.getByRole("heading", { name: "Kein Laufzettel-Tag" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "Gerät suchen" }).click();
|
||||
await page.waitForURL("**/geraete");
|
||||
});
|
||||
|
||||
test("schickt einen nicht angemeldeten Scan über die Anmeldung ans Ziel", async ({ page }) => {
|
||||
/*
|
||||
* Das erste Akzeptanzkriterium des Auftrags, und der Fall, den ein Techniker
|
||||
* am häufigsten hat: das Handy scannt, die Sitzung ist abgelaufen. Danach
|
||||
* muss er auf **der Geräteseite** stehen und nicht auf der Übersicht — sonst
|
||||
* sucht er das Gerät noch einmal, das er gerade in der Hand hält.
|
||||
*/
|
||||
const geraet = await prisma.geraet.findFirstOrThrow({
|
||||
where: { hostname: "PC-001" },
|
||||
select: { id: true, tagCode: true },
|
||||
});
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.goto(`/t/${geraet.tagCode}`);
|
||||
await page.waitForURL(/\/anmelden\?ziel=/);
|
||||
|
||||
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 page.fill('input[name="email"]', email);
|
||||
await page.fill('input[name="passwort"]', passwort);
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await page.waitForURL(`**/geraete/${geraet.id}`);
|
||||
await expect(page.getByRole("heading", { name: "PC-001" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("registriert einen vorgedruckten Tag und arbeitet daran weiter", async ({ page }) => {
|
||||
test.slow();
|
||||
const [code, vorlagenName] = await Promise.all([tagAnlegen(), vorlageAnlegen()]);
|
||||
await anmelden(page);
|
||||
|
||||
// Klein geschrieben, so wie ein Handy es eintippt.
|
||||
await page.goto(`/t/${code.toLowerCase()}`);
|
||||
await expect(page.getByRole("heading", { name: "Gerät registrieren" })).toBeVisible();
|
||||
|
||||
const hostname = `ZZ-E2E-${STEMPEL}`;
|
||||
await page.getByLabel("Hostname").fill(hostname);
|
||||
await page.getByLabel("Typ").selectOption("NOTEBOOK");
|
||||
await page.getByRole("button", { name: "Gerät registrieren" }).click();
|
||||
|
||||
// Danach steht der Techniker auf der Geräteseite – nicht auf einer
|
||||
// Erfolgsmeldung, die er wegklicken muss.
|
||||
await page.waitForURL(/\/geraete\/[^/]+$/);
|
||||
await expect(page.getByRole("heading", { name: hostname })).toBeVisible();
|
||||
await expect(page.getByText(code)).toBeVisible();
|
||||
|
||||
// Derselbe Aufkleber führt von jetzt an direkt hierher.
|
||||
const adresse = page.url();
|
||||
await page.goto(`/t/${code}`);
|
||||
await page.waitForURL(adresse);
|
||||
|
||||
// Eine Vorlage zuweisen: der Lauf erscheint als „offen“ unter Zugewiesen.
|
||||
/*
|
||||
* Ueber die Ueberschrift und nicht ueber den Text: der Abschnitt „Vorlage
|
||||
* zuweisen“ erklaert sich mit dem Satz „… erscheint oben unter
|
||||
* Zugewiesen“ — ein Textfilter faende ihn damit unter beiden Namen.
|
||||
*/
|
||||
const zuweisen = page
|
||||
.locator("section")
|
||||
.filter({ has: page.getByRole("heading", { name: "Vorlage zuweisen" }) });
|
||||
// Ueber den Wert, nicht ueber die Beschriftung: die traegt Kategorie und
|
||||
// Version und liesse sich mit jeder Feinheit am Text brechen.
|
||||
const wert = await zuweisen.locator("option", { hasText: vorlagenName }).getAttribute("value");
|
||||
if (!wert) throw new Error(`Die Vorlage „${vorlagenName}“ steht nicht zur Auswahl.`);
|
||||
await zuweisen.getByLabel("Vorlage").selectOption(wert);
|
||||
await zuweisen.getByRole("button", { name: "Lauf zuweisen" }).click();
|
||||
|
||||
const zugewiesen = page
|
||||
.locator("section")
|
||||
.filter({ has: page.getByRole("heading", { name: "Zugewiesen" }) });
|
||||
await expect(zugewiesen.getByText("offen")).toBeVisible();
|
||||
|
||||
// Und „Starten“ öffnet ihn wirklich – danach ist er in Arbeit.
|
||||
await zugewiesen.getByRole("button", { name: "Starten" }).click();
|
||||
await page.waitForURL(/\/laeufe\/[^/]+$/);
|
||||
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
||||
await anmelden(page);
|
||||
await page.goto("/geraete");
|
||||
await page.getByRole("link", { name: "NB-001" }).first().click();
|
||||
await page.waitForURL(/\/geraete\/[^/]+$/);
|
||||
|
||||
const masse = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
sichtbar: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
const vorlagen = await prisma.vorlage.findMany({
|
||||
where: { name: { startsWith: "ZZ E2E Geraetelauf" } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
/*
|
||||
* Die Testgeraete und ihre Laeufe raeumen sich nicht von selbst weg: ein
|
||||
* Tag-Code bleibt absichtlich fuer immer vergeben, das Geraet dahinter muss
|
||||
* aber nicht stehen bleiben.
|
||||
*/
|
||||
/*
|
||||
* Nur die eigenen: ein „alle Tags ohne Zuordnung“ raeumte auch den Vorrat
|
||||
* weg, den jemand fuer echte Aufkleber angelegt hat — in einer
|
||||
* Entwicklungsdatenbank ist das derselbe Bestand.
|
||||
*/
|
||||
const geraete = await prisma.geraet.findMany({
|
||||
where: { OR: [{ hostname: { startsWith: "ZZ-E2E-" } }, { tagCode: { in: eigeneTags } }] },
|
||||
select: { id: true },
|
||||
});
|
||||
const kennungen = geraete.map((geraet) => geraet.id);
|
||||
if (kennungen.length > 0) {
|
||||
const laeufe = await prisma.lauf.findMany({
|
||||
where: { geraetId: { in: kennungen } },
|
||||
select: { id: true },
|
||||
});
|
||||
const laufIds = laeufe.map((lauf) => lauf.id);
|
||||
if (laufIds.length > 0) {
|
||||
await prisma.kommentar.deleteMany({ where: { laufId: { in: laufIds } } });
|
||||
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: laufIds } } });
|
||||
await prisma.protokoll.deleteMany({
|
||||
where: { OR: [{ laufId: { in: laufIds } }, { entitaetId: { in: laufIds } }] },
|
||||
});
|
||||
await prisma.lauf.deleteMany({ where: { id: { in: laufIds } } });
|
||||
}
|
||||
await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } });
|
||||
await prisma.geraet.deleteMany({ where: { id: { in: kennungen } } });
|
||||
}
|
||||
// Die Testvorlage zuletzt: an ihr hingen gerade noch die Laeufe.
|
||||
if (vorlagen.length > 0) {
|
||||
await prisma.vorlage.deleteMany({ where: { id: { in: vorlagen.map((zeile) => zeile.id) } } });
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
* Der Einstieg ueber den Aufkleber (#014).
|
||||
*
|
||||
* Die Kamera laesst sich hier nicht pruefen — Chromium bekommt im Testlauf kein
|
||||
* Bild, und ein gefaelschter Videostrom prueft am Ende die Attrappe. Geprueft
|
||||
* wird deshalb das, was ohne Kamera trotzdem gelten muss: die Seite ist
|
||||
* erreichbar, der Knopf in der Huelle fuehrt hin, und der **abgetippte** Code
|
||||
* bringt den Techniker genauso ans Ziel wie der gescannte. Das ist kein
|
||||
* Ersatzweg fuer den Notfall, sondern der Weg, den jeder geht, dessen Browser
|
||||
* keine Kamera freigibt.
|
||||
*/
|
||||
|
||||
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"]')]);
|
||||
}
|
||||
|
||||
test.describe("Scannen", () => {
|
||||
test("ist über den Knopf in der Hülle erreichbar", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.getByRole("link", { name: "Scannen" }).first().click();
|
||||
await page.waitForURL("**/scannen");
|
||||
await expect(page.getByRole("heading", { name: "Scannen" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("öffnet einen abgetippten Code", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/scannen");
|
||||
|
||||
// Ohne Kamera bleibt das Feld – und es nimmt auch Kleinschreibung an.
|
||||
await page.getByLabel("Code eintippen").fill("lz-7k3f9q");
|
||||
await page.getByRole("button", { name: "Öffnen" }).click();
|
||||
|
||||
await page.waitForURL("**/t/LZ-7K3F9Q");
|
||||
});
|
||||
|
||||
test("lässt einen unsinnigen Code gar nicht erst abschicken", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/scannen");
|
||||
|
||||
await page.getByLabel("Code eintippen").fill("PC-001");
|
||||
await expect(page.getByRole("button", { name: "Öffnen" })).toBeDisabled();
|
||||
await expect(page.getByText(/Die Zeichen 0, O, 1 und I gibt es darin nicht/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
||||
await anmelden(page);
|
||||
await page.goto("/scannen");
|
||||
|
||||
const masse = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
sichtbar: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user