Aufräumarbeiten: ESLint, DOM-Verschachtelung, eigene Testwehr (#13)
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 808 KiB After Width: | Height: | Size: 745 KiB |
|
Before Width: | Height: | Size: 830 KiB After Width: | Height: | Size: 764 KiB |
|
Before Width: | Height: | Size: 988 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 930 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 946 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 760 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 766 KiB After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 559 KiB After Width: | Height: | Size: 726 KiB |
|
Before Width: | Height: | Size: 563 KiB After Width: | Height: | Size: 742 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 391 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 991 KiB After Width: | Height: | Size: 627 KiB |
|
Before Width: | Height: | Size: 1012 KiB After Width: | Height: | Size: 638 KiB |
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 328 KiB |
@@ -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;
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"}`);
|
||||||
|
|||||||
@@ -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() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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")}?=`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||