Aufräumarbeiten: ESLint, DOM-Verschachtelung, eigene Testwehr (#13)

This commit was merged in pull request #13.
This commit is contained in:
2026-09-04 13:43:53 +00:00
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"]
} }