diff --git a/e2e/support/catalog.ts b/e2e/support/catalog.ts new file mode 100644 index 0000000..3787d34 --- /dev/null +++ b/e2e/support/catalog.ts @@ -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; + +/** Loescht alle Zeilen des Sets (anhand des UUID-Praefixes). */ +export async function cleanupCatalog(c: Client, ds: Pick): Promise { + 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 { + 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 { + await withDb((c) => cleanupCatalog(c, ds)); +} diff --git a/e2e/support/flutter.ts b/e2e/support/flutter.ts new file mode 100644 index 0000000..70e65a2 --- /dev/null +++ b/e2e/support/flutter.ts @@ -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 { + 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 { + await page.goto(path); + await enableSemantics(page); +} + +/** + * Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte 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 { + 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 }); +} diff --git a/e2e/support/web.ts b/e2e/support/web.ts index 77b5bce..f9395c3 100644 --- a/e2e/support/web.ts +++ b/e2e/support/web.ts @@ -7,6 +7,7 @@ */ import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test'; import { env, TERMS_VERSION } from './auth-config'; +import { enableSemantics, fillField, open, textOf } from './flutter'; export { TERMS_VERSION }; @@ -28,41 +29,7 @@ export const t = { home: 'Welcome to Shuffle. The board game catalog is coming soon.', } as const; -/** - * 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 { - 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 { - await page.goto(path); - await enableSemantics(page); -} - -/** - * Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte 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 { - 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 { textOf, enableSemantics, open, fillField }; export async function fillRegistration( page: Page, diff --git a/e2e/tests/api/kat01-suche.spec.ts b/e2e/tests/api/kat01-suche.spec.ts new file mode 100644 index 0000000..ae3456d --- /dev/null +++ b/e2e/tests/api/kat01-suche.spec.ts @@ -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 { + status: number; + body: T; +} + +/** POST /rest/v1/rpc/search_catalog_games, Standard: Rolle anon (apikey = Anon-Key). */ +async function rpc( + params: Record, + auth: { apikey?: string | null; token?: string } = {}, +): Promise> { + const apikey = auth.apikey === undefined ? env.anonKey : auth.apikey; + const headers: Record = { '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, auth?: Parameters[1]): Promise { + 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, 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); +}); diff --git a/e2e/tests/web/kat01-suche.spec.ts b/e2e/tests/web/kat01-suche.spec.ts new file mode 100644 index 0000000..2137147 --- /dev/null +++ b/e2e/tests/web/kat01-suche.spec.ts @@ -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 { + 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(); +}