Files
Florian WildeandClaude Opus 5 7196c7110c Aufraeumarbeiten: ESLint, DOM-Verschachtelung, eigene Testwehr
ESLint hatte keine Konfiguration — das Skript "npm run lint" pruefte nichts.
Jetzt Flat Config ohne Formatierungsregeln, dafuer mit den Regeln, die die
Fehler dieses Projekts gemeldet haetten. Er hat gleich zwei gefunden, die
niemand gesucht hatte.

Das Badge rendert ein span statt eines div. Damit ist die Meldung
"div cannot be a descendant of p" weg, die seit Phase 3 auf der Einsatzseite
stand — sie ist der klassische Ausloeser fuer einen Hydration-Fehler.

Die E2E-Tests laufen gegen eine eigene Wehr. Nach fuenf Phasen standen in der
Beispielwehr 120 Atemschutz-Ueberwachungen statt einer und 115 Geraete statt
45; bei jedem Lauf aenderten sich alle Bildschirmfotos. Der Bestand ist einmal
zurueckgesetzt, npm run db:test-reset baut die Testwehr neu auf.

Behoben nebenbei:
- Die Fehlerflaeche der Karte konnte nie etwas anzeigen.
- Zwei useMemo der Lagekarte rechneten bei jedem Rendern neu.
- tests/e2e war vom Typecheck ausgenommen und liess einen doppelt
  deklarierten Bezeichner durch.

Closes #10
Closes #11
Closes #12

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 13:24:48 +02:00

423 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from "@playwright/test";
import { anmelden } from "./wehr";
/**
* 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" });
/** 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 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]!;
}