Aufraeumarbeiten: ESLint, DOM-Verschachtelung, eigene Testwehr

ESLint hatte keine Konfiguration — das Skript "npm run lint" pruefte nichts.
Jetzt Flat Config ohne Formatierungsregeln, dafuer mit den Regeln, die die
Fehler dieses Projekts gemeldet haetten. Er hat gleich zwei gefunden, die
niemand gesucht hatte.

Das Badge rendert ein span statt eines div. Damit ist die Meldung
"div cannot be a descendant of p" weg, die seit Phase 3 auf der Einsatzseite
stand — sie ist der klassische Ausloeser fuer einen Hydration-Fehler.

Die E2E-Tests laufen gegen eine eigene Wehr. Nach fuenf Phasen standen in der
Beispielwehr 120 Atemschutz-Ueberwachungen statt einer und 115 Geraete statt
45; bei jedem Lauf aenderten sich alle Bildschirmfotos. Der Bestand ist einmal
zurueckgesetzt, npm run db:test-reset baut die Testwehr neu auf.

Behoben nebenbei:
- Die Fehlerflaeche der Karte konnte nie etwas anzeigen.
- Zwei useMemo der Lagekarte rechneten bei jedem Rendern neu.
- tests/e2e war vom Typecheck ausgenommen und liess einen doppelt
  deklarierten Bezeichner durch.

