All checks were successful
Jede Suche enthaelt das Kunstwort des eigenen Datensets (Kollision web/api behoben); Kachelzaehlung ohne first(), konkrete Trefferzahlen; screenText wartet auf die Live-Region statt sie zu entfernen; Latenzmessung ueberspringt bereits sichtbare Treffer, Filtersuchen ergaenzt; Ladeskripte mit Exit-Code und TCP-Schutz; Doku aktualisiert. Refs #3 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
280 lines
15 KiB
TypeScript
280 lines
15 KiB
TypeScript
/**
|
|
* KAT-01 Kriterium 7 (Latenz der Suche), Projekt `perf`. Spec docs/specs/3-katalog-suche.md § 7.3.
|
|
*
|
|
* Misst in der gebauten Web-App mit einem mobilen 4G-Profil (Chrome-DevTools-Drosselung: 100 ms
|
|
* Round-Trip, 10 Mbit/s), wie lange es vom LETZTEN Tastendruck (inklusive Entprellung 300 ms) bis zur
|
|
* ersten sichtbaren Trefferkachel (bzw. zum Leerzustand bei absichtlich leeren Suchen) dauert.
|
|
*
|
|
* NUR LESEND: kein Datenbankzugriff, keine Anmeldung, nur der oeffentliche Anon-Schluessel. Importiert
|
|
* bewusst nichts aus support/auth-config.ts, damit der Lauf auch gegen eine fremde Umgebung moeglich
|
|
* ist (spaeter Staging). Die Suchliste entsteht aus dem tatsaechlichen Katalog:
|
|
* - Fuer jede Suche wird der erwartete erste Treffer ZUERST ungedrosselt bei der RPC erfragt (nur
|
|
* lesend, gleicher Aufruf wie die App) und danach in der App abgewartet. So ist die Liste
|
|
* selbstpruefend und unabhaengig von Datenstand und Relevanzregeln.
|
|
* - Zwischen zwei Messungen wird das Feld geleert; gemessen wird erst, wenn der ganze Katalog wieder
|
|
* sichtbar ist (die alten Treffer koennen die Messung so nie vorzeitig beenden).
|
|
*
|
|
* Aufruf (lokal; Daten siehe supabase/perf/catalog_search_perf_load.sql):
|
|
* PERF_WEB_URL=http://localhost:3000 PERF_SUPABASE_URL=http://127.0.0.1:54321 PERF_ANON_KEY=<anon> \
|
|
* PERF_ONLY=1 npx playwright test --project=perf
|
|
* Das Web-Bundle muss gegen dieselbe Supabase-URL gebaut sein (--dart-define-from-file).
|
|
*/
|
|
import { expect, test, type CDPSession, type Page } from '@playwright/test';
|
|
import { enableSemantics, fillField } from '../../support/flutter';
|
|
|
|
const supabaseUrl = process.env.PERF_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
|
const anonKey = process.env.PERF_ANON_KEY ?? '';
|
|
const SEARCHES = Number(process.env.PERF_SEARCHES ?? 110);
|
|
/** Grenze aus KAT-01 Kriterium 7: 95 % der Suchen liefern die ersten Treffer in hoechstens 2 Sekunden. */
|
|
const P95_LIMIT_MS = 2000;
|
|
/** Mobiles 4G-Profil (Spec 7.3): 100 ms Round-Trip, 10 Mbit/s = 1 250 000 Byte/s. PERF_LATENCY_MS nur zur Gegenprobe des Gates. */
|
|
const NETWORK = { offline: false, latency: Number(process.env.PERF_LATENCY_MS ?? 100), downloadThroughput: 1_250_000, uploadThroughput: 1_250_000 };
|
|
const MEASURE_TIMEOUT_MS = 15_000;
|
|
|
|
interface Row {
|
|
title: string;
|
|
publisher_names: string[];
|
|
total_count: number;
|
|
}
|
|
|
|
async function rpc(params: Record<string, unknown>): Promise<Row[]> {
|
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/search_catalog_games`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', apikey: anonKey, authorization: `Bearer ${anonKey}` },
|
|
body: JSON.stringify(params),
|
|
});
|
|
if (res.status !== 200) throw new Error(`RPC ${res.status}: ${await res.text()}`);
|
|
return (await res.json()) as Row[];
|
|
}
|
|
|
|
interface Sample {
|
|
kind: string;
|
|
query: string;
|
|
/** Erwarteter Titel der ersten Kachel; null = Leerzustand erwartet. */
|
|
expectedFirst: string | null;
|
|
ms: number;
|
|
total: number;
|
|
}
|
|
|
|
type Plan = { kind: string; query: string };
|
|
|
|
const words = (title: string) => title.split(/\s+/).filter((w) => /^[\p{L}\p{N}]{3,}$/u.test(w));
|
|
|
|
/** Deterministische Suchliste aus dem Katalogbestand (feste Schrittweite, keine Zufallszahlen). */
|
|
async function buildPlan(count: number): Promise<{ plan: Plan[]; first: string; visibleAtStart: Set<string> }> {
|
|
const rows: Row[] = [];
|
|
for (let offset = 0; offset < 600; offset += 50) {
|
|
const page = await rpc({ p_limit: 50, p_offset: offset });
|
|
rows.push(...page);
|
|
if (page.length < 50) break;
|
|
}
|
|
expect(rows.length, 'der Katalog muss mindestens 50 Spiele enthalten').toBeGreaterThanOrEqual(50);
|
|
// Reserve von 6 fuer Suchen, die uebersprungen werden (erster Treffer = Katalogbeginn); 5 leere, bis 12 alternative Titel.
|
|
const want = Math.max(count, 100) + 6;
|
|
const plan: Plan[] = [];
|
|
const stride = Math.max(1, Math.floor(rows.length / count));
|
|
const kinds = ['Titel', 'Titelanfang', 'Wort', 'Wortkombination', 'Verlag', 'GROSSBUCHSTABEN', 'alternativer Titel'];
|
|
for (let i = 0, k = 0; plan.length < want - 5 - 12 && i < rows.length * 4; i += stride, k++) {
|
|
const row = rows[(i * 7 + k) % rows.length];
|
|
const w = words(row.title);
|
|
const kind = kinds[k % kinds.length];
|
|
let query: string | null = null;
|
|
switch (kind) {
|
|
case 'Titel':
|
|
query = row.title;
|
|
break;
|
|
case 'Titelanfang':
|
|
query = row.title.slice(0, Math.max(2, Math.min(5, row.title.length)));
|
|
break;
|
|
case 'Wort':
|
|
query = w[w.length - 1] ?? null;
|
|
break;
|
|
case 'Wortkombination':
|
|
query = w.length >= 2 ? `${w[0]} ${w[1]}` : null;
|
|
break;
|
|
case 'Verlag':
|
|
query = row.publisher_names[0] ? (words(row.publisher_names[0])[0] ?? null) : null;
|
|
break;
|
|
case 'GROSSBUCHSTABEN':
|
|
query = (w[0] ?? row.title).toUpperCase();
|
|
break;
|
|
case 'alternativer Titel':
|
|
query = null; // unten aus der Tabelle der alternativen Titel
|
|
break;
|
|
}
|
|
if (query && !plan.some((p) => p.query === query)) plan.push({ kind, query });
|
|
}
|
|
// Alternative Titel (lesbar fuer anon): jeder 7. Eintrag.
|
|
const alt = await fetch(`${supabaseUrl}/rest/v1/game_alt_titles?select=title&order=title&limit=400`, {
|
|
headers: { apikey: anonKey, authorization: `Bearer ${anonKey}` },
|
|
});
|
|
const altTitles = alt.status === 200 ? ((await alt.json()) as { title: string }[]).map((r) => r.title) : [];
|
|
for (let i = 0; i < altTitles.length && plan.filter((p) => p.kind === 'alternativer Titel').length < 12; i += 7) {
|
|
if (!plan.some((p) => p.query === altTitles[i])) plan.push({ kind: 'alternativer Titel', query: altTitles[i] });
|
|
}
|
|
// Absichtlich leere Suchen (Leerzustand), 5 Stueck, bleiben nach dem Kuerzen erhalten.
|
|
const empty: Plan[] = ['qqzzxx', 'zzyyxk', 'kkqqww', 'xxvvzz', 'wwqqkk'].map((query) => ({ kind: 'ohne Treffer', query }));
|
|
expect(plan.length + empty.length, 'Suchliste zu kurz').toBeGreaterThanOrEqual(want);
|
|
return { plan: [...plan.slice(0, want - empty.length), ...empty], first: rows[0].title, visibleAtStart: new Set(rows.slice(0, 30).map((r) => r.title)) };
|
|
}
|
|
|
|
/**
|
|
* Installiert (einmal je Seite) den letzten-Tastendruck-Zeitstempel und die Messfunktion im Browser.
|
|
* Im Browser gemessen, damit weder Playwright-Abfrageintervalle noch die Protokolllatenz in die Zahl
|
|
* eingehen. `performance.now()` ist in Browser und Beobachter dieselbe Uhr.
|
|
*/
|
|
async function installProbe(page: Page): Promise<void> {
|
|
await page.addInitScript(() => {
|
|
const w = window as any;
|
|
w.__lastKey = 0;
|
|
window.addEventListener('keydown', () => (w.__lastKey = performance.now()), true);
|
|
// Wartet auf die erste Kachel mit diesem Titel (Textknoten beginnt mit "Titel,") oder, bei expected=null,
|
|
// auf den Leerzustand ("No games found for"). Liefert den Zeitstempel zurueck.
|
|
w.__arm = (expected: string | null, mode: 'key' | 'now') =>
|
|
new Promise<number>((resolve, reject) => {
|
|
const startedAt = performance.now();
|
|
const found = () => {
|
|
const spans = document.querySelectorAll('flt-semantics span');
|
|
for (const s of Array.from(spans)) {
|
|
const text = s.textContent ?? '';
|
|
if (expected === null ? text.startsWith('No games found for') : text === expected || text.startsWith(expected + ',')) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
const done = () => {
|
|
const t = performance.now();
|
|
obs.disconnect();
|
|
resolve(mode === 'key' ? t - w.__lastKey : t - startedAt);
|
|
};
|
|
const obs = new MutationObserver(() => {
|
|
if (found()) done();
|
|
});
|
|
obs.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true });
|
|
setTimeout(() => {
|
|
obs.disconnect();
|
|
reject(new Error(`Zeitueberschreitung beim Warten auf ${expected ?? 'Leerzustand'}`));
|
|
}, 15_000);
|
|
if (found()) done();
|
|
});
|
|
});
|
|
}
|
|
|
|
const percentile = (sorted: number[], p: number) => sorted[Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)];
|
|
|
|
test.describe('KAT-01 Kriterium 7: Latenz der Suche im mobilen 4G-Profil (perf)', () => {
|
|
test.skip(!anonKey, 'PERF_ANON_KEY nicht gesetzt (oeffentlicher Anon-Schluessel der Zielumgebung)');
|
|
|
|
test(`KAT-01 Kriterium 7: p95 der ersten Treffer <= ${P95_LIMIT_MS} ms bei ${SEARCHES} Suchen (vorlaeufig, ohne Staging)`, async ({ page, context }, testInfo) => {
|
|
const { plan, first, visibleAtStart } = await buildPlan(SEARCHES);
|
|
await installProbe(page);
|
|
|
|
// 1. App laden (ungedrosselt: Das Bundle zaehlt nicht), Semantics einschalten.
|
|
await page.goto('/');
|
|
await enableSemantics(page);
|
|
const cdp: CDPSession = await context.newCDPSession(page);
|
|
await cdp.send('Network.enable');
|
|
|
|
// 2. Drosseln und die Suche oeffnen: Die erste Suche der App (ganzer Katalog, neue Verbindung zum
|
|
// Gateway) zaehlt mit; gemessen ab dem Klick.
|
|
await cdp.send('Network.emulateNetworkConditions', NETWORK);
|
|
const samples: Sample[] = [];
|
|
const armFirst = page.evaluate(([t]) => (window as any).__arm(t, 'now'), [first] as const);
|
|
await page.getByRole('button', { name: 'Search games' }).click();
|
|
const firstMs = await armFirst;
|
|
samples.push({ kind: 'Erststart (Katalog)', query: '', expectedFirst: first, ms: firstMs, total: 0 });
|
|
|
|
const field = page.getByRole('textbox', { name: /Search games/ });
|
|
await expect(field).toBeVisible();
|
|
|
|
// 3. Suchen. Zwischen den Messungen: Feld leeren (der ganze Katalog erscheint wieder), dann tippen.
|
|
let skipped = 0;
|
|
for (const { kind, query } of plan) {
|
|
const hits = await rpc({ p_query: query, p_limit: 30 });
|
|
const expectedFirst = hits.length > 0 ? hits[0].title : null;
|
|
// Zustand zuruecksetzen: ungemessen warten, bis der Katalogbeginn wieder sichtbar ist.
|
|
// Der Katalogbeginn (erste 30 Titel) ist nach dem Leeren sichtbar (virtualisierte Liste: weit weniger als 30
|
|
// Kacheln sind im Baum, 30 ist konservativ). Ein erwarteter Titel daraus koennte die Messung vorzeitig beenden.
|
|
if (expectedFirst !== null && visibleAtStart.has(expectedFirst)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
await fillField(field, '');
|
|
await page.evaluate(([t]) => (window as any).__arm(t, 'now'), [first] as const);
|
|
// Messen: erst scharf schalten, dann tippen; Ende der Zeit = erste passende Kachel.
|
|
const armed = page.evaluate(([t]) => (window as any).__arm(t, 'key'), [expectedFirst] as const);
|
|
await field.pressSequentially(query, { delay: 15 });
|
|
const ms = await armed;
|
|
samples.push({ kind, query, expectedFirst, ms, total: hits[0]?.total_count ?? 0 });
|
|
}
|
|
|
|
// 3b. Nur Filter (Spec 7.3): Genre-Chip wählen (ungemessen, bis die Trefferzahl steht), dann Mechanik-Chip: gemessen
|
|
// von (kurz vor) dem Klick auf den zweiten Chip bis zur ersten Kachel der kombinierten Filter. Danach beide abwählen.
|
|
// Der erste Treffer ohne Suchbegriff ist alphabetisch und liegt oft im Katalogbeginn; nur Kombinationen, deren erster
|
|
// Treffer weder dort noch im Genre-Zwischenergebnis steht, taugen (sonst könnten sichtbare Kacheln die Messung beenden).
|
|
await fillField(field, ''); // die letzte Textsuche beenden
|
|
await page.evaluate(([t]) => (window as any).__arm(t, 'now'), [first] as const);
|
|
const rest = (path: string) =>
|
|
fetch(`${supabaseUrl}/rest/v1/${path}`, { headers: { apikey: anonKey, authorization: `Bearer ${anonKey}` } }).then(
|
|
(r) => r.json() as Promise<{ id: string; name_en: string }[]>,
|
|
);
|
|
const genres = await rest('genres?select=id,name_en&order=name_en');
|
|
const mechanics = await rest('mechanics?select=id,name_en&order=name_en');
|
|
let filterRuns = 0;
|
|
outer: for (const g of genres) {
|
|
const hitsG = await rpc({ p_genre_ids: [g.id], p_limit: 30 });
|
|
if (hitsG.length === 0) continue;
|
|
for (const m of mechanics) {
|
|
if (filterRuns >= 6) break outer;
|
|
const hitsGM = await rpc({ p_genre_ids: [g.id], p_mechanic_ids: [m.id], p_limit: 30 });
|
|
if (hitsGM.length === 0 || visibleAtStart.has(hitsGM[0].title) || hitsG.some((r) => r.title === hitsGM[0].title)) continue;
|
|
await page.getByRole('checkbox', { name: g.name_en, exact: true }).click();
|
|
const n = hitsG[0].total_count;
|
|
await expect(page.getByText(n === 1 ? '1 game' : `${n.toLocaleString("en-US")} games`, { exact: true })).toBeVisible();
|
|
const armed = page.evaluate(([t]) => (window as any).__arm(t, 'now'), [hitsGM[0].title] as const);
|
|
await page.getByRole('checkbox', { name: m.name_en, exact: true }).click();
|
|
samples.push({ kind: 'nur Filter (Genre+Mechanik)', query: `${g.name_en} + ${m.name_en}`, expectedFirst: hitsGM[0].title, ms: await armed, total: hitsGM[0].total_count });
|
|
await page.getByRole('checkbox', { name: m.name_en, exact: true }).click();
|
|
await page.getByRole('checkbox', { name: g.name_en, exact: true }).click();
|
|
await page.evaluate(([t]) => (window as any).__arm(t, 'now'), [first] as const);
|
|
filterRuns++;
|
|
}
|
|
}
|
|
|
|
// 4. Auswertung.
|
|
const sorted = samples.map((s) => s.ms).sort((a, b) => a - b);
|
|
const result = {
|
|
profile: NETWORK,
|
|
searches: samples.length,
|
|
skipped_searches: skipped,
|
|
p50_ms: Math.round(percentile(sorted, 50)),
|
|
p95_ms: Math.round(percentile(sorted, 95)),
|
|
max_ms: Math.round(sorted[sorted.length - 1]),
|
|
min_ms: Math.round(sorted[0]),
|
|
erststart_ms: Math.round(firstMs),
|
|
catalog_size: (await rpc({ p_limit: 1 }))[0]?.total_count,
|
|
by_kind: Object.fromEntries(
|
|
[...new Set(samples.map((s) => s.kind))].map((k) => {
|
|
const v = samples.filter((s) => s.kind === k).map((s) => s.ms).sort((a, b) => a - b);
|
|
return [k, { n: v.length, p50_ms: Math.round(percentile(v, 50)), max_ms: Math.round(v[v.length - 1]) }];
|
|
}),
|
|
),
|
|
samples: samples.map((s) => ({ ...s, ms: Math.round(s.ms) })),
|
|
};
|
|
await testInfo.attach('kat01-latenz.json', { body: JSON.stringify(result, null, 2), contentType: 'application/json' });
|
|
const { samples: _omit, ...summary } = result;
|
|
// eslint-disable-next-line no-console
|
|
console.log('KAT-01 Kriterium 7, Messung:\n' + JSON.stringify(summary, null, 2));
|
|
|
|
expect(samples.length, 'mindestens 100 Suchen').toBeGreaterThanOrEqual(100);
|
|
// Plausibilitaet: ein Messwert unter der physikalischen Untergrenze heisst, die Kachel war schon vorher sichtbar.
|
|
// Tippen: 300 ms Entprellung; Klick auf einen Filter: mindestens eine Round-Trip-Zeit.
|
|
const typed = samples.filter((s) => !s.kind.startsWith('nur Filter') && !s.kind.startsWith('Erststart'));
|
|
const filtered = samples.filter((s) => s.kind.startsWith('nur Filter'));
|
|
expect(Math.min(...typed.map((s) => s.ms)), 'getippte Suchen >= 300 ms').toBeGreaterThanOrEqual(300);
|
|
expect(filtered.length, 'Filtersuchen gemessen').toBeGreaterThanOrEqual(1);
|
|
expect(Math.min(...filtered.map((s) => s.ms)), 'Filtersuchen >= eine Round-Trip-Zeit').toBeGreaterThanOrEqual(NETWORK.latency * 0.9);
|
|
expect(result.p95_ms, `p95 ${result.p95_ms} ms`).toBeLessThanOrEqual(P95_LIMIT_MS);
|
|
});
|
|
});
|