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>
198 lines
7.8 KiB
TypeScript
198 lines
7.8 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
/**
|
|
* E2E-Tests für Phase 3.
|
|
*
|
|
* Geprüft wird der Weg vom Alarm bis zur Auswertung: schnell erfassen,
|
|
* Zeitstempel setzen, Mannschaft eintragen, melden — und am Ende die Zahlen,
|
|
* die die Gemeinde sehen will.
|
|
*
|
|
* Dazu der Portal-Link: er ist der einzige Weg in FLORIAN hinein, der ohne
|
|
* Anmeldung funktioniert, und wird deshalb ausdrücklich in einem Browser
|
|
* **ohne** Sitzung geprüft.
|
|
*
|
|
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
|
|
*/
|
|
|
|
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("/");
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await anmelden(page);
|
|
});
|
|
|
|
test.describe("Einsatzliste", () => {
|
|
test("zeigt die Einsätze der Wehr", async ({ page }) => {
|
|
await page.goto("/einsaetze");
|
|
|
|
await expect(page.getByRole("heading", { name: "Einsätze" })).toBeVisible();
|
|
// Aus dem Seed: acht Einsätze des laufenden Jahres.
|
|
await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible();
|
|
// "exact": in der Liste stehen mit der Zeit auch Einsaetze, deren Stichwort
|
|
// mit demselben Text beginnt — etwa die aus den Tests weiter unten.
|
|
await expect(page.getByText("B 3 Wohnhausbrand", { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("filtert über die Suche", async ({ page }) => {
|
|
await page.goto("/einsaetze");
|
|
await page.getByLabel("Einsätze durchsuchen").fill("Ölspur");
|
|
|
|
await expect(page.getByText("THL 1 Ölspur")).toBeVisible();
|
|
await expect(page.getByText("B 3 Wohnhausbrand")).toHaveCount(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Nacheinander: die Tests bauen aufeinander auf — erst wird ein Einsatz
|
|
* erfasst, dann daran weitergearbeitet.
|
|
*/
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
test.describe("Einsatz erfassen und bearbeiten", () => {
|
|
/** Merkt sich die Adresse des in diesem Durchlauf angelegten Einsatzes. */
|
|
let einsatzAdresse = "";
|
|
|
|
test("legt einen Einsatz über die Schnellerfassung an", async ({ page }) => {
|
|
await page.goto("/einsaetze");
|
|
await page.getByRole("button", { name: "Einsatz erfassen" }).click();
|
|
|
|
await page
|
|
.getByLabel("Einsatzstichwort")
|
|
.fill(`THL 1 Testeinsatz ${test.info().project.name}`);
|
|
await page.getByLabel("Straße").fill("Teststraße");
|
|
await page.getByLabel("Ort", { exact: true }).fill("Musterhausen");
|
|
|
|
await page.getByRole("button", { name: "Einsatz anlegen" }).click();
|
|
|
|
// Danach steht die Detailseite offen.
|
|
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
|
|
einsatzAdresse = page.url();
|
|
|
|
await expect(page.getByRole("heading", { name: /THL 1 Testeinsatz/ })).toBeVisible();
|
|
await expect(page.getByText("läuft", { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("setzt Zeitstempel und rechnet die Kennzahlen", async ({ page }) => {
|
|
await page.goto(einsatzAdresse);
|
|
|
|
await expect(page.getByRole("heading", { name: "Zeiten" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "ausgerückt jetzt" }).click();
|
|
await expect(page.getByRole("button", { name: "ausgerückt zurücknehmen" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "eingetroffen jetzt" }).click();
|
|
await expect(page.getByRole("button", { name: "eingetroffen zurücknehmen" })).toBeVisible();
|
|
|
|
// Die Hilfsfrist steht danach da — auch wenn sie null Minuten beträgt.
|
|
await expect(page.getByText("Hilfsfrist")).toBeVisible();
|
|
});
|
|
|
|
test("erfasst Mannschaft über den Vorschlag und zeigt die Stärke", async ({ page }) => {
|
|
await page.goto(einsatzAdresse);
|
|
|
|
await page.getByRole("button", { name: "Mannschaft erfassen" }).click();
|
|
await expect(page.getByText(/sortiert nach Einsätzen im letzten Jahr/)).toBeVisible();
|
|
|
|
// Die ersten drei Vorschläge übernehmen.
|
|
const vorschlaege = page.locator("button", { hasText: /\d+ Einsätze/ });
|
|
await vorschlaege.nth(0).click();
|
|
await expect(page.getByText(/Kräfte erfasst/)).toBeVisible();
|
|
|
|
// Die Stärke wird sofort angezeigt.
|
|
await expect(page.getByText(/^\d+\/\d+\/\d+\/\d+$/)).toBeVisible();
|
|
});
|
|
|
|
test("nimmt eine Lagemeldung auf", async ({ page }) => {
|
|
await page.goto(einsatzAdresse);
|
|
|
|
await page.getByLabel("Neue Lagemeldung").fill("Lage erkundet, keine Gefahr für Personen.");
|
|
await page.getByRole("button", { name: "Melden" }).click();
|
|
|
|
await expect(page.getByText("Lage erkundet, keine Gefahr für Personen.")).toBeVisible();
|
|
});
|
|
|
|
test("gibt einen Portal-Link aus, der ohne Anmeldung funktioniert", async ({ page, browser }) => {
|
|
await page.goto(einsatzAdresse);
|
|
|
|
await page.getByLabel("Für wen ist der Link?").fill("FF Nachbarhausen");
|
|
await page.getByRole("button", { name: "Erzeugen" }).click();
|
|
|
|
const linkfeld = page.locator('input[readonly]');
|
|
await expect(linkfeld).toBeVisible();
|
|
const adresse = await linkfeld.inputValue();
|
|
expect(adresse).toContain("/portal/");
|
|
|
|
// Jetzt ein Browser ohne Sitzung — genau so, wie ihn eine Fremdwehr hätte.
|
|
const fremd = await browser.newContext();
|
|
const fremdeSeite = await fremd.newPage();
|
|
|
|
await fremdeSeite.goto(adresse);
|
|
|
|
await expect(fremdeSeite.getByRole("heading", { name: /Testeinsatz/ })).toBeVisible();
|
|
await expect(fremdeSeite.getByText("Zugang für FF Nachbarhausen")).toBeVisible();
|
|
|
|
// Und eine Meldung absetzen.
|
|
await fremdeSeite.getByLabel("Wer meldet?").fill("Gruppenführer Huber");
|
|
await fremdeSeite.getByLabel("Meldung").fill("Wir sind mit einer Staffel eingetroffen.");
|
|
await fremdeSeite.getByRole("button", { name: "Melden" }).click();
|
|
|
|
await expect(fremdeSeite.getByText("Wir sind mit einer Staffel eingetroffen.")).toBeVisible();
|
|
|
|
// Kein Weg in den Rest der Anwendung.
|
|
await expect(fremdeSeite.getByRole("link", { name: "Geräte" })).toHaveCount(0);
|
|
|
|
await fremd.close();
|
|
});
|
|
|
|
test("weist einen erfundenen Portal-Link ab", async ({ browser }) => {
|
|
const fremd = await browser.newContext();
|
|
const seite = await fremd.newPage();
|
|
|
|
await seite.goto("/portal/3f2504e0-4f89-11d3-9a0c-0305e82c3301.abcdefghijklmnopqrstuvwxyz01");
|
|
|
|
await expect(seite.getByTestId("portalfehler")).toBeVisible();
|
|
await expect(seite.getByText(/nicht gültig/)).toBeVisible();
|
|
|
|
await fremd.close();
|
|
});
|
|
});
|
|
|
|
test.describe("Statistik", () => {
|
|
test("zeigt Hilfsfrist, Kategorien und Beteiligung", async ({ page }) => {
|
|
await page.goto("/einsaetze/statistik");
|
|
|
|
await expect(page.getByRole("heading", { name: "Einsatzstatistik" })).toBeVisible();
|
|
await expect(page.getByText("Hilfsfrist (80 %)")).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "Nach Kategorie" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "Beteiligung" })).toBeVisible();
|
|
|
|
// Aus dem Seed: die Kategorien der acht Einsätze.
|
|
await expect(page.getByText("Brand groß").first()).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Übungen", () => {
|
|
test("zeigt die Übungen und ihre Teilnehmerzahl", async ({ page }) => {
|
|
await page.goto("/uebungen");
|
|
|
|
await expect(page.getByRole("heading", { name: "Übungen" })).toBeVisible();
|
|
await expect(page.getByText("Innenangriff mit Atemschutz")).toBeVisible();
|
|
});
|
|
|
|
test("öffnet eine Übung und zeigt die Anwesenheitsliste", async ({ page }) => {
|
|
await page.goto("/uebungen");
|
|
await page.getByRole("link", { name: /\d{4}-\d{4}/ }).first().click();
|
|
|
|
await expect(page.getByRole("heading", { name: "Anwesenheit" })).toBeVisible();
|
|
await expect(page.getByText(/von \d+ aktiven Mitgliedern/)).toBeVisible();
|
|
});
|
|
});
|