Shuffle/e2e/tests/web/kat01-suche.spec.ts
cschlaefke cd96d91eae
All checks were successful
CI / flutter (pull_request) Successful in 1m29s
CI / web-build (pull_request) Successful in 1m1s
CI / supabase (pull_request) Successful in 8s
CI / supabase-tests (pull_request) Successful in 33s
CI / e2e (pull_request) Successful in 3m33s
test(catalog): Datensaetze trennen, Assertions und Latenzmessung schaerfen
Jede Suche enthaelt das Kunstwort des eigenen Datensets (Kollision web/api
behoben); Kachelzaehlung ohne first(), konkrete Trefferzahlen; screenText
wartet auf die Live-Region statt sie zu entfernen; Latenzmessung ueberspringt
bereits sichtbare Treffer, Filtersuchen ergaenzt; Ladeskripte mit Exit-Code
und TCP-Schutz; Doku aktualisiert.

Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 21:32:29 +01:00

335 lines
17 KiB
TypeScript

/**
* KAT-01 Spiele suchen (Issue #3, Spec docs/specs/3-katalog-suche.md § 7.2), Projekt `web`.
*
* Alle Tests laufen OHNE Anmeldung (Sicherheit Krit. 2) gegen die gebaute Web-App und den lokalen
* Stack. Testdaten: eigenes Datenset mit Kunstwort "zqvk" (e2e/support/catalog.ts), Anlage im
* `beforeAll`, Aufraeumen im `afterAll`; die Datei laeuft seriell, die Daten werden gemeinsam
* genutzt. Texte stammen aus app_en.arb (Browser-Sprache en-US).
*
* Wartet nie auf feste Zeiten: Das Ende einer Suche ist an der erwarteten Trefferzahl UND einem
* erwarteten Treffer erkennbar (waehrend einer neuen Suche bleiben die alten Treffer sichtbar).
* Nicht abgedeckt (Widget-Tests in 3b bzw. manuell): Weitere laden per Tastatur (Auto-Nachladen
* ueberholt die Schaltflaeche), Bildschirmleser-Ansage, Textskalierung 200 %.
*/
import { expect, test, type Page } from '@playwright/test';
import { catalogDataset, cleanupCatalogDataset, MASS_COUNT, seedCatalog } from '../../support/catalog';
import { enableSemantics, fillField, open } from '../../support/flutter';
const ds = catalogDataset({ prefix: 'e2ec0001', word: 'zqvk' });
const { ids, titles: t } = ds;
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
await seedCatalog(ds);
});
test.afterAll(async () => {
await cleanupCatalogDataset(ds);
});
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const searchField = (page: Page) => page.getByRole('textbox', { name: /Search games/ });
/**
* Trefferkachel: Im Semantics-Baum ein Textknoten "Titel, Jahr · Verlage, Angaben, auch: ...".
* (Die Kacheln liegen als Kinder EINES Listen-Containers, ein Rollen-Locator je Kachel gibt es nicht.)
* Die Liste ist virtualisiert: nur sichtbare und knapp daneben liegende Kacheln sind im Baum.
*/
const tile = (page: Page, title: string) => page.getByText(new RegExp(`^${escapeRe(title)}(,|$)`)).first();
const count = (page: Page, n: number) => page.getByText(n === 1 ? '1 game' : `${n} games`, { exact: true });
/** Oeffnet die Suche ueber die Schaltflaeche auf der Startseite (ohne Anmeldung). */
async function openSearchViaButton(page: Page): Promise<void> {
await open(page, '/');
await page.getByRole('button', { name: 'Search games' }).click();
await expect(page).toHaveURL(/\/catalog/);
await expect(searchField(page)).toBeVisible();
}
/**
* Oeffnet die Suche wie ein Nutzer (Startseite, Schaltflaeche) und wartet, bis die erste Suche
* (ganzer Katalog) abgeschlossen ist. Ein direkt geladenes /catalog nimmt Text, der ueber den erst
* danach eingeschalteten Semantics-Baum getippt wird, nicht verlaesslich an (Testwerkzeug-Effekt:
* mit Mausklick und Tastatur ohne Semantics funktioniert es); deshalb tippen die Tests nur nach
* diesem Einstieg. Deep Links werden ohne Tippen geprueft.
*/
async function openCatalog(page: Page): Promise<void> {
await openSearchViaButton(page);
await expect(textCount(page)).toBeVisible();
}
/** Tippt einen Suchbegriff (ersetzt den alten); die Entprellung (300 ms) wird ueber das Erwartete abgewartet. */
async function search(page: Page, text: string): Promise<void> {
await fillField(searchField(page), text);
}
/** Auswahlfelder der Filter in fester Reihenfolge; ihr Name ist "Any" oder der gewaehlte Wert. */
const dropdownIndex = { players: 0, duration: 1, age: 2 } as const;
async function choose(page: Page, field: keyof typeof dropdownIndex, option: string): Promise<void> {
await page
.getByRole('button', { name: /^(Any|\d+ players?|Up to \d+ min|Suitable from age \d+)$/ })
.nth(dropdownIndex[field])
.click();
await page.getByRole('menuitem', { name: option, exact: true }).click();
}
/**
* 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.
*/
/** Alle Kacheln zu einem Titel (ohne .first()), zum Zaehlen. */
const tiles = (page: Page, title: string) => page.getByText(new RegExp(`^${escapeRe(title)}(,|$)`));
const message = (page: Page, text: string) => page.locator('span', { hasText: text });
const chip = (page: Page, name: string) => page.getByRole('checkbox', { name, exact: true });
test.describe('KAT-01 Kriterium 1: Titelsuche (web)', () => {
test('KAT-01 Kriterium 1: Suche per Schaltfläche „Search games“, Teil eines Titels in anderer Schreibweise findet das Spiel', async ({ page }) => {
await openSearchViaButton(page);
// Gross/Klein, Bindestrich statt Leerzeichen, ohne Akzente und Umlaute, ohne ®.
await search(page, 'ZQVK CAFE-GROSSE duel');
await expect(tile(page, t.cafe)).toBeVisible();
await expect(count(page, 1)).toBeVisible();
// Nur ein Teil des Titels.
await search(page, 'zqvk dunkel');
await expect(tile(page, t.wald)).toBeVisible();
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.cafe)).toHaveCount(0);
});
test('KAT-01 Kriterium 1: Treffer sind nach Relevanz sortiert (genau, beginnt mit, Wortanfang, enthält, nur über Verlag)', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvk relevanz');
await expect(count(page, 5)).toBeVisible();
await expect(tile(page, t.relPublisher)).toBeVisible();
// Textknoten der Kacheln in Dokumentreihenfolge (= Listenreihenfolge).
const shown = (await page.getByText(/^(Die |X)?Zqvk (Relevanz|Fremdtitel)/).allTextContents()).map(
(label) => label.split(',')[0],
);
expect(shown, 'Reihenfolge nach Relevanz').toEqual([t.relExact, t.relBegins, t.relWord, t.relContains, t.relPublisher]);
});
test('KAT-01 Kriterium 1 / KAT-05 Kriterium 4: ausgeblendete und zusammengeführte Spiele erscheinen nicht, auch nicht über alternative Titel', async ({ page }) => {
await openCatalog(page);
// Je Suche die genaue Meldung abwarten: Die Meldung der vorigen Suche steht bis zur neuen Antwort noch im Baum.
for (const q of ['zqvk verborgen', 'zqvk verstecktername', 'zqvk zusammengefuehrt']) {
await search(page, q);
await expect(message(page, `No games found for “${q}”.`)).toBeVisible();
}
});
});
test.describe('KAT-01 Kriterium 2: alternative Titel (web)', () => {
test('KAT-01 Kriterium 2: deutscher und englischer alternativer Titel finden dasselbe Spiel, die Kachel nennt den Treffer-Titel', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvk dunkelforst');
await expect(tile(page, t.wald)).toBeVisible();
await expect(tile(page, t.wald)).toHaveText(new RegExp(`also: ${escapeRe(t.waldAltDe)}`));
await search(page, 'zqvk shadowwood');
await expect(tile(page, t.wald)).toHaveText(new RegExp(`also: ${escapeRe(t.waldAltEn)}`));
await expect(count(page, 1)).toBeVisible();
});
});
test.describe('KAT-01 Kriterium 3: Verlagssuche (web)', () => {
test('KAT-01 Kriterium 3: Verlagsname findet dessen Spiele; ein Spiel mit zwei Verlagen nennt beide', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvk quarzfeld');
await expect(count(page, 2)).toBeVisible();
await expect(tile(page, t.rot)).toBeVisible();
await expect(tile(page, t.blau)).toBeVisible();
// Sortiert nach normalisiertem Namen: "nebelkamm ..." vor "quarzfeld ...".
await expect(tile(page, t.blau)).toHaveText(
new RegExp(`${escapeRe(t.pubNebel)}, ${escapeRe(t.pubQuarz)}`),
);
});
test('KAT-01 Kriterium 3: der zweite Verlag eines Spiels findet es ebenfalls', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvk nebelkamm');
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.blau)).toBeVisible();
});
});
test.describe('KAT-01 Kriterium 4 und 8: Filter (web)', () => {
test('KAT-01 Kriterium 4: Filter Genre und Spielerzahl mit Suchbegriff zeigen nur passende Spiele; Zurücksetzen hebt sie auf', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvk');
// Das Genre A trägt Familienfest, Expertenrunde und Duo; "zqvk" allein trifft sehr viel mehr.
await expect(count(page, 48)).toBeVisible(); // 36 Massenspiele + 7 Einzelspiele + 5 Relevanzspiele
await chip(page, t.genreA).click();
await expect(count(page, 3)).toBeVisible();
await expect(tile(page, t.familie)).toBeVisible();
await expect(tile(page, t.experte)).toBeVisible();
await expect(tile(page, t.duo)).toBeVisible();
// Suchbegriff UND Filter: ein Begriff, der innerhalb des Genres eingrenzt ...
await search(page, 'zqvk duo');
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.duo)).toBeVisible();
// ... und einer, der dem Filter widerspricht (Massenspiele haben das Genre nicht).
await search(page, 'zqvk massenspiel');
await expect(message(page, 'No games found for “zqvk massenspiel”.')).toBeVisible();
await search(page, 'zqvk');
await expect(count(page, 3)).toBeVisible();
// Spielerzahl 5: nur das Familienfest (3 bis 8).
await choose(page, 'players', '5 players');
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.familie)).toBeVisible();
await expect(tile(page, t.duo)).toHaveCount(0);
await page.getByRole('button', { name: 'Reset filters' }).click();
await expect(count(page, 48)).toBeVisible(); // alle Spiele des Datensets wieder
// Der Suchbegriff bleibt erhalten (URL; das Semantics-Eingabefeld zeigt nach einem Auswahlmenue leer).
await expect(page).toHaveURL(/q=zqvk(&|$)/);
});
test('KAT-01 Kriterium 4: Spieldauer und Alter schließen Spiele aus, die nicht passen', async ({ page }) => {
await open(page, `/catalog?genre=${ids.genreA}`);
await expect(count(page, 3)).toBeVisible();
// Bis 45 Minuten: Familienfest (max. 45) und Duo (30), nicht Expertenrunde (180).
await choose(page, 'duration', 'Up to 45 min');
await expect(count(page, 2)).toBeVisible();
await expect(tile(page, t.experte)).toHaveCount(0);
// Geeignet ab 6 Jahren: nur Familienfest (Duo ab 8).
await choose(page, 'age', 'Suitable from age 6');
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.familie)).toBeVisible();
});
test('KAT-01 Kriterium 8: nur Filter ohne Suchbegriff, mehrere Filter sind UND-verknüpft', async ({ page }) => {
await openCatalog(page);
await expect(searchField(page)).toHaveValue('');
// Genre A allein: drei Spiele des Datensets (eigene Genres, Startbestand trifft nie).
await chip(page, t.genreA).click();
await expect(count(page, 3)).toBeVisible();
// Genre A UND Mechanik 1: Expertenrunde und Duo.
await chip(page, t.mechanicM1).click();
await expect(count(page, 2)).toBeVisible();
await expect(tile(page, t.familie)).toHaveCount(0);
// Genre A UND Genre B: nur Expertenrunde (alle gewählten Genres, Spec-F1).
await chip(page, t.mechanicM1).click();
await chip(page, t.genreB).click();
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.experte)).toBeVisible();
});
test('KAT-01 Kriterium 8 / Plattformen: im mittleren Layout öffnet „Filters“ ein Sheet, „Done“ schließt es', async ({ page }) => {
await page.setViewportSize({ width: 800, height: 900 });
await openCatalog(page);
await page.getByRole('button', { name: /^Filters/ }).click();
// Hinter dem Sheet ist die Liste fuer Bildschirmleser verborgen; geprueft wird nach „Done“.
await chip(page, t.genreB).click();
await page.getByRole('button', { name: 'Done' }).click();
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.experte)).toBeVisible();
await expect(page.getByRole('button', { name: 'Filters (1)' })).toBeVisible();
});
});
test.describe('KAT-01 Kriterium 5: Nachladen (web)', () => {
test('KAT-01 Kriterium 5: Scrollen bis zum Ende lädt die zweite Seite (p_offset 30) nach', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvk massenspiel');
await expect(count(page, MASS_COUNT)).toBeVisible();
const last = ds.mass[MASS_COUNT - 1].title;
await expect(tile(page, ds.mass[0].title)).toBeVisible();
await expect(tile(page, last)).toHaveCount(0);
const secondPage = page.waitForRequest(
(r) => r.url().includes('/rest/v1/rpc/search_catalog_games') && /"p_offset"\s*:\s*30\b/.test(r.postData() ?? ''),
);
await page.mouse.move(640, 450);
await expect(async () => {
await page.mouse.wheel(0, 1500);
await expect(tile(page, last)).toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 20_000 });
await secondPage;
// Die Trefferzahl bleibt, kein Spiel doppelt: Kachel Nr. 36 genau einmal.
await expect(tiles(page, last)).toHaveCount(1);
await expect(count(page, MASS_COUNT)).toBeVisible();
});
});
test.describe('KAT-01 Kriterium 6: keine Treffer (web)', () => {
test('KAT-01 Kriterium 6: Suche ohne Treffer zeigt den Hinweis mit dem Suchbegriff', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvkgibtesnicht');
await expect(message(page, 'No games found for “zqvkgibtesnicht”.')).toBeVisible();
await expect(message(page, 'Check the spelling or remove filters.')).toBeVisible();
// Die Aktion „Report a missing game“ wird mit #13 (KAT-04) freigeschaltet (Spec-F4, E11).
});
test('KAT-01 Kriterium 8: Filter ohne passendes Spiel zeigen den Filter-Leerzustand; „Reset filters“ stellt die Liste her', async ({ page }) => {
await open(page, `/catalog?genre=${ids.genreA},${ids.genreB}`);
await expect(count(page, 1)).toBeVisible();
await choose(page, 'players', '12 players');
await expect(message(page, 'No games match your filters.')).toBeVisible();
await page.getByRole('button', { name: 'Reset filters' }).first().click();
// Positiv: Die Liste ist zurueck (ganzer Katalog, mehr als ein Spiel), der Leerzustand ist weg.
await expect(page.getByText(/^([2-9]|\d{2,}) games$/).first()).toBeVisible();
await expect(message(page, 'No games match your filters.')).toHaveCount(0);
});
});
test.describe('KAT-01: URL-Zustand, Neuladen, Zurück (web)', () => {
test('KAT-01: Suchbegriff steht in der URL, Zurück führt in einem Schritt zur Startseite, Neuladen stellt den Stand her', async ({ page }) => {
await openSearchViaButton(page);
await search(page, 'zqvk rotkehlchen');
await expect(tile(page, t.rot)).toBeVisible();
await expect(page).toHaveURL(/\/catalog\?(.*&)?q=zqvk(\+|%20)rotkehlchen(&|$)/);
// Ersetzt, nicht angehängt: ein Schritt zurück ist die Startseite.
await page.reload();
await enableSemantics(page);
// Das Semantics-Eingabefeld spiegelt den Anfangswert nicht (Wert "" ohne Fokus); der
// wiederhergestellte Stand zeigt sich an der Trefferliste und bleibt in der URL.
await expect(page).toHaveURL(/q=zqvk(\+|%20)rotkehlchen/);
await expect(tile(page, t.rot)).toBeVisible();
await expect(count(page, 1)).toBeVisible();
await page.goBack();
await expect(page).toHaveURL((u) => u.pathname === '/' && u.search === '');
});
test('KAT-01 Kriterium 4: Filter stehen in der URL und werden beim Öffnen übernommen (Deep Link)', async ({ page }) => {
await open(page, `/catalog?genre=${ids.genreA}&players=5`);
await expect(tile(page, t.familie)).toBeVisible();
await expect(count(page, 1)).toBeVisible();
await expect(page.getByRole('button', { name: '5 players', exact: true })).toBeVisible();
// Eine Änderung schreibt sich in die URL zurück.
await chip(page, t.mechanicM1).click();
await expect(page).toHaveURL(new RegExp(`mechanic=${ids.mechanicM1}`));
});
test('KAT-01: ungültige oder unbekannte URL-Parameter werden still ignoriert', async ({ page }) => {
await open(page, '/catalog?q=zqvk%20massenspiel&players=abc&maxMinutes=7&age=x&genre=kein-uuid&mechanic=,,');
await expect(count(page, MASS_COUNT)).toBeVisible();
});
});
test.describe('KAT-01 Zustände Kriterium 1 und 2: Fehler und Netz (web)', () => {
test('KAT-01 Zustände: ohne Verbindung erscheint der Offline-Hinweis, „Try again“ liefert nach der Rückkehr die Treffer', async ({ page, context }) => {
await openCatalog(page);
await search(page, 'zqvk rotkehlchen');
await expect(tile(page, t.rot)).toBeVisible();
await context.setOffline(true);
await search(page, 'zqvk duo');
await expect(message(page, 'No connection. You need internet for the search.')).toBeVisible();
// Suchbegriff bleibt erhalten.
await expect(searchField(page)).toHaveValue('zqvk duo');
await context.setOffline(false);
await page.getByRole('button', { name: 'Try again' }).click();
await expect(tile(page, t.duo)).toBeVisible();
await expect(message(page, 'No connection. You need internet for the search.')).toHaveCount(0);
});
});
/** Trefferzahl-Text (egal welche Zahl), fuer „nicht mehr 1 Treffer“-Pruefungen. */
function textCount(page: Page) {
return page.getByText(/^\d+ games?$|^No games$/).first();
}