Compare commits
3 commits
694a66689d
...
4bf9e3052a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bf9e3052a | ||
|
|
81be119f4e | ||
|
|
b9c41bee64 |
12 changed files with 1414 additions and 54 deletions
|
|
@ -70,3 +70,50 @@ 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 nichts zurückbleibt); `seed` löscht zuerst und ist nach einem Abbruch idempotent. Die Dateien laufen intern seriell und untereinander parallel. 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, 110 inhaltliche Suchen, 5 ohne Treffer) |
|
||||||
|
| p50 | 441 ms |
|
||||||
|
| p95 | 461 ms |
|
||||||
|
| Maximum | 637 ms (der Erststart; ohne ihn 535 ms) |
|
||||||
|
| Minimum | 404 ms |
|
||||||
|
| Grenze Krit. 7 | p95 ≤ 2000 ms: erfüllt |
|
||||||
|
|
||||||
|
Je Art (p50): Titel 436, Titelanfang 455, Wort 451, Wortkombination 437, Verlag 454, GROSSBUCHSTABEN 441, alternativer Titel 437, ohne Treffer 447 ms. Der Wert besteht zu rund 300 ms aus der Entprellung der App; der Rest ist eine Round-Trip-Zeit (100 ms), Verarbeitung und Rendern. Gegenprobe: mit `PERF_LATENCY_MS=1800` steigt der gemessene p95 auf 2157 ms, läge also über der Grenze (die Teststatuszeile dieses Laufs wurde nicht ausgewertet).
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- **Semantics-Eingabefeld:** Ein direkt geladenes `/catalog` nimmt per Playwright getippten Text nicht verlässlich an (Feld bleibt leer; Autofokus ist mal gesetzt, mal nicht). Über die Startseite funktioniert es. Nicht untersucht, ob echte Nutzer (Mausklick) betroffen sind. Der Wert des 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:** Die Tabelle `game_alt_titles` ist für `anon` auch für alternative Titel ausgeblendeter Katalogspiele lesbar (die RLS-Regel folgt der Sichtbarkeit in `games`, nicht der View `catalog_games`). Über die Suche erscheinen sie nicht. Ob das gewollt ist, ist fachlich zu entscheiden (nicht geraten).
|
||||||
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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,14 @@ 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` | 70 |
|
||||||
| `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` | 44 |
|
||||||
| `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 |
|
||||||
|
|
||||||
`throttle` und `spike` werden nur registriert, wenn die jeweilige Variable gesetzt ist.
|
| `perf` | Latenzmessung der Katalogsuche (KAT-01 Krit. 7), nur lesend, nie in der CI | `PERF_WEB_URL`, `PERF_ANON_KEY` | 1 |
|
||||||
|
|
||||||
|
`throttle`, `spike` und `perf` werden nur registriert, wenn die jeweilige Variable gesetzt ist.
|
||||||
|
|
||||||
## Lokal ausführen
|
## Lokal ausführen
|
||||||
|
|
||||||
|
|
@ -39,12 +42,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. Suchen mit dem Kunstwort treffen nie Startbestand oder 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 Tipp-Eingaben nicht verlässlich an: erst ü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,11 +59,13 @@ 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
|
||||||
command: 'npx serve -s ../app/build/web -l 3000 --no-clipboard',
|
? undefined
|
||||||
url: webUrl,
|
: {
|
||||||
reuseExistingServer: !process.env.CI,
|
command: 'npx serve -s ../app/build/web -l 3000 --no-clipboard',
|
||||||
timeout: 30_000,
|
url: webUrl,
|
||||||
},
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
178
e2e/support/catalog.ts
Normal file
178
e2e/support/catalog.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
/**
|
||||||
|
* 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, Verlagen und Begriffen; Suchen mit dem
|
||||||
|
* Kunstwort treffen deshalb nie Startbestandsspiele, nie die Fixtures von pgTAP (`qxz`) und nie das
|
||||||
|
* Datenset der anderen Datei. 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,
|
||||||
|
|
|
||||||
367
e2e/tests/api/kat01-suche.spec.ts
Normal file
367
e2e/tests/api/kat01-suche.spec.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
/**
|
||||||
|
* 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®', 'cafe grosse', 'gross', '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', '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', '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 [
|
||||||
|
['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: '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: 'nebelkamm' }))).toEqual([ids.blau]);
|
||||||
|
// Wort aus Titel UND Wort aus Verlag (UND-verknuepft).
|
||||||
|
expect(idSet(await search({ p_query: 'rotkehlchen quarzfeld' }))).toEqual([ids.rot]);
|
||||||
|
expect(await search({ p_query: '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 plus Filter.
|
||||||
|
expect(idSet(await search({ p_query: 'wlpd', p_genre_ids: [ids.genreB] }))).toEqual([ids.experte]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 'eingeschleust' })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nach dem Lauf bleibt nichts zurueck: Das Aufraeumen wird selbst geprueft.
|
||||||
|
test.afterAll(async () => {
|
||||||
|
const left = await withDb(async (c) =>
|
||||||
|
Number((await c.query(`select count(*) from public.games where id::text like $1`, [`${ds.prefix}-%`])).rows[0].count),
|
||||||
|
);
|
||||||
|
expect(left).toBe(0);
|
||||||
|
});
|
||||||
233
e2e/tests/perf/kat01-suche-latenz.spec.ts
Normal file
233
e2e/tests/perf/kat01-suche-latenz.spec.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
/**
|
||||||
|
* 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 }> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 } = 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.
|
||||||
|
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.
|
||||||
|
if (expectedFirst === first) continue; // sonst koennten die alten Treffer die Messung vorzeitig beenden
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Auswertung.
|
||||||
|
const sorted = samples.map((s) => s.ms).sort((a, b) => a - b);
|
||||||
|
const result = {
|
||||||
|
profile: NETWORK,
|
||||||
|
searches: samples.length,
|
||||||
|
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);
|
||||||
|
expect(result.p95_ms, `p95 ${result.p95_ms} ms`).toBeLessThanOrEqual(P95_LIMIT_MS);
|
||||||
|
});
|
||||||
|
});
|
||||||
323
e2e/tests/web/kat01-suche.spec.ts
Normal file
323
e2e/tests/web/kat01-suche.spec.ts
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
/**
|
||||||
|
* 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 Eingaben ueber den
|
||||||
|
* Semantics-Baum nicht verlaesslich an (Feld bleibt leer, Autofokus mal gesetzt, mal nicht);
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, '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);
|
||||||
|
await search(page, 'zqvk verborgen');
|
||||||
|
await expect(page.getByText(/^No games found for/)).toBeVisible();
|
||||||
|
await search(page, 'verstecktername');
|
||||||
|
await expect(page.getByText(/^No games found for/)).toBeVisible();
|
||||||
|
await search(page, 'zqvk zusammengefuehrt');
|
||||||
|
await expect(page.getByText(/^No games found for/)).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, 'dunkelforst');
|
||||||
|
await expect(tile(page, t.wald)).toBeVisible();
|
||||||
|
await expect(tile(page, t.wald)).toHaveText(new RegExp(`also: ${escapeRe(t.waldAltDe)}`));
|
||||||
|
await search(page, '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, '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, '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 familienfest');
|
||||||
|
await expect(count(page, 1)).toBeVisible();
|
||||||
|
|
||||||
|
await search(page, 'zqvk');
|
||||||
|
// Das Genre A trägt Familienfest, Expertenrunde und Duo; "zqvk" trifft zusätzlich alle anderen.
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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(textCount(page)).not.toHaveText('1 game');
|
||||||
|
// 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(tile(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();
|
||||||
|
await expect(message(page, 'No games match your filters.')).toHaveCount(0);
|
||||||
|
await expect(count(page, 1)).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\+rotkehlchen|q=zqvk%20rotkehlchen/);
|
||||||
|
|
||||||
|
// 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(/^http:\/\/localhost:3000\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
116
supabase/perf/catalog_search_perf_load.sql
Normal file
116
supabase/perf/catalog_search_perf_load.sql
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
-- 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).'
|
||||||
|
\quit
|
||||||
|
\endif
|
||||||
|
\if :socket_ok
|
||||||
|
\else
|
||||||
|
\echo 'Abbruch: nur per Unix-Socket im lokalen Container (docker exec ... psql), nie ueber TCP gegen einen Server.'
|
||||||
|
\quit
|
||||||
|
\endif
|
||||||
|
\if :{?n_games}
|
||||||
|
\else
|
||||||
|
\set n_games 10000
|
||||||
|
\endif
|
||||||
|
|
||||||
|
do $guard$
|
||||||
|
begin
|
||||||
|
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;
|
||||||
38
supabase/perf/catalog_search_perf_unload.sql
Normal file
38
supabase/perf/catalog_search_perf_unload.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
-- 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.'
|
||||||
|
\quit
|
||||||
|
\endif
|
||||||
|
\if :socket_ok
|
||||||
|
\else
|
||||||
|
\echo 'Abbruch: nur per Unix-Socket im lokalen Container.'
|
||||||
|
\quit
|
||||||
|
\endif
|
||||||
|
|
||||||
|
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