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>
This commit is contained in:
Florian Wilde
2026-09-04 13:24:48 +02:00
co-authored by Claude Opus 5
parent 8081131eb7
commit 7196c7110c
94 changed files with 1447 additions and 428 deletions
+45 -20
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { ADMIN_EMAIL, ADMIN_PASSWORT, WEHR_NAME } from "./wehr";
/**
* Beispiel-E2E-Test für Phase 0.
@@ -11,17 +12,18 @@ import { expect, test } from "@playwright/test";
* docker compose up -d && npm run db:migrate && npm run db:seed
*/
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
test.describe("Anmeldung", () => {
test("schickt nicht angemeldete Aufrufe zur Anmeldeseite", async ({ page }) => {
test("schickt nicht angemeldete Aufrufe zur Anmeldeseite", async ({
page,
}) => {
await page.goto("/");
await expect(page).toHaveURL(/\/anmelden/);
await expect(page.getByRole("heading", { name: "FLORIAN" })).toBeVisible();
});
test("weist eine falsche Kombination ab und nennt den Grund auf Deutsch", async ({ page }) => {
test("weist eine falsche Kombination ab und nennt den Grund auf Deutsch", async ({
page,
}) => {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
@@ -37,7 +39,9 @@ test.describe("Anmeldung", () => {
await expect(page).toHaveURL(/\/anmelden/);
});
test("lässt den Administrator hinein und zeigt die Übersicht seiner Wehr", async ({ page }) => {
test("lässt den Administrator hinein und zeigt die Übersicht seiner Wehr", async ({
page,
}) => {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
@@ -46,15 +50,18 @@ test.describe("Anmeldung", () => {
await page.waitForURL("/");
// Die Wehr aus dem Seed.
await expect(
page.getByRole("heading", { name: "Freiwillige Feuerwehr Musterhausen" }),
).toBeVisible();
// Die Wehr, gegen die getestet wird — nicht die Beispielwehr aus der
// Anleitung. Welche das ist, steht in wehr.ts.
await expect(page.getByRole("heading", { name: WEHR_NAME })).toBeVisible();
// Die Kennzahlen aus dem Seed: 15 Mitglieder, 3 Fahrzeuge, 40 Geräte.
await expect(page.getByText("Aktive Mitglieder")).toBeVisible();
await expect(page.getByText("Fahrzeuge", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Geräte", { exact: true }).first()).toBeVisible();
await expect(
page.getByText("Fahrzeuge", { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText("Geräte", { exact: true }).first(),
).toBeVisible();
// Der Sicherheitshinweis muss stehen, solange nichts freigegeben ist.
await expect(page.getByRole("note")).toContainText("analoge Überwachung");
@@ -69,31 +76,49 @@ test.describe("Anmeldung", () => {
await page.getByRole("button", { name: "Anmelden" }).click();
await page.waitForURL("/");
const navigation = page.getByRole("navigation", { name: "Hauptnavigation" });
await expect(navigation.getByRole("link", { name: "Übersicht" })).toBeVisible();
const navigation = page.getByRole("navigation", {
name: "Hauptnavigation",
});
await expect(
navigation.getByRole("link", { name: "Übersicht" }),
).toBeVisible();
// Termine, Dienstausweis und Benachrichtigungen stehen seit Phase 7 zur
// Verfügung und sind Verweise.
await expect(navigation.getByRole("link", { name: "Termine" })).toBeVisible();
await expect(navigation.getByRole("link", { name: "Dienstausweis" })).toBeVisible();
await expect(navigation.getByRole("link", { name: "Benachrichtigungen" })).toBeVisible();
await expect(
navigation.getByRole("link", { name: "Termine" }),
).toBeVisible();
await expect(
navigation.getByRole("link", { name: "Dienstausweis" }),
).toBeVisible();
await expect(
navigation.getByRole("link", { name: "Benachrichtigungen" }),
).toBeVisible();
// Die Kasse kommt erst in Phase 9 — der Eintrag ist sichtbar, aber kein
// Verweis. Beim Abschluss einer Phase wandert diese Zusicherung eine Zeile
// weiter nach unten.
await expect(navigation.getByText("Kasse")).toBeVisible();
await expect(navigation.getByRole("link", { name: "Kasse" })).toHaveCount(0);
await expect(navigation.getByRole("link", { name: "Kasse" })).toHaveCount(
0,
);
});
});
test.describe("Erreichbarkeit", () => {
test("meldet den Zustand der Randsysteme unter /api/health", async ({ request }) => {
test("meldet den Zustand der Randsysteme unter /api/health", async ({
request,
}) => {
const antwort = await request.get("/api/health");
expect(antwort.status()).toBe(200);
const daten = (await antwort.json()) as {
status: string;
dienste: { datenbank: boolean; speicher: boolean; warteschlange: boolean };
dienste: {
datenbank: boolean;
speicher: boolean;
warteschlange: boolean;
};
};
expect(daten.status).toBe("ok");
+129 -56
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { anmelden } from "./wehr";
/**
* E2E-Tests für Phase 5 — die Atemschutzüberwachung.
@@ -17,25 +18,19 @@ import { expect, test, type Page } from "@playwright/test";
*/
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";
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("/");
}
/** Eröffnet eine Überwachung und gibt deren Adresse zurück. */
async function ueberwachungEroeffnen(page: Page, bezeichnung: string): Promise<string> {
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();
const eintrag = page
.getByRole("link", { name: new RegExp(bezeichnung) })
.first();
await expect(eintrag).toBeVisible();
await eintrag.click();
@@ -79,17 +74,23 @@ test.describe("Atemschutz", () => {
await expect(page.getByTestId("atemschutz-hinweis")).toBeVisible();
});
test("zeigt die Tauglichkeit mit Klartext, nicht nur mit Farbe", async ({ page }) => {
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();
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("führt eine Überwachung von der Eröffnung bis zum Protokoll", async ({
page,
}) => {
test.setTimeout(180_000);
await anmelden(page);
@@ -151,13 +152,17 @@ test.describe("Atemschutz", () => {
// 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({
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" });
const protokoll = page
.getByTestId("ueberwachung")
.getByRole("link", { name: "Protokoll" });
await expect(protokoll).toBeVisible();
const antwort = await page.request.get(
@@ -167,11 +172,16 @@ test.describe("Atemschutz", () => {
expect(antwort.headers()["content-type"]).toContain("application/pdf");
});
test("lässt eine Überwachung mit einem Trupp im Einsatz nicht beenden", async ({ page }) => {
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 ueberwachungEroeffnen(
page,
`E2E Offen ${test.info().project.name} ${Date.now()}`,
);
await truppAnlegen(page, "Sicherheitstrupp");
const trupp = page.locator("[data-testid^='trupp-']").first();
@@ -183,15 +193,22 @@ test.describe("Atemschutz", () => {
// 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();
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 ueberwachungEroeffnen(
page,
`E2E Mayday ${test.info().project.name} ${Date.now()}`,
);
await truppAnlegen(page, "Notfalltrupp");
const trupp = page.locator("[data-testid^='trupp-']").first();
@@ -218,7 +235,10 @@ test.describe("Die Uhr übersteht alles", () => {
test.setTimeout(180_000);
await anmelden(page);
await ueberwachungEroeffnen(page, `E2E Neuladen ${test.info().project.name} ${Date.now()}`);
await ueberwachungEroeffnen(
page,
`E2E Neuladen ${test.info().project.name} ${Date.now()}`,
);
await truppAnlegen(page, "Uhrtrupp");
const trupp = page.locator("[data-testid^='trupp-']").first();
@@ -227,18 +247,26 @@ test.describe("Die Uhr übersteht alles", () => {
// 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,
})
.poll(
async () =>
alsSekunden(await page.getByTestId("trupp-dauer").innerText()),
{
timeout: 30_000,
},
)
.toBeGreaterThanOrEqual(5);
const vorher = alsSekunden(await page.getByTestId("trupp-dauer").innerText());
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());
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.
@@ -246,18 +274,26 @@ test.describe("Die Uhr übersteht alles", () => {
expect(nachher).toBeLessThan(vorher + 60);
});
test("läuft weiter, wenn der Server nicht mehr antwortet", async ({ page, context }) => {
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 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());
const vorher = alsSekunden(
await page.getByTestId("trupp-dauer").innerText(),
);
// --- Netz weg ------------------------------------------------------------
await context.setOffline(true);
@@ -265,16 +301,22 @@ test.describe("Die Uhr übersteht alles", () => {
// 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,
})
.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());
const nachher = alsSekunden(
await page.getByTestId("trupp-dauer").innerText(),
);
expect(nachher).toBeGreaterThan(vorher);
});
@@ -282,22 +324,35 @@ test.describe("Die Uhr übersteht alles", () => {
test.setTimeout(180_000);
await anmelden(page);
await ueberwachungEroeffnen(page, `E2E Stromabriss ${test.info().project.name} ${Date.now()}`);
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(page.getByTestId("verbindung")).toHaveAttribute(
"data-verbunden",
"true",
{
timeout: 30_000,
},
);
await expect
.poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), {
timeout: 30_000,
})
.poll(
async () =>
alsSekunden(await page.getByTestId("trupp-dauer").innerText()),
{
timeout: 30_000,
},
)
.toBeGreaterThanOrEqual(4);
const vorher = alsSekunden(await page.getByTestId("trupp-dauer").innerText());
const vorher = alsSekunden(
await page.getByTestId("trupp-dauer").innerText(),
);
/*
Den Strom abschneiden.
@@ -308,33 +363,51 @@ test.describe("Die Uhr übersteht alles", () => {
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.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();
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());
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,
})
.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,
});
await expect(page.getByTestId("verbindung")).toHaveAttribute(
"data-verbunden",
"true",
{
timeout: 60_000,
},
);
});
});
+76 -37
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { anmelden } from "./wehr";
/**
* E2E-Tests für Phase 3.
@@ -14,17 +15,6 @@ import { expect, test, type Page } from "@playwright/test";
* 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);
});
@@ -35,10 +25,17 @@ test.describe("Einsatzliste", () => {
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();
await expect(
page.getByRole("link", { name: /\d{4}-\d{4}/ }).first(),
).toBeVisible();
// Die Zelle enthaelt Stichwort UND Objekt ("B 3 Wohnhausbrand" +
// "Reihenhaus, zwei Vollgeschosse"). Deshalb der Anker am Anfang statt
// einer Pruefung auf Gleichheit — die ging bis Phase 8 nur deshalb durch,
// weil in der Beispielwehr Einsaetze aus frueheren Testlaeufen ohne Objekt
// standen.
await expect(
page.getByRole("cell", { name: /^B 3 Wohnhausbrand/ }).first(),
).toBeVisible();
});
test("filtert über die Suche", async ({ page }) => {
@@ -76,7 +73,9 @@ test.describe("Einsatz erfassen und bearbeiten", () => {
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
einsatzAdresse = page.url();
await expect(page.getByRole("heading", { name: /THL 1 Testeinsatz/ })).toBeVisible();
await expect(
page.getByRole("heading", { name: /THL 1 Testeinsatz/ }),
).toBeVisible();
await expect(page.getByText("läuft", { exact: true })).toBeVisible();
});
@@ -86,20 +85,28 @@ test.describe("Einsatz erfassen und bearbeiten", () => {
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 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();
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 }) => {
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();
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/ });
@@ -113,19 +120,26 @@ test.describe("Einsatz erfassen und bearbeiten", () => {
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
.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();
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 }) => {
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]');
const linkfeld = page.locator("input[readonly]");
await expect(linkfeld).toBeVisible();
const adresse = await linkfeld.inputValue();
expect(adresse).toContain("/portal/");
@@ -136,18 +150,28 @@ test.describe("Einsatz erfassen und bearbeiten", () => {
await fremdeSeite.goto(adresse);
await expect(fremdeSeite.getByRole("heading", { name: /Testeinsatz/ })).toBeVisible();
await expect(fremdeSeite.getByText("Zugang für FF Nachbarhausen")).toBeVisible();
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
.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();
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 expect(fremdeSeite.getByRole("link", { name: "Geräte" })).toHaveCount(
0,
);
await fremd.close();
});
@@ -156,7 +180,9 @@ test.describe("Einsatz erfassen und bearbeiten", () => {
const fremd = await browser.newContext();
const seite = await fremd.newPage();
await seite.goto("/portal/3f2504e0-4f89-11d3-9a0c-0305e82c3301.abcdefghijklmnopqrstuvwxyz01");
await seite.goto(
"/portal/3f2504e0-4f89-11d3-9a0c-0305e82c3301.abcdefghijklmnopqrstuvwxyz01",
);
await expect(seite.getByTestId("portalfehler")).toBeVisible();
await expect(seite.getByText(/nicht gültig/)).toBeVisible();
@@ -169,10 +195,16 @@ 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.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();
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();
@@ -187,11 +219,18 @@ test.describe("Übungen", () => {
await expect(page.getByText("Innenangriff mit Atemschutz")).toBeVisible();
});
test("öffnet eine Übung und zeigt die Anwesenheitsliste", async ({ page }) => {
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 page
.getByRole("link", { name: /\d{4}-\d{4}/ })
.first()
.click();
await expect(page.getByRole("heading", { name: "Anwesenheit" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Anwesenheit" }),
).toBeVisible();
await expect(page.getByText(/von \d+ aktiven Mitgliedern/)).toBeVisible();
});
});
+45 -24
View File
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { anmelden } from "./wehr";
/**
* E2E-Tests für das Geräteformular.
@@ -18,16 +19,18 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
* werden deshalb getippt, so wie es ein Mensch auch täte.
*/
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
/** Eindeutig je Testlauf, damit sich parallele Läufe nicht ins Gehege kommen. */
function testnummer(kennzeichen: string): string {
return `E2E-${kennzeichen}-${Date.now().toString().slice(-6)}`;
}
/** Tippt ein Datum in ein date-Feld. Reihenfolge nach deutscher Locale. */
async function datumTippen(feld: Locator, tag: string, monat: string, jahr: string) {
async function datumTippen(
feld: Locator,
tag: string,
monat: string,
jahr: string,
) {
await feld.click();
await feld.pressSequentially(`${tag}${monat}${jahr}`, { delay: 30 });
}
@@ -44,16 +47,12 @@ function auswahl(page: Page, name: string): Locator {
return page.getByRole("combobox", { name, exact: true });
}
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("/");
}
/** Füllt die beiden Pflichtfelder und wählt eine Lagerung. */
async function grunddatenAusfuellen(page: Page, nummer: string, bezeichnung: string) {
async function grunddatenAusfuellen(
page: Page,
nummer: string,
bezeichnung: string,
) {
await feld(page, "Inventarnummer").fill(nummer);
await feld(page, "Bezeichnung").fill(bezeichnung);
}
@@ -63,11 +62,15 @@ test.beforeEach(async ({ page }) => {
});
test.describe("Gerät anlegen", () => {
test("legt ein Gerät an einem Standort an und öffnet es danach", async ({ page }) => {
test("legt ein Gerät an einem Standort an und öffnet es danach", async ({
page,
}) => {
const nummer = testnummer("STANDORT");
await page.goto("/geraete/neu");
await expect(page.getByRole("heading", { name: "Neues Gerät" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Neues Gerät" }),
).toBeVisible();
await grunddatenAusfuellen(page, nummer, "Testgerät Standort");
await feld(page, "Hersteller").fill("Prüffirma");
@@ -78,11 +81,15 @@ test.describe("Gerät anlegen", () => {
await page.getByRole("button", { name: "Gerät anlegen" }).click();
await page.waitForURL(/\/geraete\/[0-9a-f-]{36}$/);
await expect(page.getByRole("heading", { name: "Testgerät Standort" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Testgerät Standort" }),
).toBeVisible();
await expect(page.getByText(nummer).first()).toBeVisible();
});
test("legt ein Gerät auf einem Fahrzeug samt Geräteraum an", async ({ page }) => {
test("legt ein Gerät auf einem Fahrzeug samt Geräteraum an", async ({
page,
}) => {
const nummer = testnummer("FAHRZEUG");
await page.goto("/geraete/neu");
@@ -91,7 +98,9 @@ test.describe("Gerät anlegen", () => {
await page.getByRole("radio", { name: "Auf einem Fahrzeug" }).click();
await auswahl(page, "Fahrzeug").click();
await page.getByRole("option", { name: /Florian Musterhausen 40\/1/ }).click();
await page
.getByRole("option", { name: /Florian Musterhausen 40\/1/ })
.click();
await auswahl(page, "Geräteraum").click();
await page.getByRole("option", { name: /^G1/ }).click();
@@ -113,12 +122,16 @@ test.describe("Gerät anlegen", () => {
await page.getByRole("radio", { name: "Auf einem Fahrzeug" }).click();
await page.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(page.getByTestId("formularfehler")).toContainText("Fahrzeug auswählen");
await expect(page.getByTestId("formularfehler")).toContainText(
"Fahrzeug auswählen",
);
// Nichts gespeichert: die Adresse hat sich nicht geändert.
await expect(page).toHaveURL(/\/geraete\/neu$/);
});
test("rechnet die Fristen schon beim Ausfüllen vor", async ({ page }, angaben) => {
test("rechnet die Fristen schon beim Ausfüllen vor", async ({
page,
}, angaben) => {
// Unter Touch-Emulation nimmt Chromium keine zeichenweise Eingabe in
// Datumsfelder an — dort öffnet sich der Auswähler des Betriebssystems.
// Die Rechnung selbst prüfen die Unit-Tests in qr-und-fristen.test.ts;
@@ -142,7 +155,9 @@ test.describe("Gerät anlegen", () => {
await expect(page.getByText("01.03.2036")).toBeVisible();
});
test("übernimmt Prüfintervall und Lebensdauer aus dem Gerätetyp", async ({ page }, angaben) => {
test("übernimmt Prüfintervall und Lebensdauer aus dem Gerätetyp", async ({
page,
}, angaben) => {
test.skip(
angaben.project.name === "tablet",
"Datumsfelder lassen sich unter Touch-Emulation nicht tippen.",
@@ -166,7 +181,9 @@ test.describe("Gerät anlegen", () => {
});
test.describe("Gerät bearbeiten", () => {
test("ändert ein vorhandenes Gerät und protokolliert es", async ({ page }) => {
test("ändert ein vorhandenes Gerät und protokolliert es", async ({
page,
}) => {
await page.goto("/geraete");
await page.getByRole("link", { name: "INV-0002" }).click();
await page.getByRole("link", { name: "Bearbeiten" }).click();
@@ -182,10 +199,14 @@ test.describe("Gerät bearbeiten", () => {
// Die Änderung steht im Verlauf. Bei wiederholten Läufen sammeln sich dort
// mehrere Einträge — der Log ist append-only, das ist so gewollt.
await expect(page.getByText(/Gerät INV-0002 geändert/).first()).toBeVisible();
await expect(
page.getByText(/Gerät INV-0002 geändert/).first(),
).toBeVisible();
});
test("schaltet die Lagerung von Fahrzeug auf Standort um", async ({ page }) => {
test("schaltet die Lagerung von Fahrzeug auf Standort um", async ({
page,
}) => {
await page.goto("/geraete");
await page.getByRole("link", { name: "INV-0003" }).click();
await page.getByRole("link", { name: "Bearbeiten" }).click();
+79 -35
View File
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { expect, test, type Locator } from "@playwright/test";
import { anmelden } from "./wehr";
/**
* E2E-Tests für Phase 6 — die Kartenmodule.
@@ -16,9 +17,6 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
*/
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.
*
@@ -28,7 +26,9 @@ const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
* 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 }> {
async function kartenflaeche(
karte: Locator,
): Promise<{ x: number; y: number; width: number; height: number }> {
await karte.scrollIntoViewIfNeeded();
await expect(karte.locator("canvas")).toBeVisible();
@@ -37,14 +37,6 @@ async function kartenflaeche(karte: Locator): Promise<{ x: number; y: number; wi
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);
@@ -69,16 +61,22 @@ test.describe("Karte", () => {
// ungeprüft.
.toBeGreaterThanOrEqual(20);
await expect(page.getByRole("button", { name: /Objekte \(\d+\)/ })).toBeVisible();
await expect(
page.getByRole("button", { name: /Objekte \(\d+\)/ }),
).toBeVisible();
});
test("sagt es, wenn kein Kartenhintergrund eingerichtet ist", async ({ page }) => {
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");
await expect(page.getByTestId("karte-ohne-kacheln")).toContainText(
"Punkte liegen richtig",
);
});
test("findet über die Suche einen bestimmten Hydranten", async ({ page }) => {
@@ -87,7 +85,9 @@ test.describe("Karte", () => {
// „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.getByRole("cell", { name: "H-0004", exact: true }),
).toBeVisible();
await expect(page.getByText("Weiher Kiesgrube")).toBeVisible();
});
@@ -97,8 +97,12 @@ test.describe("Karte", () => {
// „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();
await expect(
page.getByRole("cell", { name: "defekt" }).first(),
).toBeVisible();
await expect(
page.getByRole("cell", { name: "ungeprüft" }).first(),
).toBeVisible();
});
});
@@ -115,7 +119,9 @@ test.describe("Objekte und Feuerwehrpläne", () => {
// 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(
"Sauerstoffflaschen",
);
await expect(page.getByTestId("objekt-detail")).toContainText("FSD");
});
@@ -143,25 +149,37 @@ test.describe("Objekte und Feuerwehrpläne", () => {
});
test.describe("Wasserförderung", () => {
test("rechnet eine Strecke durch, sobald zwei Punkte stehen", async ({ page }) => {
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");
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 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);
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");
await expect(page.getByTestId("foerderplan")).toContainText(
"Schlauchlängen",
);
// Und der Satz, der immer dazugehört.
await expect(page.getByText(/Planungshilfe/)).toBeVisible();
@@ -178,13 +196,21 @@ test.describe("Wasserförderung", () => {
// 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 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 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();
await expect(
page.getByRole("columnheader", { name: "Längen" }),
).toBeVisible();
const pumpen = Number(await page.getByTestId("pumpenzahl").innerText());
expect(pumpen).toBeGreaterThanOrEqual(1);
@@ -192,13 +218,18 @@ test.describe("Wasserförderung", () => {
});
test.describe("Lagekarte", () => {
test("trägt ein taktisches Zeichen ein und nimmt es zurück", async ({ page }) => {
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
.getByRole("link", { name: /\d{4}-\d{4}/ })
.first()
.click();
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
const lagekarte = page.getByTestId("lagekarte");
@@ -209,13 +240,23 @@ test.describe("Lagekarte", () => {
const flaeche = await kartenflaeche(lagekarte.getByTestId("karte"));
await page.mouse.click(flaeche.x + flaeche.width / 2, flaeche.y + flaeche.height / 2);
await page.mouse.click(
flaeche.x + flaeche.width / 2,
flaeche.y + flaeche.height / 2,
);
await expect(lagekarte.getByText("loeschfahrzeug").first()).toBeVisible({ timeout: 30_000 });
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 });
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 }) => {
@@ -223,7 +264,10 @@ test.describe("Lagekarte", () => {
await anmelden(page);
await page.goto("/einsaetze");
await page.getByRole("link", { name: /\d{4}-\d{4}/ }).first().click();
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
+85 -34
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { ADMIN_EMAIL, ADMIN_PASSWORT, WEHR_SLUG } from "./wehr";
/**
* E2E-Tests für Phase 4 — das Terminal im Gerätehaus.
@@ -19,16 +20,14 @@ import { expect, test, type Page } from "@playwright/test";
*/
test.describe.configure({ mode: "serial" });
const GERAETE_SCHLUESSEL = process.env.KIOSK_DEVICE_SECRET ?? "bitte_aendern_kiosk_geheimnis";
const WEHR = "ff-musterhausen";
const GERAETE_SCHLUESSEL =
process.env.KIOSK_DEVICE_SECRET ?? "bitte_aendern_kiosk_geheimnis";
const WEHR = WEHR_SLUG;
/** Aus dem Seed: Andreas Huber, Mitgliedsnummer 0001. */
const MITGLIEDSNUMMER = "0001";
const PIN = "471203";
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
/**
* Beide Playwright-Projekte (Desktop und Tablet) laufen gegen dieselbe
* Datenbank. Damit sich die Testdaten nicht überlagern, trägt jedes seine
@@ -43,12 +42,16 @@ async function terminalEinrichten(page: Page): Promise<void> {
await page.goto(
`/kiosk/einrichten?wehr=${WEHR}&schluessel=${encodeURIComponent(GERAETE_SCHLUESSEL)}`,
);
await expect(page.getByRole("heading", { name: "Terminal einrichten" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Terminal einrichten" }),
).toBeVisible();
// Erst wenn die Felder aus der Adresse gefüllt sind, hat das Formular etwas
// abzuschicken — sie kommen aus einem Effekt, also nach der Hydration.
await expect(page.getByLabel("Kurzname der Wehr")).toHaveValue(WEHR);
await expect(page.getByLabel("Gerätegeheimnis")).toHaveValue(GERAETE_SCHLUESSEL);
await expect(page.getByLabel("Gerätegeheimnis")).toHaveValue(
GERAETE_SCHLUESSEL,
);
await page.getByRole("button", { name: "Terminal einrichten" }).click();
await page.waitForURL("**/kiosk");
@@ -81,17 +84,23 @@ async function verwaltungAnmelden(page: Page): Promise<void> {
}
test.describe("Terminal", () => {
test("verlangt die Einrichtung, bevor sich jemand anmelden kann", async ({ page }) => {
test("verlangt die Einrichtung, bevor sich jemand anmelden kann", async ({
page,
}) => {
// Frischer Kontext, also leerer localStorage: das Tablet kennt keine Wehr.
await page.goto("/kiosk");
await expect(page.getByRole("heading", { name: "Terminal nicht eingerichtet" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Terminal nicht eingerichtet" }),
).toBeVisible();
});
test("meldet mit Mitgliedsnummer und PIN an", async ({ page }) => {
await terminalAnmelden(page);
// Die Kacheln richten sich nach den Rechten — Kommen/Gehen hat jeder.
await expect(page.getByText("Anwesenheit erfassen").or(page.getByText("da seit"))).toBeVisible();
await expect(
page.getByText("Anwesenheit erfassen").or(page.getByText("da seit")),
).toBeVisible();
await expect(page.getByText("Mangel melden")).toBeVisible();
});
@@ -104,7 +113,9 @@ test.describe("Terminal", () => {
await page.getByRole("button", { name: "Anmelden" }).click();
await expect(page.getByTestId("anmeldefehler")).toBeVisible();
await expect(page.getByTestId("anmeldefehler")).toContainText("Mitgliedsnummer und PIN");
await expect(page.getByTestId("anmeldefehler")).toContainText(
"Mitgliedsnummer und PIN",
);
});
test("erfasst Kommen oder Gehen mit zwei Tipps", async ({ page }) => {
@@ -134,20 +145,31 @@ test.describe("Offline-Betrieb", () => {
let mangeltext = "";
let waeschestueck = "";
test("erfasst ohne Netz und überträgt, sobald das Netz zurück ist", async ({ page, context }) => {
test("erfasst ohne Netz und überträgt, sobald das Netz zurück ist", async ({
page,
context,
}) => {
await terminalAnmelden(page);
// Einmal mit Netz laden — so, wie das Tablet im Gerätehaus hängt, bevor
// der Router streikt.
await page.goto("/kiosk/mangel");
await expect(page.getByRole("heading", { name: "Mangel melden" })).toBeVisible();
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offline", "false");
await expect(
page.getByRole("heading", { name: "Mangel melden" }),
).toBeVisible();
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
"data-offline",
"false",
);
// --- Netz weg -----------------------------------------------------------
await context.setOffline(true);
// Die Zustandsleiste sagt es, ohne dass jemand danach suchen muss.
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offline", "true");
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
"data-offline",
"true",
);
await expect(page.getByText("Kein Netz")).toBeVisible();
// Trotzdem erfassen: Nummer eintippen (eine Kamera gibt es im Test nicht),
@@ -164,28 +186,41 @@ test.describe("Offline-Betrieb", () => {
// Und der Vorgang wartet sichtbar in der Warteschlange. Die Bestätigung
// springt nach ein paar Sekunden zurück auf die Kacheln; die Leiste läuft
// dort weiter, deshalb hält die Zusicherung über den Wechsel hinweg.
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offen", "1");
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
"data-offen",
"1",
);
await expect(page.getByText("Änderung wartet")).toBeVisible();
// --- Netz zurück --------------------------------------------------------
await context.setOffline(false);
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offline", "false", {
timeout: 30_000,
});
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
"data-offline",
"false",
{
timeout: 30_000,
},
);
// Die Warteschlange leert sich von selbst — der Knopf ist nur die Abkürzung
// für den Ungeduldigen und darf deshalb auch schon verschwunden sein.
const uebertragen = page.getByRole("button", { name: "Jetzt übertragen" });
if (await uebertragen.isVisible()) await uebertragen.click();
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offen", "0", {
timeout: 60_000,
});
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
"data-offen",
"0",
{
timeout: 60_000,
},
);
await expect(page.getByText("alles übertragen")).toBeVisible();
});
test("der offline erfasste Mangel steht danach in der Verwaltung", async ({ page }) => {
test("der offline erfasste Mangel steht danach in der Verwaltung", async ({
page,
}) => {
expect(mangeltext, "der vorherige Test muss gelaufen sein").not.toBe("");
await verwaltungAnmelden(page);
@@ -206,7 +241,9 @@ test.describe("Offline-Betrieb", () => {
await terminalAnmelden(page);
await page.goto("/kiosk/waesche");
await expect(page.getByRole("heading", { name: "Wäsche abgeben" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Wäsche abgeben" }),
).toBeVisible();
/**
* Der Fall, für den es den Idempotenzschlüssel gibt: die Anfrage kommt beim
@@ -221,7 +258,10 @@ test.describe("Offline-Betrieb", () => {
let ersteAntwortVerschluckt = false;
await context.route("**/api/trpc/**", async (route) => {
const adresse = route.request().url();
if (!ersteAntwortVerschluckt && adresse.includes("kiosk.vorgaengeSenden")) {
if (
!ersteAntwortVerschluckt &&
adresse.includes("kiosk.vorgaengeSenden")
) {
ersteAntwortVerschluckt = true;
// Anfrage geht durch (der Server bucht), die Antwort kommt nie an.
await route.fetch();
@@ -247,14 +287,19 @@ test.describe("Offline-Betrieb", () => {
await page.getByRole("button", { name: "Jetzt übertragen" }).click();
// Der zweite Versuch läuft durch — der Vorgang ist damit erledigt.
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offen", "0", {
timeout: 60_000,
});
expect(ersteAntwortVerschluckt, "die erste Übertragung muss unterbrochen worden sein").toBe(
true,
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
"data-offen",
"0",
{
timeout: 60_000,
},
);
expect(
ersteAntwortVerschluckt,
"die erste Übertragung muss unterbrochen worden sein",
).toBe(true);
// Erst abräumen, wenn nichts mehr unterwegs ist — sonst schlägt eine noch
// laufende Umleitung beim Testende zu.
await context.unrouteAll({ behavior: "ignoreErrors" });
@@ -275,13 +320,19 @@ test.describe("Offline-Betrieb", () => {
});
test.describe("Hallenmonitor", () => {
test("zeigt Uhrzeit, Anwesende, Fahrzeuge und fällige Prüfungen", async ({ page }) => {
test("zeigt Uhrzeit, Anwesende, Fahrzeuge und fällige Prüfungen", async ({
page,
}) => {
await terminalAnmelden(page);
await page.goto("/kiosk/monitor");
await expect(page.getByText("Im Haus")).toBeVisible();
await expect(page.getByText("Fahrzeuge", { exact: true }).first()).toBeVisible();
await expect(
page.getByText("Fahrzeuge", { exact: true }).first(),
).toBeVisible();
// Die Uhr läuft — das Format genügt als Nachweis.
await expect(page.getByText(/^\d{2}:\d{2}(:\d{2})?$/).first()).toBeVisible();
await expect(
page.getByText(/^\d{2}:\d{2}(:\d{2})?$/).first(),
).toBeVisible();
});
});
+112 -46
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { anmelden } from "./wehr";
/**
* E2E-Tests für Phase 2.
@@ -15,50 +16,65 @@ import { expect, test, type Page } from "@playwright/test";
* 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("Fristen-Dashboard", () => {
test("zeigt die vier Zeitfenster und die offenen Termine", async ({ page }) => {
test("zeigt die vier Zeitfenster und die offenen Termine", async ({
page,
}) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Prüfungen und Fristen" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Prüfungen und Fristen" }),
).toBeVisible();
// Die vier Töpfe sind gleichzeitig Filter.
for (const bezeichnung of ["überfällig", "diese Woche", "diesen Monat", "nächste 90 Tage"]) {
await expect(page.getByRole("button", { name: new RegExp(bezeichnung) })).toBeVisible();
for (const bezeichnung of [
"überfällig",
"diese Woche",
"diesen Monat",
"nächste 90 Tage",
]) {
await expect(
page.getByRole("button", { name: new RegExp(bezeichnung) }),
).toBeVisible();
}
await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Offene Termine" }),
).toBeVisible();
// Aus dem Seed entstehen Termine für die Geräte mit Prüfvorgabe.
await expect(page.getByRole("cell", { name: /INV-\d{4}/ }).first()).toBeVisible();
await expect(
page.getByRole("cell", { name: /INV-\d{4}/ }).first(),
).toBeVisible();
});
test("filtert die Terminliste über ein Zeitfenster", async ({ page }) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Offene Termine" }),
).toBeVisible();
await page.getByRole("button", { name: /überfällig/ }).first().click();
await page
.getByRole("button", { name: /überfällig/ })
.first()
.click();
await expect(page.getByRole("button", { name: "Filter aufheben" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Filter aufheben" }),
).toBeVisible();
});
test("bündelt die Termine nach Fahrzeug und lässt umschalten", async ({ page }) => {
test("bündelt die Termine nach Fahrzeug und lässt umschalten", async ({
page,
}) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Verteilung" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Verteilung" }),
).toBeVisible();
await page.getByLabel("Bündelung wählen").click();
await page.getByRole("option", { name: "nach Gerätetyp" }).click();
@@ -76,19 +92,29 @@ test.describe("Wartungsvorlagen", () => {
test("zeigt die Vorlagen mit Intervall und Prüfpunkten", async ({ page }) => {
await page.goto("/pruefungen/vorlagen");
await expect(page.getByRole("heading", { name: "Wartungsvorlagen" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Wartungsvorlagen" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }),
).toBeVisible();
await expect(page.getByRole("cell", { name: "DGUV Vorschrift 3, DIN VDE 0701-0702" })).toBeVisible();
await expect(
page.getByRole("cell", { name: "DGUV Vorschrift 3, DIN VDE 0701-0702" }),
).toBeVisible();
});
test("öffnet die Checkliste einer Vorlage", async ({ page }) => {
await page.goto("/pruefungen/vorlagen");
await page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }).click();
await page
.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" })
.click();
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible();
await expect(page.getByText("Ansprechdruck der Warneinrichtung")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Checkliste" }),
).toBeVisible();
await expect(
page.getByText("Ansprechdruck der Warneinrichtung"),
).toBeVisible();
// Der zulässige Bereich steht am Punkt.
await expect(page.getByText(/zulässig 50 bis 60 bar/)).toBeVisible();
});
@@ -114,12 +140,17 @@ function geraetIndex(): number {
async function pruefungStarten(page: Page): Promise<void> {
await page.goto("/geraete");
await page.getByLabel("Geräte durchsuchen").fill("Feuerwehrleine");
await page.getByRole("link", { name: /INV-\d{4}/ }).nth(geraetIndex()).click();
await page
.getByRole("link", { name: /INV-\d{4}/ })
.nth(geraetIndex())
.click();
await page.getByRole("link", { name: "Prüfung durchführen" }).click();
}
test.describe("Prüfwizard", () => {
test("führt von der Checkliste bis zum unterschriebenen Protokoll", async ({ page }) => {
test("führt von der Checkliste bis zum unterschriebenen Protokoll", async ({
page,
}) => {
// Der laengste Weg der Anwendung: Wizard, Unterschrift, Nummernkreis und
// Protokoll. Laeuft die ganze Suite parallel, reicht das Standardlimit
// dafuer nicht — das ist Uebersetzungszeit des Entwicklungsservers, kein
@@ -133,15 +164,22 @@ test.describe("Prüfwizard", () => {
// Freitext. Vor jedem Schritt wird auf die Schrittanzeige gewartet — sonst
// klickt der Test schneller, als die Oberfläche umschaltet.
for (const nummer of [1, 2, 3, 4]) {
await expect(page.getByText(new RegExp(`Schritt ${nummer} von 4`))).toBeVisible();
await expect(
page.getByText(new RegExp(`Schritt ${nummer} von 4`)),
).toBeVisible();
// "in Ordnung" exakt: sonst trifft der Name auch "nicht in Ordnung".
const inOrdnung = page.getByRole("button", { name: "in Ordnung", exact: true });
const inOrdnung = page.getByRole("button", {
name: "in Ordnung",
exact: true,
});
if ((await inOrdnung.count()) > 0) await inOrdnung.click();
const befund = page.getByLabel("Befund");
if ((await befund.count()) > 0) {
await befund.fill("Leine vollständig durch die Hand gezogen, ohne Befund.");
await befund.fill(
"Leine vollständig durch die Hand gezogen, ohne Befund.",
);
}
await page.getByRole("button", { name: "Weiter" }).click();
@@ -149,7 +187,9 @@ test.describe("Prüfwizard", () => {
// Abschluss: Ergebnis ist vorbelegt, Unterschrift fehlt noch.
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled();
await expect(
page.getByRole("button", { name: "Prüfung abschließen" }),
).toBeDisabled();
// Unterschrift zeichnen — mit der Maus, wie mit dem Finger auf dem Tablet.
// Erst ins Bild holen: die Mauskoordinaten beziehen sich auf das sichtbare
@@ -163,7 +203,9 @@ test.describe("Prüfwizard", () => {
await page.mouse.move(rahmen!.x + 30, rahmen!.y + rahmen!.height / 2);
await page.mouse.down();
await page.mouse.move(rahmen!.x + 90, rahmen!.y + 20, { steps: 8 });
await page.mouse.move(rahmen!.x + 150, rahmen!.y + rahmen!.height - 20, { steps: 8 });
await page.mouse.move(rahmen!.x + 150, rahmen!.y + rahmen!.height - 20, {
steps: 8,
});
await page.mouse.move(rahmen!.x + 210, rahmen!.y + 30, { steps: 8 });
await page.mouse.up();
@@ -171,20 +213,30 @@ test.describe("Prüfwizard", () => {
await expect(page.getByRole("button", { name: "Löschen" })).toBeEnabled();
await page.getByRole("button", { name: "Übernehmen" }).click();
await expect(page.getByRole("button", { name: "übernommen" })).toBeVisible();
await expect(
page.getByRole("button", { name: "übernommen" }),
).toBeVisible();
await page.getByRole("button", { name: "Prüfung abschließen" }).click();
// Danach steht das Protokoll da — mit Nummer und Ergebnis.
await page.waitForURL(/\/pruefungen\/[0-9a-f-]{36}$/);
await expect(page.getByRole("heading", { name: /Prüfprotokoll \d{4}-\d{4}/ })).toBeVisible();
await expect(
page.getByRole("heading", { name: /Prüfprotokoll \d{4}-\d{4}/ }),
).toBeVisible();
// Genau der Kleinschreibung wegen "exact": darüber steht auch das Feld
// "Abgeschlossen" mit dem Zeitpunkt.
await expect(page.getByText("abgeschlossen", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible();
await expect(
page.getByText("abgeschlossen", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Checkliste" }),
).toBeVisible();
});
test("verlangt eine Unterschrift, bevor abgeschlossen werden kann", async ({ page }) => {
test("verlangt eine Unterschrift, bevor abgeschlossen werden kann", async ({
page,
}) => {
await pruefungStarten(page);
// Erst warten, bis der Wizard steht — er legt die Prüfung beim Öffnen an.
@@ -198,7 +250,9 @@ test.describe("Prüfwizard", () => {
}
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled();
await expect(
page.getByRole("button", { name: "Prüfung abschließen" }),
).toBeDisabled();
});
});
@@ -207,17 +261,25 @@ test.describe("Mängel und Rückrufe", () => {
await page.goto("/maengel");
await expect(page.getByRole("heading", { name: "Mängel" })).toBeVisible();
await expect(page.getByText(/Leinenbeutel an einer Naht aufgescheuert/)).toBeVisible();
await expect(
page.getByText(/Leinenbeutel an einer Naht aufgescheuert/),
).toBeVisible();
});
test("zeigt den Rückruf und seine betroffenen Geräte", async ({ page }) => {
await page.goto("/rueckrufe");
await expect(page.getByRole("heading", { name: "Rückrufe" })).toBeVisible();
await page.getByRole("link", { name: /Sicherheitshinweis Ausatemventil/ }).click();
await page
.getByRole("link", { name: /Sicherheitshinweis Ausatemventil/ })
.click();
await expect(page.getByRole("heading", { name: "Betroffene Geräte" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Mögliche Treffer im Bestand" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Betroffene Geräte" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Mögliche Treffer im Bestand" }),
).toBeVisible();
});
});
@@ -225,7 +287,11 @@ test.describe("Protokolle", () => {
test("listet abgeschlossene Prüfungen mit Ergebnis", async ({ page }) => {
await page.goto("/pruefungen/verlauf");
await expect(page.getByRole("heading", { name: "Prüfprotokolle" })).toBeVisible();
await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible();
await expect(
page.getByRole("heading", { name: "Prüfprotokolle" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: /\d{4}-\d{4}/ }).first(),
).toBeVisible();
});
});
+80 -38
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { anmelden } from "./wehr";
/**
* E2E-Tests für Phase 1.
@@ -10,17 +11,6 @@ import { expect, test, type Page } from "@playwright/test";
* 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);
});
@@ -42,11 +32,17 @@ test.describe("Geräteliste", () => {
await page.getByLabel("Geräte durchsuchen").fill("Pressluftatmer");
// Die Suche ist entprellt — deshalb auf das Ergebnis warten, nicht auf die Zeit.
await expect(page.getByRole("cell", { name: "Pressluftatmer PA 94" }).first()).toBeVisible();
await expect(page.getByRole("cell", { name: "Feuerwehrhelm F220" })).toHaveCount(0);
await expect(
page.getByRole("cell", { name: "Pressluftatmer PA 94" }).first(),
).toBeVisible();
await expect(
page.getByRole("cell", { name: "Feuerwehrhelm F220" }),
).toHaveCount(0);
});
test("meldet verständlich, wenn die Suche nichts findet", async ({ page }) => {
test("meldet verständlich, wenn die Suche nichts findet", async ({
page,
}) => {
await page.goto("/geraete");
await page.getByLabel("Geräte durchsuchen").fill("gibtesnichtxyz");
@@ -55,22 +51,30 @@ test.describe("Geräteliste", () => {
test("blendet Spalten aus und wieder ein", async ({ page }) => {
await page.goto("/geraete");
await expect(page.getByRole("columnheader", { name: /Hersteller/i })).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /Hersteller/i }),
).toBeVisible();
await page.getByRole("button", { name: "Spalten" }).click();
await page.getByRole("checkbox", { name: "Hersteller" }).click();
await expect(page.getByRole("columnheader", { name: /Hersteller/i })).toHaveCount(0);
await expect(
page.getByRole("columnheader", { name: /Hersteller/i }),
).toHaveCount(0);
});
test("zeigt Sammelaktionen, sobald etwas ausgewählt ist", async ({ page }) => {
test("zeigt Sammelaktionen, sobald etwas ausgewählt ist", async ({
page,
}) => {
await page.goto("/geraete");
await expect(page.getByRole("link", { name: "INV-0001" })).toBeVisible();
await page.getByRole("checkbox", { name: "INV-0001 auswählen" }).click();
await expect(page.getByText("1 Gerät ausgewählt")).toBeVisible();
await expect(page.getByRole("button", { name: "Etiketten drucken" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Etiketten drucken" }),
).toBeVisible();
});
});
@@ -96,14 +100,18 @@ test.describe("Gerätedetail", () => {
});
test.describe("QR-Route", () => {
test("leitet von /g/<inventarnummer> auf das Gerät weiter", async ({ page }) => {
test("leitet von /g/<inventarnummer> auf das Gerät weiter", async ({
page,
}) => {
await page.goto("/g/INV-0001");
await expect(page).toHaveURL(/\/geraete\/[0-9a-f-]{36}/);
await expect(page.getByText("INV-0001").first()).toBeVisible();
});
test("erklärt verständlich, wenn es die Nummer nicht gibt", async ({ page }) => {
test("erklärt verständlich, wenn es die Nummer nicht gibt", async ({
page,
}) => {
await page.goto("/g/INV-GIBTESNICHT");
await expect(page.getByText("Kein Gerät gefunden")).toBeVisible();
@@ -126,13 +134,19 @@ test.describe("Fahrzeuge", () => {
test("zeigt die Fahrzeuge als Kacheln", async ({ page }) => {
await page.goto("/fahrzeuge");
await expect(page.getByRole("heading", { name: "Fahrzeuge" })).toBeVisible();
await expect(page.getByRole("link", { name: /Florian Musterhausen 40\/1/ })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Fahrzeuge" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: /Florian Musterhausen 40\/1/ }),
).toBeVisible();
});
test("zeigt die Beladung nach Geräteräumen", async ({ page }) => {
await page.goto("/fahrzeuge");
await page.getByRole("link", { name: /Florian Musterhausen 40\/1/ }).click();
await page
.getByRole("link", { name: /Florian Musterhausen 40\/1/ })
.click();
await expect(page.getByRole("heading", { name: "Beladung" })).toBeVisible();
// Die Beladeordnung aus dem Seed: G1 bis G6, Heck, Dach, Mannschaftsraum.
@@ -145,10 +159,14 @@ test.describe("Qualifikationen", () => {
test("zeigt Ablaufwarnungen und den Katalog", async ({ page }) => {
await page.goto("/qualifikationen");
await expect(page.getByRole("heading", { name: "Qualifikationen" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Qualifikationen" }),
).toBeVisible();
await expect(page.getByRole("heading", { name: "Läuft ab" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Katalog" })).toBeVisible();
await expect(page.getByText("Atemschutzgeräteträger").first()).toBeVisible();
await expect(
page.getByText("Atemschutzgeräteträger").first(),
).toBeVisible();
});
});
@@ -156,25 +174,39 @@ test.describe("Mitglieder", () => {
test("gruppiert nach Abteilung", async ({ page }) => {
await page.goto("/mitglieder");
await expect(page.getByRole("heading", { name: "Mitglieder" })).toBeVisible();
await expect(page.getByRole("heading", { name: /^Aktive \(/ })).toBeVisible();
await expect(page.getByRole("link", { name: "Huber, Andreas" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Mitglieder" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: /^Aktive \(/ }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Huber, Andreas" }),
).toBeVisible();
});
test("zeigt Qualifikationen am Mitglied", async ({ page }) => {
await page.goto("/mitglieder");
await page.getByRole("link", { name: "Huber, Andreas" }).click();
await expect(page.getByRole("heading", { name: "Qualifikationen" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Terminal-Zugang" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Qualifikationen" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Terminal-Zugang" }),
).toBeVisible();
});
});
test.describe("Import", () => {
test("führt durch die drei Schritte und zeigt die Vorschau", async ({ page }) => {
test("führt durch die drei Schritte und zeigt die Vorschau", async ({
page,
}) => {
await page.goto("/geraete/importieren");
await expect(page.getByRole("heading", { name: "1. Datei wählen" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "1. Datei wählen" }),
).toBeVisible();
const csv = [
"Inventarnummer;Bezeichnung;Hersteller;Indienststellung",
@@ -189,15 +221,23 @@ test.describe("Import", () => {
});
// Schritt 2 erscheint erst, wenn der Server die Datei gelesen hat.
await expect(page.getByRole("heading", { name: "2. Spalten zuordnen" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "2. Spalten zuordnen" }),
).toBeVisible();
await expect(page.getByRole("heading", { name: "Vorschau" })).toBeVisible();
// Eine Zeile ist gut, eine hat ein unlesbares Datum.
await expect(page.getByText("1 von 2 Zeilen sind übernehmbar")).toBeVisible();
await expect(page.getByRole("button", { name: /1 Zeilen übernehmen/ })).toBeEnabled();
await expect(
page.getByText("1 von 2 Zeilen sind übernehmbar"),
).toBeVisible();
await expect(
page.getByRole("button", { name: /1 Zeilen übernehmen/ }),
).toBeEnabled();
});
test("verweigert die Übernahme, wenn ein Pflichtfeld nicht zugeordnet ist", async ({ page }) => {
test("verweigert die Übernahme, wenn ein Pflichtfeld nicht zugeordnet ist", async ({
page,
}) => {
await page.goto("/geraete/importieren");
await page.setInputFiles("#datei", {
@@ -206,6 +246,8 @@ test.describe("Import", () => {
buffer: Buffer.from("Inventarnummer\nE2E-100\n", "utf8"),
});
await expect(page.getByText(/Diesen Pflichtfeldern ist keine Spalte zugeordnet/)).toBeVisible();
await expect(
page.getByText(/Diesen Pflichtfeldern ist keine Spalte zugeordnet/),
).toBeVisible();
});
});
+103 -55
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { anmelden, eigenerName } from "./wehr";
/**
* E2E-Tests für Phase 7 — Termine, Benachrichtigungen und Dienstausweis.
@@ -18,37 +19,27 @@ import { expect, test, type Page } from "@playwright/test";
*/
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";
/** Eindeutiger Titel je Lauf, damit sich parallele Läufe nicht ins Gehege kommen. */
function eigenerTitel(zweck: string): string {
return `E2E-${zweck}-${Date.now().toString(36)}`;
}
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("Terminkalender", () => {
test("zeigt die Termine aus dem Bestand", async ({ page }) => {
await anmelden(page);
await page.goto("/termine");
await expect(page.getByRole("heading", { name: "Termine", level: 1 })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Termine", level: 1 }),
).toBeVisible();
// Aus dem Seed: der Übungsabend als Serie und der Lehrgang mit Plätzen.
await expect(page.getByRole("link", { name: "Übungsabend", exact: true }).first()).toBeVisible();
await expect(
page.getByRole("link", { name: "Übungsabend", exact: true }).first(),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Maschinistenlehrgang Teil 1" }),
).toBeVisible();
});
test("zeigt einen abgesagten Termin mit Grund statt ihn zu verstecken", async ({ page }) => {
test("zeigt einen abgesagten Termin mit Grund statt ihn zu verstecken", async ({
page,
}) => {
await anmelden(page);
await page.goto("/termine");
@@ -67,7 +58,9 @@ test.describe("Terminkalender", () => {
const jhv = page.getByRole("link", { name: "Jahreshauptversammlung" });
await jhv.click();
await expect(page.getByRole("heading", { name: "Jahreshauptversammlung" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Jahreshauptversammlung" }),
).toBeVisible();
// "Absagen" exakt: sonst trifft es auch "Termin absagen" daneben.
await page.getByRole("button", { name: "Zusagen", exact: true }).click();
@@ -87,22 +80,30 @@ test.describe("Terminkalender", () => {
* schon einen Platz hat, hängt an der Reihenfolge im Seed. Was hier zählt,
* ist die Anzeige der Warteliste — die gilt für alle.
*/
test("zeigt Belegung und Warteliste eines begrenzten Lehrgangs", async ({ page }) => {
test("zeigt Belegung und Warteliste eines begrenzten Lehrgangs", async ({
page,
}) => {
await anmelden(page);
await page.goto("/termine");
await page.getByRole("link", { name: "Maschinistenlehrgang Teil 1" }).click();
await page
.getByRole("link", { name: "Maschinistenlehrgang Teil 1" })
.click();
await expect(page.getByText(/von 8 belegt/)).toBeVisible();
await expect(page.getByRole("heading", { name: /^Zugesagt \(8\)$/ })).toBeVisible();
await expect(page.getByRole("heading", { name: /^Warteliste \(\d+\)$/ })).toBeVisible();
await expect(
page.getByRole("heading", { name: /^Zugesagt \(8\)$/ }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: /^Warteliste \(\d+\)$/ }),
).toBeVisible();
// Die Warteliste ist nummeriert — sonst weiß niemand, wer nachrückt.
await expect(page.getByText("1.", { exact: false }).first()).toBeVisible();
});
test("legt einen Termin an", async ({ page }) => {
const titel = eigenerTitel("Einzeltermin");
const titel = eigenerName("Einzeltermin");
await anmelden(page);
await page.goto("/termine");
@@ -121,24 +122,28 @@ test.describe("Terminkalender", () => {
* Jahr" einstellt, soll vorher lesen, dass daraus über fünfzig Einträge
* werden — nicht danach.
*/
test("nennt vor dem Anlegen, wie viele Termine eine Serie ergibt", async ({ page }) => {
test("nennt vor dem Anlegen, wie viele Termine eine Serie ergibt", async ({
page,
}) => {
await anmelden(page);
await page.goto("/termine");
await page.getByRole("button", { name: "Termin anlegen" }).click();
await page.getByLabel("Titel").fill(eigenerTitel("Serienvorschau"));
await page.getByLabel("Titel").fill(eigenerName("Serienvorschau"));
await page.getByLabel("Wiederholung").click();
await page.getByRole("option", { name: "wöchentlich" }).click();
const inAchtWochen = new Date(Date.now() + 56 * 864e5);
await page.getByLabel("Serie läuft bis").fill(inAchtWochen.toISOString().slice(0, 10));
await page
.getByLabel("Serie läuft bis")
.fill(inAchtWochen.toISOString().slice(0, 10));
await expect(page.getByText(/Das legt \d+ Termine an/)).toBeVisible();
});
test("verlangt für eine Absage einen Grund", async ({ page }) => {
const titel = eigenerTitel("Absage");
const titel = eigenerName("Absage");
await anmelden(page);
await page.goto("/termine");
@@ -155,16 +160,22 @@ test.describe("Terminkalender", () => {
const dialog = page.getByRole("dialog");
// Ohne Grund geht es nicht weiter.
await expect(dialog.getByRole("button", { name: "Absagen", exact: true })).toBeDisabled();
await expect(
dialog.getByRole("button", { name: "Absagen", exact: true }),
).toBeDisabled();
await dialog.getByLabel("Grund").fill("Sturmwarnung, Übungsgelände nicht befahrbar.");
await dialog
.getByLabel("Grund")
.fill("Sturmwarnung, Übungsgelände nicht befahrbar.");
await dialog.getByRole("button", { name: "Absagen", exact: true }).click();
await expect(page.getByText("Dieser Termin fällt aus.")).toBeVisible();
await expect(page.getByText(/Sturmwarnung/)).toBeVisible();
});
test("führt eine Anwesenheitsliste über alle aktiven Mitglieder", async ({ page }) => {
test("führt eine Anwesenheitsliste über alle aktiven Mitglieder", async ({
page,
}) => {
await anmelden(page);
await page.goto("/termine");
@@ -172,20 +183,30 @@ test.describe("Terminkalender", () => {
await page.getByRole("button", { name: "Anwesenheitsliste" }).click();
await expect(page.getByRole("heading", { name: "Anwesenheitsliste" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Anwesenheitsliste" }),
).toBeVisible();
// Auf einem Blatt zum Abhaken steht jeder — nicht nur die Angemeldeten.
await expect(page.getByRole("columnheader", { name: "Anwesend" })).toBeVisible();
await expect(
page.getByRole("columnheader", { name: "Anwesend" }),
).toBeVisible();
await expect(page.getByRole("row")).not.toHaveCount(1);
});
});
test.describe("Benachrichtigungen", () => {
test("sagt deutlich, dass ohne eigenes Zutun nichts kommt", async ({ page }) => {
test("sagt deutlich, dass ohne eigenes Zutun nichts kommt", async ({
page,
}) => {
await anmelden(page);
await page.goto("/benachrichtigungen");
await expect(page.getByRole("heading", { name: "Benachrichtigungen", level: 1 })).toBeVisible();
await expect(page.getByText(/Niemand wird ungefragt benachrichtigt/)).toBeVisible();
await expect(
page.getByRole("heading", { name: "Benachrichtigungen", level: 1 }),
).toBeVisible();
await expect(
page.getByText(/Niemand wird ungefragt benachrichtigt/),
).toBeVisible();
});
/**
@@ -198,19 +219,23 @@ test.describe("Benachrichtigungen", () => {
await page.getByLabel("Über").click();
await expect(page.getByRole("option", { name: /Telegram/ })).toBeVisible();
await expect(page.getByRole("option", { name: /Browser-Push/ })).toBeVisible();
await expect(
page.getByRole("option", { name: /Browser-Push/ }),
).toBeVisible();
});
test("erzeugt eine Kalenderadresse, die genau einmal sichtbar ist", async ({ page }) => {
test("erzeugt eine Kalenderadresse, die genau einmal sichtbar ist", async ({
page,
}) => {
await anmelden(page);
await page.goto("/benachrichtigungen");
await page.getByLabel("Für welches Gerät?").fill(eigenerTitel("Kalender"));
await page.getByLabel("Für welches Gerät?").fill(eigenerName("Kalender"));
await page.getByRole("button", { name: "Adresse erzeugen" }).click();
await expect(page.getByText(/wird nur jetzt angezeigt/)).toBeVisible();
const feld = page.locator('input[readonly]');
const feld = page.locator("input[readonly]");
await expect(feld).toHaveValue(/\/api\/kalender\/.+\.ics$/);
// Nach dem Neuladen ist sie weg — gespeichert wird nur der Hash.
@@ -218,14 +243,17 @@ test.describe("Benachrichtigungen", () => {
await expect(page.getByText(/wird nur jetzt angezeigt/)).toBeHidden();
});
test("liefert unter der Kalenderadresse eine gültige ICS-Datei", async ({ page, request }) => {
test("liefert unter der Kalenderadresse eine gültige ICS-Datei", async ({
page,
request,
}) => {
await anmelden(page);
await page.goto("/benachrichtigungen");
await page.getByLabel("Für welches Gerät?").fill(eigenerTitel("ICS"));
await page.getByLabel("Für welches Gerät?").fill(eigenerName("ICS"));
await page.getByRole("button", { name: "Adresse erzeugen" }).click();
const adresse = await page.locator('input[readonly]').inputValue();
const adresse = await page.locator("input[readonly]").inputValue();
const antwort = await request.get(adresse);
expect(antwort.status()).toBe(200);
@@ -242,21 +270,29 @@ test.describe("Benachrichtigungen", () => {
expect(antwort.status()).toBe(404);
});
test("zeigt die Textvorlagen mit Platzhaltern und Vorschau", async ({ page }) => {
test("zeigt die Textvorlagen mit Platzhaltern und Vorschau", async ({
page,
}) => {
await anmelden(page);
await page.goto("/benachrichtigungen/vorlagen");
await expect(page.getByRole("heading", { name: "Textvorlagen", level: 1 })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Textvorlagen", level: 1 }),
).toBeVisible();
await expect(page.getByRole("heading", { name: "Vorschau" })).toBeVisible();
// Die Platzhalter stehen zum Anklicken bereit — als Knopf, nicht nur im Text.
await expect(page.getByRole("button", { name: "{titel}" })).toBeVisible();
// In der Vorschau stehen die Beispielwerte, nicht die Platzhalter.
await expect(page.getByText("Übungsabend", { exact: false }).first()).toBeVisible();
await expect(
page.getByText("Übungsabend", { exact: false }).first(),
).toBeVisible();
});
test("warnt bei einem Platzhalter, den es für das Ereignis nicht gibt", async ({ page }) => {
test("warnt bei einem Platzhalter, den es für das Ereignis nicht gibt", async ({
page,
}) => {
await anmelden(page);
await page.goto("/benachrichtigungen/vorlagen");
@@ -264,7 +300,9 @@ test.describe("Benachrichtigungen", () => {
await feld.fill("Am {datum} um {uhrzeit}. Und ein {tippfehler}.");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.getByTestId("vorlagenfehler")).toContainText("tippfehler");
await expect(page.getByTestId("vorlagenfehler")).toContainText(
"tippfehler",
);
});
});
@@ -273,10 +311,16 @@ test.describe("Dienstausweis", () => {
await anmelden(page);
await page.goto("/ausweis");
await expect(page.getByRole("heading", { name: "Dienstausweis", level: 1 })).toBeVisible();
await expect(page.getByRole("img", { name: "Dienstausweis als QR-Code" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Dienstausweis", level: 1 }),
).toBeVisible();
await expect(
page.getByRole("img", { name: "Dienstausweis als QR-Code" }),
).toBeVisible();
await expect(page.getByText(/Gültig noch/)).toBeVisible();
await expect(page.getByText(/Nach fünf Minuten ist er wertlos/)).toBeVisible();
await expect(
page.getByText(/Nach fünf Minuten ist er wertlos/),
).toBeVisible();
});
/**
@@ -284,13 +328,17 @@ test.describe("Dienstausweis", () => {
* scannt, hat keinen Zugang zu FLORIAN. Ein erfundener Code muss trotzdem
* abgewiesen werden, und zwar mit einem Satz, den ein Mensch versteht.
*/
test("weist einen erfundenen Ausweis ab, ohne Anmeldung", async ({ browser }) => {
test("weist einen erfundenen Ausweis ab, ohne Anmeldung", async ({
browser,
}) => {
const kontext = await browser.newContext();
const seite = await kontext.newPage();
await seite.goto("/ausweis/ausweis.a.b.c.d");
await expect(seite.getByRole("heading", { name: "Ausweis nicht gültig" })).toBeVisible();
await expect(
seite.getByRole("heading", { name: "Ausweis nicht gültig" }),
).toBeVisible();
await expect(seite.getByText(/Diese Seite zeigt an/)).toBeVisible();
await kontext.close();
+71
View File
@@ -0,0 +1,71 @@
import { expect, type Page } from "@playwright/test";
/**
* Die Wehr, gegen die getestet wird.
*
* **Eine eigene, nicht die Beispielwehr.** Bis Phase 8 schrieben die Tests in
* `ff-musterhausen` — also in genau den Bestand, den die Anleitung zeigt und
* aus dem die Bildschirmfotos entstehen. Nach fünf Phasen standen dort
* 94 Atemschutz-Überwachungen statt einer, 49 Geräte statt 45 und Termine mit
* Namen wie „E2E-Absage-m1x8kq". Zweimal fielen Tests aus, weil ein gesuchter
* Eintrag von Seite 1 gerutscht war, und bei jedem Lauf unterschieden sich
* alle 72 Bildschirmfotos.
*
* Vor dem Lauf:
*
* npm run db:test-reset
*
* Das legt die Testwehr neu an — vollständig, mit denselben Beispieldaten wie
* die Musterwehr, aber unter eigener Kennung. Die Beispielwehr wird dabei
* nicht angefasst.
*/
export const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? "e2e@teststelle.de";
export const ADMIN_PASSWORT = process.env.E2E_ADMIN_PASSWORT ?? "Feuerwehr2026";
/** Der Name, der nach der Anmeldung auf der Uebersicht steht. */
export const WEHR_NAME = process.env.E2E_NAME ?? "Freiwillige Feuerwehr Teststelle";
/**
* Die Kennung der Testwehr.
*
* Wird von der Terminal-Einrichtung gebraucht: das Wandtablet merkt sich, zu
* welcher Wehr es gehoert, und schreibt Maengel, Fahrten und Anwesenheiten
* genau dorthin. Bis Phase 8 stand hier fest "ff-musterhausen" — das war der
* letzte Weg, ueber den die Tests in die Beispielwehr geschrieben haben.
*/
export const WEHR_SLUG = process.env.E2E_SLUG ?? "ff-teststelle";
/**
* Als Administrator der Testwehr anmelden.
*
* Stand bis Phase 8 achtmal wortgleich in acht Dateien. Dass die Zugangsdaten
* damit an neun Stellen zu ändern waren, war der eigentliche Grund, warum die
* Umstellung auf einen eigenen Mandanten so lange liegen geblieben ist.
*/
export 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();
// Mit Begruendung statt mit einem nackten Zeitlimit: fehlt die Testwehr,
// schlagen sonst zweihundert Tests mit „Timeout" fehl, und niemand sieht
// die eine Ursache dahinter.
await expect(
page,
`Anmeldung als ${ADMIN_EMAIL} fehlgeschlagen. Steht die Testwehr? ` +
"Vor dem Lauf einmal: npm run db:test-reset",
).toHaveURL("/");
}
/**
* Ein eindeutiger Name je Lauf.
*
* Auch mit eigenem Mandanten bleibt er sinnvoll: zwei Arbeiter laufen
* parallel, und zwei Termine namens „Testtermin" wären nicht zu unterscheiden.
*/
export function eigenerName(zweck: string): string {
return `E2E-${zweck}-${Date.now().toString(36)}`;
}
+81 -44
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { anmelden, eigenerName } from "./wehr";
/**
* E2E-Tests für Phase 8 — Wissen, Belege und die KI-Protokolle.
@@ -20,32 +21,23 @@ import { expect, test, type Page } from "@playwright/test";
*/
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";
function eigenerTitel(zweck: string): string {
return `E2E-${zweck}-${Date.now().toString(36)}`;
}
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("Wissen", () => {
test("zeigt die Seiten aus dem Bestand", async ({ page }) => {
await anmelden(page);
await page.goto("/wiki");
await expect(page.getByRole("heading", { name: "Wissen", level: 1 })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Wissen", level: 1 }),
).toBeVisible();
// Aus dem Seed.
await expect(page.getByRole("link", { name: "Prüffristen im Überblick" })).toBeVisible();
await expect(
page.getByRole("link", { name: "Atemschutz — was vor jedem Einsatz gilt" }),
page.getByRole("link", { name: "Prüffristen im Überblick" }),
).toBeVisible();
await expect(
page.getByRole("link", {
name: "Atemschutz — was vor jedem Einsatz gilt",
}),
).toBeVisible();
});
@@ -53,11 +45,15 @@ test.describe("Wissen", () => {
* Der wichtigste Test der Datei: ohne Modell muss dastehen, dass keines da
* ist — und im selben Atemzug, dass die Wortsuche trotzdem funktioniert.
*/
test("sagt ohne Sprachmodell, was fehlt und was trotzdem geht", async ({ page }) => {
test("sagt ohne Sprachmodell, was fehlt und was trotzdem geht", async ({
page,
}) => {
await anmelden(page);
await page.goto("/wiki");
await expect(page.getByText("Es ist kein Sprachmodell eingerichtet.")).toBeVisible();
await expect(
page.getByText("Es ist kein Sprachmodell eingerichtet."),
).toBeVisible();
await expect(page.getByText(/Wortsuche/)).toBeVisible();
});
@@ -72,7 +68,9 @@ test.describe("Wissen", () => {
// Der Ausschnitt zeigt die Textstelle mitsamt ihrer Umgebung, nicht bloß
// den Seitennamen — daran erkennt man beim Suchen, ob es die richtige
// Stelle ist, ohne die Seite zu öffnen.
await expect(page.getByText(/Der Rückzugsdruck ist der Druck/).first()).toBeVisible();
await expect(
page.getByText(/Der Rückzugsdruck ist der Druck/).first(),
).toBeVisible();
});
test("zeigt eine Seite als formatierten Text", async ({ page }) => {
@@ -80,11 +78,16 @@ test.describe("Wissen", () => {
await page.goto("/wiki/atemschutz-merkblatt");
await expect(
page.getByRole("heading", { name: "Atemschutz — was vor jedem Einsatz gilt", level: 1 }),
page.getByRole("heading", {
name: "Atemschutz — was vor jedem Einsatz gilt",
level: 1,
}),
).toBeVisible();
// Markdown wird als echte Überschrift dargestellt, nicht als Raute im Text.
await expect(page.getByRole("heading", { name: "Rückzugsdruck" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Rückzugsdruck" }),
).toBeVisible();
await expect(page.getByText(/FwDV 7, Ausgabe 2002/)).toBeVisible();
});
@@ -92,7 +95,9 @@ test.describe("Wissen", () => {
await anmelden(page);
await page.goto("/wiki/schlaeuche-pflegen");
await expect(page.getByRole("columnheader", { name: "Prüfdruck" })).toBeVisible();
await expect(
page.getByRole("columnheader", { name: "Prüfdruck" }),
).toBeVisible();
await expect(page.getByRole("cell", { name: "C 42 / C 52" })).toBeVisible();
});
@@ -104,22 +109,30 @@ test.describe("Wissen", () => {
await anmelden(page);
await page.goto("/wiki/alarmierungsordnung");
await expect(page.getByText(/Suchstand ist älter als der Text/)).toBeVisible();
await expect(
page.getByText(/Suchstand ist älter als der Text/),
).toBeVisible();
});
test("legt eine Seite an und zeigt sie sofort", async ({ page }) => {
const titel = eigenerTitel("Merkblatt");
const titel = eigenerName("Merkblatt");
await anmelden(page);
await page.goto("/wiki");
await page.getByRole("button", { name: "Seite anlegen" }).click();
await page.getByLabel("Titel").fill(titel);
await page.getByLabel("Inhalt").fill("## Erster Abschnitt\n\nHier steht der Text.");
await page
.getByLabel("Inhalt")
.fill("## Erster Abschnitt\n\nHier steht der Text.");
await page.getByRole("button", { name: "Anlegen" }).click();
await expect(page.getByRole("heading", { name: titel, level: 1 })).toBeVisible();
await expect(page.getByRole("heading", { name: "Erster Abschnitt" })).toBeVisible();
await expect(
page.getByRole("heading", { name: titel, level: 1 }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Erster Abschnitt" }),
).toBeVisible();
});
/**
@@ -127,7 +140,7 @@ test.describe("Wissen", () => {
* die sich unbemerkt ändert, ist schlimmer als keine.
*/
test("hebt die vorherige Fassung auf", async ({ page }) => {
const titel = eigenerTitel("Fassungen");
const titel = eigenerName("Fassungen");
await anmelden(page);
await page.goto("/wiki");
@@ -136,12 +149,16 @@ test.describe("Wissen", () => {
await page.getByLabel("Titel").fill(titel);
await page.getByLabel("Inhalt").fill("Der erste Text.");
await page.getByRole("button", { name: "Anlegen" }).click();
await expect(page.getByRole("heading", { name: titel, level: 1 })).toBeVisible();
await expect(
page.getByRole("heading", { name: titel, level: 1 }),
).toBeVisible();
await page.getByRole("button", { name: "Bearbeiten" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByLabel("Inhalt").fill("Der zweite Text.");
await dialog.getByLabel("Was wurde geändert? (freiwillig)").fill("Text berichtigt");
await dialog
.getByLabel("Was wurde geändert? (freiwillig)")
.fill("Text berichtigt");
await dialog.getByRole("button", { name: "Speichern" }).click();
await expect(page.getByText("Der zweite Text.")).toBeVisible();
@@ -154,16 +171,20 @@ test.describe("Wissen", () => {
await expect(verlauf.getByText("Der erste Text.")).toBeVisible();
});
test("zeigt beim Bearbeiten, in wie viele Abschnitte zerlegt wird", async ({ page }) => {
test("zeigt beim Bearbeiten, in wie viele Abschnitte zerlegt wird", async ({
page,
}) => {
await anmelden(page);
await page.goto("/wiki/pruefristen-im-ueberblick");
await page.getByRole("button", { name: "Bearbeiten" }).click();
await expect(page.getByText(/Wird für die Suche in \d+ Abschnitte zerlegt/)).toBeVisible();
await expect(
page.getByText(/Wird für die Suche in \d+ Abschnitte zerlegt/),
).toBeVisible();
});
test("ein Entwurf wird als solcher gekennzeichnet", async ({ page }) => {
const titel = eigenerTitel("Entwurf");
const titel = eigenerName("Entwurf");
await anmelden(page);
await page.goto("/wiki");
@@ -182,14 +203,22 @@ test.describe("Wissen", () => {
});
test.describe("Belege", () => {
test("sagt ohne Vision-Modell, was fehlt und was trotzdem geht", async ({ page }) => {
test("sagt ohne Vision-Modell, was fehlt und was trotzdem geht", async ({
page,
}) => {
await anmelden(page);
await page.goto("/belege");
await expect(page.getByRole("heading", { name: "Belege", level: 1 })).toBeVisible();
await expect(page.getByText(/kein Modell eingerichtet, das Bilder lesen kann/)).toBeVisible();
await expect(
page.getByRole("heading", { name: "Belege", level: 1 }),
).toBeVisible();
await expect(
page.getByText(/kein Modell eingerichtet, das Bilder lesen kann/),
).toBeVisible();
await expect(page.getByText(/von Hand eingetragen/)).toBeVisible();
await expect(page.getByRole("button", { name: "Beleg fotografieren" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Beleg fotografieren" }),
).toBeVisible();
});
});
@@ -198,19 +227,27 @@ test.describe("Protokolle", () => {
await anmelden(page);
await page.goto("/ki-protokoll");
await expect(page.getByRole("heading", { name: "KI-Protokoll", level: 1 })).toBeVisible();
await expect(
page.getByRole("heading", { name: "KI-Protokoll", level: 1 }),
).toBeVisible();
await expect(page.getByText("Vorgänge insgesamt")).toBeVisible();
await expect(page.getByText("erzeugt, aber nicht entschieden")).toBeVisible();
await expect(
page.getByText("erzeugt, aber nicht entschieden"),
).toBeVisible();
// Die Zusicherung, die den Zweck der Liste ausmacht.
await expect(page.getByText(/lassen sich nachträglich nicht ändern/)).toBeVisible();
await expect(
page.getByText(/lassen sich nachträglich nicht ändern/),
).toBeVisible();
});
test("zeigt das Audit-Log", async ({ page }) => {
await anmelden(page);
await page.goto("/protokoll");
await expect(page.getByRole("heading", { name: "Protokoll", level: 1 })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Protokoll", level: 1 }),
).toBeVisible();
await expect(page.getByText(/append-only/)).toBeVisible();
// Die Anmeldung von eben steht schon drin.