Files
FireAdministration/tests/e2e/geraet-formular.spec.ts
T
Florian WildeandClaude Opus 5 1744403620 Geraeteformular zum Anlegen und Bearbeiten ergaenzen
Schliesst die letzte offene Luecke aus Phase 1. Bis dahin liessen sich Geraete
nur ueber den Import erfassen — fuer einen Bestand richtig, fuer ein einzelnes
nachbeschafftes Geraet umstaendlich.

Neu:
- /geraete/neu und /geraete/<id>/bearbeiten, beide ueber dieselbe Komponente
- Die Lagerung ist ein Umschalter statt zweier Auswahlfelder. Standort und
  Fahrzeug schliessen sich aus; so kann der Fehler gar nicht erst entstehen,
  den die Datenbank sonst abweisen wuerde. Beim Wechsel des Fahrzeugs wird die
  Raumangabe zurueckgesetzt.
- Prueftermin und Lebensdauerende werden beim Tippen vorgerechnet, mit
  denselben Funktionen wie auf dem Server. Beim Speichern rechnet der Server
  erneut und behaelt das letzte Wort.
- Gibt es nur einen Standort, ist er beim Anlegen vorbelegt.
- Knopf "Neues Geraet" in der Liste, "Bearbeiten" auf der Detailseite

Behoben:
- Das Formular stuerzte beim ersten Rechnen ab. Die Vorgabewerte lagen in einem
  "use client"-Modul und wurden von einer Server-Komponente importiert; dort
  kommt nur eine Client-Referenz an, kein Objekt, und das Spreaden ergibt lauter
  undefined. Die Werte liegen jetzt in geraet-formular-werte.ts ohne
  "use client".
- Das Sternchen an Pflichtfeldern wurde von Sprachausgaben mitgelesen
  ("Bezeichnung Stern"). Es traegt jetzt aria-hidden; dass ein Feld ausgefuellt
  werden muss, sagt bereits das required-Attribut.
- Fehlermeldungen in Formularen sind eindeutig ansteuerbar. Next.js haengt
  einen eigenen Routen-Ansager mit der Rolle "alert" in jede Seite.

Tests:
- 7 neue Playwright-Tests zum Formular, darunter die Lagerungsregel und die
  Fristenvorschau
- Zwei davon laufen nicht unter Touch-Emulation: Chromium nimmt dort keine
  zeichenweise Eingabe in Datumsfelder an. Begruendet uebersprungen, die
  Rechnung selbst deckt qr-und-fristen.test.ts ab.
- Insgesamt 104 Unit-Tests und 56 E2E-Tests gruen

Anleitung, Changelog, Devlog, Aufwandsschaetzung und Roadmap fortgeschrieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:20:30 +02:00

