test(catalog): Playwright-E2E fuer die Katalogsuche (web und api)
Web: Suche per Schaltflaeche, Relevanz, alternative Titel, Verlage, Filter, Nachladen, leere Zustaende, URL/Reload/Zurueck, Offline. API: RPC als anon ueber Kong inkl. Fehlerfaellen. Eigene Datensets je Spec-Datei. Refs #3 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
694a66689d
commit
b9c41bee64
5 changed files with 914 additions and 35 deletions
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 { 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<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 { textOf, enableSemantics, open, fillField };
|
||||
|
||||
export async function fillRegistration(
|
||||
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);
|
||||
});
|
||||
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();
|
||||
}
|
||||
Loading…
Reference in a new issue