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

282 lines
9.4 KiB
TypeScript

import { expect, test, type Locator } from "@playwright/test";
import { anmelden } from "./wehr";
/**
* E2E-Tests für Phase 6 — die Kartenmodule.
*
* Zwei Dinge sind hier wichtiger als die Karte selbst:
*
* 1. **Die Umkreissuche.** Sie beantwortet die Frage, wegen der es das Modul
* gibt: was liegt in der Nähe der Einsatzstelle? Geprüft wird gegen die
* Beispieldaten mit bekannten Entfernungen.
* 2. **Die Karte bleibt brauchbar, wenn der Kartenserver fehlt.** Im
* Testlauf läuft keiner — also ist genau das der Normalfall hier, und die
* Anwendung muss ihn ehrlich anzeigen statt kaputtzugehen.
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
*/
test.describe.configure({ mode: "serial" });
/**
* Die Fläche der Karte, bereit zum Anklicken.
*
* Zwei Dinge müssen stimmen, bevor ein Klick etwas bewirkt: die Karte muss im
* Fenster liegen (page.mouse klickt in Fensterkoordinaten) und MapLibre muss
* seine Zeichenfläche aufgebaut haben. Ohne das Warten landet der erste Klick
* gelegentlich im Nichts — und der Test prüft dann eine Strecke aus einem
* einzigen Punkt.
*/
async function kartenflaeche(
karte: Locator,
): Promise<{ x: number; y: number; width: number; height: number }> {
await karte.scrollIntoViewIfNeeded();
await expect(karte.locator("canvas")).toBeVisible();
const flaeche = await karte.boundingBox();
if (!flaeche) throw new Error("Karte hat keine Fläche.");
return flaeche;
}
test.describe("Karte", () => {
test("zeigt Löschwasser und Objekte aus dem Bestand", async ({ page }) => {
await anmelden(page);
await page.goto("/karte");
// Erst die Oberfläche, dann die Zahlen: der Knopf trägt die Anzahl schon,
// bevor die Abfrage zurück ist, und steht dann auf null. Unter Last dauert
// das — deshalb wird auf den Wert gewartet, nicht auf ein Erscheinen.
await expect(page.getByTestId("karte")).toBeVisible();
await expect
.poll(
async () => {
const beschriftung = await page
.getByRole("button", { name: /Hydranten \(\d+\)/ })
.innerText();
return Number(beschriftung.replace(/\D/g, ""));
},
{ timeout: 30_000 },
)
// Aus dem Seed: 24 Löschwasserstellen, davon zwei defekt und zwei
// ungeprüft.
.toBeGreaterThanOrEqual(20);
await expect(
page.getByRole("button", { name: /Objekte \(\d+\)/ }),
).toBeVisible();
});
test("sagt es, wenn kein Kartenhintergrund eingerichtet ist", async ({
page,
}) => {
await anmelden(page);
await page.goto("/karte");
// Im Testlauf gibt es keinen Kartenserver. Die Anwendung muss das sagen
// und trotzdem arbeiten — nicht eine leere Fläche zeigen und schweigen.
await expect(page.getByTestId("karte-ohne-kacheln")).toContainText(
"Punkte liegen richtig",
);
});
test("findet über die Suche einen bestimmten Hydranten", async ({ page }) => {
await anmelden(page);
await page.goto("/karte/hydranten");
// „exact": daneben steht die Zelle mit dem Entfernen-Knopf, die den Namen
// in ihrer Beschriftung trägt.
await expect(
page.getByRole("cell", { name: "H-0004", exact: true }),
).toBeVisible();
await expect(page.getByText("Weiher Kiesgrube")).toBeVisible();
});
test("zeigt Zustände im Klartext, nicht nur als Farbe", async ({ page }) => {
await anmelden(page);
await page.goto("/karte/hydranten");
// „defekt" und „ungeprüft" müssen lesbar dastehen — eine rote Markierung
// allein sagt am Fahrzeug niemandem, was los ist.
await expect(
page.getByRole("cell", { name: "defekt" }).first(),
).toBeVisible();
await expect(
page.getByRole("cell", { name: "ungeprüft" }).first(),
).toBeVisible();
});
});
test.describe("Objekte und Feuerwehrpläne", () => {
test("listet die Objekte und öffnet eines", async ({ page }) => {
await anmelden(page);
await page.goto("/karte/objekte");
await expect(page.getByText("Grundschule Musterhausen")).toBeVisible();
await expect(page.getByText("Seniorenheim St. Anna")).toBeVisible();
await page.getByText("Seniorenheim St. Anna").click();
await page.waitForURL(/\/karte\/objekte\/[0-9a-f-]{36}$/);
// Was auf der Anfahrt zählt, steht oben.
await expect(page.getByTestId("objekt-detail")).toContainText("Zufahrt");
await expect(page.getByTestId("objekt-detail")).toContainText(
"Sauerstoffflaschen",
);
await expect(page.getByTestId("objekt-detail")).toContainText("FSD");
});
test("findet ein Objekt über ein Gefahrenstichwort", async ({ page }) => {
await anmelden(page);
await page.goto("/karte/objekte");
// Wer im Einsatz sucht, weiß selten den Namen — aber oft, was dort ist.
await page.getByLabel("Objekte durchsuchen").fill("Photovoltaik");
await expect(page.getByText("Grundschule Musterhausen")).toBeVisible();
await expect(page.getByText("Seniorenheim St. Anna")).toHaveCount(0);
});
test("zeigt die Ansprechpartner mit Erreichbarkeit", async ({ page }) => {
await anmelden(page);
await page.goto("/karte/objekte");
await page.getByText("Aussiedlerhof Huber").click();
await page.waitForURL(/\/karte\/objekte\/[0-9a-f-]{36}$/);
await expect(page.getByText("Josef Huber")).toBeVisible();
await expect(page.getByText("jederzeit").first()).toBeVisible();
});
});
test.describe("Wasserförderung", () => {
test("rechnet eine Strecke durch, sobald zwei Punkte stehen", async ({
page,
}) => {
test.setTimeout(120_000);
await anmelden(page);
await page.goto("/karte/wasserfoerderung");
await expect(page.getByTestId("foerderplan")).toContainText(
"Noch keine Strecke",
);
// Zwei Punkte in die Karte tippen — die Karte hat keine Kacheln, aber
// Koordinaten hat sie trotzdem.
const flaeche = await kartenflaeche(page.getByTestId("karte"));
await page.mouse.click(
flaeche.x + flaeche.width * 0.35,
flaeche.y + flaeche.height * 0.5,
);
await expect(page.getByRole("heading", { name: "Punkte" })).toBeVisible();
await page.mouse.click(
flaeche.x + flaeche.width * 0.65,
flaeche.y + flaeche.height * 0.5,
);
// Ab jetzt steht eine Rechnung da: Pumpen, Längen, Höhenunterschied.
await expect(page.getByTestId("pumpenzahl")).toBeVisible();
await expect(page.getByTestId("foerderplan")).toContainText(
"Schlauchlängen",
);
// Und der Satz, der immer dazugehört.
await expect(page.getByText(/Planungshilfe/)).toBeVisible();
});
test("nennt die Pumpenzahl und die Abschnitte", async ({ page }) => {
test.setTimeout(120_000);
await anmelden(page);
await page.goto("/karte/wasserfoerderung");
const flaeche = await kartenflaeche(page.getByTestId("karte"));
// Eine lange Strecke quer über die Karte: die braucht mehr als eine Pumpe.
// Zwischen den Klicks warten, bis der erste Punkt angekommen ist — sonst
// prüft der Test bei einem verschluckten Klick eine Strecke aus einem
// einzigen Punkt.
await page.mouse.click(
flaeche.x + flaeche.width * 0.1,
flaeche.y + flaeche.height * 0.5,
);
await expect(page.getByRole("heading", { name: "Punkte" })).toBeVisible();
await page.mouse.click(
flaeche.x + flaeche.width * 0.9,
flaeche.y + flaeche.height * 0.5,
);
await expect(page.getByText("Abschnitte")).toBeVisible();
await expect(
page.getByRole("columnheader", { name: "Längen" }),
).toBeVisible();
const pumpen = Number(await page.getByTestId("pumpenzahl").innerText());
expect(pumpen).toBeGreaterThanOrEqual(1);
});
});
test.describe("Lagekarte", () => {
test("trägt ein taktisches Zeichen ein und nimmt es zurück", async ({
page,
}) => {
test.setTimeout(180_000);
await anmelden(page);
// Irgendein Einsatz aus dem Seed.
await page.goto("/einsaetze");
await page
.getByRole("link", { name: /\d{4}-\d{4}/ })
.first()
.click();
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
const lagekarte = page.getByTestId("lagekarte");
await expect(lagekarte).toBeVisible();
// Werkzeug wählen, in die Karte tippen.
await lagekarte.getByTestId("zeichen-loeschfahrzeug").click();
const flaeche = await kartenflaeche(lagekarte.getByTestId("karte"));
await page.mouse.click(
flaeche.x + flaeche.width / 2,
flaeche.y + flaeche.height / 2,
);
await expect(lagekarte.getByText("loeschfahrzeug").first()).toBeVisible({
timeout: 30_000,
});
// Zurücknehmen: es verschwindet von der Karte.
await lagekarte
.getByRole("button", { name: "zurücknehmen" })
.first()
.click();
await expect(lagekarte.getByText("loeschfahrzeug")).toHaveCount(0, {
timeout: 30_000,
});
});
test("zeigt den Zustand des Abgleichs", async ({ page }) => {
test.setTimeout(120_000);
await anmelden(page);
await page.goto("/einsaetze");
await page
.getByRole("link", { name: /\d{4}-\d{4}/ })
.first()
.click();
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
// Wer nicht sieht, dass der Draht weg ist, hält eine alte Lage für die
// aktuelle — deshalb steht der Zustand sichtbar da.
await expect(page.getByTestId("lagekarte-verbindung")).toHaveAttribute(
"data-verbunden",
"true",
{ timeout: 30_000 },
);
});
});