203 lines
8.1 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test";
/**
* E2E-Tests für das Geräteformular.
*
* Der interessante Teil ist nicht das Speichern, sondern die Lagerungsregel:
* Ein Gerät steht entweder an einem Standort oder auf einem Fahrzeug. Das
* Formular führt das als Umschalter, damit der Fehler gar nicht erst möglich
* ist — und die Fristenvorschau zeigt vorab, was der Server daraus rechnen wird.
*
* Zwei Eigenheiten, die die Selektoren hier erklären:
*
* - `getByLabel` vergleicht den Text des `<label>`, nicht den zugänglichen
* Namen. An Pflichtfeldern steht dort ein Sternchen, das für Hilfsmittel
* ausgeblendet ist. Deshalb wird über die Rolle gesucht: der Name, den eine
* Sprachausgabe vorliest, ist auch der, auf den sich der Test verlässt.
* - `fill()` greift bei `input[type=date]` in Chromium nicht durch. Datumsfelder
* 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) {
await feld.click();
await feld.pressSequentially(`${tag}${monat}${jahr}`, { delay: 30 });
}
function feld(page: Page, name: string): Locator {
return page.getByRole("textbox", { name, exact: true });
}
function zahlfeld(page: Page, name: string): Locator {
return page.getByRole("spinbutton", { name, exact: true });
}
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) {
await feld(page, "Inventarnummer").fill(nummer);
await feld(page, "Bezeichnung").fill(bezeichnung);
}
test.beforeEach(async ({ page }) => {
await anmelden(page);
});
test.describe("Gerät anlegen", () => {
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 grunddatenAusfuellen(page, nummer, "Testgerät Standort");
await feld(page, "Hersteller").fill("Prüffirma");
await auswahl(page, "Standort").click();
await page.getByRole("option").first().click();
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.getByText(nummer).first()).toBeVisible();
});
test("legt ein Gerät auf einem Fahrzeug samt Geräteraum an", async ({ page }) => {
const nummer = testnummer("FAHRZEUG");
await page.goto("/geraete/neu");
await grunddatenAusfuellen(page, nummer, "Testgerät Fahrzeug");
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 auswahl(page, "Geräteraum").click();
await page.getByRole("option", { name: /^G1/ }).click();
await page.getByRole("button", { name: "Gerät anlegen" }).click();
await page.waitForURL(/\/geraete\/[0-9a-f-]{36}$/);
await expect(page.getByText(/Verlastet auf/)).toBeVisible();
await expect(page.getByText(/G1/).first()).toBeVisible();
});
test("verlangt eine Angabe zur Lagerung, statt sie stillschweigend wegzulassen", async ({
page,
}) => {
await page.goto("/geraete/neu");
await grunddatenAusfuellen(page, testnummer("OHNE"), "Testgerät ohne Ort");
// Auf Fahrzeug umschalten, aber keines wählen.
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");
// 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) => {
// 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;
// hier geht es nur um die Verdrahtung, und die deckt der Desktop ab.
test.skip(
angaben.project.name === "tablet",
"Datumsfelder lassen sich unter Touch-Emulation nicht tippen.",
);
await page.goto("/geraete/neu");
await expect(page.getByText("Nächste Prüfung:")).toBeVisible();
await expect(page.getByText("keine", { exact: true })).toBeVisible();
await datumTippen(feld(page, "Indienststellung"), "01", "03", "2026");
await zahlfeld(page, "Prüfintervall (Monate)").fill("12");
await expect(page.getByText("01.03.2027")).toBeVisible();
await zahlfeld(page, "Lebensdauer (Jahre)").fill("10");
await expect(page.getByText("01.03.2036")).toBeVisible();
});
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.",
);
await page.goto("/geraete/neu");
await auswahl(page, "Gerätetyp").click();
await page.getByRole("option", { name: "Pressluftatmer PA 94" }).click();
// Die Bezeichnung wird aus dem Typ vorbelegt, solange sie leer ist.
await expect(feld(page, "Bezeichnung")).toHaveValue("Pressluftatmer PA 94");
await datumTippen(feld(page, "Indienststellung"), "01", "03", "2026");
await expect(page.getByText(/aus dem Gerätetyp/)).toBeVisible();
await expect(page.getByText("01.03.2027")).toBeVisible();
// Pressluftatmer haben 20 Jahre Lebensdauer ab Indienststellung.
await expect(page.getByText("01.03.2046")).toBeVisible();
});
});
test.describe("Gerät bearbeiten", () => {
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();
await expect(feld(page, "Inventarnummer")).toHaveValue("INV-0002");
const bemerkung = `Vom E2E-Test geändert um ${Date.now()}`;
await feld(page, "Bemerkung").fill(bemerkung);
await page.getByRole("button", { name: "Änderungen speichern" }).click();
await page.waitForURL(/\/geraete\/[0-9a-f-]{36}$/);
await expect(page.getByText(bemerkung)).toBeVisible();
// 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();
});
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();
await page.getByRole("radio", { name: "An einem Standort" }).click();
await auswahl(page, "Standort").click();
await page.getByRole("option").first().click();
await page.getByRole("button", { name: "Änderungen speichern" }).click();
await page.waitForURL(/\/geraete\/[0-9a-f-]{36}$/);
await expect(page.getByText(/^Standort /)).toBeVisible();
});
});