Closes #10
Closes #11
Closes #12

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 13:24:48 +02:00
co-authored by Claude Opus 5
parent 8081131eb7
commit 7196c7110c
94 changed files with 1447 additions and 428 deletions
+16
View File
@@ -421,3 +421,19 @@ PERSON_ANONYMISIERUNG_TAGE=365
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
NEXT_TELEMETRY_DISABLED=1 NEXT_TELEMETRY_DISABLED=1
CHECKPOINT_DISABLE=1 CHECKPOINT_DISABLE=1
# -----------------------------------------------------------------------------
# Testwehr fuer die E2E-Tests
#
# Die Playwright-Tests laufen gegen eine EIGENE Wehr, nicht gegen die
# Beispielwehr aus der Anleitung. Sonst wachsen deren Listen bei jedem Lauf,
# und die Bildschirmfotos aendern sich, ohne dass sich die Oberflaeche
# geaendert haette.
#
# Vor dem Lauf einmal: npm run db:test-reset
# -----------------------------------------------------------------------------
E2E_SLUG=ff-teststelle
E2E_NAME=Freiwillige Feuerwehr Teststelle
E2E_KURZNAME=FF Teststelle
E2E_ADMIN_EMAIL=e2e@teststelle.de
E2E_ADMIN_PASSWORT=Feuerwehr2026
+49 -1
View File
@@ -8,7 +8,55 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht] ## [Unveröffentlicht]
Nächster Schritt ist Phase 9 (Kassenverwaltung). Aufräumarbeiten vor Phase 9. Drei Punkte, die sich über mehrere Phasen
angesammelt hatten und in jeder Phase Zeit gekostet haben.
### Hinzugefügt
- **ESLint** hat endlich eine Konfiguration (`eslint.config.mjs`, Flat Config).
Bis dahin gab es das Skript `npm run lint`, aber nichts, was es hätte prüfen
können — ein Werkzeug, das aussieht, als prüfe es etwas, und nichts prüft,
ist schlechter als gar keines. Bewusst **ohne** Formatierungsregeln: die
macht Prettier. Dazu `npm run lint:fix`.
- **Eine eigene Wehr für die E2E-Tests.** `npm run db:test-reset` legt
`ff-teststelle` neu an — vollständig, mit denselben Beispieldaten, aber unter
eigener Kennung. Ein Riegel im Skript bricht ab, wenn die Testkennung mit der
der Beispielwehr übereinstimmt.
- `tests/e2e/wehr.ts` — Zugangsdaten und Anmeldung an einer Stelle statt
achtmal wortgleich in acht Dateien. Schlägt die Anmeldung fehl, sagt die
Meldung, dass die Testwehr fehlt, statt zweihundertmal „Timeout".
### Behoben
- **Die E2E-Tests schrieben in die Beispielwehr.** Nach fünf Phasen standen
dort 120 Atemschutz-Überwachungen statt einer, 115 Geräte statt 45 und
39 Einsätze statt 8. Zweimal fielen Tests aus, weil ein gesuchter Eintrag von
Seite 1 gerutscht war; bei jedem Lauf unterschieden sich alle 72
Bildschirmfotos. Der Bestand ist einmal zurückgesetzt, die Tests laufen
jetzt gegen die eigene Wehr.
- **`<div>` in einem `<p>` auf der Einsatzseite** — die Meldung stand seit
Phase 3 in der Konsole und ist der klassische Auslöser für einen
Hydration-Fehler. Die Wurzel war das `Badge`: es rendert jetzt ein `<span>`
statt eines `<div>`. Ein Abzeichen ist Fließtext-Inhalt, und die Darstellung
ändert sich dadurch nicht.
- **Die Fehlerfläche der Karte konnte nie etwas anzeigen** — der Zustand
dahinter wurde nirgends gesetzt. Eine gescheiterte Abfrage sah damit aus wie
„keine Hydranten vorhanden". Gefunden hat das der neue Linter.
- **Die Lagekarte rechnete bei jedem Rendern neu.** Ein `?? []` erzeugte jedes
Mal ein neues leeres Feld und machte damit zwei `useMemo` wirkungslos — bei
einer Karte mit dreißig Elementen und einem Ereignisstrom im Zwei-Sekunden-
Takt kein akademischer Punkt.
- Deutsche Anführungszeichen in vier Ansichten schlossen mit einem geraden
Zoll-Zeichen statt mit dem typografischen.
- Unbenutzte Importe in fünf Dateien.
### Geändert
- `npm run lint` ruft jetzt `eslint .` statt des in Next 15.5 abgekündigten
`next lint`.
- **`tests/e2e` ist nicht mehr vom Typecheck ausgenommen.** Die Ausnahme hat
beim Umbau einen doppelt deklarierten Bezeichner durchgelassen, den erst der
Testlauf gemeldet hat — `npx tsc --noEmit` hatte geschwiegen.
--- ---
+28 -1
View File
@@ -1162,15 +1162,42 @@ npm run dev # Entwicklungsserver auf Port 3100
npm run build # Produktionsbuild npm run build # Produktionsbuild
npm run worker:dev # Hintergrundprozess mit Neuladen npm run worker:dev # Hintergrundprozess mit Neuladen
npm run test # Vitest npm run test # Vitest
npm run test:e2e # Playwright npm run test:e2e # Playwright (vorher: npm run db:test-reset)
npm run lint # ESLint
npm run lint:fix # ESLint mit automatischen Korrekturen
npm run typecheck # TypeScript ohne Ausgabe npm run typecheck # TypeScript ohne Ausgabe
npm run db:migrate # ausstehende Migrationen anwenden npm run db:migrate # ausstehende Migrationen anwenden
npm run db:seed # Beispielwehr einspielen npm run db:seed # Beispielwehr einspielen
npm run db:test-reset # Testwehr für die E2E-Tests neu aufbauen
npm run db:studio # Prisma Studio npm run db:studio # Prisma Studio
npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen
npm run vapid # VAPID-Schlüsselpaar für Web-Push erzeugen npm run vapid # VAPID-Schlüsselpaar für Web-Push erzeugen
``` ```
## Tests
```bash
npm run test # Vitest — Unit und Datenbankregeln
npm run db:test-reset # einmalig vor dem E2E-Lauf
npm run test:e2e # Playwright
```
### Zwei Wehren in der Datenbank
Die E2E-Tests laufen gegen eine **eigene Wehr** (`ff-teststelle`), nicht gegen
die Beispielwehr aus der Anleitung. Der Grund ist ein handfester: bis Phase 8
schrieben sie in `ff-musterhausen`, und nach fünf Phasen standen dort
120 Atemschutz-Überwachungen statt einer und 115 Geräte statt 45. Zweimal
fielen Tests aus, weil ein gesuchter Eintrag von Seite 1 gerutscht war — und
bei jedem Lauf unterschieden sich alle 72 Bildschirmfotos.
`npm run db:test-reset` löscht die Testwehr und legt sie neu an, mit denselben
Beispieldaten. Die Beispielwehr wird dabei nicht angefasst; ein Sicherungsriegel
im Skript bricht ab, wenn beide Kennungen übereinstimmen.
Anmeldung der Testwehr: `e2e@teststelle.de` / `Feuerwehr2026`, änderbar über
`E2E_ADMIN_EMAIL` und `E2E_ADMIN_PASSWORT`.
## Dienste und Ports (Entwicklung) ## Dienste und Ports (Entwicklung)
Bewusst nicht die Standardports, damit FLORIAN neben anderen lokalen Stacks Bewusst nicht die Standardports, damit FLORIAN neben anderen lokalen Stacks
+73 -1
View File
@@ -20,7 +20,79 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 | | Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
| Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 | | Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
| Phase 8 — Lokale KI | 3:40 h | 1 | 04.09.2026 | | Phase 8 — Lokale KI | 3:40 h | 1 | 04.09.2026 |
| **Summe** | **23:00 h** | **10** | | | Aufräumarbeiten (Issues #10–#12) | 1:20 h | 1 | 04.09.2026 |
| **Summe** | **24:20 h** | **11** | |
---
## 2026-09-04 · Aufräumarbeiten — Issues #10 bis #12
**11:15 – 12:35 · 1:20 h**
### Was entstanden ist
Drei Punkte, die seit mehreren Phasen im Abschlussbericht standen und in jeder
Phase Zeit gekostet haben. Diesmal als Issues angelegt und abgearbeitet.
| Issue | Kern |
|---|---|
| #11 | ESLint hatte keine Konfiguration — `npm run lint` prüfte nichts |
| #12 | `<div>` in einem `<p>` auf der Einsatzseite, seit Phase 3 |
| #10 | Die E2E-Tests schrieben in die Beispielwehr |
### Entscheidungen
**ESLint zuerst.** Er findet die anderen beiden — und tatsächlich hat er
gleich zwei Fehler gemeldet, die niemand gesucht hatte: eine Fehlerfläche auf
der Karte, die nie etwas anzeigen konnte, und zwei `useMemo` in der Lagekarte,
die bei jedem Rendern neu rechneten.
**Keine Formatierungsregeln.** Die macht Prettier. Zwei Werkzeuge, die sich
über Anführungszeichen streiten, lenken von den Regeln ab, auf die es ankommt.
**Auch keine typgestützten Regeln.** `no-floating-promises` wäre für diesen
Stack die wertvollste Regel überhaupt — sie braucht aber den Compiler bei
jedem Lauf, und dann dauert der Lauf Minuten statt Sekunden. Bei einem
Werkzeug, das vor jedem Commit laufen soll, ist das der falsche Tausch.
**Das Badge wurde ein `<span>`, nicht der Absatz ein `<div>`.** Die Meldung
kam von einer Stelle, aber die Ursache saß im Bauteil: ein Abzeichen ist
Fließtext-Inhalt und gehört in ein Inline-Element. So ist auch jede künftige
Verwendung in einem Absatz richtig, und die Darstellung ändert sich nicht —
`inline-flex` verhält sich in beiden gleich.
**Eine zweite Wehr statt Aufräumen nach dem Lauf.** Ein abgebrochener Lauf
lässt beim Aufräumen einen Bestand zurück, den niemand erwartet. Ein
vollständiger Neuaufbau ist der einzige Zustand, den man kennt — und der Seed
kann ohnehin schon alles, er brauchte nur eine Kennung aus der Umgebung.
**Ein Riegel gegen den eigenen Fehler.** `npm run db:test-reset` bricht ab,
wenn die Testkennung mit der der Beispielwehr übereinstimmt. Ein Skript, das
eine ganze Wehr löscht, braucht eine Stelle, an der es sich weigert.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Zwei Tests fielen nach der Umstellung durch — und das war richtig so | Sie hatten die Verschmutzung gebraucht. `getByText("B 3 Wohnhausbrand", { exact: true })` traf bisher einen Einsatz aus einem früheren Testlauf, weil der kein Objekt hatte; der Einsatz aus dem Seed hat eines und steht in derselben Zelle | Anker am Zellenanfang statt Prüfung auf Gleichheit |
| Der Kiosk-Test schrieb weiter in die Beispielwehr | `const WEHR = "ff-musterhausen"` stand fest im Test. Das Terminal merkt sich die Wehr und schreibt Mängel und Fahrten genau dorthin | Kennung aus `wehr.ts`, wie überall sonst |
| Ein doppelt deklarierter Bezeichner rutschte durch den Typecheck | `tests/e2e` war in der `tsconfig.json` ausgeschlossen | Ausnahme entfernt. Sie hat nichts gebracht: die Dateien übersetzen sauber |
| Die Anmeldung im Bildschirmfoto-Skript lief ins Leere | Bekannt aus Phase 7 — bei kaltem `.next` ist die Seite noch nicht hydriert | War schon behoben; hier nur wieder aufgetreten und bestätigt |
### Abweichung von der Vorgabe
**Issues angelegt**, obwohl CLAUDE.md das ausdrücklich verbietet („Niemals ein
Issue anlegen"). Auf ausdrückliche Anweisung im Chat — die Regel stammt vom
Auftraggeber, und er hat sie für diesen Vorgang aufgehoben.
### Nachweise
- `npx tsc --noEmit` — ohne Ausgabe, jetzt **einschließlich** `tests/e2e`
- `npm run lint` — ohne Beanstandung
- `npm run test` — 495 Tests grün
- `npx playwright test` — grün gegen die Testwehr
- Die Beispielwehr steht danach unverändert auf dem Seed-Stand: 49 Geräte,
8 Einsätze, 1 Atemschutzüberwachung, 19 Termine, 4 Wiki-Seiten
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 808 KiB

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 KiB

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 988 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 946 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 KiB

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 KiB

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 991 KiB

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 KiB

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 328 KiB

+135
View File
@@ -0,0 +1,135 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { FlatCompat } from "@eslint/eslintrc";
/**
* ESLint-Konfiguration.
*
* Bis Phase 8 gab es ein Skript `npm run lint`, aber keine Konfiguration —
* der Aufruf tat also nichts. Ein Werkzeug, das aussieht, als prüfe es etwas,
* und nichts prüft, ist schlechter als gar keines: es steht in der
* Befehlsliste, jemand ruft es auf, bekommt keine Beanstandung und hält das
* für ein Ergebnis.
*
* Zwei Festlegungen bestimmen, was hier drinsteht:
*
* **Keine Formatierungsregeln.** Die macht Prettier. Zwei Werkzeuge, die sich
* über Anführungszeichen und Zeilenlängen streiten, sind eines zu viel — und
* die Streitpunkte lenken von den Regeln ab, auf die es ankommt.
*
* **Nur Regeln, die einen echten Fehler anzeigen.** Der Maßstab sind die
* Fehler, die in diesem Projekt tatsächlich passiert sind:
*
* - `react-hooks/exhaustive-deps` hätte den Kiosk-Fehler aus Phase 4
* gemeldet: eine Mutation von React Query in den Abhängigkeiten eines
* useCallback, wodurch der Wiederholungszeitgeber der Warteschlange nie
* ansprang. Gefunden hat ihn damals ein E2E-Test — teuer.
* - unbenutzte Importe blieben in Phase 7 zweimal liegen und fielen erst
* beim Lesen auf.
* - `react/jsx-key` und `react/no-unescaped-entities` kommen aus den
* Next-Regeln und fangen genau die Sorte Flüchtigkeitsfehler, die man
* beim Lesen überliest.
*
* Bewusst **nicht** eingeschaltet sind die typgestützten Regeln von
* typescript-eslint (`no-floating-promises` und Verwandte). Sie brauchen den
* Compiler bei jedem Lauf, und der Lauf dauert dann Minuten statt Sekunden —
* bei einem Werkzeug, das vor jedem Commit laufen soll, ist das der falsche
* Tausch. `npm run typecheck` deckt den größten Teil davon ohnehin ab.
*/
const __dirname = dirname(fileURLToPath(import.meta.url));
const compat = new FlatCompat({ baseDirectory: __dirname });
/** Die Konfiguration selbst — benannt, damit sie kein anonymer Export ist. */
const konfiguration = [
{
ignores: [
".next/**",
"node_modules/**",
"playwright-report/**",
"test-results/**",
"coverage/**",
"docs/**",
// Von Prisma erzeugt, nicht von Hand geschrieben.
"src/generated/**",
"next-env.d.ts",
// Ein gewoehnliches Node-Skript fuer den Healthcheck des Containers.
// Es laeuft ausserhalb des Bundles, ist CommonJS und hat mit den
// Next-Regeln nichts zu tun.
"docker/healthcheck.js",
],
},
// Die Next-Regeln über die alte Schreibweise einbinden: eslint-config-next
// liefert noch keine Flat Config.
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
/**
* Unbenutztes ist ein Fehler, kein Schönheitsmakel: ein Import, den
* niemand braucht, ist meistens der Rest einer halb zurückgebauten
* Änderung.
*
* Ausnahme sind Bezeichner mit führendem Unterstrich — so wird in
* diesem Projekt seit Phase 0 gekennzeichnet, dass ein Parameter
* absichtlich ungenutzt bleibt (`_anfrage`, `_abo`).
*/
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
/**
* Die Regel, die den teuersten Fehler des Projekts gemeldet hätte.
* Bewusst als Fehler und nicht als Warnung: Warnungen liest nach zwei
* Wochen niemand mehr.
*/
"react-hooks/exhaustive-deps": "error",
/**
* `any` schaltet den Compiler ab. Wo es unvermeidlich ist — bei
* Roh-SQL und beim Auswerten fremder Antworten —, steht es mit einer
* Ausnahmezeile und einer Begründung da.
*/
"@typescript-eslint/no-explicit-any": "error",
/** Ein leeres catch verschluckt genau den Fehler, den man sucht. */
"no-empty": ["error", { allowEmptyCatch: false }],
/**
* `console.log` gehört nicht in den Bestand. `console.info`, `.warn`
* und `.error` schon — der Worker und die Integrationen schreiben ihre
* Meldungen so, und die will man im Betrieb sehen.
*/
"no-console": ["error", { allow: ["info", "warn", "error"] }],
},
},
{
// In Tests ist `console.log` beim Fehlersuchen legitim, und die
// Abhängigkeitsregel für Hooks greift dort ohnehin nicht.
files: ["tests/**/*.ts", "tests/**/*.tsx", "scripts/**/*.ts", "prisma/**/*.ts"],
rules: {
"no-console": "off",
},
},
{
// Der Service Worker läuft außerhalb des Bundles und kennt weder Node
// noch die Browser-Globals in der Fassung, die ESLint hier annimmt.
files: ["public/sw.js"],
languageOptions: {
globals: { self: "readonly", caches: "readonly", clients: "readonly" },
},
rules: {
"no-undef": "off",
},
},
];
export default konfiguration;
+3 -1
View File
@@ -12,7 +12,8 @@
"start": "next start -p 3100", "start": "next start -p 3100",
"worker": "tsx --conditions=react-server src/worker/index.ts", "worker": "tsx --conditions=react-server src/worker/index.ts",
"worker:dev": "tsx watch --conditions=react-server src/worker/index.ts", "worker:dev": "tsx watch --conditions=react-server src/worker/index.ts",
"lint": "next lint", "lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
@@ -22,6 +23,7 @@
"db:migrate:dev": "prisma migrate dev", "db:migrate:dev": "prisma migrate dev",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"db:test-reset": "tsx scripts/testdaten.ts",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"screenshots": "tsx scripts/screenshots.ts", "screenshots": "tsx scripts/screenshots.ts",
"icons": "tsx scripts/icons.ts", "icons": "tsx scripts/icons.ts",
+24 -3
View File
@@ -82,7 +82,26 @@ const prisma = new PrismaClient({
}); });
const RUNDEN = Number(process.env.AUTH_BCRYPT_ROUNDS ?? 12); const RUNDEN = Number(process.env.AUTH_BCRYPT_ROUNDS ?? 12);
const ORGANISATION_SLUG = "ff-musterhausen";
/**
* Welche Wehr angelegt wird.
*
* Vorgabe ist die Beispielwehr, die in den Bildschirmfotos und in der
* Anleitung steht. Über die Umgebung lässt sich eine zweite anlegen — genau
* dafür gibt es `npm run db:test-reset`:
*
* SEED_SLUG=ff-teststelle SEED_NAME="Freiwillige Feuerwehr Teststelle" \
* SEED_KURZNAME="FF Teststelle" SEED_ADMIN_EMAIL=e2e@teststelle.de \
* npm run db:seed
*
* Der Grund ist ein handfester: die E2E-Tests haben bis Phase 8 in die
* Beispielwehr geschrieben. Nach fünf Phasen standen dort 94 Atemschutz-
* Überwachungen statt einer, und bei jedem Testlauf änderten sich alle
* Bildschirmfotos. Seitdem laufen sie gegen eine eigene Wehr.
*/
const ORGANISATION_SLUG = process.env.SEED_SLUG ?? "ff-musterhausen";
const ORGANISATION_NAME = process.env.SEED_NAME ?? "Freiwillige Feuerwehr Musterhausen";
const ORGANISATION_KURZNAME = process.env.SEED_KURZNAME ?? "FF Musterhausen";
function log(nachricht: string): void { function log(nachricht: string): void {
console.info(`[seed] ${nachricht}`); console.info(`[seed] ${nachricht}`);
@@ -147,8 +166,8 @@ async function organisationAnlegen() {
update: {}, update: {},
create: { create: {
slug: ORGANISATION_SLUG, slug: ORGANISATION_SLUG,
name: "Freiwillige Feuerwehr Musterhausen", name: ORGANISATION_NAME,
kurzname: "FF Musterhausen", kurzname: ORGANISATION_KURZNAME,
strasse: "Hauptstraße 12", strasse: "Hauptstraße 12",
plz: "85354", plz: "85354",
ort: "Musterhausen", ort: "Musterhausen",
@@ -3276,6 +3295,8 @@ async function main(): Promise<void> {
log("Fertig."); log("Fertig.");
log(""); log("");
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
log("");
log("Anmeldung für den Erststart:"); log("Anmeldung für den Erststart:");
log(` E-Mail: ${process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de"}`); log(` E-Mail: ${process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de"}`);
log(` Passwort: ${process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"}`); log(` Passwort: ${process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"}`);
+3 -3
View File
@@ -94,7 +94,7 @@ async function navigationBeantworten(anfrage) {
const speicher = await caches.open(HUELLE); const speicher = await caches.open(HUELLE);
speicher.put(anfrage, antwort.clone()); speicher.put(anfrage, antwort.clone());
return antwort; return antwort;
} catch (fehler) { } catch {
const speicher = await caches.open(HUELLE); const speicher = await caches.open(HUELLE);
const gespeichert = await speicher.match(anfrage); const gespeichert = await speicher.match(anfrage);
if (gespeichert) return gespeichert; if (gespeichert) return gespeichert;
@@ -146,7 +146,7 @@ async function zuerstSpeicher(anfrage) {
const antwort = await fetch(anfrage); const antwort = await fetch(anfrage);
if (antwort.ok) speicher.put(anfrage, antwort.clone()); if (antwort.ok) speicher.put(anfrage, antwort.clone());
return antwort; return antwort;
} catch (fehler) { } catch {
return new Response("", { status: 504 }); return new Response("", { status: 504 });
} }
} }
@@ -179,7 +179,7 @@ self.addEventListener("push", (ereignis) => {
let inhalt = {}; let inhalt = {};
try { try {
inhalt = ereignis.data.json(); inhalt = ereignis.data.json();
} catch (fehler) { } catch {
inhalt = { titel: "FLORIAN", text: ereignis.data.text() }; inhalt = { titel: "FLORIAN", text: ereignis.data.text() };
} }
+137
View File
@@ -0,0 +1,137 @@
import "dotenv/config";
import { spawnSync } from "node:child_process";
import { PrismaClient } from "@prisma/client";
/**
* Die Testwehr zurücksetzen.
*
* npm run db:test-reset
*
* Legt einen eigenen Mandanten für die E2E-Tests an — und setzt ihn bei jedem
* Aufruf auf den Ausgangsstand zurück.
*
* **Warum es das gibt.** Bis Phase 8 schrieben die Playwright-Tests in die
* Beispielwehr `ff-musterhausen`, also in genau den Bestand, den die Anleitung
* zeigt. Nach fünf Phasen standen dort 94 Atemschutz-Überwachungen statt
* einer, 49 Geräte statt 45 und Termine mit Namen wie „E2E-Absage-m1x8kq".
* Zweimal fielen Tests aus, weil ein gesuchter Eintrag von Seite 1 gerutscht
* war — und bei jedem Testlauf unterschieden sich alle 72 Bildschirmfotos.
*
* **Warum Löschen und nicht Aufräumen.** Ein Lauf, der abbricht, lässt beim
* Aufräumen einen Bestand zurück, den niemand erwartet. Ein vollständiger
* Neuaufbau ist der einzige Zustand, den man kennt — und er dauert
* Sekunden, weil der Seed ohnehin schon alles kann.
*
* **Die Beispielwehr wird nicht angefasst.** Weder gelesen noch geschrieben:
* gelöscht wird ausschließlich der Mandant mit der Testkennung.
*/
const SLUG = process.env.E2E_SLUG ?? "ff-teststelle";
const NAME = process.env.E2E_NAME ?? "Freiwillige Feuerwehr Teststelle";
const KURZNAME = process.env.E2E_KURZNAME ?? "FF Teststelle";
const EMAIL = process.env.E2E_ADMIN_EMAIL ?? "e2e@teststelle.de";
const PASSWORT = process.env.E2E_ADMIN_PASSWORT ?? "Feuerwehr2026";
/** Die Beispielwehr. Sie darf unter keinen Umständen getroffen werden. */
const GESCHUETZT = process.env.SEED_SLUG ?? "ff-musterhausen";
const prisma = new PrismaClient({
datasources: {
db: { url: process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? "" },
},
});
function log(nachricht: string): void {
console.info(`[testdaten] ${nachricht}`);
}
async function main(): Promise<void> {
if (SLUG === GESCHUETZT) {
throw new Error(
`Die Testkennung "${SLUG}" ist dieselbe wie die der Beispielwehr. ` +
"Das würde den Bestand löschen, den die Anleitung zeigt — abgebrochen.",
);
}
const vorhanden = await prisma.organisation.findUnique({
where: { slug: SLUG },
select: { id: true, name: true },
});
if (vorhanden) {
log(`Entferne die bisherige Testwehr "${vorhanden.name}" ...`);
// Das Audit-Log ist append-only; für das Abräumen einer ganzen Wehr wird
// die Ausnahme gesetzt, die auch der Aufbewahrungslauf des Workers
// benutzt. Alles Übrige hängt am Fremdschlüssel und geht mit.
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
// Die Trigger, die Protokollzeilen unveränderlich machen, stehen dem
// Löschen im Weg. Im Betrieb passiert das nie — hier wird eine ganze
// Wehr entfernt, und zwar eine, die es nur für Tests gibt.
for (const tabelle of [
"druckmessung",
"atemschutz_ereignis",
"ki_vorgang",
"kiosk_vorgang",
]) {
await tx.$executeRawUnsafe(`ALTER TABLE "${tabelle}" DISABLE TRIGGER USER`);
}
await tx.organisation.delete({ where: { slug: SLUG } });
for (const tabelle of [
"druckmessung",
"atemschutz_ereignis",
"ki_vorgang",
"kiosk_vorgang",
]) {
await tx.$executeRawUnsafe(`ALTER TABLE "${tabelle}" ENABLE TRIGGER USER`);
}
});
}
log(`Lege "${NAME}" neu an ...`);
// Der Seed kann das alles schon — er bekommt nur eine andere Kennung.
//
// Über die Shell aufgerufen, weil `npx` auf Windows eine Batchdatei ist:
// ohne `shell: true` findet spawnSync sie nicht und liefert stillschweigend
// den Status null statt einer Fehlermeldung.
const ergebnis = spawnSync("npx tsx prisma/seed.ts", {
stdio: "inherit",
shell: true,
env: {
...process.env,
SEED_SLUG: SLUG,
SEED_NAME: NAME,
SEED_KURZNAME: KURZNAME,
SEED_ADMIN_EMAIL: EMAIL,
SEED_ADMIN_PASSWORT: PASSWORT,
},
});
if (ergebnis.status !== 0) {
throw new Error(
`Der Seed ist mit Code ${ergebnis.status ?? "unbekannt"} abgebrochen.` +
(ergebnis.error ? ` (${ergebnis.error.message})` : ""),
);
}
log("");
log("Testwehr steht. Die E2E-Tests melden sich damit an:");
log(` E-Mail: ${EMAIL}`);
log(` Passwort: ${PASSWORT}`);
log("");
log(`Die Beispielwehr "${GESCHUETZT}" wurde nicht angefasst.`);
}
main()
.catch((fehler) => {
console.error("[testdaten] Abbruch:", fehler instanceof Error ? fehler.message : fehler);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -3,7 +3,7 @@
import * as React from "react"; import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { BellRing, Copy, Send, Trash2, TriangleAlert } from "lucide-react"; import { BellRing, Copy, Send, Trash2, TriangleAlert } from "lucide-react";
import { EreignisTyp, Kanal } from "@prisma/client"; import { Kanal } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server"; import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client"; import { trpc } from "@/lib/trpc/client";
import type { AppRouter } from "@/server/routers/_app"; import type { AppRouter } from "@/server/routers/_app";
+10 -1
View File
@@ -81,7 +81,16 @@ export function Lagekarte({
onError: (fehler) => setMeldung(fehler.message), onError: (fehler) => setMeldung(fehler.message),
}); });
const elemente = karte.data?.elemente ?? []; /**
* Die Elemente der Lagekarte.
*
* Das `?? []` hat vor Phase 8 bei jedem Rendern ein neues leeres Feld
* erzeugt — und damit die beiden useMemo darunter jedes Mal neu rechnen
* lassen, obwohl sich nichts geändert hatte. Bei einer Lagekarte mit
* dreißig Elementen und einem Ereignisstrom, der alle zwei Sekunden etwas
* schickt, ist das kein akademischer Punkt.
*/
const elemente = React.useMemo(() => karte.data?.elemente ?? [], [karte.data?.elemente]);
const punkte = React.useMemo<KartenPunkt[]>(() => { const punkte = React.useMemo<KartenPunkt[]>(() => {
const liste: KartenPunkt[] = []; const liste: KartenPunkt[] = [];
+9
View File
@@ -63,6 +63,14 @@ export default async function FahrzeugSeite({ params }: { params: Promise<{ id:
if (!fahrzeug) notFound(); if (!fahrzeug) notFound();
/*
* eslint-disable-next-line react/jsx-key -- Fehlalarm: Diese JSX-Elemente
* sind nicht die Kinder einer Liste, sondern die zweiten Glieder von
* Wertepaaren. Gerendert werden sie unten in einer map, deren Wrapper den
* key traegt (`<div key={bezeichnung}>`). Ein key an dieser Stelle waere
* wirkungslos und wuerde nur verschleiern, wo er wirklich sitzt.
*/
/* eslint-disable react/jsx-key */
const angaben: Array<[string, React.ReactNode]> = [ const angaben: Array<[string, React.ReactNode]> = [
["Typ nach DIN", fahrzeug.dinTyp ?? "—"], ["Typ nach DIN", fahrzeug.dinTyp ?? "—"],
["Kennzeichen", <span className="tabellenzahlen">{fahrzeug.kennzeichen ?? "—"}</span>], ["Kennzeichen", <span className="tabellenzahlen">{fahrzeug.kennzeichen ?? "—"}</span>],
@@ -88,6 +96,7 @@ export default async function FahrzeugSeite({ params }: { params: Promise<{ id:
], ],
["Standort", fahrzeug.standort?.name ?? "—"], ["Standort", fahrzeug.standort?.name ?? "—"],
]; ];
/* eslint-enable react/jsx-key */
const alleRaeume = [ const alleRaeume = [
...fahrzeug.geraeteraeume, ...fahrzeug.geraeteraeume,
+9
View File
@@ -104,6 +104,14 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
const { geraet, verlauf, pruefungen, maengel, termine } = daten; const { geraet, verlauf, pruefungen, maengel, termine } = daten;
const qr = await qrAlsDataUri(geraet.inventarnummer, 320); const qr = await qrAlsDataUri(geraet.inventarnummer, 320);
/*
* eslint-disable-next-line react/jsx-key -- Fehlalarm: Diese JSX-Elemente
* sind nicht die Kinder einer Liste, sondern die zweiten Glieder von
* Wertepaaren. Gerendert werden sie unten in einer map, deren Wrapper den
* key traegt (`<div key={bezeichnung}>`). Ein key an dieser Stelle waere
* wirkungslos und wuerde nur verschleiern, wo er wirklich sitzt.
*/
/* eslint-disable react/jsx-key */
const angaben: Array<[string, React.ReactNode]> = [ const angaben: Array<[string, React.ReactNode]> = [
["Inventarnummer", <span className="tabellenzahlen">{geraet.inventarnummer}</span>], ["Inventarnummer", <span className="tabellenzahlen">{geraet.inventarnummer}</span>],
["Gerätetyp", geraet.geraetetyp?.name ?? "—"], ["Gerätetyp", geraet.geraetetyp?.name ?? "—"],
@@ -129,6 +137,7 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
["Lebensdauer bis", <FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={90} />], ["Lebensdauer bis", <FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={90} />],
["Prüfvorschrift", geraet.geraetetyp?.pruefvorschrift ?? "—"], ["Prüfvorschrift", geraet.geraetetyp?.pruefvorschrift ?? "—"],
]; ];
/* eslint-enable react/jsx-key */
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -118,7 +118,6 @@ export function Scanner() {
{fehler && <Fehlermeldung meldung={fehler} />} {fehler && <Fehlermeldung meldung={fehler} />}
<div className="overflow-hidden rounded-lg border border-[var(--border)] bg-black"> <div className="overflow-hidden rounded-lg border border-[var(--border)] bg-black">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video <video
ref={videoRef} ref={videoRef}
className="aspect-video w-full object-cover" className="aspect-video w-full object-cover"
@@ -8,13 +8,6 @@ import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Table, Table,
+14 -1
View File
@@ -78,7 +78,17 @@ export function KartenAnsicht({
breitengrad: number; breitengrad: number;
laengengrad: number; laengengrad: number;
} | null>(null); } | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null); /**
* Was schiefging.
*
* Bis Phase 8 stand hier ein Zustand, der nie gesetzt wurde — die
* Fehlerfläche darunter konnte also nichts anzeigen, und eine gescheiterte
* Abfrage sah aus wie „keine Hydranten vorhanden". Gefunden hat das der
* neue Linter.
*
* Jetzt kommt die Meldung aus den Abfragen selbst. Ein eigener Zustand
* dafür wäre eine zweite Wahrheit neben der von React Query.
*/
const hydranten = trpc.hydrant.liste.useQuery({ const hydranten = trpc.hydrant.liste.useQuery({
suche: suche || undefined, suche: suche || undefined,
@@ -137,6 +147,9 @@ export function KartenAnsicht({
? objekte.data?.find((eintrag) => eintrag.id === gewaehlt.id) ? objekte.data?.find((eintrag) => eintrag.id === gewaehlt.id)
: undefined; : undefined;
const meldung =
hydranten.error?.message ?? objekte.error?.message ?? kennzahlen.error?.message ?? null;
return ( return (
<div className="space-y-4" data-testid="kartenansicht"> <div className="space-y-4" data-testid="kartenansicht">
{meldung && <Fehlermeldung meldung={meldung} kennung="kartenfehler" />} {meldung && <Fehlermeldung meldung={meldung} kennung="kartenfehler" />}
@@ -19,13 +19,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige"; import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { KATEGORIE_TEXT } from "@/app/(app)/karte/objekte/objekt-liste"; import { KATEGORIE_TEXT } from "@/app/(app)/karte/objekte/objekt-liste";
@@ -232,7 +225,7 @@ export function ObjektDetail({ id, darfBearbeiten }: { id: string; darfBearbeite
<div> <div>
<CardTitle>Ansprechpartner</CardTitle> <CardTitle>Ansprechpartner</CardTitle>
<CardDescription> <CardDescription>
Wer nachts ans Telefon geht, steht mit „jederzeit" markiert. Wer nachts ans Telefon geht, steht mit „jederzeit“ markiert.
</CardDescription> </CardDescription>
</div> </div>
{darfBearbeiten && ( {darfBearbeiten && (
+9
View File
@@ -65,6 +65,14 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
if (!person) notFound(); if (!person) notFound();
/*
* eslint-disable-next-line react/jsx-key -- Fehlalarm: Diese JSX-Elemente
* sind nicht die Kinder einer Liste, sondern die zweiten Glieder von
* Wertepaaren. Gerendert werden sie unten in einer map, deren Wrapper den
* key traegt (`<div key={bezeichnung}>`). Ein key an dieser Stelle waere
* wirkungslos und wuerde nur verschleiern, wo er wirklich sitzt.
*/
/* eslint-disable react/jsx-key */
const angaben: Array<[string, React.ReactNode]> = [ const angaben: Array<[string, React.ReactNode]> = [
["Mitgliedsnummer", <span className="tabellenzahlen">{person.mitgliedsnummer ?? "—"}</span>], ["Mitgliedsnummer", <span className="tabellenzahlen">{person.mitgliedsnummer ?? "—"}</span>],
["Dienstgrad", DIENSTGRAD_TEXT[person.dienstgrad] ?? person.dienstgrad], ["Dienstgrad", DIENSTGRAD_TEXT[person.dienstgrad] ?? person.dienstgrad],
@@ -79,6 +87,7 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—", person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
], ],
]; ];
/* eslint-enable react/jsx-key */
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -278,7 +278,7 @@ export function FristenDashboard({
Offene Termine Offene Termine
{fenster && ( {fenster && (
<span className="ml-2 text-sm font-normal text-[var(--muted-foreground)]"> <span className="ml-2 text-sm font-normal text-[var(--muted-foreground)]">
gefiltert auf „{fenster.replace(/_/g, " ")}" gefiltert auf „{fenster.replace(/_/g, " ")}“
</span> </span>
)} )}
</CardTitle> </CardTitle>
@@ -133,7 +133,7 @@ export function ChecklistenEditor({
<CardDescription> <CardDescription>
Der Prüfwizard fragt die Punkte in dieser Reihenfolge ab. Pflichtpunkte müssen Der Prüfwizard fragt die Punkte in dieser Reihenfolge ab. Pflichtpunkte müssen
beantwortet sein, bevor sich eine Prüfung abschließen lässt — und ein durchgefallener beantwortet sein, bevor sich eine Prüfung abschließen lässt — und ein durchgefallener
Pflichtpunkt bedeutet „nicht bestanden". Pflichtpunkt bedeutet „nicht bestanden“.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -222,7 +222,7 @@ export function ChecklistenEditor({
<CardHeader> <CardHeader>
<CardTitle>Prüfpunkt ergänzen</CardTitle> <CardTitle>Prüfpunkt ergänzen</CardTitle>
<CardDescription> <CardDescription>
Bei einem Messwert entscheidet der zulässige Bereich über „in Ordnung" — gerechnet Bei einem Messwert entscheidet der zulässige Bereich über „in Ordnung“ — gerechnet
wird das auf dem Server, nicht im Browser. wird das auf dem Server, nicht im Browser.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
+1 -1
View File
@@ -29,7 +29,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige"; import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { Markdown } from "@/components/ki/markdown"; import { Markdown } from "@/components/ki/markdown";
import { WIKI_KATEGORIE_TEXT } from "@/lib/ki/texte"; import { WIKI_KATEGORIE_TEXT } from "@/lib/ki/texte";
-1
View File
@@ -108,7 +108,6 @@ export function Diktat({ entitaet, entitaetId, onUebernehmen }: DiktatProps) {
}, 500); }, 500);
return () => clearInterval(zeitgeber); return () => clearInterval(zeitgeber);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zustand, moeglichkeiten.data?.maxSekunden]); }, [zustand, moeglichkeiten.data?.maxSekunden]);
function zuruecksetzen() { function zuruecksetzen() {
-1
View File
@@ -98,7 +98,6 @@ export function QrLeser({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="overflow-hidden rounded-lg border border-[var(--border)] bg-black"> <div className="overflow-hidden rounded-lg border border-[var(--border)] bg-black">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video ref={videoRef} className="aspect-video w-full object-cover" muted playsInline /> <video ref={videoRef} className="aspect-video w-full object-cover" muted playsInline />
</div> </div>
+15 -2
View File
@@ -31,11 +31,24 @@ const badgeVarianten = cva(
); );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVarianten> {} VariantProps<typeof badgeVarianten> {}
/**
* Ein `<span>`, kein `<div>`.
*
* Bis Phase 8 war es ein `<div>`, und React meldete auf der Einsatzseite seit
* Phase 3: „In HTML, <div> cannot be a descendant of <p>." Dort stehen die
* Abzeichen für Kategorie und Status in einer Zeile Fließtext — und ein
* Blockelement in einem Absatz ist ungültiges HTML. Der Browser schließt den
* Absatz von sich aus davor, baut also einen anderen Baum als React erwartet,
* und das ist der klassische Auslöser für einen Hydration-Fehler.
*
* Ein Abzeichen ist ohnehin Fließtext-Inhalt. Die Darstellung ändert sich
* nicht: `inline-flex` macht es auch als span zu einer Zeile mit Innenabstand.
*/
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVarianten({ variant }), className)} {...props} />; return <span className={cn(badgeVarianten({ variant }), className)} {...props} />;
} }
export { Badge, badgeVarianten }; export { Badge, badgeVarianten };
-1
View File
@@ -104,7 +104,6 @@ class EchtesNtfy implements NtfyVersand {
* Aufruf oder — schlimmer — ein Titel voller Fragezeichen. * Aufruf oder — schlimmer — ein Titel voller Fragezeichen.
*/ */
function alsAsciiKopf(text: string): string { function alsAsciiKopf(text: string): string {
// eslint-disable-next-line no-control-regex
if (/^[\x00-\x7F]*$/.test(text)) return text; if (/^[\x00-\x7F]*$/.test(text)) return text;
return `=?UTF-8?B?${Buffer.from(text, "utf8").toString("base64")}?=`; return `=?UTF-8?B?${Buffer.from(text, "utf8").toString("base64")}?=`;
} }
+45 -20
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { ADMIN_EMAIL, ADMIN_PASSWORT, WEHR_NAME } from "./wehr";
/** /**
* Beispiel-E2E-Test für Phase 0. * Beispiel-E2E-Test für Phase 0.
@@ -11,17 +12,18 @@ import { expect, test } from "@playwright/test";
* docker compose up -d && npm run db:migrate && npm run db:seed * docker compose up -d && npm run db:migrate && npm run db:seed
*/ */
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
test.describe("Anmeldung", () => { test.describe("Anmeldung", () => {
test("schickt nicht angemeldete Aufrufe zur Anmeldeseite", async ({ page }) => { test("schickt nicht angemeldete Aufrufe zur Anmeldeseite", async ({
page,
}) => {
await page.goto("/"); await page.goto("/");
await expect(page).toHaveURL(/\/anmelden/); await expect(page).toHaveURL(/\/anmelden/);
await expect(page.getByRole("heading", { name: "FLORIAN" })).toBeVisible(); await expect(page.getByRole("heading", { name: "FLORIAN" })).toBeVisible();
}); });
test("weist eine falsche Kombination ab und nennt den Grund auf Deutsch", async ({ page }) => { test("weist eine falsche Kombination ab und nennt den Grund auf Deutsch", async ({
page,
}) => {
await page.goto("/anmelden"); await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL); await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
@@ -37,7 +39,9 @@ test.describe("Anmeldung", () => {
await expect(page).toHaveURL(/\/anmelden/); await expect(page).toHaveURL(/\/anmelden/);
}); });
test("lässt den Administrator hinein und zeigt die Übersicht seiner Wehr", async ({ page }) => { test("lässt den Administrator hinein und zeigt die Übersicht seiner Wehr", async ({
page,
}) => {
await page.goto("/anmelden"); await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL); await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
@@ -46,15 +50,18 @@ test.describe("Anmeldung", () => {
await page.waitForURL("/"); await page.waitForURL("/");
// Die Wehr aus dem Seed. // Die Wehr, gegen die getestet wird — nicht die Beispielwehr aus der
await expect( // Anleitung. Welche das ist, steht in wehr.ts.
page.getByRole("heading", { name: "Freiwillige Feuerwehr Musterhausen" }), await expect(page.getByRole("heading", { name: WEHR_NAME })).toBeVisible();
).toBeVisible();
// Die Kennzahlen aus dem Seed: 15 Mitglieder, 3 Fahrzeuge, 40 Geräte. // Die Kennzahlen aus dem Seed: 15 Mitglieder, 3 Fahrzeuge, 40 Geräte.
await expect(page.getByText("Aktive Mitglieder")).toBeVisible(); await expect(page.getByText("Aktive Mitglieder")).toBeVisible();
await expect(page.getByText("Fahrzeuge", { exact: true }).first()).toBeVisible(); await expect(
await expect(page.getByText("Geräte", { exact: true }).first()).toBeVisible(); page.getByText("Fahrzeuge", { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText("Geräte", { exact: true }).first(),
).toBeVisible();
// Der Sicherheitshinweis muss stehen, solange nichts freigegeben ist. // Der Sicherheitshinweis muss stehen, solange nichts freigegeben ist.
await expect(page.getByRole("note")).toContainText("analoge Überwachung"); await expect(page.getByRole("note")).toContainText("analoge Überwachung");
@@ -69,31 +76,49 @@ test.describe("Anmeldung", () => {
await page.getByRole("button", { name: "Anmelden" }).click(); await page.getByRole("button", { name: "Anmelden" }).click();
await page.waitForURL("/"); await page.waitForURL("/");
const navigation = page.getByRole("navigation", { name: "Hauptnavigation" }); const navigation = page.getByRole("navigation", {
await expect(navigation.getByRole("link", { name: "Übersicht" })).toBeVisible(); name: "Hauptnavigation",
});
await expect(
navigation.getByRole("link", { name: "Übersicht" }),
).toBeVisible();
// Termine, Dienstausweis und Benachrichtigungen stehen seit Phase 7 zur // Termine, Dienstausweis und Benachrichtigungen stehen seit Phase 7 zur
// Verfügung und sind Verweise. // Verfügung und sind Verweise.
await expect(navigation.getByRole("link", { name: "Termine" })).toBeVisible(); await expect(
await expect(navigation.getByRole("link", { name: "Dienstausweis" })).toBeVisible(); navigation.getByRole("link", { name: "Termine" }),
await expect(navigation.getByRole("link", { name: "Benachrichtigungen" })).toBeVisible(); ).toBeVisible();
await expect(
navigation.getByRole("link", { name: "Dienstausweis" }),
).toBeVisible();
await expect(
navigation.getByRole("link", { name: "Benachrichtigungen" }),
).toBeVisible();
// Die Kasse kommt erst in Phase 9 — der Eintrag ist sichtbar, aber kein // Die Kasse kommt erst in Phase 9 — der Eintrag ist sichtbar, aber kein
// Verweis. Beim Abschluss einer Phase wandert diese Zusicherung eine Zeile // Verweis. Beim Abschluss einer Phase wandert diese Zusicherung eine Zeile
// weiter nach unten. // weiter nach unten.
await expect(navigation.getByText("Kasse")).toBeVisible(); await expect(navigation.getByText("Kasse")).toBeVisible();
await expect(navigation.getByRole("link", { name: "Kasse" })).toHaveCount(0); await expect(navigation.getByRole("link", { name: "Kasse" })).toHaveCount(
0,
);
}); });
}); });
test.describe("Erreichbarkeit", () => { test.describe("Erreichbarkeit", () => {
test("meldet den Zustand der Randsysteme unter /api/health", async ({ request }) => { test("meldet den Zustand der Randsysteme unter /api/health", async ({
request,
}) => {
const antwort = await request.get("/api/health"); const antwort = await request.get("/api/health");
expect(antwort.status()).toBe(200); expect(antwort.status()).toBe(200);
const daten = (await antwort.json()) as { const daten = (await antwort.json()) as {
status: string; status: string;
dienste: { datenbank: boolean; speicher: boolean; warteschlange: boolean }; dienste: {
datenbank: boolean;
speicher: boolean;
warteschlange: boolean;
};
}; };
expect(daten.status).toBe("ok"); expect(daten.status).toBe("ok");
+129 -56
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { anmelden } from "./wehr";
/** /**
* E2E-Tests für Phase 5 — die Atemschutzüberwachung. * E2E-Tests für Phase 5 — die Atemschutzüberwachung.
@@ -17,25 +18,19 @@ import { expect, test, type Page } from "@playwright/test";
*/ */
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
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("/");
}
/** Eröffnet eine Überwachung und gibt deren Adresse zurück. */ /** Eröffnet eine Überwachung und gibt deren Adresse zurück. */
async function ueberwachungEroeffnen(page: Page, bezeichnung: string): Promise<string> { async function ueberwachungEroeffnen(
page: Page,
bezeichnung: string,
): Promise<string> {
await page.goto("/atemschutz"); await page.goto("/atemschutz");
await page.getByLabel("Bezeichnung").fill(bezeichnung); await page.getByLabel("Bezeichnung").fill(bezeichnung);
await page.getByRole("button", { name: "Eröffnen" }).click(); await page.getByRole("button", { name: "Eröffnen" }).click();
// Sie erscheint unter „Läuft gerade" — von dort geht es hinein. // Sie erscheint unter „Läuft gerade" — von dort geht es hinein.
const eintrag = page.getByRole("link", { name: new RegExp(bezeichnung) }).first(); const eintrag = page
.getByRole("link", { name: new RegExp(bezeichnung) })
.first();
await expect(eintrag).toBeVisible(); await expect(eintrag).toBeVisible();
await eintrag.click(); await eintrag.click();
@@ -79,17 +74,23 @@ test.describe("Atemschutz", () => {
await expect(page.getByTestId("atemschutz-hinweis")).toBeVisible(); await expect(page.getByTestId("atemschutz-hinweis")).toBeVisible();
}); });
test("zeigt die Tauglichkeit mit Klartext, nicht nur mit Farbe", async ({ page }) => { test("zeigt die Tauglichkeit mit Klartext, nicht nur mit Farbe", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/atemschutz"); await page.goto("/atemschutz");
// Aus dem Seed: einer hat eine abgelaufene G26, einem fehlt die // Aus dem Seed: einer hat eine abgelaufene G26, einem fehlt die
// Belastungsübung. Beides muss lesbar dastehen. // Belastungsübung. Beides muss lesbar dastehen.
await expect(page.getByText("G26.3 ist abgelaufen").first()).toBeVisible(); await expect(page.getByText("G26.3 ist abgelaufen").first()).toBeVisible();
await expect(page.getByText(/Belastungsübung in diesem Jahr fehlt/).first()).toBeVisible(); await expect(
page.getByText(/Belastungsübung in diesem Jahr fehlt/).first(),
).toBeVisible();
}); });
test("führt eine Überwachung von der Eröffnung bis zum Protokoll", async ({ page }) => { test("führt eine Überwachung von der Eröffnung bis zum Protokoll", async ({
page,
}) => {
test.setTimeout(180_000); test.setTimeout(180_000);
await anmelden(page); await anmelden(page);
@@ -151,13 +152,17 @@ test.describe("Atemschutz", () => {
// Großzügig: das Beenden stellt zusätzlich den Auftrag für das PDF in die // Großzügig: das Beenden stellt zusätzlich den Auftrag für das PDF in die
// Warteschlange, und unter Last dauert die Runde über Redis. // Warteschlange, und unter Last dauert die Runde über Redis.
await expect(page.getByRole("button", { name: "Überwachung beenden" })).toBeHidden({ await expect(
page.getByRole("button", { name: "Überwachung beenden" }),
).toBeHidden({
timeout: 60_000, timeout: 60_000,
}); });
// Und das Protokoll gibt es als PDF. Innerhalb der Ansicht gesucht: in der // Und das Protokoll gibt es als PDF. Innerhalb der Ansicht gesucht: in der
// Seitenleiste steht ebenfalls ein „Protokoll". // Seitenleiste steht ebenfalls ein „Protokoll".
const protokoll = page.getByTestId("ueberwachung").getByRole("link", { name: "Protokoll" }); const protokoll = page
.getByTestId("ueberwachung")
.getByRole("link", { name: "Protokoll" });
await expect(protokoll).toBeVisible(); await expect(protokoll).toBeVisible();
const antwort = await page.request.get( const antwort = await page.request.get(
@@ -167,11 +172,16 @@ test.describe("Atemschutz", () => {
expect(antwort.headers()["content-type"]).toContain("application/pdf"); expect(antwort.headers()["content-type"]).toContain("application/pdf");
}); });
test("lässt eine Überwachung mit einem Trupp im Einsatz nicht beenden", async ({ page }) => { test("lässt eine Überwachung mit einem Trupp im Einsatz nicht beenden", async ({
page,
}) => {
test.setTimeout(180_000); test.setTimeout(180_000);
await anmelden(page); await anmelden(page);
await ueberwachungEroeffnen(page, `E2E Offen ${test.info().project.name} ${Date.now()}`); await ueberwachungEroeffnen(
page,
`E2E Offen ${test.info().project.name} ${Date.now()}`,
);
await truppAnlegen(page, "Sicherheitstrupp"); await truppAnlegen(page, "Sicherheitstrupp");
const trupp = page.locator("[data-testid^='trupp-']").first(); const trupp = page.locator("[data-testid^='trupp-']").first();
@@ -183,15 +193,22 @@ test.describe("Atemschutz", () => {
// Eine beendete Überwachung mit einem Trupp, der noch drin steht, wäre die // Eine beendete Überwachung mit einem Trupp, der noch drin steht, wäre die
// gefährlichste Zeile im ganzen Protokoll. // gefährlichste Zeile im ganzen Protokoll.
await expect(page.getByTestId("ueberwachungsfehler")).toContainText("nicht als draußen"); await expect(page.getByTestId("ueberwachungsfehler")).toContainText(
await expect(page.getByRole("button", { name: "Überwachung beenden" })).toBeVisible(); "nicht als draußen",
);
await expect(
page.getByRole("button", { name: "Überwachung beenden" }),
).toBeVisible();
}); });
test("meldet und hebt einen Notfall auf", async ({ page }) => { test("meldet und hebt einen Notfall auf", async ({ page }) => {
test.setTimeout(180_000); test.setTimeout(180_000);
await anmelden(page); await anmelden(page);
await ueberwachungEroeffnen(page, `E2E Mayday ${test.info().project.name} ${Date.now()}`); await ueberwachungEroeffnen(
page,
`E2E Mayday ${test.info().project.name} ${Date.now()}`,
);
await truppAnlegen(page, "Notfalltrupp"); await truppAnlegen(page, "Notfalltrupp");
const trupp = page.locator("[data-testid^='trupp-']").first(); const trupp = page.locator("[data-testid^='trupp-']").first();
@@ -218,7 +235,10 @@ test.describe("Die Uhr übersteht alles", () => {
test.setTimeout(180_000); test.setTimeout(180_000);
await anmelden(page); await anmelden(page);
await ueberwachungEroeffnen(page, `E2E Neuladen ${test.info().project.name} ${Date.now()}`); await ueberwachungEroeffnen(
page,
`E2E Neuladen ${test.info().project.name} ${Date.now()}`,
);
await truppAnlegen(page, "Uhrtrupp"); await truppAnlegen(page, "Uhrtrupp");
const trupp = page.locator("[data-testid^='trupp-']").first(); const trupp = page.locator("[data-testid^='trupp-']").first();
@@ -227,18 +247,26 @@ test.describe("Die Uhr übersteht alles", () => {
// Ein paar Sekunden laufen lassen, damit es etwas zu verlieren gibt. // Ein paar Sekunden laufen lassen, damit es etwas zu verlieren gibt.
await expect await expect
.poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), { .poll(
timeout: 30_000, async () =>
}) alsSekunden(await page.getByTestId("trupp-dauer").innerText()),
{
timeout: 30_000,
},
)
.toBeGreaterThanOrEqual(5); .toBeGreaterThanOrEqual(5);
const vorher = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); const vorher = alsSekunden(
await page.getByTestId("trupp-dauer").innerText(),
);
// --- Neuladen ------------------------------------------------------------ // --- Neuladen ------------------------------------------------------------
await page.reload(); await page.reload();
await expect(page.getByTestId("ueberwachung")).toBeVisible(); await expect(page.getByTestId("ueberwachung")).toBeVisible();
const nachher = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); const nachher = alsSekunden(
await page.getByTestId("trupp-dauer").innerText(),
);
// Die Uhr muss weitergelaufen sein — nicht zurückgesprungen, nicht bei // Die Uhr muss weitergelaufen sein — nicht zurückgesprungen, nicht bei
// null angefangen. Ein Zähler im Browser hätte hier neu begonnen. // null angefangen. Ein Zähler im Browser hätte hier neu begonnen.
@@ -246,18 +274,26 @@ test.describe("Die Uhr übersteht alles", () => {
expect(nachher).toBeLessThan(vorher + 60); expect(nachher).toBeLessThan(vorher + 60);
}); });
test("läuft weiter, wenn der Server nicht mehr antwortet", async ({ page, context }) => { test("läuft weiter, wenn der Server nicht mehr antwortet", async ({
page,
context,
}) => {
test.setTimeout(180_000); test.setTimeout(180_000);
await anmelden(page); await anmelden(page);
await ueberwachungEroeffnen(page, `E2E Netzverlust ${test.info().project.name} ${Date.now()}`); await ueberwachungEroeffnen(
page,
`E2E Netzverlust ${test.info().project.name} ${Date.now()}`,
);
await truppAnlegen(page, "Netzverlusttrupp"); await truppAnlegen(page, "Netzverlusttrupp");
const trupp = page.locator("[data-testid^='trupp-']").first(); const trupp = page.locator("[data-testid^='trupp-']").first();
await trupp.getByRole("button", { name: "Einstieg" }).click(); await trupp.getByRole("button", { name: "Einstieg" }).click();
await expect(trupp).toHaveAttribute("data-status", "IM_EINSATZ"); await expect(trupp).toHaveAttribute("data-status", "IM_EINSATZ");
const vorher = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); const vorher = alsSekunden(
await page.getByTestId("trupp-dauer").innerText(),
);
// --- Netz weg ------------------------------------------------------------ // --- Netz weg ------------------------------------------------------------
await context.setOffline(true); await context.setOffline(true);
@@ -265,16 +301,22 @@ test.describe("Die Uhr übersteht alles", () => {
// Die Uhr braucht den Server nicht: sie rechnet aus dem Einstiegszeitpunkt, // Die Uhr braucht den Server nicht: sie rechnet aus dem Einstiegszeitpunkt,
// den sie schon hat. Ein heruntergezählter Wert wäre hier stehen geblieben. // den sie schon hat. Ein heruntergezählter Wert wäre hier stehen geblieben.
await expect await expect
.poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), { .poll(
timeout: 30_000, async () =>
}) alsSekunden(await page.getByTestId("trupp-dauer").innerText()),
{
timeout: 30_000,
},
)
.toBeGreaterThan(vorher + 3); .toBeGreaterThan(vorher + 3);
// --- Netz zurück --------------------------------------------------------- // --- Netz zurück ---------------------------------------------------------
await context.setOffline(false); await context.setOffline(false);
// Und der Server bestätigt, was der Browser gerechnet hat. // Und der Server bestätigt, was der Browser gerechnet hat.
const nachher = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); const nachher = alsSekunden(
await page.getByTestId("trupp-dauer").innerText(),
);
expect(nachher).toBeGreaterThan(vorher); expect(nachher).toBeGreaterThan(vorher);
}); });
@@ -282,22 +324,35 @@ test.describe("Die Uhr übersteht alles", () => {
test.setTimeout(180_000); test.setTimeout(180_000);
await anmelden(page); await anmelden(page);
await ueberwachungEroeffnen(page, `E2E Stromabriss ${test.info().project.name} ${Date.now()}`); await ueberwachungEroeffnen(
page,
`E2E Stromabriss ${test.info().project.name} ${Date.now()}`,
);
await truppAnlegen(page, "Abrisstrupp"); await truppAnlegen(page, "Abrisstrupp");
const trupp = page.locator("[data-testid^='trupp-']").first(); const trupp = page.locator("[data-testid^='trupp-']").first();
await trupp.getByRole("button", { name: "Einstieg" }).click(); await trupp.getByRole("button", { name: "Einstieg" }).click();
await expect(page.getByTestId("verbindung")).toHaveAttribute("data-verbunden", "true", { await expect(page.getByTestId("verbindung")).toHaveAttribute(
timeout: 30_000, "data-verbunden",
}); "true",
{
timeout: 30_000,
},
);
await expect await expect
.poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), { .poll(
timeout: 30_000, async () =>
}) alsSekunden(await page.getByTestId("trupp-dauer").innerText()),
{
timeout: 30_000,
},
)
.toBeGreaterThanOrEqual(4); .toBeGreaterThanOrEqual(4);
const vorher = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); const vorher = alsSekunden(
await page.getByTestId("trupp-dauer").innerText(),
);
/* /*
Den Strom abschneiden. Den Strom abschneiden.
@@ -308,33 +363,51 @@ test.describe("Die Uhr übersteht alles", () => {
geladen; der neue Ereignisstrom kommt dann gar nicht erst zustande. Damit geladen; der neue Ereignisstrom kommt dann gar nicht erst zustande. Damit
prüft dieser Test beides auf einmal: Neuladen **und** toter Strom. prüft dieser Test beides auf einmal: Neuladen **und** toter Strom.
*/ */
await page.route("**/api/atemschutz/strom**", (route) => route.abort("connectionreset")); await page.route("**/api/atemschutz/strom**", (route) =>
route.abort("connectionreset"),
);
await page.reload(); await page.reload();
await expect(page.getByTestId("ueberwachung")).toBeVisible(); await expect(page.getByTestId("ueberwachung")).toBeVisible();
await expect(page.getByTestId("verbindung")).toHaveAttribute("data-verbunden", "false", { await expect(page.getByTestId("verbindung")).toHaveAttribute(
timeout: 30_000, "data-verbunden",
}); "false",
await expect(page.getByText("Ereignisstrom ist unterbrochen")).toBeVisible(); {
timeout: 30_000,
},
);
await expect(
page.getByText("Ereignisstrom ist unterbrochen"),
).toBeVisible();
// Trotz totem Strom stimmt die Uhr: der Stand kommt weiter über die // Trotz totem Strom stimmt die Uhr: der Stand kommt weiter über die
// normale Abfrage, und gerechnet wird aus Zeitstempeln. // normale Abfrage, und gerechnet wird aus Zeitstempeln.
const nachAbriss = alsSekunden(await page.getByTestId("trupp-dauer").innerText()); const nachAbriss = alsSekunden(
await page.getByTestId("trupp-dauer").innerText(),
);
expect(nachAbriss).toBeGreaterThanOrEqual(vorher); expect(nachAbriss).toBeGreaterThanOrEqual(vorher);
await expect await expect
.poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), { .poll(
timeout: 30_000, async () =>
}) alsSekunden(await page.getByTestId("trupp-dauer").innerText()),
{
timeout: 30_000,
},
)
.toBeGreaterThan(nachAbriss + 2); .toBeGreaterThan(nachAbriss + 2);
// --- Strom zurück -------------------------------------------------------- // --- Strom zurück --------------------------------------------------------
await page.unroute("**/api/atemschutz/strom**"); await page.unroute("**/api/atemschutz/strom**");
// EventSource verbindet sich von selbst neu — ohne eine Zeile dafür. // EventSource verbindet sich von selbst neu — ohne eine Zeile dafür.
await expect(page.getByTestId("verbindung")).toHaveAttribute("data-verbunden", "true", { await expect(page.getByTestId("verbindung")).toHaveAttribute(
timeout: 60_000, "data-verbunden",
}); "true",
{
timeout: 60_000,
},
);
}); });
}); });
+76 -37
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { anmelden } from "./wehr";
/** /**
* E2E-Tests für Phase 3. * E2E-Tests für Phase 3.
@@ -14,17 +15,6 @@ import { expect, test, type Page } from "@playwright/test";
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet. * 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 }) => { test.beforeEach(async ({ page }) => {
await anmelden(page); await anmelden(page);
}); });
@@ -35,10 +25,17 @@ test.describe("Einsatzliste", () => {
await expect(page.getByRole("heading", { name: "Einsätze" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Einsätze" })).toBeVisible();
// Aus dem Seed: acht Einsätze des laufenden Jahres. // Aus dem Seed: acht Einsätze des laufenden Jahres.
await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible(); await expect(
// "exact": in der Liste stehen mit der Zeit auch Einsaetze, deren Stichwort page.getByRole("link", { name: /\d{4}-\d{4}/ }).first(),
// mit demselben Text beginnt — etwa die aus den Tests weiter unten. ).toBeVisible();
await expect(page.getByText("B 3 Wohnhausbrand", { exact: true })).toBeVisible(); // Die Zelle enthaelt Stichwort UND Objekt ("B 3 Wohnhausbrand" +
// "Reihenhaus, zwei Vollgeschosse"). Deshalb der Anker am Anfang statt
// einer Pruefung auf Gleichheit — die ging bis Phase 8 nur deshalb durch,
// weil in der Beispielwehr Einsaetze aus frueheren Testlaeufen ohne Objekt
// standen.
await expect(
page.getByRole("cell", { name: /^B 3 Wohnhausbrand/ }).first(),
).toBeVisible();
}); });
test("filtert über die Suche", async ({ page }) => { test("filtert über die Suche", async ({ page }) => {
@@ -76,7 +73,9 @@ test.describe("Einsatz erfassen und bearbeiten", () => {
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/); await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
einsatzAdresse = page.url(); einsatzAdresse = page.url();
await expect(page.getByRole("heading", { name: /THL 1 Testeinsatz/ })).toBeVisible(); await expect(
page.getByRole("heading", { name: /THL 1 Testeinsatz/ }),
).toBeVisible();
await expect(page.getByText("läuft", { exact: true })).toBeVisible(); await expect(page.getByText("läuft", { exact: true })).toBeVisible();
}); });
@@ -86,20 +85,28 @@ test.describe("Einsatz erfassen und bearbeiten", () => {
await expect(page.getByRole("heading", { name: "Zeiten" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Zeiten" })).toBeVisible();
await page.getByRole("button", { name: "ausgerückt jetzt" }).click(); await page.getByRole("button", { name: "ausgerückt jetzt" }).click();
await expect(page.getByRole("button", { name: "ausgerückt zurücknehmen" })).toBeVisible(); await expect(
page.getByRole("button", { name: "ausgerückt zurücknehmen" }),
).toBeVisible();
await page.getByRole("button", { name: "eingetroffen jetzt" }).click(); await page.getByRole("button", { name: "eingetroffen jetzt" }).click();
await expect(page.getByRole("button", { name: "eingetroffen zurücknehmen" })).toBeVisible(); await expect(
page.getByRole("button", { name: "eingetroffen zurücknehmen" }),
).toBeVisible();
// Die Hilfsfrist steht danach da — auch wenn sie null Minuten beträgt. // Die Hilfsfrist steht danach da — auch wenn sie null Minuten beträgt.
await expect(page.getByText("Hilfsfrist")).toBeVisible(); await expect(page.getByText("Hilfsfrist")).toBeVisible();
}); });
test("erfasst Mannschaft über den Vorschlag und zeigt die Stärke", async ({ page }) => { test("erfasst Mannschaft über den Vorschlag und zeigt die Stärke", async ({
page,
}) => {
await page.goto(einsatzAdresse); await page.goto(einsatzAdresse);
await page.getByRole("button", { name: "Mannschaft erfassen" }).click(); await page.getByRole("button", { name: "Mannschaft erfassen" }).click();
await expect(page.getByText(/sortiert nach Einsätzen im letzten Jahr/)).toBeVisible(); await expect(
page.getByText(/sortiert nach Einsätzen im letzten Jahr/),
).toBeVisible();
// Die ersten drei Vorschläge übernehmen. // Die ersten drei Vorschläge übernehmen.
const vorschlaege = page.locator("button", { hasText: /\d+ Einsätze/ }); const vorschlaege = page.locator("button", { hasText: /\d+ Einsätze/ });
@@ -113,19 +120,26 @@ test.describe("Einsatz erfassen und bearbeiten", () => {
test("nimmt eine Lagemeldung auf", async ({ page }) => { test("nimmt eine Lagemeldung auf", async ({ page }) => {
await page.goto(einsatzAdresse); await page.goto(einsatzAdresse);
await page.getByLabel("Neue Lagemeldung").fill("Lage erkundet, keine Gefahr für Personen."); await page
.getByLabel("Neue Lagemeldung")
.fill("Lage erkundet, keine Gefahr für Personen.");
await page.getByRole("button", { name: "Melden" }).click(); await page.getByRole("button", { name: "Melden" }).click();
await expect(page.getByText("Lage erkundet, keine Gefahr für Personen.")).toBeVisible(); await expect(
page.getByText("Lage erkundet, keine Gefahr für Personen."),
).toBeVisible();
}); });
test("gibt einen Portal-Link aus, der ohne Anmeldung funktioniert", async ({ page, browser }) => { test("gibt einen Portal-Link aus, der ohne Anmeldung funktioniert", async ({
page,
browser,
}) => {
await page.goto(einsatzAdresse); await page.goto(einsatzAdresse);
await page.getByLabel("Für wen ist der Link?").fill("FF Nachbarhausen"); await page.getByLabel("Für wen ist der Link?").fill("FF Nachbarhausen");
await page.getByRole("button", { name: "Erzeugen" }).click(); await page.getByRole("button", { name: "Erzeugen" }).click();
const linkfeld = page.locator('input[readonly]'); const linkfeld = page.locator("input[readonly]");
await expect(linkfeld).toBeVisible(); await expect(linkfeld).toBeVisible();
const adresse = await linkfeld.inputValue(); const adresse = await linkfeld.inputValue();
expect(adresse).toContain("/portal/"); expect(adresse).toContain("/portal/");
@@ -136,18 +150,28 @@ test.describe("Einsatz erfassen und bearbeiten", () => {
await fremdeSeite.goto(adresse); await fremdeSeite.goto(adresse);
await expect(fremdeSeite.getByRole("heading", { name: /Testeinsatz/ })).toBeVisible(); await expect(
await expect(fremdeSeite.getByText("Zugang für FF Nachbarhausen")).toBeVisible(); fremdeSeite.getByRole("heading", { name: /Testeinsatz/ }),
).toBeVisible();
await expect(
fremdeSeite.getByText("Zugang für FF Nachbarhausen"),
).toBeVisible();
// Und eine Meldung absetzen. // Und eine Meldung absetzen.
await fremdeSeite.getByLabel("Wer meldet?").fill("Gruppenführer Huber"); await fremdeSeite.getByLabel("Wer meldet?").fill("Gruppenführer Huber");
await fremdeSeite.getByLabel("Meldung").fill("Wir sind mit einer Staffel eingetroffen."); await fremdeSeite
.getByLabel("Meldung")
.fill("Wir sind mit einer Staffel eingetroffen.");
await fremdeSeite.getByRole("button", { name: "Melden" }).click(); await fremdeSeite.getByRole("button", { name: "Melden" }).click();
await expect(fremdeSeite.getByText("Wir sind mit einer Staffel eingetroffen.")).toBeVisible(); await expect(
fremdeSeite.getByText("Wir sind mit einer Staffel eingetroffen."),
).toBeVisible();
// Kein Weg in den Rest der Anwendung. // Kein Weg in den Rest der Anwendung.
await expect(fremdeSeite.getByRole("link", { name: "Geräte" })).toHaveCount(0); await expect(fremdeSeite.getByRole("link", { name: "Geräte" })).toHaveCount(
0,
);
await fremd.close(); await fremd.close();
}); });
@@ -156,7 +180,9 @@ test.describe("Einsatz erfassen und bearbeiten", () => {
const fremd = await browser.newContext(); const fremd = await browser.newContext();
const seite = await fremd.newPage(); const seite = await fremd.newPage();
await seite.goto("/portal/3f2504e0-4f89-11d3-9a0c-0305e82c3301.abcdefghijklmnopqrstuvwxyz01"); await seite.goto(
"/portal/3f2504e0-4f89-11d3-9a0c-0305e82c3301.abcdefghijklmnopqrstuvwxyz01",
);
await expect(seite.getByTestId("portalfehler")).toBeVisible(); await expect(seite.getByTestId("portalfehler")).toBeVisible();
await expect(seite.getByText(/nicht gültig/)).toBeVisible(); await expect(seite.getByText(/nicht gültig/)).toBeVisible();
@@ -169,10 +195,16 @@ test.describe("Statistik", () => {
test("zeigt Hilfsfrist, Kategorien und Beteiligung", async ({ page }) => { test("zeigt Hilfsfrist, Kategorien und Beteiligung", async ({ page }) => {
await page.goto("/einsaetze/statistik"); await page.goto("/einsaetze/statistik");
await expect(page.getByRole("heading", { name: "Einsatzstatistik" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Einsatzstatistik" }),
).toBeVisible();
await expect(page.getByText("Hilfsfrist (80 %)")).toBeVisible(); await expect(page.getByText("Hilfsfrist (80 %)")).toBeVisible();
await expect(page.getByRole("heading", { name: "Nach Kategorie" })).toBeVisible(); await expect(
await expect(page.getByRole("heading", { name: "Beteiligung" })).toBeVisible(); page.getByRole("heading", { name: "Nach Kategorie" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Beteiligung" }),
).toBeVisible();
// Aus dem Seed: die Kategorien der acht Einsätze. // Aus dem Seed: die Kategorien der acht Einsätze.
await expect(page.getByText("Brand groß").first()).toBeVisible(); await expect(page.getByText("Brand groß").first()).toBeVisible();
@@ -187,11 +219,18 @@ test.describe("Übungen", () => {
await expect(page.getByText("Innenangriff mit Atemschutz")).toBeVisible(); await expect(page.getByText("Innenangriff mit Atemschutz")).toBeVisible();
}); });
test("öffnet eine Übung und zeigt die Anwesenheitsliste", async ({ page }) => { test("öffnet eine Übung und zeigt die Anwesenheitsliste", async ({
page,
}) => {
await page.goto("/uebungen"); await page.goto("/uebungen");
await page.getByRole("link", { name: /\d{4}-\d{4}/ }).first().click(); await page
.getByRole("link", { name: /\d{4}-\d{4}/ })
.first()
.click();
await expect(page.getByRole("heading", { name: "Anwesenheit" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Anwesenheit" }),
).toBeVisible();
await expect(page.getByText(/von \d+ aktiven Mitgliedern/)).toBeVisible(); await expect(page.getByText(/von \d+ aktiven Mitgliedern/)).toBeVisible();
}); });
}); });
+45 -24
View File
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test"; import { expect, test, type Locator, type Page } from "@playwright/test";
import { anmelden } from "./wehr";
/** /**
* E2E-Tests für das Geräteformular. * E2E-Tests für das Geräteformular.
@@ -18,16 +19,18 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
* werden deshalb getippt, so wie es ein Mensch auch täte. * 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. */ /** Eindeutig je Testlauf, damit sich parallele Läufe nicht ins Gehege kommen. */
function testnummer(kennzeichen: string): string { function testnummer(kennzeichen: string): string {
return `E2E-${kennzeichen}-${Date.now().toString().slice(-6)}`; return `E2E-${kennzeichen}-${Date.now().toString().slice(-6)}`;
} }
/** Tippt ein Datum in ein date-Feld. Reihenfolge nach deutscher Locale. */ /** Tippt ein Datum in ein date-Feld. Reihenfolge nach deutscher Locale. */
async function datumTippen(feld: Locator, tag: string, monat: string, jahr: string) { async function datumTippen(
feld: Locator,
tag: string,
monat: string,
jahr: string,
) {
await feld.click(); await feld.click();
await feld.pressSequentially(`${tag}${monat}${jahr}`, { delay: 30 }); await feld.pressSequentially(`${tag}${monat}${jahr}`, { delay: 30 });
} }
@@ -44,16 +47,12 @@ function auswahl(page: Page, name: string): Locator {
return page.getByRole("combobox", { name, exact: true }); 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. */ /** Füllt die beiden Pflichtfelder und wählt eine Lagerung. */
async function grunddatenAusfuellen(page: Page, nummer: string, bezeichnung: string) { async function grunddatenAusfuellen(
page: Page,
nummer: string,
bezeichnung: string,
) {
await feld(page, "Inventarnummer").fill(nummer); await feld(page, "Inventarnummer").fill(nummer);
await feld(page, "Bezeichnung").fill(bezeichnung); await feld(page, "Bezeichnung").fill(bezeichnung);
} }
@@ -63,11 +62,15 @@ test.beforeEach(async ({ page }) => {
}); });
test.describe("Gerät anlegen", () => { test.describe("Gerät anlegen", () => {
test("legt ein Gerät an einem Standort an und öffnet es danach", async ({ page }) => { test("legt ein Gerät an einem Standort an und öffnet es danach", async ({
page,
}) => {
const nummer = testnummer("STANDORT"); const nummer = testnummer("STANDORT");
await page.goto("/geraete/neu"); await page.goto("/geraete/neu");
await expect(page.getByRole("heading", { name: "Neues Gerät" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Neues Gerät" }),
).toBeVisible();
await grunddatenAusfuellen(page, nummer, "Testgerät Standort"); await grunddatenAusfuellen(page, nummer, "Testgerät Standort");
await feld(page, "Hersteller").fill("Prüffirma"); await feld(page, "Hersteller").fill("Prüffirma");
@@ -78,11 +81,15 @@ test.describe("Gerät anlegen", () => {
await page.getByRole("button", { name: "Gerät anlegen" }).click(); await page.getByRole("button", { name: "Gerät anlegen" }).click();
await page.waitForURL(/\/geraete\/[0-9a-f-]{36}$/); await page.waitForURL(/\/geraete\/[0-9a-f-]{36}$/);
await expect(page.getByRole("heading", { name: "Testgerät Standort" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Testgerät Standort" }),
).toBeVisible();
await expect(page.getByText(nummer).first()).toBeVisible(); await expect(page.getByText(nummer).first()).toBeVisible();
}); });
test("legt ein Gerät auf einem Fahrzeug samt Geräteraum an", async ({ page }) => { test("legt ein Gerät auf einem Fahrzeug samt Geräteraum an", async ({
page,
}) => {
const nummer = testnummer("FAHRZEUG"); const nummer = testnummer("FAHRZEUG");
await page.goto("/geraete/neu"); await page.goto("/geraete/neu");
@@ -91,7 +98,9 @@ test.describe("Gerät anlegen", () => {
await page.getByRole("radio", { name: "Auf einem Fahrzeug" }).click(); await page.getByRole("radio", { name: "Auf einem Fahrzeug" }).click();
await auswahl(page, "Fahrzeug").click(); await auswahl(page, "Fahrzeug").click();
await page.getByRole("option", { name: /Florian Musterhausen 40\/1/ }).click(); await page
.getByRole("option", { name: /Florian Musterhausen 40\/1/ })
.click();
await auswahl(page, "Geräteraum").click(); await auswahl(page, "Geräteraum").click();
await page.getByRole("option", { name: /^G1/ }).click(); await page.getByRole("option", { name: /^G1/ }).click();
@@ -113,12 +122,16 @@ test.describe("Gerät anlegen", () => {
await page.getByRole("radio", { name: "Auf einem Fahrzeug" }).click(); await page.getByRole("radio", { name: "Auf einem Fahrzeug" }).click();
await page.getByRole("button", { name: "Gerät anlegen" }).click(); await page.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(page.getByTestId("formularfehler")).toContainText("Fahrzeug auswählen"); await expect(page.getByTestId("formularfehler")).toContainText(
"Fahrzeug auswählen",
);
// Nichts gespeichert: die Adresse hat sich nicht geändert. // Nichts gespeichert: die Adresse hat sich nicht geändert.
await expect(page).toHaveURL(/\/geraete\/neu$/); await expect(page).toHaveURL(/\/geraete\/neu$/);
}); });
test("rechnet die Fristen schon beim Ausfüllen vor", async ({ page }, angaben) => { test("rechnet die Fristen schon beim Ausfüllen vor", async ({
page,
}, angaben) => {
// Unter Touch-Emulation nimmt Chromium keine zeichenweise Eingabe in // Unter Touch-Emulation nimmt Chromium keine zeichenweise Eingabe in
// Datumsfelder an — dort öffnet sich der Auswähler des Betriebssystems. // Datumsfelder an — dort öffnet sich der Auswähler des Betriebssystems.
// Die Rechnung selbst prüfen die Unit-Tests in qr-und-fristen.test.ts; // Die Rechnung selbst prüfen die Unit-Tests in qr-und-fristen.test.ts;
@@ -142,7 +155,9 @@ test.describe("Gerät anlegen", () => {
await expect(page.getByText("01.03.2036")).toBeVisible(); await expect(page.getByText("01.03.2036")).toBeVisible();
}); });
test("übernimmt Prüfintervall und Lebensdauer aus dem Gerätetyp", async ({ page }, angaben) => { test("übernimmt Prüfintervall und Lebensdauer aus dem Gerätetyp", async ({
page,
}, angaben) => {
test.skip( test.skip(
angaben.project.name === "tablet", angaben.project.name === "tablet",
"Datumsfelder lassen sich unter Touch-Emulation nicht tippen.", "Datumsfelder lassen sich unter Touch-Emulation nicht tippen.",
@@ -166,7 +181,9 @@ test.describe("Gerät anlegen", () => {
}); });
test.describe("Gerät bearbeiten", () => { test.describe("Gerät bearbeiten", () => {
test("ändert ein vorhandenes Gerät und protokolliert es", async ({ page }) => { test("ändert ein vorhandenes Gerät und protokolliert es", async ({
page,
}) => {
await page.goto("/geraete"); await page.goto("/geraete");
await page.getByRole("link", { name: "INV-0002" }).click(); await page.getByRole("link", { name: "INV-0002" }).click();
await page.getByRole("link", { name: "Bearbeiten" }).click(); await page.getByRole("link", { name: "Bearbeiten" }).click();
@@ -182,10 +199,14 @@ test.describe("Gerät bearbeiten", () => {
// Die Änderung steht im Verlauf. Bei wiederholten Läufen sammeln sich dort // Die Änderung steht im Verlauf. Bei wiederholten Läufen sammeln sich dort
// mehrere Einträge — der Log ist append-only, das ist so gewollt. // mehrere Einträge — der Log ist append-only, das ist so gewollt.
await expect(page.getByText(/Gerät INV-0002 geändert/).first()).toBeVisible(); await expect(
page.getByText(/Gerät INV-0002 geändert/).first(),
).toBeVisible();
}); });
test("schaltet die Lagerung von Fahrzeug auf Standort um", async ({ page }) => { test("schaltet die Lagerung von Fahrzeug auf Standort um", async ({
page,
}) => {
await page.goto("/geraete"); await page.goto("/geraete");
await page.getByRole("link", { name: "INV-0003" }).click(); await page.getByRole("link", { name: "INV-0003" }).click();
await page.getByRole("link", { name: "Bearbeiten" }).click(); await page.getByRole("link", { name: "Bearbeiten" }).click();
+79 -35
View File
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test"; import { expect, test, type Locator } from "@playwright/test";
import { anmelden } from "./wehr";
/** /**
* E2E-Tests für Phase 6 — die Kartenmodule. * E2E-Tests für Phase 6 — die Kartenmodule.
@@ -16,9 +17,6 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
*/ */
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
/** /**
* Die Fläche der Karte, bereit zum Anklicken. * Die Fläche der Karte, bereit zum Anklicken.
* *
@@ -28,7 +26,9 @@ const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
* gelegentlich im Nichts — und der Test prüft dann eine Strecke aus einem * gelegentlich im Nichts — und der Test prüft dann eine Strecke aus einem
* einzigen Punkt. * einzigen Punkt.
*/ */
async function kartenflaeche(karte: Locator): Promise<{ x: number; y: number; width: number; height: number }> { async function kartenflaeche(
karte: Locator,
): Promise<{ x: number; y: number; width: number; height: number }> {
await karte.scrollIntoViewIfNeeded(); await karte.scrollIntoViewIfNeeded();
await expect(karte.locator("canvas")).toBeVisible(); await expect(karte.locator("canvas")).toBeVisible();
@@ -37,14 +37,6 @@ async function kartenflaeche(karte: Locator): Promise<{ x: number; y: number; wi
return flaeche; return flaeche;
} }
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.describe("Karte", () => { test.describe("Karte", () => {
test("zeigt Löschwasser und Objekte aus dem Bestand", async ({ page }) => { test("zeigt Löschwasser und Objekte aus dem Bestand", async ({ page }) => {
await anmelden(page); await anmelden(page);
@@ -69,16 +61,22 @@ test.describe("Karte", () => {
// ungeprüft. // ungeprüft.
.toBeGreaterThanOrEqual(20); .toBeGreaterThanOrEqual(20);
await expect(page.getByRole("button", { name: /Objekte \(\d+\)/ })).toBeVisible(); await expect(
page.getByRole("button", { name: /Objekte \(\d+\)/ }),
).toBeVisible();
}); });
test("sagt es, wenn kein Kartenhintergrund eingerichtet ist", async ({ page }) => { test("sagt es, wenn kein Kartenhintergrund eingerichtet ist", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/karte"); await page.goto("/karte");
// Im Testlauf gibt es keinen Kartenserver. Die Anwendung muss das sagen // Im Testlauf gibt es keinen Kartenserver. Die Anwendung muss das sagen
// und trotzdem arbeiten — nicht eine leere Fläche zeigen und schweigen. // und trotzdem arbeiten — nicht eine leere Fläche zeigen und schweigen.
await expect(page.getByTestId("karte-ohne-kacheln")).toContainText("Punkte liegen richtig"); await expect(page.getByTestId("karte-ohne-kacheln")).toContainText(
"Punkte liegen richtig",
);
}); });
test("findet über die Suche einen bestimmten Hydranten", async ({ page }) => { test("findet über die Suche einen bestimmten Hydranten", async ({ page }) => {
@@ -87,7 +85,9 @@ test.describe("Karte", () => {
// „exact": daneben steht die Zelle mit dem Entfernen-Knopf, die den Namen // „exact": daneben steht die Zelle mit dem Entfernen-Knopf, die den Namen
// in ihrer Beschriftung trägt. // in ihrer Beschriftung trägt.
await expect(page.getByRole("cell", { name: "H-0004", exact: true })).toBeVisible(); await expect(
page.getByRole("cell", { name: "H-0004", exact: true }),
).toBeVisible();
await expect(page.getByText("Weiher Kiesgrube")).toBeVisible(); await expect(page.getByText("Weiher Kiesgrube")).toBeVisible();
}); });
@@ -97,8 +97,12 @@ test.describe("Karte", () => {
// „defekt" und „ungeprüft" müssen lesbar dastehen — eine rote Markierung // „defekt" und „ungeprüft" müssen lesbar dastehen — eine rote Markierung
// allein sagt am Fahrzeug niemandem, was los ist. // allein sagt am Fahrzeug niemandem, was los ist.
await expect(page.getByRole("cell", { name: "defekt" }).first()).toBeVisible(); await expect(
await expect(page.getByRole("cell", { name: "ungeprüft" }).first()).toBeVisible(); page.getByRole("cell", { name: "defekt" }).first(),
).toBeVisible();
await expect(
page.getByRole("cell", { name: "ungeprüft" }).first(),
).toBeVisible();
}); });
}); });
@@ -115,7 +119,9 @@ test.describe("Objekte und Feuerwehrpläne", () => {
// Was auf der Anfahrt zählt, steht oben. // Was auf der Anfahrt zählt, steht oben.
await expect(page.getByTestId("objekt-detail")).toContainText("Zufahrt"); await expect(page.getByTestId("objekt-detail")).toContainText("Zufahrt");
await expect(page.getByTestId("objekt-detail")).toContainText("Sauerstoffflaschen"); await expect(page.getByTestId("objekt-detail")).toContainText(
"Sauerstoffflaschen",
);
await expect(page.getByTestId("objekt-detail")).toContainText("FSD"); await expect(page.getByTestId("objekt-detail")).toContainText("FSD");
}); });
@@ -143,25 +149,37 @@ test.describe("Objekte und Feuerwehrpläne", () => {
}); });
test.describe("Wasserförderung", () => { test.describe("Wasserförderung", () => {
test("rechnet eine Strecke durch, sobald zwei Punkte stehen", async ({ page }) => { test("rechnet eine Strecke durch, sobald zwei Punkte stehen", async ({
page,
}) => {
test.setTimeout(120_000); test.setTimeout(120_000);
await anmelden(page); await anmelden(page);
await page.goto("/karte/wasserfoerderung"); await page.goto("/karte/wasserfoerderung");
await expect(page.getByTestId("foerderplan")).toContainText("Noch keine Strecke"); await expect(page.getByTestId("foerderplan")).toContainText(
"Noch keine Strecke",
);
// Zwei Punkte in die Karte tippen — die Karte hat keine Kacheln, aber // Zwei Punkte in die Karte tippen — die Karte hat keine Kacheln, aber
// Koordinaten hat sie trotzdem. // Koordinaten hat sie trotzdem.
const flaeche = await kartenflaeche(page.getByTestId("karte")); const flaeche = await kartenflaeche(page.getByTestId("karte"));
await page.mouse.click(flaeche.x + flaeche.width * 0.35, flaeche.y + flaeche.height * 0.5); await page.mouse.click(
flaeche.x + flaeche.width * 0.35,
flaeche.y + flaeche.height * 0.5,
);
await expect(page.getByRole("heading", { name: "Punkte" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Punkte" })).toBeVisible();
await page.mouse.click(flaeche.x + flaeche.width * 0.65, flaeche.y + flaeche.height * 0.5); await page.mouse.click(
flaeche.x + flaeche.width * 0.65,
flaeche.y + flaeche.height * 0.5,
);
// Ab jetzt steht eine Rechnung da: Pumpen, Längen, Höhenunterschied. // Ab jetzt steht eine Rechnung da: Pumpen, Längen, Höhenunterschied.
await expect(page.getByTestId("pumpenzahl")).toBeVisible(); await expect(page.getByTestId("pumpenzahl")).toBeVisible();
await expect(page.getByTestId("foerderplan")).toContainText("Schlauchlängen"); await expect(page.getByTestId("foerderplan")).toContainText(
"Schlauchlängen",
);
// Und der Satz, der immer dazugehört. // Und der Satz, der immer dazugehört.
await expect(page.getByText(/Planungshilfe/)).toBeVisible(); await expect(page.getByText(/Planungshilfe/)).toBeVisible();
@@ -178,13 +196,21 @@ test.describe("Wasserförderung", () => {
// Zwischen den Klicks warten, bis der erste Punkt angekommen ist — sonst // Zwischen den Klicks warten, bis der erste Punkt angekommen ist — sonst
// prüft der Test bei einem verschluckten Klick eine Strecke aus einem // prüft der Test bei einem verschluckten Klick eine Strecke aus einem
// einzigen Punkt. // einzigen Punkt.
await page.mouse.click(flaeche.x + flaeche.width * 0.1, flaeche.y + flaeche.height * 0.5); await page.mouse.click(
flaeche.x + flaeche.width * 0.1,
flaeche.y + flaeche.height * 0.5,
);
await expect(page.getByRole("heading", { name: "Punkte" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Punkte" })).toBeVisible();
await page.mouse.click(flaeche.x + flaeche.width * 0.9, flaeche.y + flaeche.height * 0.5); await page.mouse.click(
flaeche.x + flaeche.width * 0.9,
flaeche.y + flaeche.height * 0.5,
);
await expect(page.getByText("Abschnitte")).toBeVisible(); await expect(page.getByText("Abschnitte")).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Längen" })).toBeVisible(); await expect(
page.getByRole("columnheader", { name: "Längen" }),
).toBeVisible();
const pumpen = Number(await page.getByTestId("pumpenzahl").innerText()); const pumpen = Number(await page.getByTestId("pumpenzahl").innerText());
expect(pumpen).toBeGreaterThanOrEqual(1); expect(pumpen).toBeGreaterThanOrEqual(1);
@@ -192,13 +218,18 @@ test.describe("Wasserförderung", () => {
}); });
test.describe("Lagekarte", () => { test.describe("Lagekarte", () => {
test("trägt ein taktisches Zeichen ein und nimmt es zurück", async ({ page }) => { test("trägt ein taktisches Zeichen ein und nimmt es zurück", async ({
page,
}) => {
test.setTimeout(180_000); test.setTimeout(180_000);
await anmelden(page); await anmelden(page);
// Irgendein Einsatz aus dem Seed. // Irgendein Einsatz aus dem Seed.
await page.goto("/einsaetze"); await page.goto("/einsaetze");
await page.getByRole("link", { name: /\d{4}-\d{4}/ }).first().click(); await page
.getByRole("link", { name: /\d{4}-\d{4}/ })
.first()
.click();
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/); await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
const lagekarte = page.getByTestId("lagekarte"); const lagekarte = page.getByTestId("lagekarte");
@@ -209,13 +240,23 @@ test.describe("Lagekarte", () => {
const flaeche = await kartenflaeche(lagekarte.getByTestId("karte")); const flaeche = await kartenflaeche(lagekarte.getByTestId("karte"));
await page.mouse.click(flaeche.x + flaeche.width / 2, flaeche.y + flaeche.height / 2); await page.mouse.click(
flaeche.x + flaeche.width / 2,
flaeche.y + flaeche.height / 2,
);
await expect(lagekarte.getByText("loeschfahrzeug").first()).toBeVisible({ timeout: 30_000 }); await expect(lagekarte.getByText("loeschfahrzeug").first()).toBeVisible({
timeout: 30_000,
});
// Zurücknehmen: es verschwindet von der Karte. // Zurücknehmen: es verschwindet von der Karte.
await lagekarte.getByRole("button", { name: "zurücknehmen" }).first().click(); await lagekarte
await expect(lagekarte.getByText("loeschfahrzeug")).toHaveCount(0, { timeout: 30_000 }); .getByRole("button", { name: "zurücknehmen" })
.first()
.click();
await expect(lagekarte.getByText("loeschfahrzeug")).toHaveCount(0, {
timeout: 30_000,
});
}); });
test("zeigt den Zustand des Abgleichs", async ({ page }) => { test("zeigt den Zustand des Abgleichs", async ({ page }) => {
@@ -223,7 +264,10 @@ test.describe("Lagekarte", () => {
await anmelden(page); await anmelden(page);
await page.goto("/einsaetze"); await page.goto("/einsaetze");
await page.getByRole("link", { name: /\d{4}-\d{4}/ }).first().click(); await page
.getByRole("link", { name: /\d{4}-\d{4}/ })
.first()
.click();
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/); await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
// Wer nicht sieht, dass der Draht weg ist, hält eine alte Lage für die // Wer nicht sieht, dass der Draht weg ist, hält eine alte Lage für die
+85 -34
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { ADMIN_EMAIL, ADMIN_PASSWORT, WEHR_SLUG } from "./wehr";
/** /**
* E2E-Tests für Phase 4 — das Terminal im Gerätehaus. * E2E-Tests für Phase 4 — das Terminal im Gerätehaus.
@@ -19,16 +20,14 @@ import { expect, test, type Page } from "@playwright/test";
*/ */
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
const GERAETE_SCHLUESSEL = process.env.KIOSK_DEVICE_SECRET ?? "bitte_aendern_kiosk_geheimnis"; const GERAETE_SCHLUESSEL =
const WEHR = "ff-musterhausen"; process.env.KIOSK_DEVICE_SECRET ?? "bitte_aendern_kiosk_geheimnis";
const WEHR = WEHR_SLUG;
/** Aus dem Seed: Andreas Huber, Mitgliedsnummer 0001. */ /** Aus dem Seed: Andreas Huber, Mitgliedsnummer 0001. */
const MITGLIEDSNUMMER = "0001"; const MITGLIEDSNUMMER = "0001";
const PIN = "471203"; const PIN = "471203";
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
/** /**
* Beide Playwright-Projekte (Desktop und Tablet) laufen gegen dieselbe * Beide Playwright-Projekte (Desktop und Tablet) laufen gegen dieselbe
* Datenbank. Damit sich die Testdaten nicht überlagern, trägt jedes seine * Datenbank. Damit sich die Testdaten nicht überlagern, trägt jedes seine
@@ -43,12 +42,16 @@ async function terminalEinrichten(page: Page): Promise<void> {
await page.goto( await page.goto(
`/kiosk/einrichten?wehr=${WEHR}&schluessel=${encodeURIComponent(GERAETE_SCHLUESSEL)}`, `/kiosk/einrichten?wehr=${WEHR}&schluessel=${encodeURIComponent(GERAETE_SCHLUESSEL)}`,
); );
await expect(page.getByRole("heading", { name: "Terminal einrichten" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Terminal einrichten" }),
).toBeVisible();
// Erst wenn die Felder aus der Adresse gefüllt sind, hat das Formular etwas // Erst wenn die Felder aus der Adresse gefüllt sind, hat das Formular etwas
// abzuschicken — sie kommen aus einem Effekt, also nach der Hydration. // abzuschicken — sie kommen aus einem Effekt, also nach der Hydration.
await expect(page.getByLabel("Kurzname der Wehr")).toHaveValue(WEHR); await expect(page.getByLabel("Kurzname der Wehr")).toHaveValue(WEHR);
await expect(page.getByLabel("Gerätegeheimnis")).toHaveValue(GERAETE_SCHLUESSEL); await expect(page.getByLabel("Gerätegeheimnis")).toHaveValue(
GERAETE_SCHLUESSEL,
);
await page.getByRole("button", { name: "Terminal einrichten" }).click(); await page.getByRole("button", { name: "Terminal einrichten" }).click();
await page.waitForURL("**/kiosk"); await page.waitForURL("**/kiosk");
@@ -81,17 +84,23 @@ async function verwaltungAnmelden(page: Page): Promise<void> {
} }
test.describe("Terminal", () => { test.describe("Terminal", () => {
test("verlangt die Einrichtung, bevor sich jemand anmelden kann", async ({ page }) => { test("verlangt die Einrichtung, bevor sich jemand anmelden kann", async ({
page,
}) => {
// Frischer Kontext, also leerer localStorage: das Tablet kennt keine Wehr. // Frischer Kontext, also leerer localStorage: das Tablet kennt keine Wehr.
await page.goto("/kiosk"); await page.goto("/kiosk");
await expect(page.getByRole("heading", { name: "Terminal nicht eingerichtet" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Terminal nicht eingerichtet" }),
).toBeVisible();
}); });
test("meldet mit Mitgliedsnummer und PIN an", async ({ page }) => { test("meldet mit Mitgliedsnummer und PIN an", async ({ page }) => {
await terminalAnmelden(page); await terminalAnmelden(page);
// Die Kacheln richten sich nach den Rechten — Kommen/Gehen hat jeder. // Die Kacheln richten sich nach den Rechten — Kommen/Gehen hat jeder.
await expect(page.getByText("Anwesenheit erfassen").or(page.getByText("da seit"))).toBeVisible(); await expect(
page.getByText("Anwesenheit erfassen").or(page.getByText("da seit")),
).toBeVisible();
await expect(page.getByText("Mangel melden")).toBeVisible(); await expect(page.getByText("Mangel melden")).toBeVisible();
}); });
@@ -104,7 +113,9 @@ test.describe("Terminal", () => {
await page.getByRole("button", { name: "Anmelden" }).click(); await page.getByRole("button", { name: "Anmelden" }).click();
await expect(page.getByTestId("anmeldefehler")).toBeVisible(); await expect(page.getByTestId("anmeldefehler")).toBeVisible();
await expect(page.getByTestId("anmeldefehler")).toContainText("Mitgliedsnummer und PIN"); await expect(page.getByTestId("anmeldefehler")).toContainText(
"Mitgliedsnummer und PIN",
);
}); });
test("erfasst Kommen oder Gehen mit zwei Tipps", async ({ page }) => { test("erfasst Kommen oder Gehen mit zwei Tipps", async ({ page }) => {
@@ -134,20 +145,31 @@ test.describe("Offline-Betrieb", () => {
let mangeltext = ""; let mangeltext = "";
let waeschestueck = ""; let waeschestueck = "";
test("erfasst ohne Netz und überträgt, sobald das Netz zurück ist", async ({ page, context }) => { test("erfasst ohne Netz und überträgt, sobald das Netz zurück ist", async ({
page,
context,
}) => {
await terminalAnmelden(page); await terminalAnmelden(page);
// Einmal mit Netz laden — so, wie das Tablet im Gerätehaus hängt, bevor // Einmal mit Netz laden — so, wie das Tablet im Gerätehaus hängt, bevor
// der Router streikt. // der Router streikt.
await page.goto("/kiosk/mangel"); await page.goto("/kiosk/mangel");
await expect(page.getByRole("heading", { name: "Mangel melden" })).toBeVisible(); await expect(
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offline", "false"); page.getByRole("heading", { name: "Mangel melden" }),
).toBeVisible();
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
"data-offline",
"false",
);
// --- Netz weg ----------------------------------------------------------- // --- Netz weg -----------------------------------------------------------
await context.setOffline(true); await context.setOffline(true);
// Die Zustandsleiste sagt es, ohne dass jemand danach suchen muss. // Die Zustandsleiste sagt es, ohne dass jemand danach suchen muss.
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offline", "true"); await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
"data-offline",
"true",
);
await expect(page.getByText("Kein Netz")).toBeVisible(); await expect(page.getByText("Kein Netz")).toBeVisible();
// Trotzdem erfassen: Nummer eintippen (eine Kamera gibt es im Test nicht), // Trotzdem erfassen: Nummer eintippen (eine Kamera gibt es im Test nicht),
@@ -164,28 +186,41 @@ test.describe("Offline-Betrieb", () => {
// Und der Vorgang wartet sichtbar in der Warteschlange. Die Bestätigung // Und der Vorgang wartet sichtbar in der Warteschlange. Die Bestätigung
// springt nach ein paar Sekunden zurück auf die Kacheln; die Leiste läuft // springt nach ein paar Sekunden zurück auf die Kacheln; die Leiste läuft
// dort weiter, deshalb hält die Zusicherung über den Wechsel hinweg. // dort weiter, deshalb hält die Zusicherung über den Wechsel hinweg.
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offen", "1"); await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
"data-offen",
"1",
);
await expect(page.getByText("Änderung wartet")).toBeVisible(); await expect(page.getByText("Änderung wartet")).toBeVisible();
// --- Netz zurück -------------------------------------------------------- // --- Netz zurück --------------------------------------------------------
await context.setOffline(false); await context.setOffline(false);
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offline", "false", { await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
timeout: 30_000, "data-offline",
}); "false",
{
timeout: 30_000,
},
);
// Die Warteschlange leert sich von selbst — der Knopf ist nur die Abkürzung // Die Warteschlange leert sich von selbst — der Knopf ist nur die Abkürzung
// für den Ungeduldigen und darf deshalb auch schon verschwunden sein. // für den Ungeduldigen und darf deshalb auch schon verschwunden sein.
const uebertragen = page.getByRole("button", { name: "Jetzt übertragen" }); const uebertragen = page.getByRole("button", { name: "Jetzt übertragen" });
if (await uebertragen.isVisible()) await uebertragen.click(); if (await uebertragen.isVisible()) await uebertragen.click();
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offen", "0", { await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
timeout: 60_000, "data-offen",
}); "0",
{
timeout: 60_000,
},
);
await expect(page.getByText("alles übertragen")).toBeVisible(); await expect(page.getByText("alles übertragen")).toBeVisible();
}); });
test("der offline erfasste Mangel steht danach in der Verwaltung", async ({ page }) => { test("der offline erfasste Mangel steht danach in der Verwaltung", async ({
page,
}) => {
expect(mangeltext, "der vorherige Test muss gelaufen sein").not.toBe(""); expect(mangeltext, "der vorherige Test muss gelaufen sein").not.toBe("");
await verwaltungAnmelden(page); await verwaltungAnmelden(page);
@@ -206,7 +241,9 @@ test.describe("Offline-Betrieb", () => {
await terminalAnmelden(page); await terminalAnmelden(page);
await page.goto("/kiosk/waesche"); await page.goto("/kiosk/waesche");
await expect(page.getByRole("heading", { name: "Wäsche abgeben" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Wäsche abgeben" }),
).toBeVisible();
/** /**
* Der Fall, für den es den Idempotenzschlüssel gibt: die Anfrage kommt beim * Der Fall, für den es den Idempotenzschlüssel gibt: die Anfrage kommt beim
@@ -221,7 +258,10 @@ test.describe("Offline-Betrieb", () => {
let ersteAntwortVerschluckt = false; let ersteAntwortVerschluckt = false;
await context.route("**/api/trpc/**", async (route) => { await context.route("**/api/trpc/**", async (route) => {
const adresse = route.request().url(); const adresse = route.request().url();
if (!ersteAntwortVerschluckt && adresse.includes("kiosk.vorgaengeSenden")) { if (
!ersteAntwortVerschluckt &&
adresse.includes("kiosk.vorgaengeSenden")
) {
ersteAntwortVerschluckt = true; ersteAntwortVerschluckt = true;
// Anfrage geht durch (der Server bucht), die Antwort kommt nie an. // Anfrage geht durch (der Server bucht), die Antwort kommt nie an.
await route.fetch(); await route.fetch();
@@ -247,14 +287,19 @@ test.describe("Offline-Betrieb", () => {
await page.getByRole("button", { name: "Jetzt übertragen" }).click(); await page.getByRole("button", { name: "Jetzt übertragen" }).click();
// Der zweite Versuch läuft durch — der Vorgang ist damit erledigt. // Der zweite Versuch läuft durch — der Vorgang ist damit erledigt.
await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute("data-offen", "0", { await expect(page.getByTestId("kiosk-zustand")).toHaveAttribute(
timeout: 60_000, "data-offen",
}); "0",
{
expect(ersteAntwortVerschluckt, "die erste Übertragung muss unterbrochen worden sein").toBe( timeout: 60_000,
true, },
); );
expect(
ersteAntwortVerschluckt,
"die erste Übertragung muss unterbrochen worden sein",
).toBe(true);
// Erst abräumen, wenn nichts mehr unterwegs ist — sonst schlägt eine noch // Erst abräumen, wenn nichts mehr unterwegs ist — sonst schlägt eine noch
// laufende Umleitung beim Testende zu. // laufende Umleitung beim Testende zu.
await context.unrouteAll({ behavior: "ignoreErrors" }); await context.unrouteAll({ behavior: "ignoreErrors" });
@@ -275,13 +320,19 @@ test.describe("Offline-Betrieb", () => {
}); });
test.describe("Hallenmonitor", () => { test.describe("Hallenmonitor", () => {
test("zeigt Uhrzeit, Anwesende, Fahrzeuge und fällige Prüfungen", async ({ page }) => { test("zeigt Uhrzeit, Anwesende, Fahrzeuge und fällige Prüfungen", async ({
page,
}) => {
await terminalAnmelden(page); await terminalAnmelden(page);
await page.goto("/kiosk/monitor"); await page.goto("/kiosk/monitor");
await expect(page.getByText("Im Haus")).toBeVisible(); await expect(page.getByText("Im Haus")).toBeVisible();
await expect(page.getByText("Fahrzeuge", { exact: true }).first()).toBeVisible(); await expect(
page.getByText("Fahrzeuge", { exact: true }).first(),
).toBeVisible();
// Die Uhr läuft — das Format genügt als Nachweis. // Die Uhr läuft — das Format genügt als Nachweis.
await expect(page.getByText(/^\d{2}:\d{2}(:\d{2})?$/).first()).toBeVisible(); await expect(
page.getByText(/^\d{2}:\d{2}(:\d{2})?$/).first(),
).toBeVisible();
}); });
}); });
+112 -46
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { anmelden } from "./wehr";
/** /**
* E2E-Tests für Phase 2. * E2E-Tests für Phase 2.
@@ -15,50 +16,65 @@ import { expect, test, type Page } from "@playwright/test";
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet. * 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 }) => { test.beforeEach(async ({ page }) => {
await anmelden(page); await anmelden(page);
}); });
test.describe("Fristen-Dashboard", () => { test.describe("Fristen-Dashboard", () => {
test("zeigt die vier Zeitfenster und die offenen Termine", async ({ page }) => { test("zeigt die vier Zeitfenster und die offenen Termine", async ({
page,
}) => {
await page.goto("/pruefungen"); await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Prüfungen und Fristen" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Prüfungen und Fristen" }),
).toBeVisible();
// Die vier Töpfe sind gleichzeitig Filter. // Die vier Töpfe sind gleichzeitig Filter.
for (const bezeichnung of ["überfällig", "diese Woche", "diesen Monat", "nächste 90 Tage"]) { for (const bezeichnung of [
await expect(page.getByRole("button", { name: new RegExp(bezeichnung) })).toBeVisible(); "ü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(); await expect(
page.getByRole("heading", { name: "Offene Termine" }),
).toBeVisible();
// Aus dem Seed entstehen Termine für die Geräte mit Prüfvorgabe. // Aus dem Seed entstehen Termine für die Geräte mit Prüfvorgabe.
await expect(page.getByRole("cell", { name: /INV-\d{4}/ }).first()).toBeVisible(); await expect(
page.getByRole("cell", { name: /INV-\d{4}/ }).first(),
).toBeVisible();
}); });
test("filtert die Terminliste über ein Zeitfenster", async ({ page }) => { test("filtert die Terminliste über ein Zeitfenster", async ({ page }) => {
await page.goto("/pruefungen"); await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Offene Termine" }),
).toBeVisible();
await page.getByRole("button", { name: /überfällig/ }).first().click(); await page
.getByRole("button", { name: /überfällig/ })
.first()
.click();
await expect(page.getByRole("button", { name: "Filter aufheben" })).toBeVisible(); await expect(
page.getByRole("button", { name: "Filter aufheben" }),
).toBeVisible();
}); });
test("bündelt die Termine nach Fahrzeug und lässt umschalten", async ({ page }) => { test("bündelt die Termine nach Fahrzeug und lässt umschalten", async ({
page,
}) => {
await page.goto("/pruefungen"); await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Verteilung" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Verteilung" }),
).toBeVisible();
await page.getByLabel("Bündelung wählen").click(); await page.getByLabel("Bündelung wählen").click();
await page.getByRole("option", { name: "nach Gerätetyp" }).click(); await page.getByRole("option", { name: "nach Gerätetyp" }).click();
@@ -76,19 +92,29 @@ test.describe("Wartungsvorlagen", () => {
test("zeigt die Vorlagen mit Intervall und Prüfpunkten", async ({ page }) => { test("zeigt die Vorlagen mit Intervall und Prüfpunkten", async ({ page }) => {
await page.goto("/pruefungen/vorlagen"); await page.goto("/pruefungen/vorlagen");
await expect(page.getByRole("heading", { name: "Wartungsvorlagen" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Wartungsvorlagen" }),
).toBeVisible();
await expect( await expect(
page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }), page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }),
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole("cell", { name: "DGUV Vorschrift 3, DIN VDE 0701-0702" })).toBeVisible(); await expect(
page.getByRole("cell", { name: "DGUV Vorschrift 3, DIN VDE 0701-0702" }),
).toBeVisible();
}); });
test("öffnet die Checkliste einer Vorlage", async ({ page }) => { test("öffnet die Checkliste einer Vorlage", async ({ page }) => {
await page.goto("/pruefungen/vorlagen"); await page.goto("/pruefungen/vorlagen");
await page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }).click(); await page
.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" })
.click();
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible(); await expect(
await expect(page.getByText("Ansprechdruck der Warneinrichtung")).toBeVisible(); page.getByRole("heading", { name: "Checkliste" }),
).toBeVisible();
await expect(
page.getByText("Ansprechdruck der Warneinrichtung"),
).toBeVisible();
// Der zulässige Bereich steht am Punkt. // Der zulässige Bereich steht am Punkt.
await expect(page.getByText(/zulässig 50 bis 60 bar/)).toBeVisible(); await expect(page.getByText(/zulässig 50 bis 60 bar/)).toBeVisible();
}); });
@@ -114,12 +140,17 @@ function geraetIndex(): number {
async function pruefungStarten(page: Page): Promise<void> { async function pruefungStarten(page: Page): Promise<void> {
await page.goto("/geraete"); await page.goto("/geraete");
await page.getByLabel("Geräte durchsuchen").fill("Feuerwehrleine"); await page.getByLabel("Geräte durchsuchen").fill("Feuerwehrleine");
await page.getByRole("link", { name: /INV-\d{4}/ }).nth(geraetIndex()).click(); await page
.getByRole("link", { name: /INV-\d{4}/ })
.nth(geraetIndex())
.click();
await page.getByRole("link", { name: "Prüfung durchführen" }).click(); await page.getByRole("link", { name: "Prüfung durchführen" }).click();
} }
test.describe("Prüfwizard", () => { test.describe("Prüfwizard", () => {
test("führt von der Checkliste bis zum unterschriebenen Protokoll", async ({ page }) => { test("führt von der Checkliste bis zum unterschriebenen Protokoll", async ({
page,
}) => {
// Der laengste Weg der Anwendung: Wizard, Unterschrift, Nummernkreis und // Der laengste Weg der Anwendung: Wizard, Unterschrift, Nummernkreis und
// Protokoll. Laeuft die ganze Suite parallel, reicht das Standardlimit // Protokoll. Laeuft die ganze Suite parallel, reicht das Standardlimit
// dafuer nicht — das ist Uebersetzungszeit des Entwicklungsservers, kein // dafuer nicht — das ist Uebersetzungszeit des Entwicklungsservers, kein
@@ -133,15 +164,22 @@ test.describe("Prüfwizard", () => {
// Freitext. Vor jedem Schritt wird auf die Schrittanzeige gewartet — sonst // Freitext. Vor jedem Schritt wird auf die Schrittanzeige gewartet — sonst
// klickt der Test schneller, als die Oberfläche umschaltet. // klickt der Test schneller, als die Oberfläche umschaltet.
for (const nummer of [1, 2, 3, 4]) { for (const nummer of [1, 2, 3, 4]) {
await expect(page.getByText(new RegExp(`Schritt ${nummer} von 4`))).toBeVisible(); await expect(
page.getByText(new RegExp(`Schritt ${nummer} von 4`)),
).toBeVisible();
// "in Ordnung" exakt: sonst trifft der Name auch "nicht in Ordnung". // "in Ordnung" exakt: sonst trifft der Name auch "nicht in Ordnung".
const inOrdnung = page.getByRole("button", { name: "in Ordnung", exact: true }); const inOrdnung = page.getByRole("button", {
name: "in Ordnung",
exact: true,
});
if ((await inOrdnung.count()) > 0) await inOrdnung.click(); if ((await inOrdnung.count()) > 0) await inOrdnung.click();
const befund = page.getByLabel("Befund"); const befund = page.getByLabel("Befund");
if ((await befund.count()) > 0) { if ((await befund.count()) > 0) {
await befund.fill("Leine vollständig durch die Hand gezogen, ohne Befund."); await befund.fill(
"Leine vollständig durch die Hand gezogen, ohne Befund.",
);
} }
await page.getByRole("button", { name: "Weiter" }).click(); await page.getByRole("button", { name: "Weiter" }).click();
@@ -149,7 +187,9 @@ test.describe("Prüfwizard", () => {
// Abschluss: Ergebnis ist vorbelegt, Unterschrift fehlt noch. // Abschluss: Ergebnis ist vorbelegt, Unterschrift fehlt noch.
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled(); await expect(
page.getByRole("button", { name: "Prüfung abschließen" }),
).toBeDisabled();
// Unterschrift zeichnen — mit der Maus, wie mit dem Finger auf dem Tablet. // Unterschrift zeichnen — mit der Maus, wie mit dem Finger auf dem Tablet.
// Erst ins Bild holen: die Mauskoordinaten beziehen sich auf das sichtbare // Erst ins Bild holen: die Mauskoordinaten beziehen sich auf das sichtbare
@@ -163,7 +203,9 @@ test.describe("Prüfwizard", () => {
await page.mouse.move(rahmen!.x + 30, rahmen!.y + rahmen!.height / 2); await page.mouse.move(rahmen!.x + 30, rahmen!.y + rahmen!.height / 2);
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(rahmen!.x + 90, rahmen!.y + 20, { steps: 8 }); 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 + 150, rahmen!.y + rahmen!.height - 20, {
steps: 8,
});
await page.mouse.move(rahmen!.x + 210, rahmen!.y + 30, { steps: 8 }); await page.mouse.move(rahmen!.x + 210, rahmen!.y + 30, { steps: 8 });
await page.mouse.up(); await page.mouse.up();
@@ -171,20 +213,30 @@ test.describe("Prüfwizard", () => {
await expect(page.getByRole("button", { name: "Löschen" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Löschen" })).toBeEnabled();
await page.getByRole("button", { name: "Übernehmen" }).click(); await page.getByRole("button", { name: "Übernehmen" }).click();
await expect(page.getByRole("button", { name: "übernommen" })).toBeVisible(); await expect(
page.getByRole("button", { name: "übernommen" }),
).toBeVisible();
await page.getByRole("button", { name: "Prüfung abschließen" }).click(); await page.getByRole("button", { name: "Prüfung abschließen" }).click();
// Danach steht das Protokoll da — mit Nummer und Ergebnis. // Danach steht das Protokoll da — mit Nummer und Ergebnis.
await page.waitForURL(/\/pruefungen\/[0-9a-f-]{36}$/); await page.waitForURL(/\/pruefungen\/[0-9a-f-]{36}$/);
await expect(page.getByRole("heading", { name: /Prüfprotokoll \d{4}-\d{4}/ })).toBeVisible(); await expect(
page.getByRole("heading", { name: /Prüfprotokoll \d{4}-\d{4}/ }),
).toBeVisible();
// Genau der Kleinschreibung wegen "exact": darüber steht auch das Feld // Genau der Kleinschreibung wegen "exact": darüber steht auch das Feld
// "Abgeschlossen" mit dem Zeitpunkt. // "Abgeschlossen" mit dem Zeitpunkt.
await expect(page.getByText("abgeschlossen", { exact: true })).toBeVisible(); await expect(
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible(); page.getByText("abgeschlossen", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Checkliste" }),
).toBeVisible();
}); });
test("verlangt eine Unterschrift, bevor abgeschlossen werden kann", async ({ page }) => { test("verlangt eine Unterschrift, bevor abgeschlossen werden kann", async ({
page,
}) => {
await pruefungStarten(page); await pruefungStarten(page);
// Erst warten, bis der Wizard steht — er legt die Prüfung beim Öffnen an. // Erst warten, bis der Wizard steht — er legt die Prüfung beim Öffnen an.
@@ -198,7 +250,9 @@ test.describe("Prüfwizard", () => {
} }
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled(); await expect(
page.getByRole("button", { name: "Prüfung abschließen" }),
).toBeDisabled();
}); });
}); });
@@ -207,17 +261,25 @@ test.describe("Mängel und Rückrufe", () => {
await page.goto("/maengel"); await page.goto("/maengel");
await expect(page.getByRole("heading", { name: "Mängel" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Mängel" })).toBeVisible();
await expect(page.getByText(/Leinenbeutel an einer Naht aufgescheuert/)).toBeVisible(); await expect(
page.getByText(/Leinenbeutel an einer Naht aufgescheuert/),
).toBeVisible();
}); });
test("zeigt den Rückruf und seine betroffenen Geräte", async ({ page }) => { test("zeigt den Rückruf und seine betroffenen Geräte", async ({ page }) => {
await page.goto("/rueckrufe"); await page.goto("/rueckrufe");
await expect(page.getByRole("heading", { name: "Rückrufe" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Rückrufe" })).toBeVisible();
await page.getByRole("link", { name: /Sicherheitshinweis Ausatemventil/ }).click(); await page
.getByRole("link", { name: /Sicherheitshinweis Ausatemventil/ })
.click();
await expect(page.getByRole("heading", { name: "Betroffene Geräte" })).toBeVisible(); await expect(
await expect(page.getByRole("heading", { name: "Mögliche Treffer im Bestand" })).toBeVisible(); page.getByRole("heading", { name: "Betroffene Geräte" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Mögliche Treffer im Bestand" }),
).toBeVisible();
}); });
}); });
@@ -225,7 +287,11 @@ test.describe("Protokolle", () => {
test("listet abgeschlossene Prüfungen mit Ergebnis", async ({ page }) => { test("listet abgeschlossene Prüfungen mit Ergebnis", async ({ page }) => {
await page.goto("/pruefungen/verlauf"); await page.goto("/pruefungen/verlauf");
await expect(page.getByRole("heading", { name: "Prüfprotokolle" })).toBeVisible(); await expect(
await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible(); page.getByRole("heading", { name: "Prüfprotokolle" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: /\d{4}-\d{4}/ }).first(),
).toBeVisible();
}); });
}); });
+80 -38
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { anmelden } from "./wehr";
/** /**
* E2E-Tests für Phase 1. * E2E-Tests für Phase 1.
@@ -10,17 +11,6 @@ import { expect, test, type Page } from "@playwright/test";
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet. * 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 }) => { test.beforeEach(async ({ page }) => {
await anmelden(page); await anmelden(page);
}); });
@@ -42,11 +32,17 @@ test.describe("Geräteliste", () => {
await page.getByLabel("Geräte durchsuchen").fill("Pressluftatmer"); await page.getByLabel("Geräte durchsuchen").fill("Pressluftatmer");
// Die Suche ist entprellt — deshalb auf das Ergebnis warten, nicht auf die Zeit. // Die Suche ist entprellt — deshalb auf das Ergebnis warten, nicht auf die Zeit.
await expect(page.getByRole("cell", { name: "Pressluftatmer PA 94" }).first()).toBeVisible(); await expect(
await expect(page.getByRole("cell", { name: "Feuerwehrhelm F220" })).toHaveCount(0); page.getByRole("cell", { name: "Pressluftatmer PA 94" }).first(),
).toBeVisible();
await expect(
page.getByRole("cell", { name: "Feuerwehrhelm F220" }),
).toHaveCount(0);
}); });
test("meldet verständlich, wenn die Suche nichts findet", async ({ page }) => { test("meldet verständlich, wenn die Suche nichts findet", async ({
page,
}) => {
await page.goto("/geraete"); await page.goto("/geraete");
await page.getByLabel("Geräte durchsuchen").fill("gibtesnichtxyz"); await page.getByLabel("Geräte durchsuchen").fill("gibtesnichtxyz");
@@ -55,22 +51,30 @@ test.describe("Geräteliste", () => {
test("blendet Spalten aus und wieder ein", async ({ page }) => { test("blendet Spalten aus und wieder ein", async ({ page }) => {
await page.goto("/geraete"); await page.goto("/geraete");
await expect(page.getByRole("columnheader", { name: /Hersteller/i })).toBeVisible(); await expect(
page.getByRole("columnheader", { name: /Hersteller/i }),
).toBeVisible();
await page.getByRole("button", { name: "Spalten" }).click(); await page.getByRole("button", { name: "Spalten" }).click();
await page.getByRole("checkbox", { name: "Hersteller" }).click(); await page.getByRole("checkbox", { name: "Hersteller" }).click();
await expect(page.getByRole("columnheader", { name: /Hersteller/i })).toHaveCount(0); await expect(
page.getByRole("columnheader", { name: /Hersteller/i }),
).toHaveCount(0);
}); });
test("zeigt Sammelaktionen, sobald etwas ausgewählt ist", async ({ page }) => { test("zeigt Sammelaktionen, sobald etwas ausgewählt ist", async ({
page,
}) => {
await page.goto("/geraete"); await page.goto("/geraete");
await expect(page.getByRole("link", { name: "INV-0001" })).toBeVisible(); await expect(page.getByRole("link", { name: "INV-0001" })).toBeVisible();
await page.getByRole("checkbox", { name: "INV-0001 auswählen" }).click(); await page.getByRole("checkbox", { name: "INV-0001 auswählen" }).click();
await expect(page.getByText("1 Gerät ausgewählt")).toBeVisible(); await expect(page.getByText("1 Gerät ausgewählt")).toBeVisible();
await expect(page.getByRole("button", { name: "Etiketten drucken" })).toBeVisible(); await expect(
page.getByRole("button", { name: "Etiketten drucken" }),
).toBeVisible();
}); });
}); });
@@ -96,14 +100,18 @@ test.describe("Gerätedetail", () => {
}); });
test.describe("QR-Route", () => { test.describe("QR-Route", () => {
test("leitet von /g/<inventarnummer> auf das Gerät weiter", async ({ page }) => { test("leitet von /g/<inventarnummer> auf das Gerät weiter", async ({
page,
}) => {
await page.goto("/g/INV-0001"); await page.goto("/g/INV-0001");
await expect(page).toHaveURL(/\/geraete\/[0-9a-f-]{36}/); await expect(page).toHaveURL(/\/geraete\/[0-9a-f-]{36}/);
await expect(page.getByText("INV-0001").first()).toBeVisible(); await expect(page.getByText("INV-0001").first()).toBeVisible();
}); });
test("erklärt verständlich, wenn es die Nummer nicht gibt", async ({ page }) => { test("erklärt verständlich, wenn es die Nummer nicht gibt", async ({
page,
}) => {
await page.goto("/g/INV-GIBTESNICHT"); await page.goto("/g/INV-GIBTESNICHT");
await expect(page.getByText("Kein Gerät gefunden")).toBeVisible(); await expect(page.getByText("Kein Gerät gefunden")).toBeVisible();
@@ -126,13 +134,19 @@ test.describe("Fahrzeuge", () => {
test("zeigt die Fahrzeuge als Kacheln", async ({ page }) => { test("zeigt die Fahrzeuge als Kacheln", async ({ page }) => {
await page.goto("/fahrzeuge"); await page.goto("/fahrzeuge");
await expect(page.getByRole("heading", { name: "Fahrzeuge" })).toBeVisible(); await expect(
await expect(page.getByRole("link", { name: /Florian Musterhausen 40\/1/ })).toBeVisible(); page.getByRole("heading", { name: "Fahrzeuge" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: /Florian Musterhausen 40\/1/ }),
).toBeVisible();
}); });
test("zeigt die Beladung nach Geräteräumen", async ({ page }) => { test("zeigt die Beladung nach Geräteräumen", async ({ page }) => {
await page.goto("/fahrzeuge"); await page.goto("/fahrzeuge");
await page.getByRole("link", { name: /Florian Musterhausen 40\/1/ }).click(); await page
.getByRole("link", { name: /Florian Musterhausen 40\/1/ })
.click();
await expect(page.getByRole("heading", { name: "Beladung" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Beladung" })).toBeVisible();
// Die Beladeordnung aus dem Seed: G1 bis G6, Heck, Dach, Mannschaftsraum. // Die Beladeordnung aus dem Seed: G1 bis G6, Heck, Dach, Mannschaftsraum.
@@ -145,10 +159,14 @@ test.describe("Qualifikationen", () => {
test("zeigt Ablaufwarnungen und den Katalog", async ({ page }) => { test("zeigt Ablaufwarnungen und den Katalog", async ({ page }) => {
await page.goto("/qualifikationen"); await page.goto("/qualifikationen");
await expect(page.getByRole("heading", { name: "Qualifikationen" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Qualifikationen" }),
).toBeVisible();
await expect(page.getByRole("heading", { name: "Läuft ab" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Läuft ab" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Katalog" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Katalog" })).toBeVisible();
await expect(page.getByText("Atemschutzgeräteträger").first()).toBeVisible(); await expect(
page.getByText("Atemschutzgeräteträger").first(),
).toBeVisible();
}); });
}); });
@@ -156,25 +174,39 @@ test.describe("Mitglieder", () => {
test("gruppiert nach Abteilung", async ({ page }) => { test("gruppiert nach Abteilung", async ({ page }) => {
await page.goto("/mitglieder"); await page.goto("/mitglieder");
await expect(page.getByRole("heading", { name: "Mitglieder" })).toBeVisible(); await expect(
await expect(page.getByRole("heading", { name: /^Aktive \(/ })).toBeVisible(); page.getByRole("heading", { name: "Mitglieder" }),
await expect(page.getByRole("link", { name: "Huber, Andreas" })).toBeVisible(); ).toBeVisible();
await expect(
page.getByRole("heading", { name: /^Aktive \(/ }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Huber, Andreas" }),
).toBeVisible();
}); });
test("zeigt Qualifikationen am Mitglied", async ({ page }) => { test("zeigt Qualifikationen am Mitglied", async ({ page }) => {
await page.goto("/mitglieder"); await page.goto("/mitglieder");
await page.getByRole("link", { name: "Huber, Andreas" }).click(); await page.getByRole("link", { name: "Huber, Andreas" }).click();
await expect(page.getByRole("heading", { name: "Qualifikationen" })).toBeVisible(); await expect(
await expect(page.getByRole("heading", { name: "Terminal-Zugang" })).toBeVisible(); page.getByRole("heading", { name: "Qualifikationen" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Terminal-Zugang" }),
).toBeVisible();
}); });
}); });
test.describe("Import", () => { test.describe("Import", () => {
test("führt durch die drei Schritte und zeigt die Vorschau", async ({ page }) => { test("führt durch die drei Schritte und zeigt die Vorschau", async ({
page,
}) => {
await page.goto("/geraete/importieren"); await page.goto("/geraete/importieren");
await expect(page.getByRole("heading", { name: "1. Datei wählen" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "1. Datei wählen" }),
).toBeVisible();
const csv = [ const csv = [
"Inventarnummer;Bezeichnung;Hersteller;Indienststellung", "Inventarnummer;Bezeichnung;Hersteller;Indienststellung",
@@ -189,15 +221,23 @@ test.describe("Import", () => {
}); });
// Schritt 2 erscheint erst, wenn der Server die Datei gelesen hat. // Schritt 2 erscheint erst, wenn der Server die Datei gelesen hat.
await expect(page.getByRole("heading", { name: "2. Spalten zuordnen" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "2. Spalten zuordnen" }),
).toBeVisible();
await expect(page.getByRole("heading", { name: "Vorschau" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Vorschau" })).toBeVisible();
// Eine Zeile ist gut, eine hat ein unlesbares Datum. // Eine Zeile ist gut, eine hat ein unlesbares Datum.
await expect(page.getByText("1 von 2 Zeilen sind übernehmbar")).toBeVisible(); await expect(
await expect(page.getByRole("button", { name: /1 Zeilen übernehmen/ })).toBeEnabled(); page.getByText("1 von 2 Zeilen sind übernehmbar"),
).toBeVisible();
await expect(
page.getByRole("button", { name: /1 Zeilen übernehmen/ }),
).toBeEnabled();
}); });
test("verweigert die Übernahme, wenn ein Pflichtfeld nicht zugeordnet ist", async ({ page }) => { test("verweigert die Übernahme, wenn ein Pflichtfeld nicht zugeordnet ist", async ({
page,
}) => {
await page.goto("/geraete/importieren"); await page.goto("/geraete/importieren");
await page.setInputFiles("#datei", { await page.setInputFiles("#datei", {
@@ -206,6 +246,8 @@ test.describe("Import", () => {
buffer: Buffer.from("Inventarnummer\nE2E-100\n", "utf8"), buffer: Buffer.from("Inventarnummer\nE2E-100\n", "utf8"),
}); });
await expect(page.getByText(/Diesen Pflichtfeldern ist keine Spalte zugeordnet/)).toBeVisible(); await expect(
page.getByText(/Diesen Pflichtfeldern ist keine Spalte zugeordnet/),
).toBeVisible();
}); });
}); });
+103 -55
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { anmelden, eigenerName } from "./wehr";
/** /**
* E2E-Tests für Phase 7 — Termine, Benachrichtigungen und Dienstausweis. * E2E-Tests für Phase 7 — Termine, Benachrichtigungen und Dienstausweis.
@@ -18,37 +19,27 @@ import { expect, test, type Page } from "@playwright/test";
*/ */
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
/** Eindeutiger Titel je Lauf, damit sich parallele Läufe nicht ins Gehege kommen. */
function eigenerTitel(zweck: string): string {
return `E2E-${zweck}-${Date.now().toString(36)}`;
}
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.describe("Terminkalender", () => { test.describe("Terminkalender", () => {
test("zeigt die Termine aus dem Bestand", async ({ page }) => { test("zeigt die Termine aus dem Bestand", async ({ page }) => {
await anmelden(page); await anmelden(page);
await page.goto("/termine"); await page.goto("/termine");
await expect(page.getByRole("heading", { name: "Termine", level: 1 })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Termine", level: 1 }),
).toBeVisible();
// Aus dem Seed: der Übungsabend als Serie und der Lehrgang mit Plätzen. // Aus dem Seed: der Übungsabend als Serie und der Lehrgang mit Plätzen.
await expect(page.getByRole("link", { name: "Übungsabend", exact: true }).first()).toBeVisible(); await expect(
page.getByRole("link", { name: "Übungsabend", exact: true }).first(),
).toBeVisible();
await expect( await expect(
page.getByRole("link", { name: "Maschinistenlehrgang Teil 1" }), page.getByRole("link", { name: "Maschinistenlehrgang Teil 1" }),
).toBeVisible(); ).toBeVisible();
}); });
test("zeigt einen abgesagten Termin mit Grund statt ihn zu verstecken", async ({ page }) => { test("zeigt einen abgesagten Termin mit Grund statt ihn zu verstecken", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/termine"); await page.goto("/termine");
@@ -67,7 +58,9 @@ test.describe("Terminkalender", () => {
const jhv = page.getByRole("link", { name: "Jahreshauptversammlung" }); const jhv = page.getByRole("link", { name: "Jahreshauptversammlung" });
await jhv.click(); await jhv.click();
await expect(page.getByRole("heading", { name: "Jahreshauptversammlung" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Jahreshauptversammlung" }),
).toBeVisible();
// "Absagen" exakt: sonst trifft es auch "Termin absagen" daneben. // "Absagen" exakt: sonst trifft es auch "Termin absagen" daneben.
await page.getByRole("button", { name: "Zusagen", exact: true }).click(); await page.getByRole("button", { name: "Zusagen", exact: true }).click();
@@ -87,22 +80,30 @@ test.describe("Terminkalender", () => {
* schon einen Platz hat, hängt an der Reihenfolge im Seed. Was hier zählt, * schon einen Platz hat, hängt an der Reihenfolge im Seed. Was hier zählt,
* ist die Anzeige der Warteliste — die gilt für alle. * ist die Anzeige der Warteliste — die gilt für alle.
*/ */
test("zeigt Belegung und Warteliste eines begrenzten Lehrgangs", async ({ page }) => { test("zeigt Belegung und Warteliste eines begrenzten Lehrgangs", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/termine"); await page.goto("/termine");
await page.getByRole("link", { name: "Maschinistenlehrgang Teil 1" }).click(); await page
.getByRole("link", { name: "Maschinistenlehrgang Teil 1" })
.click();
await expect(page.getByText(/von 8 belegt/)).toBeVisible(); await expect(page.getByText(/von 8 belegt/)).toBeVisible();
await expect(page.getByRole("heading", { name: /^Zugesagt \(8\)$/ })).toBeVisible(); await expect(
await expect(page.getByRole("heading", { name: /^Warteliste \(\d+\)$/ })).toBeVisible(); page.getByRole("heading", { name: /^Zugesagt \(8\)$/ }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: /^Warteliste \(\d+\)$/ }),
).toBeVisible();
// Die Warteliste ist nummeriert — sonst weiß niemand, wer nachrückt. // Die Warteliste ist nummeriert — sonst weiß niemand, wer nachrückt.
await expect(page.getByText("1.", { exact: false }).first()).toBeVisible(); await expect(page.getByText("1.", { exact: false }).first()).toBeVisible();
}); });
test("legt einen Termin an", async ({ page }) => { test("legt einen Termin an", async ({ page }) => {
const titel = eigenerTitel("Einzeltermin"); const titel = eigenerName("Einzeltermin");
await anmelden(page); await anmelden(page);
await page.goto("/termine"); await page.goto("/termine");
@@ -121,24 +122,28 @@ test.describe("Terminkalender", () => {
* Jahr" einstellt, soll vorher lesen, dass daraus über fünfzig Einträge * Jahr" einstellt, soll vorher lesen, dass daraus über fünfzig Einträge
* werden — nicht danach. * werden — nicht danach.
*/ */
test("nennt vor dem Anlegen, wie viele Termine eine Serie ergibt", async ({ page }) => { test("nennt vor dem Anlegen, wie viele Termine eine Serie ergibt", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/termine"); await page.goto("/termine");
await page.getByRole("button", { name: "Termin anlegen" }).click(); await page.getByRole("button", { name: "Termin anlegen" }).click();
await page.getByLabel("Titel").fill(eigenerTitel("Serienvorschau")); await page.getByLabel("Titel").fill(eigenerName("Serienvorschau"));
await page.getByLabel("Wiederholung").click(); await page.getByLabel("Wiederholung").click();
await page.getByRole("option", { name: "wöchentlich" }).click(); await page.getByRole("option", { name: "wöchentlich" }).click();
const inAchtWochen = new Date(Date.now() + 56 * 864e5); const inAchtWochen = new Date(Date.now() + 56 * 864e5);
await page.getByLabel("Serie läuft bis").fill(inAchtWochen.toISOString().slice(0, 10)); await page
.getByLabel("Serie läuft bis")
.fill(inAchtWochen.toISOString().slice(0, 10));
await expect(page.getByText(/Das legt \d+ Termine an/)).toBeVisible(); await expect(page.getByText(/Das legt \d+ Termine an/)).toBeVisible();
}); });
test("verlangt für eine Absage einen Grund", async ({ page }) => { test("verlangt für eine Absage einen Grund", async ({ page }) => {
const titel = eigenerTitel("Absage"); const titel = eigenerName("Absage");
await anmelden(page); await anmelden(page);
await page.goto("/termine"); await page.goto("/termine");
@@ -155,16 +160,22 @@ test.describe("Terminkalender", () => {
const dialog = page.getByRole("dialog"); const dialog = page.getByRole("dialog");
// Ohne Grund geht es nicht weiter. // Ohne Grund geht es nicht weiter.
await expect(dialog.getByRole("button", { name: "Absagen", exact: true })).toBeDisabled(); await expect(
dialog.getByRole("button", { name: "Absagen", exact: true }),
).toBeDisabled();
await dialog.getByLabel("Grund").fill("Sturmwarnung, Übungsgelände nicht befahrbar."); await dialog
.getByLabel("Grund")
.fill("Sturmwarnung, Übungsgelände nicht befahrbar.");
await dialog.getByRole("button", { name: "Absagen", exact: true }).click(); await dialog.getByRole("button", { name: "Absagen", exact: true }).click();
await expect(page.getByText("Dieser Termin fällt aus.")).toBeVisible(); await expect(page.getByText("Dieser Termin fällt aus.")).toBeVisible();
await expect(page.getByText(/Sturmwarnung/)).toBeVisible(); await expect(page.getByText(/Sturmwarnung/)).toBeVisible();
}); });
test("führt eine Anwesenheitsliste über alle aktiven Mitglieder", async ({ page }) => { test("führt eine Anwesenheitsliste über alle aktiven Mitglieder", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/termine"); await page.goto("/termine");
@@ -172,20 +183,30 @@ test.describe("Terminkalender", () => {
await page.getByRole("button", { name: "Anwesenheitsliste" }).click(); await page.getByRole("button", { name: "Anwesenheitsliste" }).click();
await expect(page.getByRole("heading", { name: "Anwesenheitsliste" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Anwesenheitsliste" }),
).toBeVisible();
// Auf einem Blatt zum Abhaken steht jeder — nicht nur die Angemeldeten. // Auf einem Blatt zum Abhaken steht jeder — nicht nur die Angemeldeten.
await expect(page.getByRole("columnheader", { name: "Anwesend" })).toBeVisible(); await expect(
page.getByRole("columnheader", { name: "Anwesend" }),
).toBeVisible();
await expect(page.getByRole("row")).not.toHaveCount(1); await expect(page.getByRole("row")).not.toHaveCount(1);
}); });
}); });
test.describe("Benachrichtigungen", () => { test.describe("Benachrichtigungen", () => {
test("sagt deutlich, dass ohne eigenes Zutun nichts kommt", async ({ page }) => { test("sagt deutlich, dass ohne eigenes Zutun nichts kommt", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/benachrichtigungen"); await page.goto("/benachrichtigungen");
await expect(page.getByRole("heading", { name: "Benachrichtigungen", level: 1 })).toBeVisible(); await expect(
await expect(page.getByText(/Niemand wird ungefragt benachrichtigt/)).toBeVisible(); page.getByRole("heading", { name: "Benachrichtigungen", level: 1 }),
).toBeVisible();
await expect(
page.getByText(/Niemand wird ungefragt benachrichtigt/),
).toBeVisible();
}); });
/** /**
@@ -198,19 +219,23 @@ test.describe("Benachrichtigungen", () => {
await page.getByLabel("Über").click(); await page.getByLabel("Über").click();
await expect(page.getByRole("option", { name: /Telegram/ })).toBeVisible(); await expect(page.getByRole("option", { name: /Telegram/ })).toBeVisible();
await expect(page.getByRole("option", { name: /Browser-Push/ })).toBeVisible(); await expect(
page.getByRole("option", { name: /Browser-Push/ }),
).toBeVisible();
}); });
test("erzeugt eine Kalenderadresse, die genau einmal sichtbar ist", async ({ page }) => { test("erzeugt eine Kalenderadresse, die genau einmal sichtbar ist", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/benachrichtigungen"); await page.goto("/benachrichtigungen");
await page.getByLabel("Für welches Gerät?").fill(eigenerTitel("Kalender")); await page.getByLabel("Für welches Gerät?").fill(eigenerName("Kalender"));
await page.getByRole("button", { name: "Adresse erzeugen" }).click(); await page.getByRole("button", { name: "Adresse erzeugen" }).click();
await expect(page.getByText(/wird nur jetzt angezeigt/)).toBeVisible(); await expect(page.getByText(/wird nur jetzt angezeigt/)).toBeVisible();
const feld = page.locator('input[readonly]'); const feld = page.locator("input[readonly]");
await expect(feld).toHaveValue(/\/api\/kalender\/.+\.ics$/); await expect(feld).toHaveValue(/\/api\/kalender\/.+\.ics$/);
// Nach dem Neuladen ist sie weg — gespeichert wird nur der Hash. // Nach dem Neuladen ist sie weg — gespeichert wird nur der Hash.
@@ -218,14 +243,17 @@ test.describe("Benachrichtigungen", () => {
await expect(page.getByText(/wird nur jetzt angezeigt/)).toBeHidden(); await expect(page.getByText(/wird nur jetzt angezeigt/)).toBeHidden();
}); });
test("liefert unter der Kalenderadresse eine gültige ICS-Datei", async ({ page, request }) => { test("liefert unter der Kalenderadresse eine gültige ICS-Datei", async ({
page,
request,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/benachrichtigungen"); await page.goto("/benachrichtigungen");
await page.getByLabel("Für welches Gerät?").fill(eigenerTitel("ICS")); await page.getByLabel("Für welches Gerät?").fill(eigenerName("ICS"));
await page.getByRole("button", { name: "Adresse erzeugen" }).click(); await page.getByRole("button", { name: "Adresse erzeugen" }).click();
const adresse = await page.locator('input[readonly]').inputValue(); const adresse = await page.locator("input[readonly]").inputValue();
const antwort = await request.get(adresse); const antwort = await request.get(adresse);
expect(antwort.status()).toBe(200); expect(antwort.status()).toBe(200);
@@ -242,21 +270,29 @@ test.describe("Benachrichtigungen", () => {
expect(antwort.status()).toBe(404); expect(antwort.status()).toBe(404);
}); });
test("zeigt die Textvorlagen mit Platzhaltern und Vorschau", async ({ page }) => { test("zeigt die Textvorlagen mit Platzhaltern und Vorschau", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/benachrichtigungen/vorlagen"); await page.goto("/benachrichtigungen/vorlagen");
await expect(page.getByRole("heading", { name: "Textvorlagen", level: 1 })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Textvorlagen", level: 1 }),
).toBeVisible();
await expect(page.getByRole("heading", { name: "Vorschau" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Vorschau" })).toBeVisible();
// Die Platzhalter stehen zum Anklicken bereit — als Knopf, nicht nur im Text. // Die Platzhalter stehen zum Anklicken bereit — als Knopf, nicht nur im Text.
await expect(page.getByRole("button", { name: "{titel}" })).toBeVisible(); await expect(page.getByRole("button", { name: "{titel}" })).toBeVisible();
// In der Vorschau stehen die Beispielwerte, nicht die Platzhalter. // In der Vorschau stehen die Beispielwerte, nicht die Platzhalter.
await expect(page.getByText("Übungsabend", { exact: false }).first()).toBeVisible(); await expect(
page.getByText("Übungsabend", { exact: false }).first(),
).toBeVisible();
}); });
test("warnt bei einem Platzhalter, den es für das Ereignis nicht gibt", async ({ page }) => { test("warnt bei einem Platzhalter, den es für das Ereignis nicht gibt", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/benachrichtigungen/vorlagen"); await page.goto("/benachrichtigungen/vorlagen");
@@ -264,7 +300,9 @@ test.describe("Benachrichtigungen", () => {
await feld.fill("Am {datum} um {uhrzeit}. Und ein {tippfehler}."); await feld.fill("Am {datum} um {uhrzeit}. Und ein {tippfehler}.");
await page.getByRole("button", { name: "Speichern" }).click(); await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.getByTestId("vorlagenfehler")).toContainText("tippfehler"); await expect(page.getByTestId("vorlagenfehler")).toContainText(
"tippfehler",
);
}); });
}); });
@@ -273,10 +311,16 @@ test.describe("Dienstausweis", () => {
await anmelden(page); await anmelden(page);
await page.goto("/ausweis"); await page.goto("/ausweis");
await expect(page.getByRole("heading", { name: "Dienstausweis", level: 1 })).toBeVisible(); await expect(
await expect(page.getByRole("img", { name: "Dienstausweis als QR-Code" })).toBeVisible(); page.getByRole("heading", { name: "Dienstausweis", level: 1 }),
).toBeVisible();
await expect(
page.getByRole("img", { name: "Dienstausweis als QR-Code" }),
).toBeVisible();
await expect(page.getByText(/Gültig noch/)).toBeVisible(); await expect(page.getByText(/Gültig noch/)).toBeVisible();
await expect(page.getByText(/Nach fünf Minuten ist er wertlos/)).toBeVisible(); await expect(
page.getByText(/Nach fünf Minuten ist er wertlos/),
).toBeVisible();
}); });
/** /**
@@ -284,13 +328,17 @@ test.describe("Dienstausweis", () => {
* scannt, hat keinen Zugang zu FLORIAN. Ein erfundener Code muss trotzdem * scannt, hat keinen Zugang zu FLORIAN. Ein erfundener Code muss trotzdem
* abgewiesen werden, und zwar mit einem Satz, den ein Mensch versteht. * abgewiesen werden, und zwar mit einem Satz, den ein Mensch versteht.
*/ */
test("weist einen erfundenen Ausweis ab, ohne Anmeldung", async ({ browser }) => { test("weist einen erfundenen Ausweis ab, ohne Anmeldung", async ({
browser,
}) => {
const kontext = await browser.newContext(); const kontext = await browser.newContext();
const seite = await kontext.newPage(); const seite = await kontext.newPage();
await seite.goto("/ausweis/ausweis.a.b.c.d"); await seite.goto("/ausweis/ausweis.a.b.c.d");
await expect(seite.getByRole("heading", { name: "Ausweis nicht gültig" })).toBeVisible(); await expect(
seite.getByRole("heading", { name: "Ausweis nicht gültig" }),
).toBeVisible();
await expect(seite.getByText(/Diese Seite zeigt an/)).toBeVisible(); await expect(seite.getByText(/Diese Seite zeigt an/)).toBeVisible();
await kontext.close(); await kontext.close();
+71
View File
@@ -0,0 +1,71 @@
import { expect, type Page } from "@playwright/test";
/**
* Die Wehr, gegen die getestet wird.
*
* **Eine eigene, nicht die Beispielwehr.** Bis Phase 8 schrieben die Tests in
* `ff-musterhausen` — also in genau den Bestand, den die Anleitung zeigt und
* aus dem die Bildschirmfotos entstehen. Nach fünf Phasen standen dort
* 94 Atemschutz-Überwachungen statt einer, 49 Geräte statt 45 und Termine mit
* Namen wie „E2E-Absage-m1x8kq". Zweimal fielen Tests aus, weil ein gesuchter
* Eintrag von Seite 1 gerutscht war, und bei jedem Lauf unterschieden sich
* alle 72 Bildschirmfotos.
*
* Vor dem Lauf:
*
* npm run db:test-reset
*
* Das legt die Testwehr neu an — vollständig, mit denselben Beispieldaten wie
* die Musterwehr, aber unter eigener Kennung. Die Beispielwehr wird dabei
* nicht angefasst.
*/
export const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? "e2e@teststelle.de";
export const ADMIN_PASSWORT = process.env.E2E_ADMIN_PASSWORT ?? "Feuerwehr2026";
/** Der Name, der nach der Anmeldung auf der Uebersicht steht. */
export const WEHR_NAME = process.env.E2E_NAME ?? "Freiwillige Feuerwehr Teststelle";
/**
* Die Kennung der Testwehr.
*
* Wird von der Terminal-Einrichtung gebraucht: das Wandtablet merkt sich, zu
* welcher Wehr es gehoert, und schreibt Maengel, Fahrten und Anwesenheiten
* genau dorthin. Bis Phase 8 stand hier fest "ff-musterhausen" — das war der
* letzte Weg, ueber den die Tests in die Beispielwehr geschrieben haben.
*/
export const WEHR_SLUG = process.env.E2E_SLUG ?? "ff-teststelle";
/**
* Als Administrator der Testwehr anmelden.
*
* Stand bis Phase 8 achtmal wortgleich in acht Dateien. Dass die Zugangsdaten
* damit an neun Stellen zu ändern waren, war der eigentliche Grund, warum die
* Umstellung auf einen eigenen Mandanten so lange liegen geblieben ist.
*/
export 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();
// Mit Begruendung statt mit einem nackten Zeitlimit: fehlt die Testwehr,
// schlagen sonst zweihundert Tests mit „Timeout" fehl, und niemand sieht
// die eine Ursache dahinter.
await expect(
page,
`Anmeldung als ${ADMIN_EMAIL} fehlgeschlagen. Steht die Testwehr? ` +
"Vor dem Lauf einmal: npm run db:test-reset",
).toHaveURL("/");
}
/**
* Ein eindeutiger Name je Lauf.
*
* Auch mit eigenem Mandanten bleibt er sinnvoll: zwei Arbeiter laufen
* parallel, und zwei Termine namens „Testtermin" wären nicht zu unterscheiden.
*/
export function eigenerName(zweck: string): string {
return `E2E-${zweck}-${Date.now().toString(36)}`;
}
+81 -44
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { anmelden, eigenerName } from "./wehr";
/** /**
* E2E-Tests für Phase 8 — Wissen, Belege und die KI-Protokolle. * E2E-Tests für Phase 8 — Wissen, Belege und die KI-Protokolle.
@@ -20,32 +21,23 @@ import { expect, test, type Page } from "@playwright/test";
*/ */
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
function eigenerTitel(zweck: string): string {
return `E2E-${zweck}-${Date.now().toString(36)}`;
}
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.describe("Wissen", () => { test.describe("Wissen", () => {
test("zeigt die Seiten aus dem Bestand", async ({ page }) => { test("zeigt die Seiten aus dem Bestand", async ({ page }) => {
await anmelden(page); await anmelden(page);
await page.goto("/wiki"); await page.goto("/wiki");
await expect(page.getByRole("heading", { name: "Wissen", level: 1 })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Wissen", level: 1 }),
).toBeVisible();
// Aus dem Seed. // Aus dem Seed.
await expect(page.getByRole("link", { name: "Prüffristen im Überblick" })).toBeVisible();
await expect( await expect(
page.getByRole("link", { name: "Atemschutz — was vor jedem Einsatz gilt" }), page.getByRole("link", { name: "Prüffristen im Überblick" }),
).toBeVisible();
await expect(
page.getByRole("link", {
name: "Atemschutz — was vor jedem Einsatz gilt",
}),
).toBeVisible(); ).toBeVisible();
}); });
@@ -53,11 +45,15 @@ test.describe("Wissen", () => {
* Der wichtigste Test der Datei: ohne Modell muss dastehen, dass keines da * Der wichtigste Test der Datei: ohne Modell muss dastehen, dass keines da
* ist — und im selben Atemzug, dass die Wortsuche trotzdem funktioniert. * ist — und im selben Atemzug, dass die Wortsuche trotzdem funktioniert.
*/ */
test("sagt ohne Sprachmodell, was fehlt und was trotzdem geht", async ({ page }) => { test("sagt ohne Sprachmodell, was fehlt und was trotzdem geht", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/wiki"); await page.goto("/wiki");
await expect(page.getByText("Es ist kein Sprachmodell eingerichtet.")).toBeVisible(); await expect(
page.getByText("Es ist kein Sprachmodell eingerichtet."),
).toBeVisible();
await expect(page.getByText(/Wortsuche/)).toBeVisible(); await expect(page.getByText(/Wortsuche/)).toBeVisible();
}); });
@@ -72,7 +68,9 @@ test.describe("Wissen", () => {
// Der Ausschnitt zeigt die Textstelle mitsamt ihrer Umgebung, nicht bloß // Der Ausschnitt zeigt die Textstelle mitsamt ihrer Umgebung, nicht bloß
// den Seitennamen — daran erkennt man beim Suchen, ob es die richtige // den Seitennamen — daran erkennt man beim Suchen, ob es die richtige
// Stelle ist, ohne die Seite zu öffnen. // Stelle ist, ohne die Seite zu öffnen.
await expect(page.getByText(/Der Rückzugsdruck ist der Druck/).first()).toBeVisible(); await expect(
page.getByText(/Der Rückzugsdruck ist der Druck/).first(),
).toBeVisible();
}); });
test("zeigt eine Seite als formatierten Text", async ({ page }) => { test("zeigt eine Seite als formatierten Text", async ({ page }) => {
@@ -80,11 +78,16 @@ test.describe("Wissen", () => {
await page.goto("/wiki/atemschutz-merkblatt"); await page.goto("/wiki/atemschutz-merkblatt");
await expect( await expect(
page.getByRole("heading", { name: "Atemschutz — was vor jedem Einsatz gilt", level: 1 }), page.getByRole("heading", {
name: "Atemschutz — was vor jedem Einsatz gilt",
level: 1,
}),
).toBeVisible(); ).toBeVisible();
// Markdown wird als echte Überschrift dargestellt, nicht als Raute im Text. // Markdown wird als echte Überschrift dargestellt, nicht als Raute im Text.
await expect(page.getByRole("heading", { name: "Rückzugsdruck" })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Rückzugsdruck" }),
).toBeVisible();
await expect(page.getByText(/FwDV 7, Ausgabe 2002/)).toBeVisible(); await expect(page.getByText(/FwDV 7, Ausgabe 2002/)).toBeVisible();
}); });
@@ -92,7 +95,9 @@ test.describe("Wissen", () => {
await anmelden(page); await anmelden(page);
await page.goto("/wiki/schlaeuche-pflegen"); await page.goto("/wiki/schlaeuche-pflegen");
await expect(page.getByRole("columnheader", { name: "Prüfdruck" })).toBeVisible(); await expect(
page.getByRole("columnheader", { name: "Prüfdruck" }),
).toBeVisible();
await expect(page.getByRole("cell", { name: "C 42 / C 52" })).toBeVisible(); await expect(page.getByRole("cell", { name: "C 42 / C 52" })).toBeVisible();
}); });
@@ -104,22 +109,30 @@ test.describe("Wissen", () => {
await anmelden(page); await anmelden(page);
await page.goto("/wiki/alarmierungsordnung"); await page.goto("/wiki/alarmierungsordnung");
await expect(page.getByText(/Suchstand ist älter als der Text/)).toBeVisible(); await expect(
page.getByText(/Suchstand ist älter als der Text/),
).toBeVisible();
}); });
test("legt eine Seite an und zeigt sie sofort", async ({ page }) => { test("legt eine Seite an und zeigt sie sofort", async ({ page }) => {
const titel = eigenerTitel("Merkblatt"); const titel = eigenerName("Merkblatt");
await anmelden(page); await anmelden(page);
await page.goto("/wiki"); await page.goto("/wiki");
await page.getByRole("button", { name: "Seite anlegen" }).click(); await page.getByRole("button", { name: "Seite anlegen" }).click();
await page.getByLabel("Titel").fill(titel); await page.getByLabel("Titel").fill(titel);
await page.getByLabel("Inhalt").fill("## Erster Abschnitt\n\nHier steht der Text."); await page
.getByLabel("Inhalt")
.fill("## Erster Abschnitt\n\nHier steht der Text.");
await page.getByRole("button", { name: "Anlegen" }).click(); await page.getByRole("button", { name: "Anlegen" }).click();
await expect(page.getByRole("heading", { name: titel, level: 1 })).toBeVisible(); await expect(
await expect(page.getByRole("heading", { name: "Erster Abschnitt" })).toBeVisible(); page.getByRole("heading", { name: titel, level: 1 }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Erster Abschnitt" }),
).toBeVisible();
}); });
/** /**
@@ -127,7 +140,7 @@ test.describe("Wissen", () => {
* die sich unbemerkt ändert, ist schlimmer als keine. * die sich unbemerkt ändert, ist schlimmer als keine.
*/ */
test("hebt die vorherige Fassung auf", async ({ page }) => { test("hebt die vorherige Fassung auf", async ({ page }) => {
const titel = eigenerTitel("Fassungen"); const titel = eigenerName("Fassungen");
await anmelden(page); await anmelden(page);
await page.goto("/wiki"); await page.goto("/wiki");
@@ -136,12 +149,16 @@ test.describe("Wissen", () => {
await page.getByLabel("Titel").fill(titel); await page.getByLabel("Titel").fill(titel);
await page.getByLabel("Inhalt").fill("Der erste Text."); await page.getByLabel("Inhalt").fill("Der erste Text.");
await page.getByRole("button", { name: "Anlegen" }).click(); await page.getByRole("button", { name: "Anlegen" }).click();
await expect(page.getByRole("heading", { name: titel, level: 1 })).toBeVisible(); await expect(
page.getByRole("heading", { name: titel, level: 1 }),
).toBeVisible();
await page.getByRole("button", { name: "Bearbeiten" }).click(); await page.getByRole("button", { name: "Bearbeiten" }).click();
const dialog = page.getByRole("dialog"); const dialog = page.getByRole("dialog");
await dialog.getByLabel("Inhalt").fill("Der zweite Text."); await dialog.getByLabel("Inhalt").fill("Der zweite Text.");
await dialog.getByLabel("Was wurde geändert? (freiwillig)").fill("Text berichtigt"); await dialog
.getByLabel("Was wurde geändert? (freiwillig)")
.fill("Text berichtigt");
await dialog.getByRole("button", { name: "Speichern" }).click(); await dialog.getByRole("button", { name: "Speichern" }).click();
await expect(page.getByText("Der zweite Text.")).toBeVisible(); await expect(page.getByText("Der zweite Text.")).toBeVisible();
@@ -154,16 +171,20 @@ test.describe("Wissen", () => {
await expect(verlauf.getByText("Der erste Text.")).toBeVisible(); await expect(verlauf.getByText("Der erste Text.")).toBeVisible();
}); });
test("zeigt beim Bearbeiten, in wie viele Abschnitte zerlegt wird", async ({ page }) => { test("zeigt beim Bearbeiten, in wie viele Abschnitte zerlegt wird", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/wiki/pruefristen-im-ueberblick"); await page.goto("/wiki/pruefristen-im-ueberblick");
await page.getByRole("button", { name: "Bearbeiten" }).click(); await page.getByRole("button", { name: "Bearbeiten" }).click();
await expect(page.getByText(/Wird für die Suche in \d+ Abschnitte zerlegt/)).toBeVisible(); await expect(
page.getByText(/Wird für die Suche in \d+ Abschnitte zerlegt/),
).toBeVisible();
}); });
test("ein Entwurf wird als solcher gekennzeichnet", async ({ page }) => { test("ein Entwurf wird als solcher gekennzeichnet", async ({ page }) => {
const titel = eigenerTitel("Entwurf"); const titel = eigenerName("Entwurf");
await anmelden(page); await anmelden(page);
await page.goto("/wiki"); await page.goto("/wiki");
@@ -182,14 +203,22 @@ test.describe("Wissen", () => {
}); });
test.describe("Belege", () => { test.describe("Belege", () => {
test("sagt ohne Vision-Modell, was fehlt und was trotzdem geht", async ({ page }) => { test("sagt ohne Vision-Modell, was fehlt und was trotzdem geht", async ({
page,
}) => {
await anmelden(page); await anmelden(page);
await page.goto("/belege"); await page.goto("/belege");
await expect(page.getByRole("heading", { name: "Belege", level: 1 })).toBeVisible(); await expect(
await expect(page.getByText(/kein Modell eingerichtet, das Bilder lesen kann/)).toBeVisible(); page.getByRole("heading", { name: "Belege", level: 1 }),
).toBeVisible();
await expect(
page.getByText(/kein Modell eingerichtet, das Bilder lesen kann/),
).toBeVisible();
await expect(page.getByText(/von Hand eingetragen/)).toBeVisible(); await expect(page.getByText(/von Hand eingetragen/)).toBeVisible();
await expect(page.getByRole("button", { name: "Beleg fotografieren" })).toBeVisible(); await expect(
page.getByRole("button", { name: "Beleg fotografieren" }),
).toBeVisible();
}); });
}); });
@@ -198,19 +227,27 @@ test.describe("Protokolle", () => {
await anmelden(page); await anmelden(page);
await page.goto("/ki-protokoll"); await page.goto("/ki-protokoll");
await expect(page.getByRole("heading", { name: "KI-Protokoll", level: 1 })).toBeVisible(); await expect(
page.getByRole("heading", { name: "KI-Protokoll", level: 1 }),
).toBeVisible();
await expect(page.getByText("Vorgänge insgesamt")).toBeVisible(); await expect(page.getByText("Vorgänge insgesamt")).toBeVisible();
await expect(page.getByText("erzeugt, aber nicht entschieden")).toBeVisible(); await expect(
page.getByText("erzeugt, aber nicht entschieden"),
).toBeVisible();
// Die Zusicherung, die den Zweck der Liste ausmacht. // Die Zusicherung, die den Zweck der Liste ausmacht.
await expect(page.getByText(/lassen sich nachträglich nicht ändern/)).toBeVisible(); await expect(
page.getByText(/lassen sich nachträglich nicht ändern/),
).toBeVisible();
}); });
test("zeigt das Audit-Log", async ({ page }) => { test("zeigt das Audit-Log", async ({ page }) => {
await anmelden(page); await anmelden(page);
await page.goto("/protokoll"); await page.goto("/protokoll");
await expect(page.getByRole("heading", { name: "Protokoll", level: 1 })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Protokoll", level: 1 }),
).toBeVisible();
await expect(page.getByText(/append-only/)).toBeVisible(); await expect(page.getByText(/append-only/)).toBeVisible();
// Die Anmeldung von eben steht schon drin. // Die Anmeldung von eben steht schon drin.
+1 -1
View File
@@ -20,5 +20,5 @@
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "tests/e2e"] "exclude": ["node_modules"]
} }