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>
This commit is contained in:
Matthias Hochmeister
2026-06-09 10:04:53 +02:00
parent 8f19d8e187
commit 0a7173ef38
29 changed files with 1674 additions and 0 deletions

82
tests/e2e/search.spec.ts Normal file
View File

@@ -0,0 +1,82 @@
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();
});
});