import { expect, test, type Page } from "@playwright/test"; /** * E2E-Tests für Phase 5 — die Atemschutzüberwachung. * * Phase-5.md nennt die Anforderung, an der dieses Modul steht oder fällt: * * > Timer müssen einen Browser-Reload und einen Verbindungsabbruch * > überstehen. Das ist die wichtigste Testanforderung. * * Genau darauf laufen die beiden Tests im Abschnitt „Die Uhr" hinaus. Sie * prüfen nicht, ob eine Zahl erscheint, sondern ob **dieselbe** Zahl erscheint * — vor und nach dem Neuladen, mit und ohne Verbindung. Ein Zähler im Browser * würde daran scheitern; Zeitstempel vom Server tun es nicht. * * Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet. */ test.describe.configure({ mode: "serial" }); 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("/"); } /** Eröffnet eine Überwachung und gibt deren Adresse zurück. */ async function ueberwachungEroeffnen(page: Page, bezeichnung: string): Promise { await page.goto("/atemschutz"); await page.getByLabel("Bezeichnung").fill(bezeichnung); await page.getByRole("button", { name: "Eröffnen" }).click(); // Sie erscheint unter „Läuft gerade" — von dort geht es hinein. const eintrag = page.getByRole("link", { name: new RegExp(bezeichnung) }).first(); await expect(eintrag).toBeVisible(); await eintrag.click(); await page.waitForURL(/\/atemschutz\/[0-9a-f-]{36}$/); return page.url(); } /** Legt einen Trupp mit den ersten beiden wählbaren Personen an. */ async function truppAnlegen(page: Page, funkrufname: string): Promise { await page.getByRole("button", { name: "Trupp anlegen" }).first().click(); const formular = page.getByTestId("trupp-anlegen"); await expect(formular).toBeVisible(); await formular.getByLabel("Funkrufname").fill(funkrufname); await formular.getByLabel("Auftrag").fill("Menschenrettung 1. OG"); // Zwei Personen wählen — die Knöpfe tragen den Namen der Person. const auswahl = formular.locator("button[aria-pressed]"); await auswahl.nth(0).click(); await auswahl.nth(1).click(); const druckfelder = formular.getByLabel("Eingangsdruck"); await druckfelder.nth(0).fill("300"); await druckfelder.nth(1).fill("290"); await formular.getByRole("button", { name: "Trupp anlegen" }).click(); await expect(formular).toBeHidden(); } test.describe("Atemschutz", () => { test("zeigt den Sicherheitshinweis auf jeder Ansicht", async ({ page }) => { await anmelden(page); await page.goto("/atemschutz"); await expect(page.getByTestId("atemschutz-hinweis")).toContainText( "analoge Atemschutzüberwachung bleibt führend", ); await page.goto("/atemschutz/nachweise"); await expect(page.getByTestId("atemschutz-hinweis")).toBeVisible(); }); test("zeigt die Tauglichkeit mit Klartext, nicht nur mit Farbe", async ({ page }) => { await anmelden(page); await page.goto("/atemschutz"); // Aus dem Seed: einer hat eine abgelaufene G26, einem fehlt die // Belastungsübung. Beides muss lesbar dastehen. await expect(page.getByText("G26.3 ist abgelaufen").first()).toBeVisible(); await expect(page.getByText(/Belastungsübung in diesem Jahr fehlt/).first()).toBeVisible(); }); test("führt eine Überwachung von der Eröffnung bis zum Protokoll", async ({ page }) => { test.setTimeout(180_000); await anmelden(page); const adresse = await ueberwachungEroeffnen( page, `E2E Durchlauf ${test.info().project.name} ${Date.now()}`, ); await truppAnlegen(page, "Angriffstrupp"); const trupp = page.locator("[data-testid^='trupp-']").first(); await expect(trupp).toBeVisible(); await expect(trupp).toHaveAttribute("data-status", "BEREIT"); // Der Rückzugsdruck steht schon vor dem Einstieg — vorerst nach der // Zwei-Drittel-Regel: 300 × 2/3 + 10 = 210. await expect(trupp).toContainText("210"); await expect(trupp).toContainText("Zwei-Drittel-Regel"); // Einstieg — ab hier läuft die Uhr. await trupp.getByRole("button", { name: "Einstieg" }).click(); await expect(trupp).toHaveAttribute("data-status", "IM_EINSATZ"); /* Zielmeldung: ab jetzt wird nach dem tatsächlichen Anmarschverbrauch gerechnet statt nach der Zwei-Drittel-Regel. Auf eine feste Zahl lässt sich das nicht festnageln: welche Person in der Karte oben steht, entscheidet die Reihenfolge in der Datenbank, und maßgeblich ist immer der ungünstigste Wert im Trupp. Geprüft wird deshalb, was fachlich zählt — der Wert sinkt gegenüber der vorläufigen Annahme, und der Zusatz „vorläufig" verschwindet. */ const felder = trupp.getByLabel(/^[A-ZÄÖÜ]/); await felder.nth(0).fill("260"); await felder.nth(1).fill("250"); await trupp.getByRole("button", { name: "Einsatzziel erreicht" }).click(); await expect(trupp).not.toContainText("Zwei-Drittel-Regel"); await expect(trupp).not.toContainText("210"); // Zwischenmeldung. await felder.nth(0).fill("220"); await felder.nth(1).fill("205"); await trupp.getByRole("button", { name: "Druck melden" }).click(); await expect(trupp).toContainText("205"); // Rückweg und heraus. await trupp.getByRole("button", { name: "Rückweg angetreten" }).click(); await expect(trupp).toHaveAttribute("data-status", "RUECKWEG"); await felder.nth(0).fill("160"); await felder.nth(1).fill("140"); await trupp.getByRole("button", { name: "Trupp ist draußen" }).click(); await expect(trupp).toHaveAttribute("data-status", "DRAUSSEN"); // Beenden geht jetzt — vorher hätte es der Server abgelehnt. page.once("dialog", (dialog) => void dialog.accept()); await page.getByRole("button", { name: "Überwachung beenden" }).click(); // Großzügig: das Beenden stellt zusätzlich den Auftrag für das PDF in die // Warteschlange, und unter Last dauert die Runde über Redis. await expect(page.getByRole("button", { name: "Überwachung beenden" })).toBeHidden({ timeout: 60_000, }); // Und das Protokoll gibt es als PDF. Innerhalb der Ansicht gesucht: in der // Seitenleiste steht ebenfalls ein „Protokoll". const protokoll = page.getByTestId("ueberwachung").getByRole("link", { name: "Protokoll" }); await expect(protokoll).toBeVisible(); const antwort = await page.request.get( `/api/atemschutz/protokoll?id=${adresse.split("/").pop()}`, ); expect(antwort.status()).toBe(200); expect(antwort.headers()["content-type"]).toContain("application/pdf"); }); test("lässt eine Überwachung mit einem Trupp im Einsatz nicht beenden", async ({ page }) => { test.setTimeout(180_000); await anmelden(page); await ueberwachungEroeffnen(page, `E2E Offen ${test.info().project.name} ${Date.now()}`); await truppAnlegen(page, "Sicherheitstrupp"); const trupp = page.locator("[data-testid^='trupp-']").first(); await trupp.getByRole("button", { name: "Einstieg" }).click(); await expect(trupp).toHaveAttribute("data-status", "IM_EINSATZ"); page.once("dialog", (dialog) => void dialog.accept()); await page.getByRole("button", { name: "Überwachung beenden" }).click(); // Eine beendete Überwachung mit einem Trupp, der noch drin steht, wäre die // gefährlichste Zeile im ganzen Protokoll. await expect(page.getByTestId("ueberwachungsfehler")).toContainText("nicht als draußen"); await expect(page.getByRole("button", { name: "Überwachung beenden" })).toBeVisible(); }); test("meldet und hebt einen Notfall auf", async ({ page }) => { test.setTimeout(180_000); await anmelden(page); await ueberwachungEroeffnen(page, `E2E Mayday ${test.info().project.name} ${Date.now()}`); await truppAnlegen(page, "Notfalltrupp"); const trupp = page.locator("[data-testid^='trupp-']").first(); await trupp.getByRole("button", { name: "Einstieg" }).click(); await trupp.getByRole("button", { name: "Mayday" }).click(); await expect(page.getByTestId("mayday-anzeige")).toBeVisible(); await expect(trupp).toHaveAttribute("data-mayday", "true"); await trupp.getByRole("button", { name: "Notfall aufheben" }).click(); await expect(page.getByTestId("mayday-anzeige")).toBeHidden(); await expect(trupp).toHaveAttribute("data-mayday", "false"); }); }); /** * Die Uhr. * * Der Kern der Phase: die Anzeige darf sich durch nichts aus dem Tritt bringen * lassen, was zwischen Browser und Server passiert. */ test.describe("Die Uhr übersteht alles", () => { test("zeigt nach einem Neuladen dieselbe Einsatzzeit", async ({ page }) => { test.setTimeout(180_000); await anmelden(page); await ueberwachungEroeffnen(page, `E2E Neuladen ${test.info().project.name} ${Date.now()}`); await truppAnlegen(page, "Uhrtrupp"); const trupp = page.locator("[data-testid^='trupp-']").first(); await trupp.getByRole("button", { name: "Einstieg" }).click(); await expect(trupp).toHaveAttribute("data-status", "IM_EINSATZ"); // Ein paar Sekunden laufen lassen, damit es etwas zu verlieren gibt. await expect .poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), { timeout: 30_000, }) .toBeGreaterThanOrEqual(5); const vorher = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); // --- Neuladen ------------------------------------------------------------ await page.reload(); await expect(page.getByTestId("ueberwachung")).toBeVisible(); const nachher = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); // Die Uhr muss weitergelaufen sein — nicht zurückgesprungen, nicht bei // null angefangen. Ein Zähler im Browser hätte hier neu begonnen. expect(nachher).toBeGreaterThanOrEqual(vorher); expect(nachher).toBeLessThan(vorher + 60); }); test("läuft weiter, wenn der Server nicht mehr antwortet", async ({ page, context }) => { test.setTimeout(180_000); await anmelden(page); await ueberwachungEroeffnen(page, `E2E Netzverlust ${test.info().project.name} ${Date.now()}`); await truppAnlegen(page, "Netzverlusttrupp"); const trupp = page.locator("[data-testid^='trupp-']").first(); await trupp.getByRole("button", { name: "Einstieg" }).click(); await expect(trupp).toHaveAttribute("data-status", "IM_EINSATZ"); const vorher = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); // --- Netz weg ------------------------------------------------------------ await context.setOffline(true); // Die Uhr braucht den Server nicht: sie rechnet aus dem Einstiegszeitpunkt, // den sie schon hat. Ein heruntergezählter Wert wäre hier stehen geblieben. await expect .poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), { timeout: 30_000, }) .toBeGreaterThan(vorher + 3); // --- Netz zurück --------------------------------------------------------- await context.setOffline(false); // Und der Server bestätigt, was der Browser gerechnet hat. const nachher = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); expect(nachher).toBeGreaterThan(vorher); }); test("sagt es sichtbar, wenn der Ereignisstrom abreißt", async ({ page }) => { test.setTimeout(180_000); await anmelden(page); await ueberwachungEroeffnen(page, `E2E Stromabriss ${test.info().project.name} ${Date.now()}`); await truppAnlegen(page, "Abrisstrupp"); const trupp = page.locator("[data-testid^='trupp-']").first(); await trupp.getByRole("button", { name: "Einstieg" }).click(); await expect(page.getByTestId("verbindung")).toHaveAttribute("data-verbunden", "true", { timeout: 30_000, }); await expect .poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), { timeout: 30_000, }) .toBeGreaterThanOrEqual(4); const vorher = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); /* Den Strom abschneiden. `context.setOffline()` genügt dafür nicht: eine bereits offene Verbindung liefert in Chromium weiter, und das ist auch richtig so — sie ist ja noch da. Für einen echten Abriss wird die Route abgewiesen und die Seite neu geladen; der neue Ereignisstrom kommt dann gar nicht erst zustande. Damit prüft dieser Test beides auf einmal: Neuladen **und** toter Strom. */ await page.route("**/api/atemschutz/strom**", (route) => route.abort("connectionreset")); await page.reload(); await expect(page.getByTestId("ueberwachung")).toBeVisible(); await expect(page.getByTestId("verbindung")).toHaveAttribute("data-verbunden", "false", { timeout: 30_000, }); await expect(page.getByText("Ereignisstrom ist unterbrochen")).toBeVisible(); // Trotz totem Strom stimmt die Uhr: der Stand kommt weiter über die // normale Abfrage, und gerechnet wird aus Zeitstempeln. const nachAbriss = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); expect(nachAbriss).toBeGreaterThanOrEqual(vorher); await expect .poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), { timeout: 30_000, }) .toBeGreaterThan(nachAbriss + 2); // --- Strom zurück -------------------------------------------------------- await page.unroute("**/api/atemschutz/strom**"); // EventSource verbindet sich von selbst neu — ohne eine Zeile dafür. await expect(page.getByTestId("verbindung")).toHaveAttribute("data-verbunden", "true", { timeout: 60_000, }); }); }); /** Wandelt „mm:ss" oder „h:mm:ss" in Sekunden. */ function alsSekunden(text: string): number { const teile = text.trim().split(":").map(Number); if (teile.some((zahl) => Number.isNaN(zahl))) return 0; return teile.length === 3 ? teile[0]! * 3600 + teile[1]! * 60 + teile[2]! : teile[0]! * 60 + teile[1]!; }