Files
FireAdministration/scripts/screenshots.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

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);
});