/** * 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 { 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 { 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 { 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 { 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(); }