import { expect, test, type Page } from "@playwright/test"; /** * E2E-Tests für Phase 2. * * Geprüft wird der Weg, den der Gerätewart wirklich geht: morgens ins * Fristen-Dashboard schauen, eine Vorlage nachsehen, eine Prüfung durchführen * und am Ende ein Protokoll in der Hand haben. * * Hinweis: Der Wizard-Test legt echte Prüfprotokolle an. Die lassen sich * absichtlich nicht wieder löschen — genau das ist der Sinn der Sache. Für die * Beispielwehr ist das unschädlich; vor einer Vorführung hilft ein frischer * Seed. * * Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet. */ const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de"; const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"; async function anmelden(page: Page): Promise { await page.goto("/anmelden"); await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL); await page.getByLabel("Passwort").fill(ADMIN_PASSWORT); await page.getByRole("button", { name: "Anmelden" }).click(); await page.waitForURL("/"); } test.beforeEach(async ({ page }) => { await anmelden(page); }); test.describe("Fristen-Dashboard", () => { test("zeigt die vier Zeitfenster und die offenen Termine", async ({ page }) => { await page.goto("/pruefungen"); await expect(page.getByRole("heading", { name: "Prüfungen und Fristen" })).toBeVisible(); // Die vier Töpfe sind gleichzeitig Filter. for (const bezeichnung of ["überfällig", "diese Woche", "diesen Monat", "nächste 90 Tage"]) { await expect(page.getByRole("button", { name: new RegExp(bezeichnung) })).toBeVisible(); } await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible(); // Aus dem Seed entstehen Termine für die Geräte mit Prüfvorgabe. await expect(page.getByRole("cell", { name: /INV-\d{4}/ }).first()).toBeVisible(); }); test("filtert die Terminliste über ein Zeitfenster", async ({ page }) => { await page.goto("/pruefungen"); await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible(); await page.getByRole("button", { name: /überfällig/ }).first().click(); await expect(page.getByRole("button", { name: "Filter aufheben" })).toBeVisible(); }); test("bündelt die Termine nach Fahrzeug und lässt umschalten", async ({ page }) => { await page.goto("/pruefungen"); await expect(page.getByRole("heading", { name: "Verteilung" })).toBeVisible(); await page.getByLabel("Bündelung wählen").click(); await page.getByRole("option", { name: "nach Gerätetyp" }).click(); // Nach dem Umschalten stehen Gerätetypen in der Verteilungstabelle. Der // Name steht dort allein in der Zelle — in der Terminliste darunter immer // zusammen mit der Inventarnummer, deshalb "exact". await expect( page.getByRole("cell", { name: "Feuerwehrleine 30 m", exact: true }), ).toBeVisible(); }); }); test.describe("Wartungsvorlagen", () => { test("zeigt die Vorlagen mit Intervall und Prüfpunkten", async ({ page }) => { await page.goto("/pruefungen/vorlagen"); await expect(page.getByRole("heading", { name: "Wartungsvorlagen" })).toBeVisible(); await expect( page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }), ).toBeVisible(); await expect(page.getByRole("cell", { name: "DGUV Vorschrift 3, DIN VDE 0701-0702" })).toBeVisible(); }); test("öffnet die Checkliste einer Vorlage", async ({ page }) => { await page.goto("/pruefungen/vorlagen"); await page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }).click(); await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible(); await expect(page.getByText("Ansprechdruck der Warneinrichtung")).toBeVisible(); // Der zulässige Bereich steht am Punkt. await expect(page.getByText(/zulässig 50 bis 60 bar/)).toBeVisible(); }); }); /** * Nacheinander statt parallel: der Wizard setzt einen vorhandenen Entwurf fort, * statt einen zweiten anzulegen. Zwei gleichzeitig laufende Tests würden sich * denselben Entwurf teilen — und der zweite liefe in die Sperre für * abgeschlossene Protokolle. * * Aus demselben Grund bekommt jedes Testprojekt ein eigenes Gerät: Desktop und * Tablet laufen parallel, aber unter demselben Benutzer. */ test.describe.configure({ mode: "serial" }); /** Welches der Leinen-Geräte dieses Projekt prüft. */ function geraetIndex(): number { return test.info().project.name === "tablet" ? 1 : 0; } /** Von der Geräteliste in den Wizard — der Weg des Gerätewarts. */ async function pruefungStarten(page: Page): Promise { await page.goto("/geraete"); await page.getByLabel("Geräte durchsuchen").fill("Feuerwehrleine"); await page.getByRole("link", { name: /INV-\d{4}/ }).nth(geraetIndex()).click(); await page.getByRole("link", { name: "Prüfung durchführen" }).click(); } test.describe("Prüfwizard", () => { test("führt von der Checkliste bis zum unterschriebenen Protokoll", async ({ page }) => { // Der laengste Weg der Anwendung: Wizard, Unterschrift, Nummernkreis und // Protokoll. Laeuft die ganze Suite parallel, reicht das Standardlimit // dafuer nicht — das ist Uebersetzungszeit des Entwicklungsservers, kein // Fehler der Anwendung. test.setTimeout(180_000); await pruefungStarten(page); await page.waitForURL(/\/pruefungen\/neu/); // Die Checkliste der Leinenprüfung hat vier Punkte: drei Ja/Nein und einen // Freitext. Vor jedem Schritt wird auf die Schrittanzeige gewartet — sonst // klickt der Test schneller, als die Oberfläche umschaltet. for (const nummer of [1, 2, 3, 4]) { await expect(page.getByText(new RegExp(`Schritt ${nummer} von 4`))).toBeVisible(); // "in Ordnung" exakt: sonst trifft der Name auch "nicht in Ordnung". const inOrdnung = page.getByRole("button", { name: "in Ordnung", exact: true }); if ((await inOrdnung.count()) > 0) await inOrdnung.click(); const befund = page.getByLabel("Befund"); if ((await befund.count()) > 0) { await befund.fill("Leine vollständig durch die Hand gezogen, ohne Befund."); } await page.getByRole("button", { name: "Weiter" }).click(); } // Abschluss: Ergebnis ist vorbelegt, Unterschrift fehlt noch. await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible(); await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled(); // Unterschrift zeichnen — mit der Maus, wie mit dem Finger auf dem Tablet. // Erst ins Bild holen: die Mauskoordinaten beziehen sich auf das sichtbare // Fenster, nicht auf das Dokument. Liegt das Feld unterhalb, zeichnet der // Test sonst ins Leere. const feld = page.getByLabel(/Unterschriftenfeld/); await feld.scrollIntoViewIfNeeded(); const rahmen = await feld.boundingBox(); expect(rahmen).not.toBeNull(); await page.mouse.move(rahmen!.x + 30, rahmen!.y + rahmen!.height / 2); await page.mouse.down(); await page.mouse.move(rahmen!.x + 90, rahmen!.y + 20, { steps: 8 }); await page.mouse.move(rahmen!.x + 150, rahmen!.y + rahmen!.height - 20, { steps: 8 }); await page.mouse.move(rahmen!.x + 210, rahmen!.y + 30, { steps: 8 }); await page.mouse.up(); // Sobald etwas auf dem Feld steht, lässt es sich löschen und übernehmen. await expect(page.getByRole("button", { name: "Löschen" })).toBeEnabled(); await page.getByRole("button", { name: "Übernehmen" }).click(); await expect(page.getByRole("button", { name: "übernommen" })).toBeVisible(); await page.getByRole("button", { name: "Prüfung abschließen" }).click(); // Danach steht das Protokoll da — mit Nummer und Ergebnis. await page.waitForURL(/\/pruefungen\/[0-9a-f-]{36}$/); await expect(page.getByRole("heading", { name: /Prüfprotokoll \d{4}-\d{4}/ })).toBeVisible(); // Genau der Kleinschreibung wegen "exact": darüber steht auch das Feld // "Abgeschlossen" mit dem Zeitpunkt. await expect(page.getByText("abgeschlossen", { exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible(); }); test("verlangt eine Unterschrift, bevor abgeschlossen werden kann", async ({ page }) => { await pruefungStarten(page); // Erst warten, bis der Wizard steht — er legt die Prüfung beim Öffnen an. await expect(page.getByText(/Schritt 1 von \d/)).toBeVisible(); // Dann bis zum Abschluss durchklicken, ohne etwas zu beantworten. for (let schritt = 0; schritt < 8; schritt += 1) { const weiter = page.getByRole("button", { name: "Weiter" }); if (!(await weiter.isVisible())) break; await weiter.click(); } await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible(); await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled(); }); }); test.describe("Mängel und Rückrufe", () => { test("zeigt den Mangel aus der Beispielprüfung", async ({ page }) => { await page.goto("/maengel"); await expect(page.getByRole("heading", { name: "Mängel" })).toBeVisible(); await expect(page.getByText(/Leinenbeutel an einer Naht aufgescheuert/)).toBeVisible(); }); test("zeigt den Rückruf und seine betroffenen Geräte", async ({ page }) => { await page.goto("/rueckrufe"); await expect(page.getByRole("heading", { name: "Rückrufe" })).toBeVisible(); await page.getByRole("link", { name: /Sicherheitshinweis Ausatemventil/ }).click(); await expect(page.getByRole("heading", { name: "Betroffene Geräte" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Mögliche Treffer im Bestand" })).toBeVisible(); }); }); test.describe("Protokolle", () => { test("listet abgeschlossene Prüfungen mit Ergebnis", async ({ page }) => { await page.goto("/pruefungen/verlauf"); await expect(page.getByRole("heading", { name: "Prüfprotokolle" })).toBeVisible(); await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible(); }); });