test(catalog): E2E und vorläufige Latenzmessung der Katalogsuche (KAT-01, 3c) #70
13 changed files with 1572 additions and 57 deletions
|
|
@ -70,3 +70,61 @@ Lokale Werte: `log_min_duration_statement = -1`, `log_statement = ddl`, `log_par
|
||||||
## 4. Mutationsprüfung
|
## 4. Mutationsprüfung
|
||||||
|
|
||||||
Mutationen in einer Kopie der Testdatei (Rollback), nicht am echten Schema: 103 von 107 SQL-Mutationen (RPC, `search_normalize`, Rechte, Tabelle, Check, Indizes) erkannt; Überlebende: ID-Tie-Break innen und außen (der Upstream-Plan liefert bereits ID-Reihenfolge), beide `collate "C"`-Mutationen (lokal ICU-Sortierung, für `[a-z0-9 ]` gleich). Fixtures: 4 von 5 (überlebt: Rohtitel-Vergleich, gleicher Endzustand durch die Semantik datenändernder CTEs). Deno-Werkzeug: 17 von 17.
|
Mutationen in einer Kopie der Testdatei (Rollback), nicht am echten Schema: 103 von 107 SQL-Mutationen (RPC, `search_normalize`, Rechte, Tabelle, Check, Indizes) erkannt; Überlebende: ID-Tie-Break innen und außen (der Upstream-Plan liefert bereits ID-Reihenfolge), beide `collate "C"`-Mutationen (lokal ICU-Sortierung, für `[a-z0-9 ]` gleich). Fixtures: 4 von 5 (überlebt: Rohtitel-Vergleich, gleicher Endzustand durch die Semantik datenändernder CTEs). Deno-Werkzeug: 17 von 17.
|
||||||
|
|
||||||
|
## 5. PR 3c (E2E und vorläufige Latenzmessung), Stand 2026-11-04
|
||||||
|
|
||||||
|
Branch `feature/3c-katalog-suche-e2e`, `Refs #3`. Ergänzt § 7.2/7.3 der Spec; die Spec selbst wird nicht geändert.
|
||||||
|
|
||||||
|
### 5.1 Was gebaut ist
|
||||||
|
|
||||||
|
- **Web-E2E** `e2e/tests/web/kat01-suche.spec.ts` (17 Tests, ohne Anmeldung): Einstieg über die Schaltfläche „Search games“ auf der Startseite, Titel in anderer Schreibweise (Krit. 1), Relevanzreihenfolge, ausgeblendete/zusammengeführte Spiele (KAT-05 Krit. 4), alternative Titel de/en (Krit. 2), Verlage inkl. zweitem Verlag (Krit. 3), Filter Genre/Spielerzahl/Dauer/Alter (Krit. 4), nur Filter und UND-Verknüpfung (Krit. 8), Filter-Sheet im mittleren Layout, Nachladen über Scrollen mit Prüfung der Anfrage `p_offset 30` (Krit. 5), Leerzustände (Krit. 6, Krit. 8), URL-Zustand, Neuladen, Zurück in einem Schritt, Deep Link mit Filtern, ungültige URL-Parameter, Offline mit „Try again“.
|
||||||
|
- **API-E2E** `e2e/tests/api/kat01-suche.spec.ts` (18 Tests): `search_catalog_games` als `anon` über Kong (Vertrag, Normalisierung, Relevanzstufen 0 bis 4, alternative Titel, Verlage, Filter, Paging, `total_count`), nie sichtbar: ausgeblendete, zusammengeführte und private Spiele (auch nicht für den Besitzer), Fehlerfälle `search_query_too_long`, `search_filter_invalid`, `search_paging_invalid` (P0001, HTTP 400), 22003 bei smallint-Überlauf, 22P02 bei falscher UUID, `service_role` ohne Ausführungsrecht (42501), kein Schreiben auf `game_alt_titles`.
|
||||||
|
- **Testdaten** `e2e/support/catalog.ts`: je Spec-Datei ein eigenes Datenset mit eigenem UUID-Präfix und Kunstwort (`e2ec0001-`/`zqvk` web, `e2ec0002-`/`wlpd` api), Anlage als `postgres` im `beforeAll`, Aufräumen im `afterAll` (der API-Test prüft, dass Spiele, Verlage, Genres und Mechaniken nicht zurückbleiben); `seed` löscht zuerst und ist nach einem Abbruch idempotent. Die Dateien laufen intern seriell und untereinander parallel. **Die Trennung gilt nur, wenn jede Suche das Kunstwort des eigenen Datensets enthält** (z. B. `zqvk dunkelforst`): die Unterscheidungswörter (Dunkelforst, Quarzfeld, Duo, …) sind in beiden Sets gleich. Mit Kunstwort treffen Suchen weder das andere Set noch den (auch wachsenden) Startbestand noch die pgTAP-Fixtures (`qxz`), solange kein echter Titel das Kunstwort enthält. Eigene Genres/Mechaniken statt des Vokabulars, damit „nur Filter“-Tests unabhängig vom Startbestand sind.
|
||||||
|
- **Latenzmessung** (Projekt `perf`, nur mit `PERF_WEB_URL`, nie in `e2e-run.sh`/CI): `e2e/tests/perf/kat01-suche-latenz.spec.ts`, Ladeskript `supabase/perf/catalog_search_perf_load.sql` (committet, mit Präfix `9e4f0000-`) und `…_unload.sql`. Bedienung: `e2e/README.md`.
|
||||||
|
- `playwright.config.ts`: `perf` bedingt registriert; mit `PERF_ONLY=1` kein Web-Server und kein Import der lokalen Auth-Konstanten. Ohne die Variablen verhält sich die Konfiguration wie vorher. Die allgemeinen Flutter-Helfer liegen jetzt in `support/flutter.ts` (`support/web.ts` exportiert sie unverändert weiter).
|
||||||
|
|
||||||
|
### 5.2 Messung KAT-01 Krit. 7 (vorläufig, Option A)
|
||||||
|
|
||||||
|
Lauf am 2026-11-04, lokaler Stack (Supabase CLI auf Docker/macOS), Web-Build `--release`, Chrome (Playwright-Chromium, Desktop), CDP-Drosselung 100 ms Latenz, 10 Mbit/s je Richtung. Daten: Startbestand-Stand der lokalen DB (1 Spiel) plus synthetisch 10 000 Katalogspiele, 20 000 alternative Titel, 2 000 Verlage, 12 951 Verlagszuordnungen, je Spiel 2 Genres und 3 Mechaniken (wie `catalog_search_perf.sql`).
|
||||||
|
|
||||||
|
| | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Suchen gemessen | 116 (1 Erststart mit neuer Verbindung, 103 Textsuchen, 5 ohne Treffer, 6 nur Filter [Genre + Mechanik]; 7 Kandidaten übersprungen) |
|
||||||
|
| p50 | 446 ms |
|
||||||
|
| p95 | 458 ms |
|
||||||
|
| Maximum | 626 ms (der Erststart; Textsuchen ohne ihn höchstens ca. 540 ms) |
|
||||||
|
| Minimum | 172 ms (Filterklick ohne Entprellung; getippte Suchen mindestens ca. 420 ms) |
|
||||||
|
| Grenze Krit. 7 | p95 ≤ 2000 ms: erfüllt |
|
||||||
|
|
||||||
|
Je Art (p50) im ersten Lauf dieser Fassung: Textsuchen 436 bis 455 ms (Titel, Titelanfang, Wort, Wortkombination, Verlag, GROSSBUCHSTABEN, alternativer Titel, ohne Treffer), nur Filter 176 ms. Der Wert besteht bei Textsuchen zu rund 300 ms aus der Entprellung der App; der Rest ist eine Round-Trip-Zeit (100 ms), Verarbeitung und Rendern. Gegenprobe des Gates: mit `PERF_LATENCY_MS=1800` misst der Test p95 2165 ms und **schlägt fehl** (`Error: p95 2165 ms, Expected: <= 2000`, Lauf 9,2 min). **Schutz gegen vorzeitiges Ende der Messung:** Die Messung endet bei der ersten Kachel mit dem erwarteten Titel. Damit nicht schon sichtbare Kacheln sie beenden, wird vor jeder Suche das Feld geleert und der Katalogbeginn abgewartet; Suchen, deren erster Treffer unter den ersten 30 Katalogtiteln (bzw. bei Filtern unter den ersten 30 des Zwischenergebnisses) liegt, werden übersprungen. Zusätzlich prüft der Test, dass kein getippter Messwert unter 300 ms und kein Filtermesswert unter 90 % der Round-Trip-Zeit liegt. Filtersuchen werden ab einem Zeitpunkt kurz vor dem Klick gemessen (Playwright-Aufruf, wenige ms).
|
||||||
|
|
||||||
|
**Aussagegrenzen (wichtig):**
|
||||||
|
|
||||||
|
- **Kein Staging, keine Produktion.** Gemessen ist der lokale Stack auf demselben Rechner wie der Browser (Docker unter macOS); Rechenzeit von Datenbank, Gateway und Browser konkurriert miteinander und mit anderen Prozessen (auf dem Rechner lief gleichzeitig Backend-Arbeit mit eigenen Datenbank-Läufen). Die Staging-Messung bleibt Freigabebedingung in `docs/release-checkliste.md` (Abschnitt 2a).
|
||||||
|
- **Die Drosselung ist eine Browser-Emulation** (Chrome DevTools): sie verzögert Antworten und begrenzt den Durchsatz im Browser, bildet aber keine Funkstrecke, kein TLS-Handshake über echte Latenz, keine Paketverluste und keinen Wechsel zwischen Funkzellen ab. Die Latenz gilt je Anfrage; ob ein Verbindungsaufbau (TCP/TLS) mit 100 ms Round-Trip in der Emulation vollständig abgebildet ist, ist nicht geprüft.
|
||||||
|
- **Nur Web** (Desktop-Chrome, ungedrosselte CPU). Keine Stichprobe auf Android/iOS (Spec 7.3, Aufgabe C1, Chris), keine CPU-Drosselung für ein schwächeres Mobilgerät.
|
||||||
|
- **Synthetische Daten:** Wörter mit ungleicher Häufigkeit, aber kein echter Titelbestand; die Relevanz mit dem echten Startbestand bleibt C1.
|
||||||
|
- **Suchliste:** feste Schrittweite durch die ersten 600 Spiele in alphabetischer Reihenfolge, nicht durch einen echten Nutzungsverlauf. Ein Treffer-Erwartungswert je Suche kommt aus derselben RPC (selbstprüfend, aber ohne unabhängiges Orakel).
|
||||||
|
- **Kein Ratenlimit** im lokalen Kong für die RPC (Entscheidung D1 vor dem Go-live): die Messung enthält keine Drosselung durch ein Gateway-Limit.
|
||||||
|
- **Gemessen ist bis zur ersten sichtbaren Kachel** (Textknoten im Semantics-Baum), nicht bis zum gezeichneten Frame; die Differenz ist nicht gemessen.
|
||||||
|
- Eine einzelne Messreihe mit diesen Daten (kein Vergleich über mehrere Läufe/Tageszeiten). Ein früherer Lauf mit fehlerhafter Verlagsverteilung der Testdaten (alle Spiele demselben Verlag zugeordnet; behoben) ergab p95 456 ms.
|
||||||
|
|
||||||
|
**Abweichungen von Spec 7.3 / offen:**
|
||||||
|
- Die Suchart „nur Filter“ ist enthalten, aber als Kombination Genre-Chip + Mechanik-Chip (6 Läufe), nicht als einzelner Filter (der erste Treffer ohne Suchbegriff liegt alphabetisch fast immer im schon sichtbaren Katalogbeginn). Spielerzahl, Dauer und Alter sind nicht Teil der Messung.
|
||||||
|
- Die „Wortkombinationen“ sind die ersten zwei Wörter eines Titels; Mehrwortsuchen über Titel und Verlag gemischt fehlen.
|
||||||
|
- **C1** (mobile Stichprobe von 20 Suchen auf Android/iOS, Chris) ist offen. **D1** (`workflow_dispatch`-Job für `perf`, Staging-Messung als Release-Freigabebedingung) ist offen; `docs/release-checkliste.md` § 2a nennt die Messung, die Bedienung steht in `e2e/README.md`.
|
||||||
|
|
||||||
|
### 5.3 Befunde und offene Punkte
|
||||||
|
|
||||||
|
- **Trefferkacheln im Semantics-Baum:** Alle Kacheln liegen als Kinder eines einzigen Listen-Containers (`role=group`); die Texte stehen in `span`-Knoten. Der Container hat keinen eigenen Namen. Für Bildschirmleser ist das Verhalten nicht geprüft (NVDA/VoiceOver-Durchgang, manuell). Die Tests suchen die Kacheln als Textknoten.
|
||||||
|
- **Direkt geladenes `/catalog` und Playwright:** Text, der über den eingeschalteten Semantics-Baum getippt wird, geht bei einem direkt geladenen `/catalog` verloren (der Autofokus des Suchfelds ist beim Einschalten der Semantics schon gesetzt). Gegenprobe ohne Semantics: Mausklick auf das Feld und Tastatureingabe auf einem direkt geladenen `/catalog` setzen den Suchbegriff in die URL (3 von 3 Läufen). Das ist ein Effekt des Testwerkzeugs, kein Produktfehler; die Tests öffnen die Suche deshalb über die Startseite. Der Wert des Semantics-Eingabefelds ist nach einem Auswahlmenü oder Neuladen im DOM leer, obwohl die Suche den Begriff behält (Beleg: URL und Ergebnis); die Tests prüfen deshalb die URL.
|
||||||
|
- **Ausgeblendete Spiele und RLS (Frage an Chris, Issue #69):** `games_select` lässt jede Zeile mit `visibility = 'catalog'` zu, auch ausgeblendete und zusammengeführte (Spec #2 E3 versteckt sie nur in der View `catalog_games`; Spec #2 Spec-F2 und R4 bewerten die Lesbarkeit als unkritisch). R4 unterschätzt die Lage: über die Tabellen ist alles auflistbar und durchsuchbar, nicht nur per ID abrufbar, einschließlich der alternativen Titel (`game_alt_titles` folgt `games`) ausgeblendeter Spiele. Über `search_catalog_games` erscheinen sie nie. Das wurde mit #3 nicht eingeführt; ob `games_select` ausgeblendete Katalogspiele für `anon` verbergen soll, entscheidet Chris. Hier keine Änderung.
|
||||||
|
- **Gateway ohne `apikey`:** Die lokale CLI beantwortet die RPC auch ohne `apikey`-Header als `anon` (HTTP 200). Auf dem Server ist das eine Frage der Kong-Konfiguration (Release-Checkliste).
|
||||||
|
- Krit. 6: „Spiel melden“ ist erst mit #13 sichtbar; der Test prüft den Hinweis, nicht die Aktion (Spec-F4).
|
||||||
|
- Nicht automatisiert: „Weitere laden“ per Tastatur (das automatische Nachladen kommt der Schaltfläche zuvor; Widget-Tests in 3b), Ansage der Trefferzahl für Bildschirmleser, Textskalierung 200 %, Tab-Reihenfolge im Browser.
|
||||||
|
|
||||||
|
### 5.4 Laufzeit und Stabilität der Suite
|
||||||
|
|
||||||
|
- Lokal, `--workers=2` (CI-Wert), Projekte `api` und `web`: ohne die neuen Tests (87 Tests) 43,6 bis 47,8 s (4 Läufe), mit ihnen (122 Tests) rund 78 s (ein Lauf, lokal; CI nicht gemessen); die Mehrlaufzeit beträgt lokal also etwa 30 s. Die Web-Datei `kat01-suche.spec.ts` ist ein serieller Block von etwa 45 s (ein Worker); ein CI-Retry (`retries: 1`) wiederholt die ganze Datei. Die API-Datei braucht etwa 2 s. Der Job `e2e` und `e2e-run.sh` sind unverändert (`api`, `web` und `throttle` ziehen die neuen Dateien automatisch mit).
|
||||||
|
- Die bestehenden Web-Tests der Anmeldung sind lastabhängig flaky (zeitversetzte Live-Region, zu knappe Wartezeiten): Bei höherer Rechnerlast scheitert in einzelnen Läufen ein Test (mal `krit04`, `krit07`, `krit01`, `krit49`). Das trat in Läufen mit und ohne die neuen Tests auf (ohne: 1 von 4 Läufen, mit: 5 von 9; alle Läufe auf einem gemeinsam genutzten Rechner mit wechselnder Last, daher nicht belastbar). `screenText` in `krit04-06-07-anmeldung.spec.ts` sammelt jetzt über einen `MutationObserver` (Init-Skript) jede nichtleere Ansage der Live-Regionen (`flt-announcement-polite/-assertive`, getrimmt) und gibt sie zusammen mit dem Baum zurück; der Test vergleicht sie zwischen neuer und vorhandener Adresse bzw. falscher E-Mail und falschem Passwort. Der Schnappschuss entsteht erst, wenn die Region leer ist und entweder mindestens eine Ansage registriert wurde oder die Region 400 ms am Stück leer war (Flutter setzt die Ansage zeitversetzt und leert sie nach 300 ms). Damit ist die Prüfung gegenüber vorher nicht abgeschwächt, sondern um den Vergleich der Ansagen erweitert. Die übrigen Instabilitäten sind nicht untersucht (allgemeine Robustheit der Anmelde-Tests: Issue #68).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# E2E-Tests (Playwright)
|
# E2E-Tests (Playwright)
|
||||||
|
|
||||||
Auth-Tests für KTO-01 (Spec `docs/specs/1-auth-registrierung-anmeldung.md` § 7, Issue #38). Jeder Test
|
Auth-Tests für KTO-01 (Spec `docs/specs/1-auth-registrierung-anmeldung.md` § 7, Issue #38) und Tests der
|
||||||
|
Katalogsuche KAT-01 (Spec `docs/specs/3-katalog-suche.md` § 7, Issue #3). Jeder Auth-Test
|
||||||
nutzt zufällige E-Mail-Adressen und filtert Mails je Adresse (Mailpit wird nie global geleert),
|
nutzt zufällige E-Mail-Adressen und filtert Mails je Adresse (Mailpit wird nie global geleert),
|
||||||
die Tests laufen deshalb parallel.
|
die Tests laufen deshalb parallel.
|
||||||
|
|
||||||
|
|
@ -8,12 +9,13 @@ die Tests laufen deshalb parallel.
|
||||||
|
|
||||||
| Projekt | Ziel | Voraussetzung | Tests |
|
| Projekt | Ziel | Voraussetzung | Tests |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `api` | GoTrue über Kong (`:54321`) + Mailpit (`:54324`) + Postgres (`:54322`) | `supabase start` | 52 |
|
| `api` | GoTrue und `search_catalog_games` über Kong (`:54321`) + Mailpit (`:54324`) + Postgres (`:54322`) | `supabase start` | 77 |
|
||||||
| `web` | gebaute Flutter-Web-App auf `http://localhost:3000` | zusätzlich `flutter build web` | 27 |
|
| `web` | gebaute Flutter-Web-App auf `http://localhost:3000` | zusätzlich `flutter build web` | 45 |
|
||||||
| `throttle` | Extra-GoTrue-Container mit `GOTRUE_RATE_LIMIT_HEADER` (AUTH-CFG-13) | `THROTTLE_GOTRUE_URL` | 4 |
|
| `throttle` | Extra-GoTrue-Container mit `GOTRUE_RATE_LIMIT_HEADER` (AUTH-CFG-13) | `THROTTLE_GOTRUE_URL` | 4 |
|
||||||
| `spike` | GoTrue mit unerreichbarem SMTP (Issue #37), nicht blockierend | `SPIKE_GOTRUE_URL` | 3 |
|
| `spike` | GoTrue mit unerreichbarem SMTP (Issue #37), nicht blockierend | `SPIKE_GOTRUE_URL` | 3 |
|
||||||
|
| `perf` | Latenzmessung der Katalogsuche (KAT-01 Krit. 7), nur lesend, nie in der CI | `PERF_WEB_URL`, `PERF_ANON_KEY` | 1 |
|
||||||
|
|
||||||
`throttle` und `spike` werden nur registriert, wenn die jeweilige Variable gesetzt ist.
|
`throttle`, `spike` und `perf` werden nur registriert, wenn die jeweilige Variable gesetzt ist.
|
||||||
|
|
||||||
## Lokal ausführen
|
## Lokal ausführen
|
||||||
|
|
||||||
|
|
@ -39,12 +41,38 @@ Umgebungsvariablen (Standard = lokale CLI-Demo-Werte, keine Secrets): `SUPABASE_
|
||||||
`SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `MAILPIT_URL`, `E2E_DB_URL`, `WEB_URL`,
|
`SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `MAILPIT_URL`, `E2E_DB_URL`, `WEB_URL`,
|
||||||
`TERMS_VERSION`, `E2E_ALLOWED_HOSTS` (zusätzlich erlaubte Hosts für SUPABASE_URL/MAILPIT_URL/E2E_DB_URL, kommagetrennt; Standard nur 127.0.0.1, localhost, [::1]).
|
`TERMS_VERSION`, `E2E_ALLOWED_HOSTS` (zusätzlich erlaubte Hosts für SUPABASE_URL/MAILPIT_URL/E2E_DB_URL, kommagetrennt; Standard nur 127.0.0.1, localhost, [::1]).
|
||||||
|
|
||||||
|
## Katalogsuche (KAT-01)
|
||||||
|
|
||||||
|
- `tests/web/kat01-suche.spec.ts` (Suche per Schaltfläche „Search games“, Relevanz, alternative Titel, Verlage, Filter, Nachladen, leere Zustände, URL/Neuladen/Zurück, Offline) und `tests/api/kat01-suche.spec.ts` (`search_catalog_games` als `anon` über Kong, Fehlerfälle). Testnamen tragen „KAT-01 Kriterium n“.
|
||||||
|
- **Testdaten:** `support/catalog.ts` legt je Spec-Datei ein eigenes Datenset an (eigener UUID-Präfix, eigenes Kunstwort: `zqvk`/`e2ec0001-` für web, `wlpd`/`e2ec0002-` für api), im `beforeAll`, aufgeräumt im `afterAll` (idempotent: `seed` löscht zuerst). Die Dateien laufen intern seriell, untereinander parallel. Jede Suche muss das Kunstwort des eigenen Datensets enthalten (z. B. `zqvk dunkelforst`); die Unterscheidungswörter sind in beiden Sets gleich, nur mit Kunstwort treffen Suchen weder das andere Set noch Startbestand noch die pgTAP-Fixtures (`qxz`). Nur lokal/CI (`support/db.ts`).
|
||||||
|
- **Bekannte Eigenheiten der Flutter-Semantics (beim Schreiben weiterer Tests beachten):** Die Trefferkacheln sind Textknoten (`span`) unter *einem* Listen-Container, nicht einzelne Elemente mit Rolle; die Liste ist virtualisiert. Während einer neuen Suche bleiben die alten Treffer sichtbar: nie nur auf „irgendeine Kachel“ warten, sondern auf Trefferzahl und erwartete Kachel. Hinweistexte stehen zusätzlich in der Live-Region `flt-announcement-polite`; Texte mit `locator('span', { hasText })` suchen. Das Semantics-Eingabefeld zeigt seinen Wert nur, solange es Fokus hat (nach Auswahlmenü oder Neuladen ist es leer); den Suchbegriff über die URL prüfen. Ein direkt geladenes `/catalog` nimmt über den Semantics-Baum getippten Text nicht verlässlich an (Autofokus des Suchfelds ist beim Einschalten der Semantics schon gesetzt); ohne Semantics, mit Mausklick und Tastatur, funktioniert es (Probe: 3 von 3). Es ist ein Testwerkzeug-Effekt, kein Produktfehler. Deshalb die Suche in Tests über die Startseite öffnen (`openCatalog` im Spec).
|
||||||
|
|
||||||
|
### Latenzmessung (Projekt `perf`, KAT-01 Krit. 7, vorläufig)
|
||||||
|
|
||||||
|
Vorläufige Messung (Option A der Spec, `docs/specs/3-katalog-suche.md` Spec-F4): kein hartes Gate gegen Staging. Ergebnis und Aussagegrenzen: `docs/specs/3-katalog-suche-umsetzung.md`, Abschnitt „PR 3c“.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Synthetisches Volumen laden (10 000 Spiele, nur lokal, wird COMMITTET; danach wieder entfernen)
|
||||||
|
docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes -v n_games=10000 \
|
||||||
|
< supabase/perf/catalog_search_perf_load.sql
|
||||||
|
# 2. Web-Build (gegen dieselbe Supabase-URL) bereitstellen, z. B. `npx serve -s ../app/build/web -l 3000`, und messen
|
||||||
|
cd e2e
|
||||||
|
PERF_WEB_URL=http://localhost:3000 PERF_SUPABASE_URL=http://127.0.0.1:54321 PERF_ANON_KEY=<anon> \
|
||||||
|
npm run test:perf # setzt PERF_ONLY=1: nur Projekt perf, kein lokaler Web-Server
|
||||||
|
# 3. Aufräumen
|
||||||
|
docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes \
|
||||||
|
< supabase/perf/catalog_search_perf_unload.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Test drosselt Chrome per CDP (`Network.emulateNetworkConditions`: 100 ms Latenz, 10 Mbit/s), baut aus dem Katalog eine feste Liste von mindestens 100 Suchen (Titel, Titelanfang, Wort, Wortkombination, Verlag, Großbuchstaben, alternative Titel, 5 ohne Treffer), fragt den erwarteten ersten Treffer je Suche ungedrosselt bei der RPC ab und misst **im Browser** die Zeit vom letzten Tastendruck (inklusive 300 ms Entprellung) bis zur ersten passenden Kachel. Ausgabe p50/p95/Maximum, Anhang `kat01-latenz.json`, bestanden bei p95 ≤ 2000 ms. `PERF_SEARCHES` (Standard 110) setzt die Anzahl, `PERF_LATENCY_MS` (Standard 100) dient nur der Gegenprobe des Gates. Das Projekt greift weder auf `support/db.ts` noch auf `support/auth-config.ts` zu; es läuft nie in `e2e-run.sh`/CI.
|
||||||
|
|
||||||
## Aufbau
|
## Aufbau
|
||||||
|
|
||||||
- `support/auth-config.ts` – Konstanten mit `AUTH-CFG-nn`-Bezug; `tests/api/config.spec.ts` prüft sie gegen `supabase/config.toml` und `docs/auth-konfiguration.md`.
|
- `support/auth-config.ts` – Konstanten mit `AUTH-CFG-nn`-Bezug; `tests/api/config.spec.ts` prüft sie gegen `supabase/config.toml` und `docs/auth-konfiguration.md`.
|
||||||
- `support/gotrue.ts`, `support/mailpit.ts` – REST-Client und Mailfänger-Helfer.
|
- `support/gotrue.ts`, `support/mailpit.ts` – REST-Client und Mailfänger-Helfer.
|
||||||
- `support/db.ts` – direkter Postgres-Zugriff, **nur lokal/CI** (bricht bei anderem Host ab); setzt `recovery_sent_at`/`confirmation_sent_at` für Ablauftests.
|
- `support/db.ts` – direkter Postgres-Zugriff, **nur lokal/CI** (bricht bei anderem Host ab); setzt `recovery_sent_at`/`confirmation_sent_at` für Ablauftests.
|
||||||
- `support/web.ts` – Flutter-Web-Helfer (Semantics einschalten, Felder füllen, Uhr verschieben).
|
- `support/web.ts` – Flutter-Web-Helfer für Auth (Registrierung, Anmeldung, Uhr verschieben); die allgemeinen Helfer (Semantics einschalten, Felder füllen) liegen in `support/flutter.ts` (ohne Auth-Konstanten, auch für `perf`).
|
||||||
|
- `support/catalog.ts` – Testdaten der Katalogsuche (Datenset je Spec-Datei, Aufräumen über den UUID-Präfix).
|
||||||
- `support/start-throttle-gotrue.sh` – zweiter GoTrue-Container (Profile `throttle`, `smtp-down`).
|
- `support/start-throttle-gotrue.sh` – zweiter GoTrue-Container (Profile `throttle`, `smtp-down`).
|
||||||
|
|
||||||
## Hinweise zum Web-Projekt
|
## Hinweise zum Web-Projekt
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
"test:api": "playwright test --project=api",
|
"test:api": "playwright test --project=api",
|
||||||
"test:web": "playwright test --project=web",
|
"test:web": "playwright test --project=web",
|
||||||
"test:throttle": "playwright test --project=throttle",
|
"test:throttle": "playwright test --project=throttle",
|
||||||
"test:spike": "playwright test --project=spike"
|
"test:spike": "playwright test --project=spike",
|
||||||
|
"test:perf": "PERF_ONLY=1 playwright test --project=perf"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.55.0",
|
"@playwright/test": "^1.55.0",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { defineConfig, devices, type Project } from '@playwright/test';
|
import { defineConfig, devices, type Project } from '@playwright/test';
|
||||||
|
|
||||||
import { env } from './support/auth-config';
|
// Bewusst ohne Import von support/auth-config: Ein reiner perf-Lauf (PERF_ONLY=1) darf keine lokalen
|
||||||
|
// Auth-Konstanten voraussetzen. Der Standardwert ist derselbe wie env.webUrl.
|
||||||
const webUrl = env.webUrl;
|
const webUrl = process.env.WEB_URL ?? 'http://localhost:3000';
|
||||||
|
const perfUrl = process.env.PERF_WEB_URL;
|
||||||
|
/** Reiner Messlauf (Krit. 7): nur Projekt `perf`, kein lokaler Web-Server, keine lokalen Konstanten. */
|
||||||
|
const perfOnly = process.env.PERF_ONLY === '1';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Projekte (Spec #1 § 7):
|
* Projekte (Spec #1 § 7):
|
||||||
|
|
@ -10,8 +13,10 @@ const webUrl = env.webUrl;
|
||||||
* - web gebaute Flutter-Web-App (`app/build/web`, statisch auf Port 3000)
|
* - web gebaute Flutter-Web-App (`app/build/web`, statisch auf Port 3000)
|
||||||
* - throttle Drosselung (Krit. 7) gegen den Extra-Container, nur mit THROTTLE_GOTRUE_URL
|
* - throttle Drosselung (Krit. 7) gegen den Extra-Container, nur mit THROTTLE_GOTRUE_URL
|
||||||
* - spike SMTP-Spike (Issue #37), nur mit SPIKE_GOTRUE_URL, nicht blockierend
|
* - spike SMTP-Spike (Issue #37), nur mit SPIKE_GOTRUE_URL, nicht blockierend
|
||||||
|
* - perf Latenzmessung der Katalogsuche (KAT-01 Krit. 7), nur mit PERF_WEB_URL, nur lesend,
|
||||||
|
* nie in der CI (kein Teil von e2e-run.sh); PERF_ONLY=1 startet keinen lokalen Web-Server
|
||||||
*/
|
*/
|
||||||
const projects: Project[] = [
|
const projects: Project[] = perfOnly ? [] : [
|
||||||
{ name: 'api', testDir: './tests/api', use: {} },
|
{ name: 'api', testDir: './tests/api', use: {} },
|
||||||
{
|
{
|
||||||
name: 'web',
|
name: 'web',
|
||||||
|
|
@ -27,10 +32,20 @@ const projects: Project[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (process.env.THROTTLE_GOTRUE_URL) {
|
if (perfUrl) {
|
||||||
|
projects.push({
|
||||||
|
name: 'perf',
|
||||||
|
testDir: './tests/perf',
|
||||||
|
timeout: 900_000,
|
||||||
|
retries: 0,
|
||||||
|
fullyParallel: false,
|
||||||
|
use: { ...devices['Desktop Chrome'], baseURL: perfUrl, locale: 'en-US', serviceWorkers: 'block' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!perfOnly && process.env.THROTTLE_GOTRUE_URL) {
|
||||||
projects.push({ name: 'throttle', testDir: './tests/throttle', use: {} });
|
projects.push({ name: 'throttle', testDir: './tests/throttle', use: {} });
|
||||||
}
|
}
|
||||||
if (process.env.SPIKE_GOTRUE_URL) {
|
if (!perfOnly && process.env.SPIKE_GOTRUE_URL) {
|
||||||
projects.push({ name: 'spike', testDir: './tests/spike', use: {}, retries: 0 });
|
projects.push({ name: 'spike', testDir: './tests/spike', use: {}, retries: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,8 +59,10 @@ export default defineConfig({
|
||||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
|
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
|
||||||
use: { trace: 'retain-on-failure' },
|
use: { trace: 'retain-on-failure' },
|
||||||
projects,
|
projects,
|
||||||
// SPA-Fallback (`-s`), sonst liefert /auth/confirm?... 404.
|
// SPA-Fallback (`-s`), sonst liefert /auth/confirm?... 404. Entfaellt beim reinen Messlauf.
|
||||||
webServer: {
|
webServer: perfOnly
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
command: 'npx serve -s ../app/build/web -l 3000 --no-clipboard',
|
command: 'npx serve -s ../app/build/web -l 3000 --no-clipboard',
|
||||||
url: webUrl,
|
url: webUrl,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
|
|
|
||||||
181
e2e/support/catalog.ts
Normal file
181
e2e/support/catalog.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
/**
|
||||||
|
* Testdaten fuer die Katalogsuche (KAT-01, Issue #3, PR 3c). NUR lokal/CI (`withDb`, als `postgres`).
|
||||||
|
*
|
||||||
|
* Jede Spec-Datei bekommt ein EIGENES Datenset (eigener UUID-Praefix, eigenes Kunstwort), weil die
|
||||||
|
* Dateien unter `fullyParallel` gleichzeitig laufen: Raeumt eine Datei auf, darf sie keine Zeilen der
|
||||||
|
* anderen loeschen. Das Kunstwort steht in allen Titeln, alternativen Titeln und Verlagen. Die Garantie
|
||||||
|
* gilt nur, wenn JEDE Suche das Kunstwort des eigenen Datensets enthaelt (z. B. "zqvk dunkelforst"):
|
||||||
|
* Die Unterscheidungswoerter (Dunkelforst, Quarzfeld, Duo, ...) sind in beiden Sets gleich und wuerden
|
||||||
|
* ohne Kunstwort beide Sets treffen. Mit Kunstwort treffen Suchen weder das andere Set noch den
|
||||||
|
* Startbestand (auch nicht, wenn er waechst, solange kein echter Titel das Kunstwort enthaelt) noch
|
||||||
|
* die pgTAP-Fixtures (`qxz`). Ohne Suchbegriff wird nur ueber die eigenen Genres/Mechaniken gefiltert.
|
||||||
|
*
|
||||||
|
* Aufraeumen ueber den UUID-Praefix (Spiele, Verlage, Genres, Mechaniken); Zuordnungen und
|
||||||
|
* alternative Titel haengen per `on delete cascade` an den Spielen. `seed` loescht zuerst, ist also
|
||||||
|
* nach einem abgebrochenen Lauf idempotent.
|
||||||
|
*/
|
||||||
|
import type { Client } from 'pg';
|
||||||
|
import { withDb } from './db';
|
||||||
|
|
||||||
|
export interface CatalogDatasetOptions {
|
||||||
|
/** 8 Hex-Zeichen, UUID-Praefix aller Zeilen des Sets. */
|
||||||
|
prefix: string;
|
||||||
|
/** Kunstwort (nur Kleinbuchstaben a-z), steht in jedem Titel. */
|
||||||
|
word: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = (prefix: string, n: number) => `${prefix}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
|
||||||
|
const cap = (w: string) => w.charAt(0).toUpperCase() + w.slice(1);
|
||||||
|
|
||||||
|
/** Anzahl der Massenspiele (Seitengroesse 30 + 6, KAT-01 Kriterium 5). */
|
||||||
|
export const MASS_COUNT = 36;
|
||||||
|
|
||||||
|
export function catalogDataset({ prefix, word }: CatalogDatasetOptions) {
|
||||||
|
const W = cap(word);
|
||||||
|
const id = (n: number) => uuid(prefix, n);
|
||||||
|
const mass = Array.from({ length: MASS_COUNT }, (_, i) => {
|
||||||
|
const nn = String(i + 1).padStart(2, '0');
|
||||||
|
return { id: id(1000 + i + 1), title: `${W} Massenspiel ${nn}` };
|
||||||
|
});
|
||||||
|
const ids = {
|
||||||
|
cafe: id(1),
|
||||||
|
wald: id(2),
|
||||||
|
rot: id(3),
|
||||||
|
blau: id(4),
|
||||||
|
familie: id(5),
|
||||||
|
experte: id(6),
|
||||||
|
duo: id(7),
|
||||||
|
relExact: id(10),
|
||||||
|
relBegins: id(11),
|
||||||
|
relWord: id(12),
|
||||||
|
relContains: id(13),
|
||||||
|
relPublisher: id(14),
|
||||||
|
hidden: id(20),
|
||||||
|
merged: id(21),
|
||||||
|
genreA: id(101),
|
||||||
|
genreB: id(102),
|
||||||
|
mechanicM1: id(111),
|
||||||
|
mechanicM2: id(112),
|
||||||
|
pubQuarz: id(201),
|
||||||
|
pubNebel: id(202),
|
||||||
|
pubRelevanz: id(203),
|
||||||
|
};
|
||||||
|
const t = {
|
||||||
|
cafe: `${W} Café Größe: Duel®`,
|
||||||
|
wald: `${W} Schattenwald`,
|
||||||
|
waldAltDe: `${W} Dunkelforst`,
|
||||||
|
waldAltEn: `${W} Shadowwood`,
|
||||||
|
rot: `${W} Rotkehlchen`,
|
||||||
|
blau: `${W} Blaumeise`,
|
||||||
|
familie: `${W} Familienfest`,
|
||||||
|
experte: `${W} Expertenrunde`,
|
||||||
|
duo: `${W} Duo`,
|
||||||
|
relExact: `${W} Relevanz`,
|
||||||
|
relBegins: `${W} Relevanz Deluxe`,
|
||||||
|
relWord: `Die ${W} Relevanz`,
|
||||||
|
relContains: `X${W} Relevanzen`,
|
||||||
|
relPublisher: `${W} Fremdtitel`,
|
||||||
|
hidden: `${W} Verborgen`,
|
||||||
|
merged: `${W} Zusammengefuehrt`,
|
||||||
|
genreA: `${W} Genre A`,
|
||||||
|
genreB: `${W} Genre B`,
|
||||||
|
mechanicM1: `${W} Mechanik 1`,
|
||||||
|
mechanicM2: `${W} Mechanik 2`,
|
||||||
|
pubQuarz: `Quarzfeld ${W} Presse`,
|
||||||
|
pubNebel: `Nebelkamm ${W} Verlag`,
|
||||||
|
pubRelevanz: `Relevanz Verlag ${W}`,
|
||||||
|
};
|
||||||
|
return { word, W, prefix, ids, titles: t, mass };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CatalogDataset = ReturnType<typeof catalogDataset>;
|
||||||
|
|
||||||
|
/** Loescht alle Zeilen des Sets (anhand des UUID-Praefixes). */
|
||||||
|
export async function cleanupCatalog(c: Client, ds: Pick<CatalogDataset, 'prefix'>): Promise<void> {
|
||||||
|
const like = `${ds.prefix}-%`;
|
||||||
|
await c.query(`delete from public.games where id::text like $1`, [like]);
|
||||||
|
await c.query(`delete from public.publishers where id::text like $1`, [like]);
|
||||||
|
await c.query(`delete from public.genres where id::text like $1`, [like]);
|
||||||
|
await c.query(`delete from public.mechanics where id::text like $1`, [like]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt das Datenset an (nach Aufraeumen). */
|
||||||
|
export async function seedCatalog(ds: CatalogDataset): Promise<void> {
|
||||||
|
const { ids: i, titles: t } = ds;
|
||||||
|
await withDb(async (c) => {
|
||||||
|
await cleanupCatalog(c, ds);
|
||||||
|
await c.query(
|
||||||
|
`insert into public.genres (id, name_de, name_en) values ($1, $2, $2), ($3, $4, $4)`,
|
||||||
|
[i.genreA, t.genreA, i.genreB, t.genreB],
|
||||||
|
);
|
||||||
|
await c.query(
|
||||||
|
`insert into public.mechanics (id, name_de, name_en) values ($1, $2, $2), ($3, $4, $4)`,
|
||||||
|
[i.mechanicM1, t.mechanicM1, i.mechanicM2, t.mechanicM2],
|
||||||
|
);
|
||||||
|
await c.query(
|
||||||
|
`insert into public.publishers (id, name) values ($1, $2), ($3, $4), ($5, $6)`,
|
||||||
|
[i.pubQuarz, t.pubQuarz, i.pubNebel, t.pubNebel, i.pubRelevanz, t.pubRelevanz],
|
||||||
|
);
|
||||||
|
|
||||||
|
// (id, title, year, min_p, max_p, min_min, max_min, min_age)
|
||||||
|
const games: [string, string, number | null, number | null, number | null, number | null, number | null, number | null][] = [
|
||||||
|
[i.cafe, t.cafe, 2021, 2, 2, 20, 30, 12],
|
||||||
|
[i.wald, t.wald, 2019, 2, 4, 45, 60, 10],
|
||||||
|
[i.rot, t.rot, 2018, 2, 4, 30, 45, 8],
|
||||||
|
[i.blau, t.blau, 2022, 2, 5, 30, 45, 8],
|
||||||
|
[i.familie, t.familie, 2017, 3, 8, 20, 45, 6],
|
||||||
|
[i.experte, t.experte, 2016, 1, 2, 120, 180, 14],
|
||||||
|
[i.duo, t.duo, 2015, 2, 2, 30, 30, 8],
|
||||||
|
[i.relExact, t.relExact, 2000, 2, 4, 30, 60, 10],
|
||||||
|
[i.relBegins, t.relBegins, 2000, 2, 4, 30, 60, 10],
|
||||||
|
[i.relWord, t.relWord, 2000, 2, 4, 30, 60, 10],
|
||||||
|
[i.relContains, t.relContains, 2000, 2, 4, 30, 60, 10],
|
||||||
|
[i.relPublisher, t.relPublisher, 2000, 2, 4, 30, 60, 10],
|
||||||
|
...ds.mass.map(
|
||||||
|
(m) => [m.id, m.title, 2020, 2, 4, 30, 60, 10] as [string, string, number, number, number, number, number, number],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (const g of games) {
|
||||||
|
await c.query(
|
||||||
|
`insert into public.games (id, title, visibility, year, min_players, max_players, min_minutes, max_minutes, min_age)
|
||||||
|
values ($1, $2, 'catalog', $3, $4, $5, $6, $7, $8)`,
|
||||||
|
g,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Ausgeblendet und zusammengefuehrt: duerfen nie erscheinen (KAT-05 Kriterium 4).
|
||||||
|
await c.query(
|
||||||
|
`insert into public.games (id, title, visibility, hidden_at) values ($1, $2, 'catalog', now())`,
|
||||||
|
[i.hidden, t.hidden],
|
||||||
|
);
|
||||||
|
await c.query(
|
||||||
|
`insert into public.games (id, title, visibility, hidden_at, merged_into_id) values ($1, $2, 'catalog', now(), $3)`,
|
||||||
|
[i.merged, t.merged, i.relExact],
|
||||||
|
);
|
||||||
|
|
||||||
|
await c.query(
|
||||||
|
`insert into public.game_alt_titles (game_id, locale, title) values ($1, 'de', $2), ($1, 'en', $3)`,
|
||||||
|
[i.wald, t.waldAltDe, t.waldAltEn],
|
||||||
|
);
|
||||||
|
// Der alternative Titel des ausgeblendeten Spiels darf es nicht auffindbar machen.
|
||||||
|
await c.query(`insert into public.game_alt_titles (game_id, locale, title) values ($1, 'de', $2)`, [
|
||||||
|
i.hidden,
|
||||||
|
`${ds.W} Verstecktername`,
|
||||||
|
]);
|
||||||
|
await c.query(
|
||||||
|
`insert into public.game_publishers (game_id, publisher_id) values ($1, $2), ($3, $2), ($3, $4), ($5, $6)`,
|
||||||
|
[i.rot, i.pubQuarz, i.blau, i.pubNebel, i.relPublisher, i.pubRelevanz],
|
||||||
|
);
|
||||||
|
await c.query(
|
||||||
|
`insert into public.game_genres (game_id, genre_id) values ($1, $2), ($3, $2), ($4, $2), ($4, $5)`,
|
||||||
|
[i.familie, i.genreA, i.duo, i.experte, i.genreB],
|
||||||
|
);
|
||||||
|
await c.query(
|
||||||
|
`insert into public.game_mechanics (game_id, mechanic_id) values ($1, $3), ($2, $3)`,
|
||||||
|
[i.experte, i.duo, i.mechanicM1],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupCatalogDataset(ds: CatalogDataset): Promise<void> {
|
||||||
|
await withDb((c) => cleanupCatalog(c, ds));
|
||||||
|
}
|
||||||
44
e2e/support/flutter.ts
Normal file
44
e2e/support/flutter.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* Flutter-Web-Helfer ohne Abhaengigkeit von `auth-config.ts` (Projekte `web` und `perf`).
|
||||||
|
*
|
||||||
|
* Flutter zeichnet auf ein Canvas; Playwright sieht Elemente erst, wenn der Semantics-Baum
|
||||||
|
* eingeschaltet ist (Platzhalter `flt-semantics-placeholder`). `perf` darf gegen eine fremde
|
||||||
|
* Umgebung laufen und importiert deshalb nichts, was lokale Auth-Konstanten voraussetzt.
|
||||||
|
*/
|
||||||
|
import { expect, type Locator, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sichtbarer Text im Semantics-Baum. Flutter legt Texte teils als Textknoten, teils als
|
||||||
|
* `aria-label` ab (z. B. der Inhalt der CheckEmail-Seite).
|
||||||
|
*/
|
||||||
|
export function textOf(page: Page, text: string | RegExp): Locator {
|
||||||
|
return page.getByText(text).or(page.getByLabel(text)).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schaltet den Semantics-Baum ein (einmal je Seitenladen). */
|
||||||
|
export async function enableSemantics(page: Page): Promise<void> {
|
||||||
|
const placeholder = page.locator('flt-semantics-placeholder');
|
||||||
|
await placeholder.waitFor({ state: 'attached' });
|
||||||
|
await placeholder.evaluate((el: HTMLElement) => el.click());
|
||||||
|
await page.locator('flt-semantics').first().waitFor({ state: 'attached' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigiert und schaltet Semantics ein. */
|
||||||
|
export async function open(page: Page, path: string): Promise<void> {
|
||||||
|
await page.goto(path);
|
||||||
|
await enableSemantics(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte <input> erst beim Fokus an;
|
||||||
|
* `fill` allein geht dann gelegentlich verloren. Deshalb klicken, fuellen und den Wert pruefen
|
||||||
|
* (mit Wiederholung).
|
||||||
|
*/
|
||||||
|
export async function fillField(field: Locator, value: string): Promise<void> {
|
||||||
|
await expect(async () => {
|
||||||
|
await field.click();
|
||||||
|
await field.fill('');
|
||||||
|
await field.pressSequentially(value);
|
||||||
|
await expect(field).toHaveValue(value, { timeout: 1_000 });
|
||||||
|
}).toPass({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test';
|
import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test';
|
||||||
import { env, TERMS_VERSION } from './auth-config';
|
import { env, TERMS_VERSION } from './auth-config';
|
||||||
|
import { enableSemantics, fillField, open, textOf } from './flutter';
|
||||||
|
|
||||||
export { TERMS_VERSION };
|
export { TERMS_VERSION };
|
||||||
|
|
||||||
|
|
@ -28,41 +29,7 @@ export const t = {
|
||||||
home: 'Welcome to Shuffle. The board game catalog is coming soon.',
|
home: 'Welcome to Shuffle. The board game catalog is coming soon.',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
export { textOf, enableSemantics, open, fillField };
|
||||||
* Sichtbarer Text im Semantics-Baum. Flutter legt Texte teils als Textknoten, teils als
|
|
||||||
* `aria-label` ab (z. B. der Inhalt der CheckEmail-Seite).
|
|
||||||
*/
|
|
||||||
export function textOf(page: Page, text: string | RegExp): Locator {
|
|
||||||
return page.getByText(text).or(page.getByLabel(text)).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Schaltet den Semantics-Baum ein (einmal je Seitenladen). */
|
|
||||||
export async function enableSemantics(page: Page): Promise<void> {
|
|
||||||
const placeholder = page.locator('flt-semantics-placeholder');
|
|
||||||
await placeholder.waitFor({ state: 'attached' });
|
|
||||||
await placeholder.evaluate((el: HTMLElement) => el.click());
|
|
||||||
await page.locator('flt-semantics').first().waitFor({ state: 'attached' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Navigiert und schaltet Semantics ein. */
|
|
||||||
export async function open(page: Page, path: string): Promise<void> {
|
|
||||||
await page.goto(path);
|
|
||||||
await enableSemantics(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte <input> erst beim Fokus an;
|
|
||||||
* `fill` allein geht dann gelegentlich verloren. Deshalb klicken, fuellen und den Wert pruefen
|
|
||||||
* (mit Wiederholung).
|
|
||||||
*/
|
|
||||||
export async function fillField(field: Locator, value: string): Promise<void> {
|
|
||||||
await expect(async () => {
|
|
||||||
await field.click();
|
|
||||||
await field.fill('');
|
|
||||||
await field.pressSequentially(value);
|
|
||||||
await expect(field).toHaveValue(value, { timeout: 1_000 });
|
|
||||||
}).toPass({ timeout: 15_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fillRegistration(
|
export async function fillRegistration(
|
||||||
page: Page,
|
page: Page,
|
||||||
|
|
|
||||||
378
e2e/tests/api/kat01-suche.spec.ts
Normal file
378
e2e/tests/api/kat01-suche.spec.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
/**
|
||||||
|
* KAT-01 Spiele suchen (Issue #3, Spec docs/specs/3-katalog-suche.md § 4.2/§ 7), Projekt `api`:
|
||||||
|
* die RPC `search_catalog_games` als `anon` ueber Kong (`POST /rest/v1/rpc/...`), wie die App sie
|
||||||
|
* aufruft, inklusive Fehlerfaellen.
|
||||||
|
*
|
||||||
|
* Testdaten: eigenes Datenset mit Kunstwort "wlpd" (e2e/support/catalog.ts), Anlage im `beforeAll`,
|
||||||
|
* Aufraeumen im `afterAll`; die Datei laeuft seriell. Die Gegenstuecke in der Datenbank
|
||||||
|
* (supabase/tests/database/09_catalog_search.test.sql) pruefen dieselben Regeln ohne HTTP; hier
|
||||||
|
* zaehlt, dass die Regeln ueber Gateway, PostgREST und Rollenrechte ankommen.
|
||||||
|
* Drosselung: Kong hat in der lokalen Konfiguration kein Ratenlimit fuer die RPC (Spec 4.2
|
||||||
|
* Sicherheit, Entscheidung D1 vor dem Go-live); die Tests rufen nur wenige Dutzend Mal auf.
|
||||||
|
*/
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { env } from '../../support/auth-config';
|
||||||
|
import { catalogDataset, cleanupCatalog, MASS_COUNT, seedCatalog } from '../../support/catalog';
|
||||||
|
import { withDb } from '../../support/db';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
|
||||||
|
|
||||||
|
const ds = catalogDataset({ prefix: 'e2ec0002', word: 'wlpd' });
|
||||||
|
const { ids, titles: t } = ds;
|
||||||
|
|
||||||
|
/** Privates Spiel eines Nutzers (darf nie in der Katalogsuche erscheinen). */
|
||||||
|
const privateGameId = `${ds.prefix}-0000-4000-8000-0000000000f1`;
|
||||||
|
const privateTitle = `${ds.W} Privatspiel`;
|
||||||
|
let ownerId = '';
|
||||||
|
let ownerToken = '';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await seedCatalog(ds);
|
||||||
|
const email = randomEmail('kat01');
|
||||||
|
const password = randomPassword();
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
const created = await gotrue.createConfirmedUser(email, password);
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
ownerId = created.body.id;
|
||||||
|
const login = await gotrue.signIn(email, password);
|
||||||
|
expect(login.status).toBe(200);
|
||||||
|
ownerToken = login.body.access_token;
|
||||||
|
await withDb(async (c) => {
|
||||||
|
await c.query(`insert into public.games (id, title, visibility, owner_id) values ($1, $2, 'private', $3)`, [
|
||||||
|
privateGameId,
|
||||||
|
privateTitle,
|
||||||
|
ownerId,
|
||||||
|
]);
|
||||||
|
await c.query(`insert into public.game_alt_titles (game_id, locale, title) values ($1, 'de', $2)`, [
|
||||||
|
privateGameId,
|
||||||
|
`${ds.W} Geheimname`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await withDb(async (c) => {
|
||||||
|
await cleanupCatalog(c, ds);
|
||||||
|
if (ownerId) await c.query('delete from auth.users where id = $1', [ownerId]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
min_players: number | null;
|
||||||
|
max_players: number | null;
|
||||||
|
min_minutes: number | null;
|
||||||
|
max_minutes: number | null;
|
||||||
|
min_age: number | null;
|
||||||
|
image_path: string | null;
|
||||||
|
publisher_names: string[];
|
||||||
|
matched_alt_title: string | null;
|
||||||
|
match_rank: number | null;
|
||||||
|
total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Res<T> {
|
||||||
|
status: number;
|
||||||
|
body: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /rest/v1/rpc/search_catalog_games, Standard: Rolle anon (apikey = Anon-Key). */
|
||||||
|
async function rpc(
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
auth: { apikey?: string | null; token?: string } = {},
|
||||||
|
): Promise<Res<any>> {
|
||||||
|
const apikey = auth.apikey === undefined ? env.anonKey : auth.apikey;
|
||||||
|
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
||||||
|
if (apikey) headers.apikey = apikey;
|
||||||
|
const bearer = auth.token ?? apikey;
|
||||||
|
if (bearer) headers.authorization = `Bearer ${bearer}`;
|
||||||
|
const res = await fetch(`${env.supabaseUrl}/rest/v1/rpc/search_catalog_games`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return { status: res.status, body: text ? JSON.parse(text) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(params: Record<string, unknown>, auth?: Parameters<typeof rpc>[1]): Promise<Row[]> {
|
||||||
|
const res = await rpc(params, auth);
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||||
|
return res.body as Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const titlesOf = (rows: Row[]) => rows.map((r) => r.title);
|
||||||
|
const idSet = (rows: Row[]) => rows.map((r) => r.id).sort();
|
||||||
|
|
||||||
|
test.describe('KAT-01 Kriterium 1: Titelsuche als anon (api)', () => {
|
||||||
|
test('KAT-01 Sicherheit Kriterium 2: anon ruft die RPC ohne Anmeldung auf, die Zeilen haben den Vertrag aus Spec 4.2', async () => {
|
||||||
|
const rows = await search({ p_query: 'wlpd duo' });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(Object.keys(rows[0]).sort()).toEqual(
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'image_path',
|
||||||
|
'matched_alt_title',
|
||||||
|
'match_rank',
|
||||||
|
'max_minutes',
|
||||||
|
'max_players',
|
||||||
|
'min_age',
|
||||||
|
'min_minutes',
|
||||||
|
'min_players',
|
||||||
|
'publisher_names',
|
||||||
|
'title',
|
||||||
|
'total_count',
|
||||||
|
'year',
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
id: ids.duo,
|
||||||
|
title: t.duo,
|
||||||
|
year: 2015,
|
||||||
|
min_players: 2,
|
||||||
|
max_players: 2,
|
||||||
|
min_minutes: 30,
|
||||||
|
max_minutes: 30,
|
||||||
|
min_age: 8,
|
||||||
|
image_path: null,
|
||||||
|
publisher_names: [],
|
||||||
|
matched_alt_title: null,
|
||||||
|
match_rank: 0,
|
||||||
|
total_count: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 1: Teil eines Titels, Groß-/Kleinschreibung, Sonderzeichen und Akzente sind egal', async () => {
|
||||||
|
for (const q of ['WLPD CAFE-GROSSE duel', 'wlpd café größe: duel®', 'wlpd cafe grosse', 'WLPD gross', 'wlpd DUEL']) {
|
||||||
|
const rows = await search({ p_query: q });
|
||||||
|
expect(idSet(rows), q).toContain(ids.cafe);
|
||||||
|
}
|
||||||
|
// Ohne Leerzeichen ("aeons end"-Regel): der Titel ohne Leerzeichen trifft.
|
||||||
|
expect(idSet(await search({ p_query: 'wlpdcafegrossduel' }))).toEqual([]);
|
||||||
|
expect(idSet(await search({ p_query: 'wlpdcafegrosseduel' }))).toContain(ids.cafe);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 1: Relevanzstufen 0 bis 4 in dieser Reihenfolge', async () => {
|
||||||
|
const rows = await search({ p_query: 'wlpd relevanz' });
|
||||||
|
expect(titlesOf(rows)).toEqual([t.relExact, t.relBegins, t.relWord, t.relContains, t.relPublisher]);
|
||||||
|
expect(rows.map((r) => r.match_rank)).toEqual([0, 1, 2, 3, 4]);
|
||||||
|
expect(rows.every((r) => r.total_count === 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 1: nur Sonderzeichen ergibt keine Treffer; unter 2 Zeichen gilt als kein Suchbegriff', async () => {
|
||||||
|
const none = await rpc({ p_query: '!!!', p_limit: 5 });
|
||||||
|
expect(none.status).toBe(200);
|
||||||
|
expect(none.body).toEqual([]);
|
||||||
|
|
||||||
|
// "a" (1 Zeichen) = kein Suchbegriff: ganzer Katalog, match_rank null, alphabetisch.
|
||||||
|
const all = await search({ p_query: 'a', p_limit: 5 });
|
||||||
|
expect(all).toHaveLength(5);
|
||||||
|
expect(all.every((r) => r.match_rank === null)).toBe(true);
|
||||||
|
expect(all[0].total_count).toBeGreaterThanOrEqual(ds.mass.length + 12); // 36 Massenspiele + 7 Einzelspiele + 5 Relevanzspiele
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-05 Kriterium 4: ausgeblendete und zusammengeführte Spiele erscheinen nie, auch nicht über alternative Titel', async () => {
|
||||||
|
for (const q of ['wlpd verborgen', 'wlpd verstecktername', 'wlpd zusammengefuehrt']) {
|
||||||
|
expect(await search({ p_query: q }), q).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Sicherheit Kriterium 2: private Spiele erscheinen nie, auch nicht für ihren Besitzer, auch nicht über alternative Titel', async () => {
|
||||||
|
for (const q of ['wlpd privatspiel', 'wlpd geheimname']) {
|
||||||
|
expect(await search({ p_query: q }), `anon ${q}`).toEqual([]);
|
||||||
|
expect(await search({ p_query: q }, { token: ownerToken }), `Besitzer ${q}`).toEqual([]);
|
||||||
|
}
|
||||||
|
// Das private Spiel ist auch ueber die Tabelle der alternativen Titel fuer anon unsichtbar (RLS).
|
||||||
|
const res = await fetch(
|
||||||
|
`${env.supabaseUrl}/rest/v1/game_alt_titles?select=title&title=ilike.*${ds.word}*`,
|
||||||
|
{ headers: { apikey: env.anonKey, authorization: `Bearer ${env.anonKey}` } },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const visible = ((await res.json()) as { title: string }[]).map((r) => r.title);
|
||||||
|
expect(visible).toEqual(expect.arrayContaining([t.waldAltDe, t.waldAltEn]));
|
||||||
|
expect(visible).not.toContain(`${ds.W} Geheimname`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KAT-01 Kriterium 2 und 3: alternative Titel und Verlage (api)', () => {
|
||||||
|
test('KAT-01 Kriterium 2: deutscher und englischer alternativer Titel finden dasselbe Spiel; matched_alt_title nennt den Treffer', async () => {
|
||||||
|
for (const [q, alt] of [
|
||||||
|
['wlpd dunkelforst', t.waldAltDe],
|
||||||
|
['wlpd shadowwood', t.waldAltEn],
|
||||||
|
] as const) {
|
||||||
|
const rows = await search({ p_query: q });
|
||||||
|
expect(titlesOf(rows), q).toEqual([t.wald]);
|
||||||
|
expect(rows[0].matched_alt_title, q).toBe(alt);
|
||||||
|
}
|
||||||
|
// Der Haupttitel selbst trifft ohne matched_alt_title.
|
||||||
|
const main = await search({ p_query: 'wlpd schattenwald' });
|
||||||
|
expect(main).toHaveLength(1);
|
||||||
|
expect(main[0].matched_alt_title).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 3: Verlag findet seine Spiele; publisher_names ist sortiert und vollständig', async () => {
|
||||||
|
const rows = await search({ p_query: 'wlpd quarzfeld' });
|
||||||
|
expect(idSet(rows)).toEqual([ids.rot, ids.blau].sort());
|
||||||
|
const blau = rows.find((r) => r.id === ids.blau)!;
|
||||||
|
expect(blau.publisher_names).toEqual([t.pubNebel, t.pubQuarz]);
|
||||||
|
expect(rows.find((r) => r.id === ids.rot)!.publisher_names).toEqual([t.pubQuarz]);
|
||||||
|
// Der zweite Verlag findet das Spiel ebenfalls, aber nicht das Spiel des ersten allein.
|
||||||
|
expect(idSet(await search({ p_query: 'wlpd nebelkamm' }))).toEqual([ids.blau]);
|
||||||
|
// Wort aus Titel UND Wort aus Verlag (UND-verknuepft).
|
||||||
|
expect(idSet(await search({ p_query: 'wlpd rotkehlchen quarzfeld' }))).toEqual([ids.rot]);
|
||||||
|
expect(await search({ p_query: 'wlpd rotkehlchen nebelkamm' })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KAT-01 Kriterium 4 und 8: Filter (api)', () => {
|
||||||
|
const genreA = [ids.familie, ids.experte, ids.duo].sort();
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 8: nur Filter ohne Suchbegriff, alphabetisch, match_rank null', async () => {
|
||||||
|
const rows = await search({ p_genre_ids: [ids.genreA] });
|
||||||
|
expect(idSet(rows)).toEqual(genreA);
|
||||||
|
expect(titlesOf(rows)).toEqual([t.duo, t.experte, t.familie]);
|
||||||
|
expect(rows.every((r) => r.match_rank === null && r.total_count === 3)).toBe(true);
|
||||||
|
expect(idSet(await search({ p_mechanic_ids: [ids.mechanicM1] }))).toEqual([ids.experte, ids.duo].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 4: mehrere Genres bzw. Filter sind UND-verknüpft', async () => {
|
||||||
|
expect(idSet(await search({ p_genre_ids: [ids.genreA, ids.genreB] }))).toEqual([ids.experte]);
|
||||||
|
expect(idSet(await search({ p_genre_ids: [ids.genreA], p_mechanic_ids: [ids.mechanicM1] }))).toEqual(
|
||||||
|
[ids.experte, ids.duo].sort(),
|
||||||
|
);
|
||||||
|
// Doppelte IDs zaehlen einfach.
|
||||||
|
expect(idSet(await search({ p_genre_ids: [ids.genreA, ids.genreA] }))).toEqual(genreA);
|
||||||
|
// Unbekannte Genre-ID: keine Treffer.
|
||||||
|
expect(await search({ p_genre_ids: [ids.genreA, '00000000-0000-4000-8000-00000000dead'] })).toEqual([]);
|
||||||
|
// Suchbegriff UND Filter: der Begriff grenzt innerhalb des Genres ein ...
|
||||||
|
expect(idSet(await search({ p_query: 'wlpd duo', p_genre_ids: [ids.genreA] }))).toEqual([ids.duo]);
|
||||||
|
// ... und ein Begriff, der dem Filter widerspricht, ergibt nichts (Massenspiele haben das Genre nicht).
|
||||||
|
expect(await search({ p_query: 'wlpd massenspiel', p_genre_ids: [ids.genreA] })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 4: Spielerzahl, Spieldauer und Mindestalter', async () => {
|
||||||
|
const g = { p_genre_ids: [ids.genreA] };
|
||||||
|
expect(idSet(await search({ ...g, p_players: 5 }))).toEqual([ids.familie]);
|
||||||
|
expect(idSet(await search({ ...g, p_players: 1 }))).toEqual([ids.experte]);
|
||||||
|
expect(idSet(await search({ ...g, p_max_minutes: 45 }))).toEqual([ids.familie, ids.duo].sort());
|
||||||
|
expect(idSet(await search({ ...g, p_age: 6 }))).toEqual([ids.familie]);
|
||||||
|
expect(idSet(await search({ ...g, p_age: 8 }))).toEqual([ids.familie, ids.duo].sort());
|
||||||
|
expect(await search({ ...g, p_players: 12 })).toEqual([]);
|
||||||
|
// Alle Filter zusammen.
|
||||||
|
expect(idSet(await search({ ...g, p_players: 2, p_max_minutes: 30, p_age: 8 }))).toEqual([ids.duo]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KAT-01 Kriterium 5: Nachladen (api)', () => {
|
||||||
|
test('KAT-01 Kriterium 5: zwei Seiten ergeben die Gesamtmenge ohne Überschneidung, total_count bleibt gleich, Reihenfolge stabil', async () => {
|
||||||
|
const q = { p_query: 'wlpd massenspiel' };
|
||||||
|
const first = await search({ ...q, p_limit: 30, p_offset: 0 });
|
||||||
|
const second = await search({ ...q, p_limit: 30, p_offset: 30 });
|
||||||
|
expect(first).toHaveLength(30);
|
||||||
|
expect(second).toHaveLength(MASS_COUNT - 30);
|
||||||
|
expect([...first, ...second].every((r) => r.total_count === MASS_COUNT)).toBe(true);
|
||||||
|
const all = [...first, ...second];
|
||||||
|
expect(new Set(all.map((r) => r.id)).size).toBe(MASS_COUNT);
|
||||||
|
expect(titlesOf(all)).toEqual(ds.mass.map((m) => m.title));
|
||||||
|
// Wiederholung liefert dieselbe Reihenfolge.
|
||||||
|
expect(titlesOf(await search({ ...q, p_limit: 30, p_offset: 0 }))).toEqual(titlesOf(first));
|
||||||
|
// Standard-Seitengroesse 30.
|
||||||
|
expect(await search(q)).toHaveLength(30);
|
||||||
|
// Hinter dem Ende: leer.
|
||||||
|
expect(await search({ ...q, p_offset: 60 })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KAT-01 Eingabegrenzen: Fehlerfälle über Kong (api)', () => {
|
||||||
|
const expectP0001 = (res: Res<any>, message: string) => {
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBe(400);
|
||||||
|
expect(res.body.code).toBe('P0001');
|
||||||
|
expect(res.body.message).toBe(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('KAT-01: Suchbegriff über 100 Zeichen oder über 10 Wörter → search_query_too_long', async () => {
|
||||||
|
expectP0001(await rpc({ p_query: 'a'.repeat(101) }), 'search_query_too_long');
|
||||||
|
expectP0001(await rpc({ p_query: 'aa bb cc dd ee ff gg hh ii jj kk' }), 'search_query_too_long');
|
||||||
|
// Genau an der Grenze: 100 Zeichen und 10 Woerter sind zulaessig.
|
||||||
|
expect((await rpc({ p_query: 'a'.repeat(100) })).status).toBe(200);
|
||||||
|
expect((await rpc({ p_query: 'aa bb cc dd ee ff gg hh ii jj' })).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01: Filter außerhalb des Bereichs → search_filter_invalid', async () => {
|
||||||
|
const many = Array.from({ length: 21 }, (_, i) => `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`);
|
||||||
|
for (const params of [
|
||||||
|
{ p_players: 0 },
|
||||||
|
{ p_players: 100 },
|
||||||
|
{ p_max_minutes: 0 },
|
||||||
|
{ p_max_minutes: 1441 },
|
||||||
|
{ p_age: -1 },
|
||||||
|
{ p_age: 100 },
|
||||||
|
{ p_genre_ids: many },
|
||||||
|
{ p_mechanic_ids: many },
|
||||||
|
]) {
|
||||||
|
expectP0001(await rpc(params), 'search_filter_invalid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01: Seitengröße oder Versatz außerhalb des Bereichs → search_paging_invalid', async () => {
|
||||||
|
for (const params of [{ p_limit: 0 }, { p_limit: 51 }, { p_offset: -1 }, { p_offset: 10_001 }]) {
|
||||||
|
expectP0001(await rpc(params), 'search_paging_invalid');
|
||||||
|
}
|
||||||
|
expect((await rpc({ p_limit: 50, p_offset: 10_000 })).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01: smallint außerhalb des Typbereichs scheitert mit 22003, eine falsche UUID mit 22P02 (kein P0001)', async () => {
|
||||||
|
const overflow = await rpc({ p_players: 40_000 });
|
||||||
|
expect(overflow.status).toBe(400);
|
||||||
|
expect(overflow.body.code).toBe('22003');
|
||||||
|
const badUuid = await rpc({ p_genre_ids: ['kein-uuid'] });
|
||||||
|
expect(badUuid.status).toBe(400);
|
||||||
|
expect(badUuid.body.code).toBe('22P02');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Sicherheit Kriterium 2: service_role darf die RPC nicht ausführen (42501), authenticated darf', async () => {
|
||||||
|
// (Ob Kong Aufrufe ganz ohne apikey ablehnt, ist Sache der Gateway-Konfiguration: die lokale CLI laesst sie als anon zu.)
|
||||||
|
expect(await search({ p_query: 'wlpd duo' }, { token: ownerToken })).toHaveLength(1);
|
||||||
|
const service = await rpc({ p_query: 'wlpd duo' }, { apikey: env.serviceRoleKey });
|
||||||
|
expect([401, 403]).toContain(service.status);
|
||||||
|
expect(service.body.code).toBe('42501');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Sicherheit Kriterium 2: anon und authenticated können alternative Titel nicht schreiben', async () => {
|
||||||
|
for (const bearer of [env.anonKey, ownerToken]) {
|
||||||
|
const res = await fetch(`${env.supabaseUrl}/rest/v1/game_alt_titles`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
apikey: env.anonKey,
|
||||||
|
authorization: `Bearer ${bearer}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ game_id: ids.duo, locale: 'de', title: `${ds.W} Eingeschleust` }),
|
||||||
|
});
|
||||||
|
expect([401, 403]).toContain(res.status);
|
||||||
|
expect((await res.json()).code).toBe('42501');
|
||||||
|
}
|
||||||
|
expect(await search({ p_query: 'wlpd eingeschleust' })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nach dem Lauf bleibt nichts zurueck: Das Aufraeumen wird selbst geprueft. Dieser zweite afterAll-Hook
|
||||||
|
// laeuft NACH dem ersten (Hooks derselben Datei laufen in Definitionsreihenfolge).
|
||||||
|
test.afterAll(async () => {
|
||||||
|
const left = await withDb(async (c) => {
|
||||||
|
const like = `${ds.prefix}-%`;
|
||||||
|
const q = async (table: string) =>
|
||||||
|
Number((await c.query(`select count(*) from public.${table} where id::text like $1`, [like])).rows[0].count);
|
||||||
|
return {
|
||||||
|
games: await q('games'),
|
||||||
|
publishers: await q('publishers'),
|
||||||
|
genres: await q('genres'),
|
||||||
|
mechanics: await q('mechanics'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(left).toEqual({ games: 0, publishers: 0, genres: 0, mechanics: 0 });
|
||||||
|
});
|
||||||
280
e2e/tests/perf/kat01-suche-latenz.spec.ts
Normal file
280
e2e/tests/perf/kat01-suche-latenz.spec.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
/**
|
||||||
|
* KAT-01 Kriterium 7 (Latenz der Suche), Projekt `perf`. Spec docs/specs/3-katalog-suche.md § 7.3.
|
||||||
|
*
|
||||||
|
* Misst in der gebauten Web-App mit einem mobilen 4G-Profil (Chrome-DevTools-Drosselung: 100 ms
|
||||||
|
* Round-Trip, 10 Mbit/s), wie lange es vom LETZTEN Tastendruck (inklusive Entprellung 300 ms) bis zur
|
||||||
|
* ersten sichtbaren Trefferkachel (bzw. zum Leerzustand bei absichtlich leeren Suchen) dauert.
|
||||||
|
*
|
||||||
|
* NUR LESEND: kein Datenbankzugriff, keine Anmeldung, nur der oeffentliche Anon-Schluessel. Importiert
|
||||||
|
* bewusst nichts aus support/auth-config.ts, damit der Lauf auch gegen eine fremde Umgebung moeglich
|
||||||
|
* ist (spaeter Staging). Die Suchliste entsteht aus dem tatsaechlichen Katalog:
|
||||||
|
* - Fuer jede Suche wird der erwartete erste Treffer ZUERST ungedrosselt bei der RPC erfragt (nur
|
||||||
|
* lesend, gleicher Aufruf wie die App) und danach in der App abgewartet. So ist die Liste
|
||||||
|
* selbstpruefend und unabhaengig von Datenstand und Relevanzregeln.
|
||||||
|
* - Zwischen zwei Messungen wird das Feld geleert; gemessen wird erst, wenn der ganze Katalog wieder
|
||||||
|
* sichtbar ist (die alten Treffer koennen die Messung so nie vorzeitig beenden).
|
||||||
|
*
|
||||||
|
* Aufruf (lokal; Daten siehe supabase/perf/catalog_search_perf_load.sql):
|
||||||
|
* PERF_WEB_URL=http://localhost:3000 PERF_SUPABASE_URL=http://127.0.0.1:54321 PERF_ANON_KEY=<anon> \
|
||||||
|
* PERF_ONLY=1 npx playwright test --project=perf
|
||||||
|
* Das Web-Bundle muss gegen dieselbe Supabase-URL gebaut sein (--dart-define-from-file).
|
||||||
|
*/
|
||||||
|
import { expect, test, type CDPSession, type Page } from '@playwright/test';
|
||||||
|
import { enableSemantics, fillField } from '../../support/flutter';
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.PERF_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
||||||
|
const anonKey = process.env.PERF_ANON_KEY ?? '';
|
||||||
|
const SEARCHES = Number(process.env.PERF_SEARCHES ?? 110);
|
||||||
|
/** Grenze aus KAT-01 Kriterium 7: 95 % der Suchen liefern die ersten Treffer in hoechstens 2 Sekunden. */
|
||||||
|
const P95_LIMIT_MS = 2000;
|
||||||
|
/** Mobiles 4G-Profil (Spec 7.3): 100 ms Round-Trip, 10 Mbit/s = 1 250 000 Byte/s. PERF_LATENCY_MS nur zur Gegenprobe des Gates. */
|
||||||
|
const NETWORK = { offline: false, latency: Number(process.env.PERF_LATENCY_MS ?? 100), downloadThroughput: 1_250_000, uploadThroughput: 1_250_000 };
|
||||||
|
const MEASURE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
title: string;
|
||||||
|
publisher_names: string[];
|
||||||
|
total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpc(params: Record<string, unknown>): Promise<Row[]> {
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/search_catalog_games`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', apikey: anonKey, authorization: `Bearer ${anonKey}` },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
if (res.status !== 200) throw new Error(`RPC ${res.status}: ${await res.text()}`);
|
||||||
|
return (await res.json()) as Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sample {
|
||||||
|
kind: string;
|
||||||
|
query: string;
|
||||||
|
/** Erwarteter Titel der ersten Kachel; null = Leerzustand erwartet. */
|
||||||
|
expectedFirst: string | null;
|
||||||
|
ms: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plan = { kind: string; query: string };
|
||||||
|
|
||||||
|
const words = (title: string) => title.split(/\s+/).filter((w) => /^[\p{L}\p{N}]{3,}$/u.test(w));
|
||||||
|
|
||||||
|
/** Deterministische Suchliste aus dem Katalogbestand (feste Schrittweite, keine Zufallszahlen). */
|
||||||
|
async function buildPlan(count: number): Promise<{ plan: Plan[]; first: string; visibleAtStart: Set<string> }> {
|
||||||
|
const rows: Row[] = [];
|
||||||
|
for (let offset = 0; offset < 600; offset += 50) {
|
||||||
|
const page = await rpc({ p_limit: 50, p_offset: offset });
|
||||||
|
rows.push(...page);
|
||||||
|
if (page.length < 50) break;
|
||||||
|
}
|
||||||
|
expect(rows.length, 'der Katalog muss mindestens 50 Spiele enthalten').toBeGreaterThanOrEqual(50);
|
||||||
|
// Reserve von 6 fuer Suchen, die uebersprungen werden (erster Treffer = Katalogbeginn); 5 leere, bis 12 alternative Titel.
|
||||||
|
const want = Math.max(count, 100) + 6;
|
||||||
|
const plan: Plan[] = [];
|
||||||
|
const stride = Math.max(1, Math.floor(rows.length / count));
|
||||||
|
const kinds = ['Titel', 'Titelanfang', 'Wort', 'Wortkombination', 'Verlag', 'GROSSBUCHSTABEN', 'alternativer Titel'];
|
||||||
|
for (let i = 0, k = 0; plan.length < want - 5 - 12 && i < rows.length * 4; i += stride, k++) {
|
||||||
|
const row = rows[(i * 7 + k) % rows.length];
|
||||||
|
const w = words(row.title);
|
||||||
|
const kind = kinds[k % kinds.length];
|
||||||
|
let query: string | null = null;
|
||||||
|
switch (kind) {
|
||||||
|
case 'Titel':
|
||||||
|
query = row.title;
|
||||||
|
break;
|
||||||
|
case 'Titelanfang':
|
||||||
|
query = row.title.slice(0, Math.max(2, Math.min(5, row.title.length)));
|
||||||
|
break;
|
||||||
|
case 'Wort':
|
||||||
|
query = w[w.length - 1] ?? null;
|
||||||
|
break;
|
||||||
|
case 'Wortkombination':
|
||||||
|
query = w.length >= 2 ? `${w[0]} ${w[1]}` : null;
|
||||||
|
break;
|
||||||
|
case 'Verlag':
|
||||||
|
query = row.publisher_names[0] ? (words(row.publisher_names[0])[0] ?? null) : null;
|
||||||
|
break;
|
||||||
|
case 'GROSSBUCHSTABEN':
|
||||||
|
query = (w[0] ?? row.title).toUpperCase();
|
||||||
|
break;
|
||||||
|
case 'alternativer Titel':
|
||||||
|
query = null; // unten aus der Tabelle der alternativen Titel
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (query && !plan.some((p) => p.query === query)) plan.push({ kind, query });
|
||||||
|
}
|
||||||
|
// Alternative Titel (lesbar fuer anon): jeder 7. Eintrag.
|
||||||
|
const alt = await fetch(`${supabaseUrl}/rest/v1/game_alt_titles?select=title&order=title&limit=400`, {
|
||||||
|
headers: { apikey: anonKey, authorization: `Bearer ${anonKey}` },
|
||||||
|
});
|
||||||
|
const altTitles = alt.status === 200 ? ((await alt.json()) as { title: string }[]).map((r) => r.title) : [];
|
||||||
|
for (let i = 0; i < altTitles.length && plan.filter((p) => p.kind === 'alternativer Titel').length < 12; i += 7) {
|
||||||
|
if (!plan.some((p) => p.query === altTitles[i])) plan.push({ kind: 'alternativer Titel', query: altTitles[i] });
|
||||||
|
}
|
||||||
|
// Absichtlich leere Suchen (Leerzustand), 5 Stueck, bleiben nach dem Kuerzen erhalten.
|
||||||
|
const empty: Plan[] = ['qqzzxx', 'zzyyxk', 'kkqqww', 'xxvvzz', 'wwqqkk'].map((query) => ({ kind: 'ohne Treffer', query }));
|
||||||
|
expect(plan.length + empty.length, 'Suchliste zu kurz').toBeGreaterThanOrEqual(want);
|
||||||
|
return { plan: [...plan.slice(0, want - empty.length), ...empty], first: rows[0].title, visibleAtStart: new Set(rows.slice(0, 30).map((r) => r.title)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installiert (einmal je Seite) den letzten-Tastendruck-Zeitstempel und die Messfunktion im Browser.
|
||||||
|
* Im Browser gemessen, damit weder Playwright-Abfrageintervalle noch die Protokolllatenz in die Zahl
|
||||||
|
* eingehen. `performance.now()` ist in Browser und Beobachter dieselbe Uhr.
|
||||||
|
*/
|
||||||
|
async function installProbe(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const w = window as any;
|
||||||
|
w.__lastKey = 0;
|
||||||
|
window.addEventListener('keydown', () => (w.__lastKey = performance.now()), true);
|
||||||
|
// Wartet auf die erste Kachel mit diesem Titel (Textknoten beginnt mit "Titel,") oder, bei expected=null,
|
||||||
|
// auf den Leerzustand ("No games found for"). Liefert den Zeitstempel zurueck.
|
||||||
|
w.__arm = (expected: string | null, mode: 'key' | 'now') =>
|
||||||
|
new Promise<number>((resolve, reject) => {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const found = () => {
|
||||||
|
const spans = document.querySelectorAll('flt-semantics span');
|
||||||
|
for (const s of Array.from(spans)) {
|
||||||
|
const text = s.textContent ?? '';
|
||||||
|
if (expected === null ? text.startsWith('No games found for') : text === expected || text.startsWith(expected + ',')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const done = () => {
|
||||||
|
const t = performance.now();
|
||||||
|
obs.disconnect();
|
||||||
|
resolve(mode === 'key' ? t - w.__lastKey : t - startedAt);
|
||||||
|
};
|
||||||
|
const obs = new MutationObserver(() => {
|
||||||
|
if (found()) done();
|
||||||
|
});
|
||||||
|
obs.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true });
|
||||||
|
setTimeout(() => {
|
||||||
|
obs.disconnect();
|
||||||
|
reject(new Error(`Zeitueberschreitung beim Warten auf ${expected ?? 'Leerzustand'}`));
|
||||||
|
}, 15_000);
|
||||||
|
if (found()) done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentile = (sorted: number[], p: number) => sorted[Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)];
|
||||||
|
|
||||||
|
test.describe('KAT-01 Kriterium 7: Latenz der Suche im mobilen 4G-Profil (perf)', () => {
|
||||||
|
test.skip(!anonKey, 'PERF_ANON_KEY nicht gesetzt (oeffentlicher Anon-Schluessel der Zielumgebung)');
|
||||||
|
|
||||||
|
test(`KAT-01 Kriterium 7: p95 der ersten Treffer <= ${P95_LIMIT_MS} ms bei ${SEARCHES} Suchen (vorlaeufig, ohne Staging)`, async ({ page, context }, testInfo) => {
|
||||||
|
const { plan, first, visibleAtStart } = await buildPlan(SEARCHES);
|
||||||
|
await installProbe(page);
|
||||||
|
|
||||||
|
// 1. App laden (ungedrosselt: Das Bundle zaehlt nicht), Semantics einschalten.
|
||||||
|
await page.goto('/');
|
||||||
|
await enableSemantics(page);
|
||||||
|
const cdp: CDPSession = await context.newCDPSession(page);
|
||||||
|
await cdp.send('Network.enable');
|
||||||
|
|
||||||
|
// 2. Drosseln und die Suche oeffnen: Die erste Suche der App (ganzer Katalog, neue Verbindung zum
|
||||||
|
// Gateway) zaehlt mit; gemessen ab dem Klick.
|
||||||
|
await cdp.send('Network.emulateNetworkConditions', NETWORK);
|
||||||
|
const samples: Sample[] = [];
|
||||||
|
const armFirst = page.evaluate(([t]) => (window as any).__arm(t, 'now'), [first] as const);
|
||||||
|
await page.getByRole('button', { name: 'Search games' }).click();
|
||||||
|
const firstMs = await armFirst;
|
||||||
|
samples.push({ kind: 'Erststart (Katalog)', query: '', expectedFirst: first, ms: firstMs, total: 0 });
|
||||||
|
|
||||||
|
const field = page.getByRole('textbox', { name: /Search games/ });
|
||||||
|
await expect(field).toBeVisible();
|
||||||
|
|
||||||
|
// 3. Suchen. Zwischen den Messungen: Feld leeren (der ganze Katalog erscheint wieder), dann tippen.
|
||||||
|
let skipped = 0;
|
||||||
|
for (const { kind, query } of plan) {
|
||||||
|
const hits = await rpc({ p_query: query, p_limit: 30 });
|
||||||
|
const expectedFirst = hits.length > 0 ? hits[0].title : null;
|
||||||
|
// Zustand zuruecksetzen: ungemessen warten, bis der Katalogbeginn wieder sichtbar ist.
|
||||||
|
// Der Katalogbeginn (erste 30 Titel) ist nach dem Leeren sichtbar (virtualisierte Liste: weit weniger als 30
|
||||||
|
// Kacheln sind im Baum, 30 ist konservativ). Ein erwarteter Titel daraus koennte die Messung vorzeitig beenden.
|
||||||
|
if (expectedFirst !== null && visibleAtStart.has(expectedFirst)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await fillField(field, '');
|
||||||
|
await page.evaluate(([t]) => (window as any).__arm(t, 'now'), [first] as const);
|
||||||
|
// Messen: erst scharf schalten, dann tippen; Ende der Zeit = erste passende Kachel.
|
||||||
|
const armed = page.evaluate(([t]) => (window as any).__arm(t, 'key'), [expectedFirst] as const);
|
||||||
|
await field.pressSequentially(query, { delay: 15 });
|
||||||
|
const ms = await armed;
|
||||||
|
samples.push({ kind, query, expectedFirst, ms, total: hits[0]?.total_count ?? 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3b. Nur Filter (Spec 7.3): Genre-Chip wählen (ungemessen, bis die Trefferzahl steht), dann Mechanik-Chip: gemessen
|
||||||
|
// von (kurz vor) dem Klick auf den zweiten Chip bis zur ersten Kachel der kombinierten Filter. Danach beide abwählen.
|
||||||
|
// Der erste Treffer ohne Suchbegriff ist alphabetisch und liegt oft im Katalogbeginn; nur Kombinationen, deren erster
|
||||||
|
// Treffer weder dort noch im Genre-Zwischenergebnis steht, taugen (sonst könnten sichtbare Kacheln die Messung beenden).
|
||||||
|
await fillField(field, ''); // die letzte Textsuche beenden
|
||||||
|
await page.evaluate(([t]) => (window as any).__arm(t, 'now'), [first] as const);
|
||||||
|
const rest = (path: string) =>
|
||||||
|
fetch(`${supabaseUrl}/rest/v1/${path}`, { headers: { apikey: anonKey, authorization: `Bearer ${anonKey}` } }).then(
|
||||||
|
(r) => r.json() as Promise<{ id: string; name_en: string }[]>,
|
||||||
|
);
|
||||||
|
const genres = await rest('genres?select=id,name_en&order=name_en');
|
||||||
|
const mechanics = await rest('mechanics?select=id,name_en&order=name_en');
|
||||||
|
let filterRuns = 0;
|
||||||
|
outer: for (const g of genres) {
|
||||||
|
const hitsG = await rpc({ p_genre_ids: [g.id], p_limit: 30 });
|
||||||
|
if (hitsG.length === 0) continue;
|
||||||
|
for (const m of mechanics) {
|
||||||
|
if (filterRuns >= 6) break outer;
|
||||||
|
const hitsGM = await rpc({ p_genre_ids: [g.id], p_mechanic_ids: [m.id], p_limit: 30 });
|
||||||
|
if (hitsGM.length === 0 || visibleAtStart.has(hitsGM[0].title) || hitsG.some((r) => r.title === hitsGM[0].title)) continue;
|
||||||
|
await page.getByRole('checkbox', { name: g.name_en, exact: true }).click();
|
||||||
|
const n = hitsG[0].total_count;
|
||||||
|
await expect(page.getByText(n === 1 ? '1 game' : `${n.toLocaleString("en-US")} games`, { exact: true })).toBeVisible();
|
||||||
|
const armed = page.evaluate(([t]) => (window as any).__arm(t, 'now'), [hitsGM[0].title] as const);
|
||||||
|
await page.getByRole('checkbox', { name: m.name_en, exact: true }).click();
|
||||||
|
samples.push({ kind: 'nur Filter (Genre+Mechanik)', query: `${g.name_en} + ${m.name_en}`, expectedFirst: hitsGM[0].title, ms: await armed, total: hitsGM[0].total_count });
|
||||||
|
await page.getByRole('checkbox', { name: m.name_en, exact: true }).click();
|
||||||
|
await page.getByRole('checkbox', { name: g.name_en, exact: true }).click();
|
||||||
|
await page.evaluate(([t]) => (window as any).__arm(t, 'now'), [first] as const);
|
||||||
|
filterRuns++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Auswertung.
|
||||||
|
const sorted = samples.map((s) => s.ms).sort((a, b) => a - b);
|
||||||
|
const result = {
|
||||||
|
profile: NETWORK,
|
||||||
|
searches: samples.length,
|
||||||
|
skipped_searches: skipped,
|
||||||
|
p50_ms: Math.round(percentile(sorted, 50)),
|
||||||
|
p95_ms: Math.round(percentile(sorted, 95)),
|
||||||
|
max_ms: Math.round(sorted[sorted.length - 1]),
|
||||||
|
min_ms: Math.round(sorted[0]),
|
||||||
|
erststart_ms: Math.round(firstMs),
|
||||||
|
catalog_size: (await rpc({ p_limit: 1 }))[0]?.total_count,
|
||||||
|
by_kind: Object.fromEntries(
|
||||||
|
[...new Set(samples.map((s) => s.kind))].map((k) => {
|
||||||
|
const v = samples.filter((s) => s.kind === k).map((s) => s.ms).sort((a, b) => a - b);
|
||||||
|
return [k, { n: v.length, p50_ms: Math.round(percentile(v, 50)), max_ms: Math.round(v[v.length - 1]) }];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
samples: samples.map((s) => ({ ...s, ms: Math.round(s.ms) })),
|
||||||
|
};
|
||||||
|
await testInfo.attach('kat01-latenz.json', { body: JSON.stringify(result, null, 2), contentType: 'application/json' });
|
||||||
|
const { samples: _omit, ...summary } = result;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('KAT-01 Kriterium 7, Messung:\n' + JSON.stringify(summary, null, 2));
|
||||||
|
|
||||||
|
expect(samples.length, 'mindestens 100 Suchen').toBeGreaterThanOrEqual(100);
|
||||||
|
// Plausibilitaet: ein Messwert unter der physikalischen Untergrenze heisst, die Kachel war schon vorher sichtbar.
|
||||||
|
// Tippen: 300 ms Entprellung; Klick auf einen Filter: mindestens eine Round-Trip-Zeit.
|
||||||
|
const typed = samples.filter((s) => !s.kind.startsWith('nur Filter') && !s.kind.startsWith('Erststart'));
|
||||||
|
const filtered = samples.filter((s) => s.kind.startsWith('nur Filter'));
|
||||||
|
expect(Math.min(...typed.map((s) => s.ms)), 'getippte Suchen >= 300 ms').toBeGreaterThanOrEqual(300);
|
||||||
|
expect(filtered.length, 'Filtersuchen gemessen').toBeGreaterThanOrEqual(1);
|
||||||
|
expect(Math.min(...filtered.map((s) => s.ms)), 'Filtersuchen >= eine Round-Trip-Zeit').toBeGreaterThanOrEqual(NETWORK.latency * 0.9);
|
||||||
|
expect(result.p95_ms, `p95 ${result.p95_ms} ms`).toBeLessThanOrEqual(P95_LIMIT_MS);
|
||||||
|
});
|
||||||
|
});
|
||||||
335
e2e/tests/web/kat01-suche.spec.ts
Normal file
335
e2e/tests/web/kat01-suche.spec.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
/**
|
||||||
|
* KAT-01 Spiele suchen (Issue #3, Spec docs/specs/3-katalog-suche.md § 7.2), Projekt `web`.
|
||||||
|
*
|
||||||
|
* Alle Tests laufen OHNE Anmeldung (Sicherheit Krit. 2) gegen die gebaute Web-App und den lokalen
|
||||||
|
* Stack. Testdaten: eigenes Datenset mit Kunstwort "zqvk" (e2e/support/catalog.ts), Anlage im
|
||||||
|
* `beforeAll`, Aufraeumen im `afterAll`; die Datei laeuft seriell, die Daten werden gemeinsam
|
||||||
|
* genutzt. Texte stammen aus app_en.arb (Browser-Sprache en-US).
|
||||||
|
*
|
||||||
|
* Wartet nie auf feste Zeiten: Das Ende einer Suche ist an der erwarteten Trefferzahl UND einem
|
||||||
|
* erwarteten Treffer erkennbar (waehrend einer neuen Suche bleiben die alten Treffer sichtbar).
|
||||||
|
* Nicht abgedeckt (Widget-Tests in 3b bzw. manuell): Weitere laden per Tastatur (Auto-Nachladen
|
||||||
|
* ueberholt die Schaltflaeche), Bildschirmleser-Ansage, Textskalierung 200 %.
|
||||||
|
*/
|
||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { catalogDataset, cleanupCatalogDataset, MASS_COUNT, seedCatalog } from '../../support/catalog';
|
||||||
|
import { enableSemantics, fillField, open } from '../../support/flutter';
|
||||||
|
|
||||||
|
const ds = catalogDataset({ prefix: 'e2ec0001', word: 'zqvk' });
|
||||||
|
const { ids, titles: t } = ds;
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await seedCatalog(ds);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanupCatalogDataset(ds);
|
||||||
|
});
|
||||||
|
|
||||||
|
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
const searchField = (page: Page) => page.getByRole('textbox', { name: /Search games/ });
|
||||||
|
/**
|
||||||
|
* Trefferkachel: Im Semantics-Baum ein Textknoten "Titel, Jahr · Verlage, Angaben, auch: ...".
|
||||||
|
* (Die Kacheln liegen als Kinder EINES Listen-Containers, ein Rollen-Locator je Kachel gibt es nicht.)
|
||||||
|
* Die Liste ist virtualisiert: nur sichtbare und knapp daneben liegende Kacheln sind im Baum.
|
||||||
|
*/
|
||||||
|
const tile = (page: Page, title: string) => page.getByText(new RegExp(`^${escapeRe(title)}(,|$)`)).first();
|
||||||
|
const count = (page: Page, n: number) => page.getByText(n === 1 ? '1 game' : `${n} games`, { exact: true });
|
||||||
|
|
||||||
|
/** Oeffnet die Suche ueber die Schaltflaeche auf der Startseite (ohne Anmeldung). */
|
||||||
|
async function openSearchViaButton(page: Page): Promise<void> {
|
||||||
|
await open(page, '/');
|
||||||
|
await page.getByRole('button', { name: 'Search games' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/catalog/);
|
||||||
|
await expect(searchField(page)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oeffnet die Suche wie ein Nutzer (Startseite, Schaltflaeche) und wartet, bis die erste Suche
|
||||||
|
* (ganzer Katalog) abgeschlossen ist. Ein direkt geladenes /catalog nimmt Text, der ueber den erst
|
||||||
|
* danach eingeschalteten Semantics-Baum getippt wird, nicht verlaesslich an (Testwerkzeug-Effekt:
|
||||||
|
* mit Mausklick und Tastatur ohne Semantics funktioniert es); deshalb tippen die Tests nur nach
|
||||||
|
* diesem Einstieg. Deep Links werden ohne Tippen geprueft.
|
||||||
|
*/
|
||||||
|
async function openCatalog(page: Page): Promise<void> {
|
||||||
|
await openSearchViaButton(page);
|
||||||
|
await expect(textCount(page)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tippt einen Suchbegriff (ersetzt den alten); die Entprellung (300 ms) wird ueber das Erwartete abgewartet. */
|
||||||
|
async function search(page: Page, text: string): Promise<void> {
|
||||||
|
await fillField(searchField(page), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auswahlfelder der Filter in fester Reihenfolge; ihr Name ist "Any" oder der gewaehlte Wert. */
|
||||||
|
const dropdownIndex = { players: 0, duration: 1, age: 2 } as const;
|
||||||
|
async function choose(page: Page, field: keyof typeof dropdownIndex, option: string): Promise<void> {
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /^(Any|\d+ players?|Up to \d+ min|Suitable from age \d+)$/ })
|
||||||
|
.nth(dropdownIndex[field])
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: option, exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Kacheln zu einem Titel (ohne .first()), zum Zaehlen. */
|
||||||
|
const tiles = (page: Page, title: string) => page.getByText(new RegExp(`^${escapeRe(title)}(,|$)`));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hinweistext im Semantics-Baum. Nur `span`: Dieselben Texte stehen zusaetzlich in der
|
||||||
|
* Live-Region `flt-announcement-polite` (Ansage), die nach dem Verschwinden noch kurz bleibt.
|
||||||
|
*/
|
||||||
|
const message = (page: Page, text: string) => page.locator('span', { hasText: text });
|
||||||
|
|
||||||
|
const chip = (page: Page, name: string) => page.getByRole('checkbox', { name, exact: true });
|
||||||
|
|
||||||
|
test.describe('KAT-01 Kriterium 1: Titelsuche (web)', () => {
|
||||||
|
test('KAT-01 Kriterium 1: Suche per Schaltfläche „Search games“, Teil eines Titels in anderer Schreibweise findet das Spiel', async ({ page }) => {
|
||||||
|
await openSearchViaButton(page);
|
||||||
|
// Gross/Klein, Bindestrich statt Leerzeichen, ohne Akzente und Umlaute, ohne ®.
|
||||||
|
await search(page, 'ZQVK CAFE-GROSSE duel');
|
||||||
|
await expect(tile(page, t.cafe)).toBeVisible();
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
// Nur ein Teil des Titels.
|
||||||
|
await search(page, 'zqvk dunkel');
|
||||||
|
await expect(tile(page, t.wald)).toBeVisible();
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
await expect(tile(page, t.cafe)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 1: Treffer sind nach Relevanz sortiert (genau, beginnt mit, Wortanfang, enthält, nur über Verlag)', async ({ page }) => {
|
||||||
|
await openCatalog(page);
|
||||||
|
await search(page, 'zqvk relevanz');
|
||||||
|
await expect(count(page, 5)).toBeVisible();
|
||||||
|
await expect(tile(page, t.relPublisher)).toBeVisible();
|
||||||
|
// Textknoten der Kacheln in Dokumentreihenfolge (= Listenreihenfolge).
|
||||||
|
const shown = (await page.getByText(/^(Die |X)?Zqvk (Relevanz|Fremdtitel)/).allTextContents()).map(
|
||||||
|
(label) => label.split(',')[0],
|
||||||
|
);
|
||||||
|
expect(shown, 'Reihenfolge nach Relevanz').toEqual([t.relExact, t.relBegins, t.relWord, t.relContains, t.relPublisher]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 1 / KAT-05 Kriterium 4: ausgeblendete und zusammengeführte Spiele erscheinen nicht, auch nicht über alternative Titel', async ({ page }) => {
|
||||||
|
await openCatalog(page);
|
||||||
|
// Je Suche die genaue Meldung abwarten: Die Meldung der vorigen Suche steht bis zur neuen Antwort noch im Baum.
|
||||||
|
for (const q of ['zqvk verborgen', 'zqvk verstecktername', 'zqvk zusammengefuehrt']) {
|
||||||
|
await search(page, q);
|
||||||
|
await expect(message(page, `No games found for “${q}”.`)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KAT-01 Kriterium 2: alternative Titel (web)', () => {
|
||||||
|
test('KAT-01 Kriterium 2: deutscher und englischer alternativer Titel finden dasselbe Spiel, die Kachel nennt den Treffer-Titel', async ({ page }) => {
|
||||||
|
await openCatalog(page);
|
||||||
|
await search(page, 'zqvk dunkelforst');
|
||||||
|
await expect(tile(page, t.wald)).toBeVisible();
|
||||||
|
await expect(tile(page, t.wald)).toHaveText(new RegExp(`also: ${escapeRe(t.waldAltDe)}`));
|
||||||
|
await search(page, 'zqvk shadowwood');
|
||||||
|
await expect(tile(page, t.wald)).toHaveText(new RegExp(`also: ${escapeRe(t.waldAltEn)}`));
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KAT-01 Kriterium 3: Verlagssuche (web)', () => {
|
||||||
|
test('KAT-01 Kriterium 3: Verlagsname findet dessen Spiele; ein Spiel mit zwei Verlagen nennt beide', async ({ page }) => {
|
||||||
|
await openCatalog(page);
|
||||||
|
await search(page, 'zqvk quarzfeld');
|
||||||
|
await expect(count(page, 2)).toBeVisible();
|
||||||
|
await expect(tile(page, t.rot)).toBeVisible();
|
||||||
|
await expect(tile(page, t.blau)).toBeVisible();
|
||||||
|
// Sortiert nach normalisiertem Namen: "nebelkamm ..." vor "quarzfeld ...".
|
||||||
|
await expect(tile(page, t.blau)).toHaveText(
|
||||||
|
new RegExp(`${escapeRe(t.pubNebel)}, ${escapeRe(t.pubQuarz)}`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 3: der zweite Verlag eines Spiels findet es ebenfalls', async ({ page }) => {
|
||||||
|
await openCatalog(page);
|
||||||
|
await search(page, 'zqvk nebelkamm');
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
await expect(tile(page, t.blau)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KAT-01 Kriterium 4 und 8: Filter (web)', () => {
|
||||||
|
test('KAT-01 Kriterium 4: Filter Genre und Spielerzahl mit Suchbegriff zeigen nur passende Spiele; Zurücksetzen hebt sie auf', async ({ page }) => {
|
||||||
|
await openCatalog(page);
|
||||||
|
await search(page, 'zqvk');
|
||||||
|
// Das Genre A trägt Familienfest, Expertenrunde und Duo; "zqvk" allein trifft sehr viel mehr.
|
||||||
|
await expect(count(page, 48)).toBeVisible(); // 36 Massenspiele + 7 Einzelspiele + 5 Relevanzspiele
|
||||||
|
await chip(page, t.genreA).click();
|
||||||
|
await expect(count(page, 3)).toBeVisible();
|
||||||
|
await expect(tile(page, t.familie)).toBeVisible();
|
||||||
|
await expect(tile(page, t.experte)).toBeVisible();
|
||||||
|
await expect(tile(page, t.duo)).toBeVisible();
|
||||||
|
|
||||||
|
// Suchbegriff UND Filter: ein Begriff, der innerhalb des Genres eingrenzt ...
|
||||||
|
await search(page, 'zqvk duo');
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
await expect(tile(page, t.duo)).toBeVisible();
|
||||||
|
// ... und einer, der dem Filter widerspricht (Massenspiele haben das Genre nicht).
|
||||||
|
await search(page, 'zqvk massenspiel');
|
||||||
|
await expect(message(page, 'No games found for “zqvk massenspiel”.')).toBeVisible();
|
||||||
|
await search(page, 'zqvk');
|
||||||
|
await expect(count(page, 3)).toBeVisible();
|
||||||
|
|
||||||
|
// Spielerzahl 5: nur das Familienfest (3 bis 8).
|
||||||
|
await choose(page, 'players', '5 players');
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
await expect(tile(page, t.familie)).toBeVisible();
|
||||||
|
await expect(tile(page, t.duo)).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Reset filters' }).click();
|
||||||
|
await expect(count(page, 48)).toBeVisible(); // alle Spiele des Datensets wieder
|
||||||
|
// Der Suchbegriff bleibt erhalten (URL; das Semantics-Eingabefeld zeigt nach einem Auswahlmenue leer).
|
||||||
|
await expect(page).toHaveURL(/q=zqvk(&|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 4: Spieldauer und Alter schließen Spiele aus, die nicht passen', async ({ page }) => {
|
||||||
|
await open(page, `/catalog?genre=${ids.genreA}`);
|
||||||
|
await expect(count(page, 3)).toBeVisible();
|
||||||
|
// Bis 45 Minuten: Familienfest (max. 45) und Duo (30), nicht Expertenrunde (180).
|
||||||
|
await choose(page, 'duration', 'Up to 45 min');
|
||||||
|
await expect(count(page, 2)).toBeVisible();
|
||||||
|
await expect(tile(page, t.experte)).toHaveCount(0);
|
||||||
|
// Geeignet ab 6 Jahren: nur Familienfest (Duo ab 8).
|
||||||
|
await choose(page, 'age', 'Suitable from age 6');
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
await expect(tile(page, t.familie)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 8: nur Filter ohne Suchbegriff, mehrere Filter sind UND-verknüpft', async ({ page }) => {
|
||||||
|
await openCatalog(page);
|
||||||
|
await expect(searchField(page)).toHaveValue('');
|
||||||
|
// Genre A allein: drei Spiele des Datensets (eigene Genres, Startbestand trifft nie).
|
||||||
|
await chip(page, t.genreA).click();
|
||||||
|
await expect(count(page, 3)).toBeVisible();
|
||||||
|
// Genre A UND Mechanik 1: Expertenrunde und Duo.
|
||||||
|
await chip(page, t.mechanicM1).click();
|
||||||
|
await expect(count(page, 2)).toBeVisible();
|
||||||
|
await expect(tile(page, t.familie)).toHaveCount(0);
|
||||||
|
// Genre A UND Genre B: nur Expertenrunde (alle gewählten Genres, Spec-F1).
|
||||||
|
await chip(page, t.mechanicM1).click();
|
||||||
|
await chip(page, t.genreB).click();
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
await expect(tile(page, t.experte)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 8 / Plattformen: im mittleren Layout öffnet „Filters“ ein Sheet, „Done“ schließt es', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 800, height: 900 });
|
||||||
|
await openCatalog(page);
|
||||||
|
await page.getByRole('button', { name: /^Filters/ }).click();
|
||||||
|
// Hinter dem Sheet ist die Liste fuer Bildschirmleser verborgen; geprueft wird nach „Done“.
|
||||||
|
await chip(page, t.genreB).click();
|
||||||
|
await page.getByRole('button', { name: 'Done' }).click();
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
await expect(tile(page, t.experte)).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Filters (1)' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KAT-01 Kriterium 5: Nachladen (web)', () => {
|
||||||
|
test('KAT-01 Kriterium 5: Scrollen bis zum Ende lädt die zweite Seite (p_offset 30) nach', async ({ page }) => {
|
||||||
|
await openCatalog(page);
|
||||||
|
await search(page, 'zqvk massenspiel');
|
||||||
|
await expect(count(page, MASS_COUNT)).toBeVisible();
|
||||||
|
const last = ds.mass[MASS_COUNT - 1].title;
|
||||||
|
await expect(tile(page, ds.mass[0].title)).toBeVisible();
|
||||||
|
await expect(tile(page, last)).toHaveCount(0);
|
||||||
|
|
||||||
|
const secondPage = page.waitForRequest(
|
||||||
|
(r) => r.url().includes('/rest/v1/rpc/search_catalog_games') && /"p_offset"\s*:\s*30\b/.test(r.postData() ?? ''),
|
||||||
|
);
|
||||||
|
await page.mouse.move(640, 450);
|
||||||
|
await expect(async () => {
|
||||||
|
await page.mouse.wheel(0, 1500);
|
||||||
|
await expect(tile(page, last)).toBeVisible({ timeout: 1_000 });
|
||||||
|
}).toPass({ timeout: 20_000 });
|
||||||
|
await secondPage;
|
||||||
|
// Die Trefferzahl bleibt, kein Spiel doppelt: Kachel Nr. 36 genau einmal.
|
||||||
|
await expect(tiles(page, last)).toHaveCount(1);
|
||||||
|
await expect(count(page, MASS_COUNT)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KAT-01 Kriterium 6: keine Treffer (web)', () => {
|
||||||
|
test('KAT-01 Kriterium 6: Suche ohne Treffer zeigt den Hinweis mit dem Suchbegriff', async ({ page }) => {
|
||||||
|
await openCatalog(page);
|
||||||
|
await search(page, 'zqvkgibtesnicht');
|
||||||
|
await expect(message(page, 'No games found for “zqvkgibtesnicht”.')).toBeVisible();
|
||||||
|
await expect(message(page, 'Check the spelling or remove filters.')).toBeVisible();
|
||||||
|
// Die Aktion „Report a missing game“ wird mit #13 (KAT-04) freigeschaltet (Spec-F4, E11).
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 8: Filter ohne passendes Spiel zeigen den Filter-Leerzustand; „Reset filters“ stellt die Liste her', async ({ page }) => {
|
||||||
|
await open(page, `/catalog?genre=${ids.genreA},${ids.genreB}`);
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
await choose(page, 'players', '12 players');
|
||||||
|
await expect(message(page, 'No games match your filters.')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Reset filters' }).first().click();
|
||||||
|
// Positiv: Die Liste ist zurueck (ganzer Katalog, mehr als ein Spiel), der Leerzustand ist weg.
|
||||||
|
await expect(page.getByText(/^([2-9]|\d{2,}) games$/).first()).toBeVisible();
|
||||||
|
await expect(message(page, 'No games match your filters.')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KAT-01: URL-Zustand, Neuladen, Zurück (web)', () => {
|
||||||
|
test('KAT-01: Suchbegriff steht in der URL, Zurück führt in einem Schritt zur Startseite, Neuladen stellt den Stand her', async ({ page }) => {
|
||||||
|
await openSearchViaButton(page);
|
||||||
|
await search(page, 'zqvk rotkehlchen');
|
||||||
|
await expect(tile(page, t.rot)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/catalog\?(.*&)?q=zqvk(\+|%20)rotkehlchen(&|$)/);
|
||||||
|
|
||||||
|
// Ersetzt, nicht angehängt: ein Schritt zurück ist die Startseite.
|
||||||
|
await page.reload();
|
||||||
|
await enableSemantics(page);
|
||||||
|
// Das Semantics-Eingabefeld spiegelt den Anfangswert nicht (Wert "" ohne Fokus); der
|
||||||
|
// wiederhergestellte Stand zeigt sich an der Trefferliste und bleibt in der URL.
|
||||||
|
await expect(page).toHaveURL(/q=zqvk(\+|%20)rotkehlchen/);
|
||||||
|
await expect(tile(page, t.rot)).toBeVisible();
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page).toHaveURL((u) => u.pathname === '/' && u.search === '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01 Kriterium 4: Filter stehen in der URL und werden beim Öffnen übernommen (Deep Link)', async ({ page }) => {
|
||||||
|
await open(page, `/catalog?genre=${ids.genreA}&players=5`);
|
||||||
|
await expect(tile(page, t.familie)).toBeVisible();
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '5 players', exact: true })).toBeVisible();
|
||||||
|
// Eine Änderung schreibt sich in die URL zurück.
|
||||||
|
await chip(page, t.mechanicM1).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`mechanic=${ids.mechanicM1}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KAT-01: ungültige oder unbekannte URL-Parameter werden still ignoriert', async ({ page }) => {
|
||||||
|
await open(page, '/catalog?q=zqvk%20massenspiel&players=abc&maxMinutes=7&age=x&genre=kein-uuid&mechanic=,,');
|
||||||
|
await expect(count(page, MASS_COUNT)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KAT-01 Zustände Kriterium 1 und 2: Fehler und Netz (web)', () => {
|
||||||
|
test('KAT-01 Zustände: ohne Verbindung erscheint der Offline-Hinweis, „Try again“ liefert nach der Rückkehr die Treffer', async ({ page, context }) => {
|
||||||
|
await openCatalog(page);
|
||||||
|
await search(page, 'zqvk rotkehlchen');
|
||||||
|
await expect(tile(page, t.rot)).toBeVisible();
|
||||||
|
|
||||||
|
await context.setOffline(true);
|
||||||
|
await search(page, 'zqvk duo');
|
||||||
|
await expect(message(page, 'No connection. You need internet for the search.')).toBeVisible();
|
||||||
|
// Suchbegriff bleibt erhalten.
|
||||||
|
await expect(searchField(page)).toHaveValue('zqvk duo');
|
||||||
|
|
||||||
|
await context.setOffline(false);
|
||||||
|
await page.getByRole('button', { name: 'Try again' }).click();
|
||||||
|
await expect(tile(page, t.duo)).toBeVisible();
|
||||||
|
await expect(message(page, 'No connection. You need internet for the search.')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Trefferzahl-Text (egal welche Zahl), fuer „nicht mehr 1 Treffer“-Pruefungen. */
|
||||||
|
function textCount(page: Page) {
|
||||||
|
return page.getByText(/^\d+ games?$|^No games$/).first();
|
||||||
|
}
|
||||||
|
|
@ -5,10 +5,67 @@ import { freshBrowserContext, fillField, login, open, register, t, textOf } from
|
||||||
|
|
||||||
const gotrue = new GoTrueClient();
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
/** Inhalt des Semantics-Baums, ohne die (je Lauf wechselnde) Adresse. */
|
type Page = import('@playwright/test').Page;
|
||||||
async function screenText(page: import('@playwright/test').Page): Promise<string> {
|
|
||||||
|
/**
|
||||||
|
* Sammelt ab dem naechsten Seitenladen jede Ansage der Live-Regionen (`flt-announcement-polite/-assertive`)
|
||||||
|
* in `window.__announcements` (nur nichtleerer, getrimmter Text; Flutter haengt abwechselnd ein Leerzeichen an,
|
||||||
|
* damit gleiche Ansagen erneut vorgelesen werden). Vor `register`/`login` der Seite aufrufen.
|
||||||
|
*/
|
||||||
|
async function trackAnnouncements(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const w = window as any;
|
||||||
|
w.__announcements = [] as { text: string; path: string }[];
|
||||||
|
w.__annLastChange = performance.now();
|
||||||
|
let last = '';
|
||||||
|
const read = () =>
|
||||||
|
Array.from(document.querySelectorAll('flt-announcement-polite, flt-announcement-assertive'))
|
||||||
|
.map((e) => (e.textContent ?? '').trim())
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
const start = () => {
|
||||||
|
new MutationObserver(() => {
|
||||||
|
const cur = read();
|
||||||
|
if (cur !== last) w.__annLastChange = performance.now();
|
||||||
|
if (cur !== '' && last === '') w.__announcements.push({ text: cur, path: location.pathname });
|
||||||
|
last = cur;
|
||||||
|
}).observe(document, { subtree: true, childList: true, characterData: true });
|
||||||
|
};
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
|
||||||
|
else start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inhalt des Semantics-Baums, ohne die (je Lauf wechselnde) Adresse, PLUS alle gesammelten Ansagen.
|
||||||
|
* Die Live-Region ist fuer Screenreader-Nutzer Teil des Bildschirms (Enumerationsschutz): Ansagen fuer neue
|
||||||
|
* und vorhandene Adresse muessen gleich sein. Flutter setzt eine Ansage zeitversetzt und leert sie nach 300 ms;
|
||||||
|
* deshalb zuerst warten, bis die Region leer ist UND entweder mindestens eine Ansage registriert wurde oder die
|
||||||
|
* Region 400 ms am Stueck leer war (Seite ohne Ansage). Erst dann der Schnappschuss.
|
||||||
|
*/
|
||||||
|
async function screenText(page: Page): Promise<string> {
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const w = window as any;
|
||||||
|
const empty =
|
||||||
|
Array.from(document.querySelectorAll('flt-announcement-polite, flt-announcement-assertive'))
|
||||||
|
.map((e) => (e.textContent ?? '').trim())
|
||||||
|
.join('') === '';
|
||||||
|
return empty && (w.__announcements.some((a: any) => a.path === location.pathname) || performance.now() - w.__annLastChange >= 400);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
// Zaehlerstaende der Eingabefelder ("7/30") werden beim Tippen angesagt und haengen vom getippten Text ab, nicht vom Ergebnis.
|
||||||
|
// Nur Ansagen der aktuellen Seite (Pfad): Ansagen waehrend des Ausfuellens (Validierung, Zaehler) gehoeren zur
|
||||||
|
// Eingabe, nicht zum Ergebnis der Absendung.
|
||||||
|
const path = new URL(page.url()).pathname;
|
||||||
|
const announcements = ((await page.evaluate(() => (window as any).__announcements)) as { text: string; path: string }[])
|
||||||
|
.filter((a) => a.path === path && !/^\d+\/\d+$/.test(a.text))
|
||||||
|
.map((a) => a.text);
|
||||||
// Eingabefelder (mit der je Lauf wechselnden Adresse) gehoeren nicht zum Vergleich.
|
// Eingabefelder (mit der je Lauf wechselnden Adresse) gehoeren nicht zum Vergleich.
|
||||||
return (await page.locator('body').ariaSnapshot()).replace(/^\s*- textbox.*\n?/gm, '').trim();
|
const tree = (await page.locator('body').ariaSnapshot()).replace(/^\s*- textbox.*\n?/gm, '').trim();
|
||||||
|
return `${tree}\n--- Ansagen ---\n${JSON.stringify(announcements)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('KTO-01 Kriterium 4 (web): gleicher Bildschirm fuer neue und vorhandene Adresse', () => {
|
test.describe('KTO-01 Kriterium 4 (web): gleicher Bildschirm fuer neue und vorhandene Adresse', () => {
|
||||||
|
|
@ -17,6 +74,7 @@ test.describe('KTO-01 Kriterium 4 (web): gleicher Bildschirm fuer neue und vorha
|
||||||
browser,
|
browser,
|
||||||
}) => {
|
}) => {
|
||||||
// Neue Adresse.
|
// Neue Adresse.
|
||||||
|
await trackAnnouncements(page);
|
||||||
await register(page, { email: randomEmail(), password: randomPassword() });
|
await register(page, { email: randomEmail(), password: randomPassword() });
|
||||||
await expect(page).toHaveURL(/\/register\/check-email$/);
|
await expect(page).toHaveURL(/\/register\/check-email$/);
|
||||||
await expect(textOf(page, t.checkEmailTitle)).toBeVisible();
|
await expect(textOf(page, t.checkEmailTitle)).toBeVisible();
|
||||||
|
|
@ -27,6 +85,7 @@ test.describe('KTO-01 Kriterium 4 (web): gleicher Bildschirm fuer neue und vorha
|
||||||
await gotrue.createConfirmedUser(existing, randomPassword());
|
await gotrue.createConfirmedUser(existing, randomPassword());
|
||||||
const second = await (await freshBrowserContext(browser)).newPage();
|
const second = await (await freshBrowserContext(browser)).newPage();
|
||||||
try {
|
try {
|
||||||
|
await trackAnnouncements(second);
|
||||||
await register(second, { email: existing, password: randomPassword() });
|
await register(second, { email: existing, password: randomPassword() });
|
||||||
await expect(second).toHaveURL(/\/register\/check-email$/);
|
await expect(second).toHaveURL(/\/register\/check-email$/);
|
||||||
await expect(textOf(second, t.checkEmailTitle)).toBeVisible();
|
await expect(textOf(second, t.checkEmailTitle)).toBeVisible();
|
||||||
|
|
@ -63,6 +122,7 @@ test.describe('KTO-01 Kriterium 7 (web): keine Auskunft ueber Konten, Drosselung
|
||||||
const password = randomPassword();
|
const password = randomPassword();
|
||||||
await gotrue.createConfirmedUser(email, password);
|
await gotrue.createConfirmedUser(email, password);
|
||||||
|
|
||||||
|
await trackAnnouncements(page);
|
||||||
await login(page, email, `${password}-falsch`);
|
await login(page, email, `${password}-falsch`);
|
||||||
await expect(textOf(page, t.invalidCredentials)).toBeVisible();
|
await expect(textOf(page, t.invalidCredentials)).toBeVisible();
|
||||||
const wrongPassword = await screenText(page);
|
const wrongPassword = await screenText(page);
|
||||||
|
|
@ -70,6 +130,7 @@ test.describe('KTO-01 Kriterium 7 (web): keine Auskunft ueber Konten, Drosselung
|
||||||
const ctx = await freshBrowserContext(browser);
|
const ctx = await freshBrowserContext(browser);
|
||||||
try {
|
try {
|
||||||
const other = await ctx.newPage();
|
const other = await ctx.newPage();
|
||||||
|
await trackAnnouncements(other);
|
||||||
await login(other, randomEmail('unknown'), password);
|
await login(other, randomEmail('unknown'), password);
|
||||||
await expect(textOf(other, t.invalidCredentials)).toBeVisible();
|
await expect(textOf(other, t.invalidCredentials)).toBeVisible();
|
||||||
expect(await screenText(other)).toBe(wrongPassword);
|
expect(await screenText(other)).toBe(wrongPassword);
|
||||||
|
|
|
||||||
119
supabase/perf/catalog_search_perf_load.sql
Normal file
119
supabase/perf/catalog_search_perf_load.sql
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
-- Synthetisches Katalogvolumen fuer die Latenzmessung KAT-01 Kriterium 7 (Playwright-Projekt `perf`,
|
||||||
|
-- Spec #3 Abschnitt 7.3). Anders als catalog_search_perf.sql (Messung in der Datenbank, Rollback) wird hier
|
||||||
|
-- COMMITTET: Die App im Browser muss die Daten ueber Kong lesen koennen. Deshalb nur lokal, nur kurz, und immer
|
||||||
|
-- mit catalog_search_perf_unload.sql wieder entfernen.
|
||||||
|
--
|
||||||
|
-- Was es tut: legt 10 000 sichtbare Katalogspiele (Standard), je zwei alternative Titel, 2 000 Verlage
|
||||||
|
-- (12 900 Zuordnungen) und je Spiel zwei Genres und drei Mechaniken aus dem VORHANDENEN Vokabular an. Alle Spiele
|
||||||
|
-- und Verlage tragen IDs mit dem Praefix 9e4f0000- , das Entfernen loescht darueber (Zuordnungen und alternative
|
||||||
|
-- Titel haengen per on delete cascade an den Spielen). Danach vacuum (analyze), damit der Planer die echten
|
||||||
|
-- Groessen kennt.
|
||||||
|
--
|
||||||
|
-- Schutz wie in catalog_search_perf.sql: -v confirm_local=yes, nur per Unix-Socket (docker exec ... psql), Abbruch
|
||||||
|
-- bei mehr als 20 privaten Spielen. (Konten werden bewusst NICHT gezaehlt: die E2E-Tests legen laufend welche an.)
|
||||||
|
-- Aufruf:
|
||||||
|
-- docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes -v n_games=10000 \
|
||||||
|
-- < supabase/perf/catalog_search_perf_load.sql
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
\if :{?confirm_local}
|
||||||
|
\else
|
||||||
|
\set confirm_local no
|
||||||
|
\endif
|
||||||
|
select :'confirm_local' = 'yes' as confirm_ok, coalesce(:'HOST', '') like '/%' as socket_ok \gset
|
||||||
|
\if :confirm_ok
|
||||||
|
\else
|
||||||
|
\echo 'Abbruch: mit -v confirm_local=yes aufrufen (nur lokal, siehe Kopfkommentar).'
|
||||||
|
do $abort$ begin raise exception 'Abbruch (siehe Meldung davor)'; end $abort$;
|
||||||
|
\endif
|
||||||
|
\if :socket_ok
|
||||||
|
\else
|
||||||
|
\echo 'Abbruch: nur per Unix-Socket im lokalen Container (docker exec ... psql), nie ueber TCP gegen einen Server.'
|
||||||
|
do $abort$ begin raise exception 'Abbruch (siehe Meldung davor)'; end $abort$;
|
||||||
|
\endif
|
||||||
|
\if :{?n_games}
|
||||||
|
\else
|
||||||
|
\set n_games 10000
|
||||||
|
\endif
|
||||||
|
|
||||||
|
do $guard$
|
||||||
|
begin
|
||||||
|
if inet_server_addr() is not null then
|
||||||
|
raise exception 'Abbruch: Verbindung ueber TCP (inet_server_addr gesetzt); nur Unix-Socket im lokalen Container.';
|
||||||
|
end if;
|
||||||
|
if (select count(*) from public.games where visibility = 'private') > 20 then
|
||||||
|
raise exception 'Abbruch: Die Datenbank sieht nach echten Daten aus (mehr als 20 private Spiele). Nur lokal.';
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from public.games where id::text like '9e4f0000-%') then
|
||||||
|
raise exception 'Abbruch: synthetische Daten sind schon geladen (erst catalog_search_perf_unload.sql ausfuehren).';
|
||||||
|
end if;
|
||||||
|
if (select count(*) from public.genres) < 3 or (select count(*) from public.mechanics) < 3 then
|
||||||
|
raise exception 'Abbruch: Genre-/Mechanik-Vokabular fehlt (supabase db reset einspielen).';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$guard$;
|
||||||
|
|
||||||
|
begin;
|
||||||
|
select setseed(0.42);
|
||||||
|
|
||||||
|
create temp table perf_words on commit drop as
|
||||||
|
select i, translate(substr(md5('w' || i), 1, 4 + (i % 6)), '0123456789', 'ghijklmnop') as w
|
||||||
|
from generate_series(1, 3000) i;
|
||||||
|
alter table perf_words add primary key (i);
|
||||||
|
create function pg_temp.pick() returns text language sql volatile as
|
||||||
|
$$ select w from perf_words where i = (select 1 + floor(power(random(), 2.2) * 3000)::int) $$;
|
||||||
|
|
||||||
|
insert into public.publishers (id, name)
|
||||||
|
select ('9e4f0000-0000-4000-9000-' || lpad(to_hex(i), 12, '0'))::uuid, initcap(pg_temp.pick()) || ' Spiele ' || i
|
||||||
|
from generate_series(1, 2000) i;
|
||||||
|
|
||||||
|
insert into public.games (id, title, visibility, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale)
|
||||||
|
select ('9e4f0000-0000-4000-8000-' || lpad(to_hex(g), 12, '0'))::uuid,
|
||||||
|
initcap(concat_ws(' ', pg_temp.pick(), case when k >= 2 then pg_temp.pick() end, case when k >= 3 then pg_temp.pick() end,
|
||||||
|
case when k >= 4 then pg_temp.pick() end)),
|
||||||
|
'catalog', 1990 + (g % 35), mn, mn + (g % 5), 10 + (g % 12) * 10, 30 + (g % 12) * 10, 6 + (g % 4) * 3,
|
||||||
|
'Synthetischer Kurztext ' || g, 'de'
|
||||||
|
from (select g, 1 + floor(random() * 3.99)::int as k, 1 + (g % 4) as mn from generate_series(1, :n_games) g) s;
|
||||||
|
|
||||||
|
insert into public.game_alt_titles (game_id, locale, title)
|
||||||
|
select g.id, l.locale, initcap(pg_temp.pick() || ' ' || pg_temp.pick())
|
||||||
|
from public.games g, (values ('de'), ('en')) as l(locale)
|
||||||
|
where g.id::text like '9e4f0000-%'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into public.game_publishers (game_id, publisher_id)
|
||||||
|
select g.id, p.id
|
||||||
|
from public.games g
|
||||||
|
cross join lateral (select id from public.publishers where id::text like '9e4f0000-%'
|
||||||
|
order by md5(g.id::text || id::text) limit 1) p
|
||||||
|
where g.id::text like '9e4f0000-%'
|
||||||
|
on conflict do nothing;
|
||||||
|
insert into public.game_publishers (game_id, publisher_id)
|
||||||
|
select g.id, p.id
|
||||||
|
from public.games g
|
||||||
|
cross join lateral (select id from public.publishers where id::text like '9e4f0000-%'
|
||||||
|
order by md5(g.id::text || id::text || 'b') limit 1) p
|
||||||
|
where g.id::text like '9e4f0000-%' and random() < 0.3
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into public.game_genres (game_id, genre_id)
|
||||||
|
select g.id, t.id from public.games g cross join lateral
|
||||||
|
(select x.id from public.genres x order by md5(g.id::text || x.id::text) limit 2) t
|
||||||
|
where g.id::text like '9e4f0000-%' on conflict do nothing;
|
||||||
|
insert into public.game_mechanics (game_id, mechanic_id)
|
||||||
|
select g.id, t.id from public.games g cross join lateral
|
||||||
|
(select x.id from public.mechanics x order by md5(g.id::text || x.id::text) limit 3) t
|
||||||
|
where g.id::text like '9e4f0000-%' on conflict do nothing;
|
||||||
|
commit;
|
||||||
|
|
||||||
|
vacuum (analyze) public.games;
|
||||||
|
vacuum (analyze) public.game_alt_titles;
|
||||||
|
vacuum (analyze) public.publishers;
|
||||||
|
vacuum (analyze) public.game_publishers;
|
||||||
|
vacuum (analyze) public.game_genres;
|
||||||
|
vacuum (analyze) public.game_mechanics;
|
||||||
|
|
||||||
|
\echo === geladen ===
|
||||||
|
select (select count(*) from public.catalog_games) as katalogspiele,
|
||||||
|
(select count(*) from public.game_alt_titles) as alt_titel,
|
||||||
|
(select count(*) from public.publishers) as verlage,
|
||||||
|
(select count(*) from public.game_publishers) as verlag_zuordnungen;
|
||||||
46
supabase/perf/catalog_search_perf_unload.sql
Normal file
46
supabase/perf/catalog_search_perf_unload.sql
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
-- Entfernt das synthetische Volumen aus catalog_search_perf_load.sql (alle Zeilen mit ID-Praefix 9e4f0000-).
|
||||||
|
-- Alternative Titel, Verlags-, Genre- und Mechanik-Zuordnungen verschwinden per on delete cascade mit den Spielen.
|
||||||
|
-- Aufruf (gleiche Schutzstufen wie das Ladeskript):
|
||||||
|
-- docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes \
|
||||||
|
-- < supabase/perf/catalog_search_perf_unload.sql
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
\if :{?confirm_local}
|
||||||
|
\else
|
||||||
|
\set confirm_local no
|
||||||
|
\endif
|
||||||
|
select :'confirm_local' = 'yes' as confirm_ok, coalesce(:'HOST', '') like '/%' as socket_ok \gset
|
||||||
|
\if :confirm_ok
|
||||||
|
\else
|
||||||
|
\echo 'Abbruch: mit -v confirm_local=yes aufrufen.'
|
||||||
|
do $abort$ begin raise exception 'Abbruch (siehe Meldung davor)'; end $abort$;
|
||||||
|
\endif
|
||||||
|
\if :socket_ok
|
||||||
|
\else
|
||||||
|
\echo 'Abbruch: nur per Unix-Socket im lokalen Container.'
|
||||||
|
do $abort$ begin raise exception 'Abbruch (siehe Meldung davor)'; end $abort$;
|
||||||
|
\endif
|
||||||
|
|
||||||
|
do $guard$
|
||||||
|
begin
|
||||||
|
if inet_server_addr() is not null then
|
||||||
|
raise exception 'Abbruch: Verbindung ueber TCP (inet_server_addr gesetzt); nur Unix-Socket im lokalen Container.';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$guard$;
|
||||||
|
|
||||||
|
begin;
|
||||||
|
delete from public.games where id::text like '9e4f0000-%';
|
||||||
|
delete from public.publishers where id::text like '9e4f0000-%';
|
||||||
|
commit;
|
||||||
|
|
||||||
|
vacuum (analyze) public.games;
|
||||||
|
vacuum (analyze) public.game_alt_titles;
|
||||||
|
vacuum (analyze) public.publishers;
|
||||||
|
vacuum (analyze) public.game_publishers;
|
||||||
|
vacuum (analyze) public.game_genres;
|
||||||
|
vacuum (analyze) public.game_mechanics;
|
||||||
|
|
||||||
|
\echo === entfernt ===
|
||||||
|
select (select count(*) from public.games where id::text like '9e4f0000-%') as spiele_uebrig,
|
||||||
|
(select count(*) from public.publishers where id::text like '9e4f0000-%') as verlage_uebrig,
|
||||||
|
(select count(*) from public.catalog_games) as katalogspiele;
|
||||||
Loading…
Reference in a new issue