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>
209 lines
6.2 KiB
TypeScript
209 lines
6.2 KiB
TypeScript
/**
|
|
* Erzeugt die Bilder für docs/ANLEITUNG.md.
|
|
*
|
|
* Läuft gegen die laufende Anwendung, meldet sich mit dem Seed-Administrator an
|
|
* und legt die Bilder unter docs/screenshots ab — jeweils in heller und dunkler
|
|
* Darstellung, damit die Anleitung zu beiden Einstellungen passt.
|
|
*
|
|
* docker compose up -d
|
|
* npm run dev
|
|
* npm run screenshots
|
|
*
|
|
* Die Bilder werden mit eingecheckt: die Anleitung soll auch dann lesbar sein,
|
|
* wenn gerade kein Stack läuft.
|
|
*/
|
|
import "dotenv/config";
|
|
import { mkdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { chromium, type Page } from "@playwright/test";
|
|
|
|
const BASIS = process.env.E2E_BASIS_URL ?? "http://localhost:3100";
|
|
const ZIEL = path.join(process.cwd(), "docs", "screenshots");
|
|
|
|
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
|
|
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
|
|
|
interface Aufnahme {
|
|
name: string;
|
|
pfad: string;
|
|
beschreibung: string;
|
|
/** true, wenn vorher angemeldet werden muss. */
|
|
angemeldet: boolean;
|
|
}
|
|
|
|
const AUFNAHMEN: Aufnahme[] = [
|
|
{
|
|
name: "01-anmeldung",
|
|
pfad: "/anmelden",
|
|
beschreibung: "Anmeldeseite mit lokalem Login",
|
|
angemeldet: false,
|
|
},
|
|
{
|
|
name: "02-uebersicht",
|
|
pfad: "/",
|
|
beschreibung: "Übersicht nach der Anmeldung",
|
|
angemeldet: true,
|
|
},
|
|
{
|
|
name: "03-geraeteliste",
|
|
pfad: "/geraete",
|
|
beschreibung: "Geräteliste mit Suche, Filter und Spaltenauswahl",
|
|
angemeldet: true,
|
|
},
|
|
{
|
|
name: "05-fahrzeuge",
|
|
pfad: "/fahrzeuge",
|
|
beschreibung: "Fahrzeugübersicht",
|
|
angemeldet: true,
|
|
},
|
|
{
|
|
name: "07-mitglieder",
|
|
pfad: "/mitglieder",
|
|
beschreibung: "Mitgliederliste nach Abteilung",
|
|
angemeldet: true,
|
|
},
|
|
{
|
|
name: "08-qualifikationen",
|
|
pfad: "/qualifikationen",
|
|
beschreibung: "Qualifikationen mit Ablaufwarnung",
|
|
angemeldet: true,
|
|
},
|
|
{
|
|
name: "09-import",
|
|
pfad: "/geraete/importieren",
|
|
beschreibung: "Import-Assistent",
|
|
angemeldet: true,
|
|
},
|
|
{
|
|
name: "10-geraet-neu",
|
|
pfad: "/geraete/neu",
|
|
beschreibung: "Formular für ein neues Gerät",
|
|
angemeldet: true,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Seiten, deren Adresse erst zur Laufzeit feststeht — die Kennung eines
|
|
* Geräts oder Fahrzeugs kennt das Skript vorher nicht.
|
|
*/
|
|
const DYNAMISCHE_AUFNAHMEN: Array<{
|
|
name: string;
|
|
beschreibung: string;
|
|
/** Findet den Link auf der Übersichtsseite. */
|
|
von: string;
|
|
linkMuster: RegExp;
|
|
}> = [
|
|
{
|
|
name: "04-geraet",
|
|
beschreibung: "Gerätedetail mit QR-Code und Verlauf",
|
|
von: "/geraete",
|
|
linkMuster: /^\/geraete\/[0-9a-f-]{36}$/,
|
|
},
|
|
{
|
|
name: "06-fahrzeug",
|
|
beschreibung: "Fahrzeugdetail mit Beladung nach Geräteräumen",
|
|
von: "/fahrzeuge",
|
|
linkMuster: /^\/fahrzeuge\/[0-9a-f-]{36}$/,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Wartet, bis die Seite wirklich fertig ist.
|
|
*
|
|
* "networkidle" genügt nicht: Listen holen ihre Daten über tRPC nach, und der
|
|
* Aufruf startet erst, nachdem React im Browser gelaufen ist. Ohne dieses
|
|
* Warten zeigen die Bilder Platzhalterbalken statt echter Daten.
|
|
*/
|
|
async function fertigGeladen(seite: Page): Promise<void> {
|
|
await seite.waitForLoadState("networkidle");
|
|
try {
|
|
await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, {
|
|
timeout: 15_000,
|
|
});
|
|
} catch {
|
|
console.warn("[screenshots] Ladeanzeige blieb stehen — Bild wird trotzdem gemacht.");
|
|
}
|
|
await seite.waitForTimeout(500);
|
|
}
|
|
|
|
async function anmelden(seite: Page): Promise<void> {
|
|
await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" });
|
|
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
|
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
|
await seite.getByRole("button", { name: "Anmelden" }).click();
|
|
// Grosszuegig: bei kaltem .next uebersetzt "next dev" die Route erst beim
|
|
// ersten Aufruf, und das dauert deutlich laenger als eine Anmeldung.
|
|
await seite.waitForURL(`${BASIS}/`, { timeout: 90_000 });
|
|
await seite.waitForLoadState("networkidle");
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
await mkdir(ZIEL, { recursive: true });
|
|
|
|
const browser = await chromium.launch();
|
|
|
|
for (const darstellung of ["light", "dark"] as const) {
|
|
const kontext = await browser.newContext({
|
|
viewport: { width: 1440, height: 900 },
|
|
deviceScaleFactor: 2,
|
|
locale: "de-DE",
|
|
timezoneId: "Europe/Berlin",
|
|
colorScheme: darstellung,
|
|
});
|
|
|
|
const seite = await kontext.newPage();
|
|
let istAngemeldet = false;
|
|
|
|
for (const aufnahme of AUFNAHMEN) {
|
|
if (aufnahme.angemeldet && !istAngemeldet) {
|
|
await anmelden(seite);
|
|
istAngemeldet = true;
|
|
}
|
|
|
|
await seite.goto(`${BASIS}${aufnahme.pfad}`, { waitUntil: "networkidle" });
|
|
await fertigGeladen(seite);
|
|
|
|
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
|
|
await seite.screenshot({ path: datei, fullPage: true });
|
|
console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`);
|
|
}
|
|
|
|
// Detailseiten: die erste Kennung von der jeweiligen Übersicht holen.
|
|
for (const aufnahme of DYNAMISCHE_AUFNAHMEN) {
|
|
await seite.goto(`${BASIS}${aufnahme.von}`, { waitUntil: "networkidle" });
|
|
await fertigGeladen(seite);
|
|
|
|
const ziele = await seite
|
|
.locator("a[href]")
|
|
.evaluateAll((elemente) =>
|
|
elemente
|
|
.map((element) => element.getAttribute("href") ?? "")
|
|
.filter((href) => href.startsWith("/")),
|
|
);
|
|
|
|
const treffer = ziele.find((href) => aufnahme.linkMuster.test(href));
|
|
if (!treffer) {
|
|
console.warn(`[screenshots] Kein Ziel für ${aufnahme.name} gefunden — übersprungen.`);
|
|
continue;
|
|
}
|
|
|
|
await seite.goto(`${BASIS}${treffer}`, { waitUntil: "networkidle" });
|
|
await fertigGeladen(seite);
|
|
|
|
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
|
|
await seite.screenshot({ path: datei, fullPage: true });
|
|
console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`);
|
|
}
|
|
|
|
await kontext.close();
|
|
}
|
|
|
|
await browser.close();
|
|
console.info("[screenshots] Fertig.");
|
|
}
|
|
|
|
main().catch((fehler) => {
|
|
console.error("[screenshots] Abbruch:", fehler);
|
|
process.exit(1);
|
|
});
|