Files
Florian-netz/tests/e2e/search.spec.ts
Matthias Hochmeister 0a7173ef38 Workstream 5: Dynamische Suche & Filter (Phase 3)
Implementiert die Startseite mit Tabs (Fahrzeuge/Geräte/Wehren), Namens-/
Funkrufnamen-Suche und ein dynamisch aus dem aktiven Merkmal-Katalog erzeugtes
Filter-UI (Slider/Multi-Select/Tri-State Switch) plus Status-Filter.

Kern:
- src/lib/search/types.ts: uuid-IDs durchgängig (SearchHit, FacetDef, FilterValue).
- src/lib/search/parse-params.ts: typisiertes Parsen von f.<uuid>=… (number lo..hi,
  enum CSV, boolean ja/nein) + q + bereit; Ungültiges wird still verworfen.
- src/lib/search/facets.ts: lädt nur status='active', geltungsbereich in (typ,'both'),
  typ<>'text'; min/max je number, Optionen sortiert je enum.
- src/lib/search/query-vehicles.ts: Name+Funkrufname (OR) + Status + UND-verknüpfte
  EXISTS-Filter je merkmal_id; Allrad-Regel via expandNameQuery; keine Sortierung.
- src/lib/search/query-equipment.ts: wie Fahrzeuge, ohne Allrad, mit categoryId.
- src/lib/search/query-brigades.ts: Name/Ort/PLZ, nur aktive Wehren.
- src/lib/admin/codes.ts: gemeinsame Allrad-Namensregel (HLFA->HLF, Allrad impliziert);
  Eigentum Admin-WS, hier rein/testbar bereitgestellt und importiert.
- src/lib/db/indexes-trgm.sql: nur pg_trgm-GIN-Indizes auf vehicles.name/funkrufname
  (idempotent); merkmal_values-Indizes bleiben Eigentum des DB-WS.

UI:
- src/components/search: SearchTabs, SearchBar (debounced q), FilterPanel (dispatch +
  Status-Switch), useSearchParams (router.replace ohne Reload, atomares setParams),
  StandortBar; facets/{NumberRange,Enum,Boolean}; results/{ResultList,Vehicle,Equipment,
  Brigade}Row mit Empty-State und offenem ETA-Slot.
- src/app/(app)/{page,fahrzeuge,geraete,wehren}: Server Components mit requireSession()
  als erster Zeile (default-deny in der Tiefe zusätzlich zum Layout-Gate). /fahrzeuge
  sortiert bei gesetztem Standort via searchHitsToGeoCandidates + orderByEintreffzeit.

Tests:
- Units (ohne DB): codes, parse-params, query-vehicles (SQL-Render via PgDialect).
- tests/e2e/search.spec.ts geschrieben (deferred — kein Server/DB in Sandbox).

Verifiziert offline: tsc --noEmit (0 Fehler), eslint (0), drizzle-kit check (ok),
vitest src/lib (57 grün), next build (Compiled successfully, Routen registriert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:04:53 +02:00

83 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from "@playwright/test";
/**
* E2E-Tests der dynamischen Suche & Filter (Workstream 5).
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
* `npm run test:e2e` gegen einen laufenden, geseedeten Server mit
* authentifizierter Session (Storage-State aus dem Auth-Setup) ausgeführt.
*
* Abgedeckte Garantien (Plan WS5 Verifikation 810):
* - /fahrzeuge rendert je active-Merkmal genau ein Filter-UI des richtigen Typs.
* - Filter ändern schreibt `f.<id>=…` bzw. `?bereit=1` in die URL (kein Reload),
* Trefferzahl sinkt, Reload liefert identisches Ergebnis.
* - Anonymer Aufruf von /fahrzeuge redirectet auf /login.
*/
test.describe("Suche Default-deny", () => {
test("anonymer Aufruf von /fahrzeuge leitet auf /login um", async ({
browser,
}) => {
// Frischer Kontext OHNE gespeicherte Session.
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
await page.goto("/fahrzeuge");
await expect(page).toHaveURL(/\/login/);
await ctx.close();
});
});
test.describe("Suche Tabs & URL-Sync (authentifiziert)", () => {
test("Tabs navigieren zwischen Fahrzeuge/Geräte/Wehren", async ({ page }) => {
await page.goto("/fahrzeuge");
await page.getByRole("tab", { name: "Geräte" }).click();
await expect(page).toHaveURL(/\/geraete/);
await page.getByRole("tab", { name: "Wehren" }).click();
await expect(page).toHaveURL(/\/wehren/);
});
test("Status-Switch schreibt ?bereit=1 in die URL ohne Reload", async ({
page,
}) => {
await page.goto("/fahrzeuge");
await page.getByLabel("Nur einsatzbereit").click();
await expect(page).toHaveURL(/bereit=1/);
});
test("Filter-Reset entfernt f.*-Parameter, behält q", async ({ page }) => {
await page.goto("/fahrzeuge?q=HLF&bereit=1");
await page.getByRole("button", { name: "Filter zurücksetzen" }).click();
await expect(page).toHaveURL(/q=HLF/);
await expect(page).not.toHaveURL(/bereit=1/);
});
test("Freitext-Suche schreibt q debounced in die URL", async ({ page }) => {
await page.goto("/fahrzeuge");
await page.getByLabel("Suchbegriff").fill("HLFA 3");
await expect(page).toHaveURL(/q=HLFA(\+|%20)3/, { timeout: 2000 });
});
test("Reload mit Filter-URL liefert identische Trefferzahl", async ({
page,
}) => {
await page.goto("/fahrzeuge?bereit=1");
const before = await page.getByText(/Treffer/).textContent();
await page.reload();
const after = await page.getByText(/Treffer/).textContent();
expect(after).toBe(before);
});
});
test.describe("Suche dynamisches Filter-UI", () => {
test("jede Number-Facette hat einen Slider, jede Boolean einen Tri-State", async ({
page,
}) => {
await page.goto("/fahrzeuge");
// Mindestens ein Slider (number) und eine Ja/Nein/egal-Gruppe (boolean).
await expect(page.getByRole("slider").first()).toBeVisible();
await expect(
page.getByRole("button", { name: "egal" }).first(),
).toBeVisible();
});
});