Phase 5: das sicherheitsrelevanteste Modul des Projekts. Ueber jeder Ansicht steht dauerhaft, dass die analoge Atemschutzueberwachung fuehrend bleibt — abschaltbar nur zusammen mit der Fundstelle einer Freigabe des Kommandanten. Es gibt keinen Timer. In der Datenbank stehen Zeitstempel; wie lange ein Trupp unter Atemschutz ist, rechnet jede Ansicht bei jedem Bild neu aus. Damit ist die Anforderung "uebersteht Neuladen und Verbindungsabbruch" keine Funktion, die gelingen muss, sondern eine Eigenschaft, die sich nicht verlieren laesst. Server und Browser rechnen mit denselben Funktionen. Der Rueckzugsdruck folgt dem doppelten Anmarschverbrauch: was der Weg hinein gekostet hat, kostet der Weg zurueck noch einmal. Bis der Trupp das Einsatzziel meldet, greift die Zwei-Drittel-Regel — sie liegt hoeher und ist damit die vorsichtigere Annahme. Das Protokoll ist doppelt gesichert: entzogene Rechte und ein Trigger auf druckmessung und atemschutz_ereignis. Ein Fehler wird durch einen neuen Eintrag richtiggestellt, nicht durch Aendern. Verteilt wird ueber Server-Sent Events statt Socket.IO — es geht nur in eine Richtung, und so bleibt das Deployment bei "next start". Behoben: ein abgerissener Ereignisstrom meldete sich nicht. Die Anzeige haette weiter "Verbunden" behauptet, waehrend der Draht tot war. Jetzt gilt Stille von acht Sekunden als Abriss. Dazu zwei Altlasten: der Navigationseintrag "Protokoll" fuehrte ins Leere, und die Startseite stand noch auf Phase 1. 68 neue Tests (51 Unit, 17 gegen eine echte Datenbank, 8 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
340 lines
14 KiB
TypeScript
340 lines
14 KiB
TypeScript
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<void> {
|
||
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<string> {
|
||
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<void> {
|
||
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 Anmarschverbrauch gerechnet.
|
||
// 300 → 260 und 290 → 250 sind je 40 bar; 2 × 40 + 55 + 10 = 145.
|
||
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).toContainText("145");
|
||
|
||
// 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]!;
|
||
}
|