Files
Florian WildeandClaude Opus 5 7196c7110c Aufraeumarbeiten: ESLint, DOM-Verschachtelung, eigene Testwehr
ESLint hatte keine Konfiguration — das Skript "npm run lint" pruefte nichts.
Jetzt Flat Config ohne Formatierungsregeln, dafuer mit den Regeln, die die
Fehler dieses Projekts gemeldet haetten. Er hat gleich zwei gefunden, die
niemand gesucht hatte.

Das Badge rendert ein span statt eines div. Damit ist die Meldung
"div cannot be a descendant of p" weg, die seit Phase 3 auf der Einsatzseite
stand — sie ist der klassische Ausloeser fuer einen Hydration-Fehler.

Die E2E-Tests laufen gegen eine eigene Wehr. Nach fuenf Phasen standen in der
Beispielwehr 120 Atemschutz-Ueberwachungen statt einer und 115 Geraete statt
45; bei jedem Lauf aenderten sich alle Bildschirmfotos. Der Bestand ist einmal
zurueckgesetzt, npm run db:test-reset baut die Testwehr neu auf.

Behoben nebenbei:
- Die Fehlerflaeche der Karte konnte nie etwas anzeigen.
- Zwei useMemo der Lagekarte rechneten bei jedem Rendern neu.
- tests/e2e war vom Typecheck ausgenommen und liess einen doppelt
  deklarierten Bezeichner durch.

Closes #10
Closes #11
Closes #12

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

298 lines
10 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { anmelden } from "./wehr";
/**
* E2E-Tests für Phase 2.
*
* Geprüft wird der Weg, den der Gerätewart wirklich geht: morgens ins
* Fristen-Dashboard schauen, eine Vorlage nachsehen, eine Prüfung durchführen
* und am Ende ein Protokoll in der Hand haben.
*
* Hinweis: Der Wizard-Test legt echte Prüfprotokolle an. Die lassen sich
* absichtlich nicht wieder löschen — genau das ist der Sinn der Sache. Für die
* Beispielwehr ist das unschädlich; vor einer Vorführung hilft ein frischer
* Seed.
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
*/
test.beforeEach(async ({ page }) => {
await anmelden(page);
});
test.describe("Fristen-Dashboard", () => {
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();
// 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();
}
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();
});
test("filtert die Terminliste über ein Zeitfenster", async ({ page }) => {
await page.goto("/pruefungen");
await expect(
page.getByRole("heading", { name: "Offene Termine" }),
).toBeVisible();
await page
.getByRole("button", { name: /überfällig/ })
.first()
.click();
await expect(
page.getByRole("button", { name: "Filter aufheben" }),
).toBeVisible();
});
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 page.getByLabel("Bündelung wählen").click();
await page.getByRole("option", { name: "nach Gerätetyp" }).click();
// Nach dem Umschalten stehen Gerätetypen in der Verteilungstabelle. Der
// Name steht dort allein in der Zelle — in der Terminliste darunter immer
// zusammen mit der Inventarnummer, deshalb "exact".
await expect(
page.getByRole("cell", { name: "Feuerwehrleine 30 m", exact: true }),
).toBeVisible();
});
});
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("link", { name: "Pressluftatmer — Jahresprüfung" }),
).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 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();
});
});
/**
* Nacheinander statt parallel: der Wizard setzt einen vorhandenen Entwurf fort,
* statt einen zweiten anzulegen. Zwei gleichzeitig laufende Tests würden sich
* denselben Entwurf teilen — und der zweite liefe in die Sperre für
* abgeschlossene Protokolle.
*
* Aus demselben Grund bekommt jedes Testprojekt ein eigenes Gerät: Desktop und
* Tablet laufen parallel, aber unter demselben Benutzer.
*/
test.describe.configure({ mode: "serial" });
/** Welches der Leinen-Geräte dieses Projekt prüft. */
function geraetIndex(): number {
return test.info().project.name === "tablet" ? 1 : 0;
}
/** Von der Geräteliste in den Wizard — der Weg des Gerätewarts. */
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: "Prüfung durchführen" }).click();
}
test.describe("Prüfwizard", () => {
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
// Fehler der Anwendung.
test.setTimeout(180_000);
await pruefungStarten(page);
await page.waitForURL(/\/pruefungen\/neu/);
// Die Checkliste der Leinenprüfung hat vier Punkte: drei Ja/Nein und einen
// 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();
// "in Ordnung" exakt: sonst trifft der Name auch "nicht in Ordnung".
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 page.getByRole("button", { name: "Weiter" }).click();
}
// 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();
// Unterschrift zeichnen — mit der Maus, wie mit dem Finger auf dem Tablet.
// Erst ins Bild holen: die Mauskoordinaten beziehen sich auf das sichtbare
// Fenster, nicht auf das Dokument. Liegt das Feld unterhalb, zeichnet der
// Test sonst ins Leere.
const feld = page.getByLabel(/Unterschriftenfeld/);
await feld.scrollIntoViewIfNeeded();
const rahmen = await feld.boundingBox();
expect(rahmen).not.toBeNull();
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 + 210, rahmen!.y + 30, { steps: 8 });
await page.mouse.up();
// Sobald etwas auf dem Feld steht, lässt es sich löschen und übernehmen.
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 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();
// 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();
});
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.
await expect(page.getByText(/Schritt 1 von \d/)).toBeVisible();
// Dann bis zum Abschluss durchklicken, ohne etwas zu beantworten.
for (let schritt = 0; schritt < 8; schritt += 1) {
const weiter = page.getByRole("button", { name: "Weiter" });
if (!(await weiter.isVisible())) break;
await weiter.click();
}
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Prüfung abschließen" }),
).toBeDisabled();
});
});
test.describe("Mängel und Rückrufe", () => {
test("zeigt den Mangel aus der Beispielprüfung", async ({ page }) => {
await page.goto("/maengel");
await expect(page.getByRole("heading", { name: "Mängel" })).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 expect(
page.getByRole("heading", { name: "Betroffene Geräte" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Mögliche Treffer im Bestand" }),
).toBeVisible();
});
});
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();
});
});