Phase 2: Wartungs- und Pruefmanagement
Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf, Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung. Elf neue Tabellen, alle mit organisationId und Row Level Security. Die Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung mit Verweis auf die alte, beide bleiben lesbar. 79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7f866ac35c
commit
e503aafc18
@@ -0,0 +1,225 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user