Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz weg ist. Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang, Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel; kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und verweigert UPDATE und DELETE auf kiosk_vorgang. Offline: von Hand geschriebener Service Worker, Warteschlange in der IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als Konflikt unter /terminal statt verworfen zu werden. Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy, Hallenmonitor mit optionalen DWD-Warnungen, Installation als App. Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal geschickt. Gefunden vom Offline-E2E-Test. 31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 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")).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();
|
|
});
|
|
});
|