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>
203 lines
8.1 KiB
TypeScript
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();
|
|
});
|
|
});
|