Files
FireAdministration/tests/e2e/karte.spec.ts
T
Florian WildeandClaude Opus 5 1c1e141666 Kartenmodule ergaenzen
Phase 6: alles, was einen Ort hat — Loeschwasser, Objekte mit Feuerwehrplan,
die Lagekarte eines Einsatzes und die geplante Foerderstrecke.

Keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre Hydranten
liegen, ist niemandes Geschaeft; der Kartenserver laeuft selbst
(tileserver-gl im Compose-Profil "karte"). Ohne ihn zeigt die Karte einen
leeren Grund und sagt das auch — Punkte, Entfernungen und Umkreissuche
funktionieren trotzdem. Dasselbe gilt fuer einen eingetragenen, aber nicht
erreichbaren Server.

Die Umkreissuche laeuft ueber PostGIS: ST_DWithin auf der Geografie mit
GiST-Index rechnet in Metern und nutzt den Index. Gefragt wird waehrend der
Fahrt, und bei tausend Hydranten waere alles andere ein vollstaendiger
Tabellendurchlauf.

Der Import aus GeoJSON und Stadtwerke-CSV ist nachsichtig, aber ehrlich: er
erkennt "DN 100", deutsche Kommazahlen, Datumsangaben und vertauschte Achsen
— raet aber nichts. Fehlt die Position, wird die Zeile uebersprungen; ohne
Statusangabe steht "ungeprueft", nicht "einsatzbereit".

22 taktische Zeichen nach FwDV 102 selbst gezeichnet, die Lagekarte gleicht
ueber denselben Ereignisstrom ab wie die Atemschutzueberwachung.

Die Wasserfoerderung rechnet Reibung, Hoehe und Pumpenzahl; das Ergebnis wird
nie gespeichert, sondern bei jeder Anzeige neu gerechnet. Geht eine Strecke
nicht auf, sagt die Ausgabe das.

Behoben: die Reibungsbeiwerte lagen im ersten Wurf um den Faktor zehn daneben.
Aufgefallen ist es nur, weil die Tests gegen die Tabellenwerte der Ausbildung
pruefen und nicht gegen die eigene Implementierung.

Dazu ein Abruf fuer abgelegte Dateien (/api/dateien/[id]) — bis hierher gab es
nur den Upload.

59 neue Tests (48 Unit, 11 E2E je Browser).

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

223 lines
9.0 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test";
/**
* 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" });
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
/**
* 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;
}
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("/");
}
test.describe("Karte", () => {
test("zeigt Löschwasser und Objekte aus dem Bestand", async ({ page }) => {
await anmelden(page);
await page.goto("/karte");
// Aus dem Seed: 24 Löschwasserstellen, davon zwei defekt und zwei
// ungeprüft, dazu drei Objekte.
await expect(page.getByRole("button", { name: /Hydranten \(2[0-9]\)/ })).toBeVisible();
await expect(page.getByRole("button", { name: /Objekte \(\d+\)/ })).toBeVisible();
await expect(page.getByTestId("karte")).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 },
);
});
});