SRC-06: facetten-ui-filter-chips
Facetten-Oberfläche mit Filter-Chips: aktive Filter sichtbar, einzeln entfernbar, Trefferzahl je Facettenwert live angezeigt. - app/api/facets/route.ts: neue Backend-for-Frontend-Route, spiegelt mail/internal/search/facets.go minimal (nur Trefferzahl je Facettenwert). - lib/manticoreQuery.ts: gemeinsamer statischer bool.must-Aufbau für Such- und Facetten-Route, kein Sprintf/Join-artiger Klauselbau. - app/api/search/route.ts (SRC-04): akzeptiert jetzt wiederholbare ?filter=feld:wert-Parameter. - app/FacetPanel.tsx: ActiveFilterChips (echte <button>-Elemente, nativ tastaturbedienbar) + FacetPanel (Klick fügt Filter hinzu) + "Alle Filter zurücksetzen". - lib/filterState.ts: reine Filterzustandsfunktionen, ohne React. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-06-PRUEFPROTOKOLL.md): 1. Manueller Test gegen echten next start + live Manticore auf 192.168.1.131: Filterkombination reduziert Treffer UND Facettenzählungen real konsistent von 2 auf 1. 2. @testing-library/user-event: echte Tastatursimulation (Enter) löst Chip-Entfernung real aus. 3. 20 gleichzeitig aktive Filter erzeugen real 20 einzelne, nicht zusammengefasste Chips, kein Absturz. Kein Umbau: mail/internal/*, web/shl, web/retention-admin unverändert, bestehendes SRC-04-Verhalten unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0065b959b3
commit
acc2b0c5dd
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import SearchPage from "./page";
|
||||
|
||||
const ORIGINAL_ENV = process.env;
|
||||
@@ -13,10 +14,18 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockSearchResponse(hits: unknown[]) {
|
||||
// SRC-06: SearchPage ruft jetzt sowohl /api/search als auch /api/facets
|
||||
// auf (Promise.all) — der Fake muss beide unterscheiden, sonst würde
|
||||
// facets() versuchen, {hits:[...]} als FacetsResponse zu lesen.
|
||||
function mockSearchResponse(hits: unknown[], facetValues: Record<string, unknown[]> = {}) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(JSON.stringify({ hits }), { status: 200 }))
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes("/api/facets")) {
|
||||
return Promise.resolve(new Response(JSON.stringify({ values: facetValues }), { status: 200 }));
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({ hits }), { status: 200 }));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,3 +113,113 @@ describe("SearchPage — Trefferliste mit Hervorhebung", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// SRC-06: Facetten-UI & Filter-Chips.
|
||||
describe("SearchPage — Facetten-UI & Filter-Chips", () => {
|
||||
it("Klick auf Facettenwert fügt einen entfernbaren Chip hinzu und aktualisiert Trefferzahlen live (AC1/AC2)", async () => {
|
||||
mockSearchResponse(
|
||||
[{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }],
|
||||
{ sender: [{ value: "alice@example.com", count: 3 }], mailbox: [], attachment_type: [], tag: [] }
|
||||
);
|
||||
|
||||
render(<SearchPage />);
|
||||
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
|
||||
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
|
||||
|
||||
const facetButton = await screen.findByRole("button", { name: /alice@example\.com \(3\)/ });
|
||||
fireEvent.click(facetButton);
|
||||
|
||||
const chip = await screen.findByRole("button", { name: /Filter Absender: alice@example\.com entfernen/ });
|
||||
expect(chip).toHaveTextContent("Absender: alice@example.com");
|
||||
|
||||
// Trefferzahl-Update live: nach dem Filtern liefert der (gemockte)
|
||||
// Server jetzt einen anderen Wert für denselben Facettenwert.
|
||||
mockSearchResponse(
|
||||
[{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }],
|
||||
{ sender: [{ value: "alice@example.com", count: 1 }], mailbox: [], attachment_type: [], tag: [] }
|
||||
);
|
||||
fireEvent.click(chip);
|
||||
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
|
||||
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
|
||||
await screen.findByRole("button", { name: /alice@example\.com \(1\)/ });
|
||||
});
|
||||
|
||||
it("Zurücksetzen aller Filter entfernt alle Chips mit einem Klick (AC3)", async () => {
|
||||
mockSearchResponse(
|
||||
[{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }],
|
||||
{
|
||||
sender: [{ value: "alice@example.com", count: 2 }],
|
||||
mailbox: [{ value: "inbox", count: 2 }],
|
||||
attachment_type: [],
|
||||
tag: [],
|
||||
}
|
||||
);
|
||||
|
||||
render(<SearchPage />);
|
||||
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
|
||||
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /alice@example\.com \(2\)/ }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /inbox \(2\)/ }));
|
||||
await screen.findByRole("button", { name: /Filter Absender: alice@example\.com entfernen/ });
|
||||
await screen.findByRole("button", { name: /Filter Postfach: inbox entfernen/ });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Alle Filter zurücksetzen" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /Filter Absender/ })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Filter Postfach/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Pflichtprüfung 2: Tastaturbedienbarkeit der Filter-Chips.
|
||||
it("Filter-Chip ist per Tastatur (Tab + Enter) auslösbar", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSearchResponse(
|
||||
[{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }],
|
||||
{ sender: [{ value: "alice@example.com", count: 1 }], mailbox: [], attachment_type: [], tag: [] }
|
||||
);
|
||||
|
||||
render(<SearchPage />);
|
||||
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
|
||||
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
|
||||
await user.click(await screen.findByRole("button", { name: /alice@example\.com \(1\)/ }));
|
||||
|
||||
const chip = await screen.findByRole("button", { name: /Filter Absender: alice@example\.com entfernen/ });
|
||||
chip.focus();
|
||||
expect(document.activeElement).toBe(chip);
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /Filter Absender/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Pflichtprüfung 3: Test mit vielen aktiven Filtern bleibt die Ansicht
|
||||
// übersichtlich — real mit 20 gleichzeitig aktiven Filtern geprüft,
|
||||
// jeder bleibt einzeln als eigener, erkennbarer Chip vorhanden (keine
|
||||
// Zusammenfassung/Verlust von Einträgen), kein Absturz.
|
||||
it("bleibt mit vielen aktiven Filtern übersichtlich (je eigener Chip, kein Absturz)", async () => {
|
||||
const manyValues = Array.from({ length: 20 }, (_, i) => ({ value: `tag-${i}`, count: i + 1 }));
|
||||
mockSearchResponse(
|
||||
[{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }],
|
||||
{ sender: [], mailbox: [], attachment_type: [], tag: manyValues }
|
||||
);
|
||||
|
||||
render(<SearchPage />);
|
||||
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
|
||||
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
|
||||
|
||||
for (let i = 0; i < manyValues.length; i++) {
|
||||
const facetButton = await screen.findByRole("button", { name: new RegExp(`tag-${i} \\(${i + 1}\\)`) });
|
||||
fireEvent.click(facetButton);
|
||||
}
|
||||
|
||||
const chipRegion = await screen.findByLabelText("Aktive Filter");
|
||||
await waitFor(() => {
|
||||
const chips = within(chipRegion).getAllByRole("button", { name: /^Filter Tag: /i });
|
||||
expect(chips).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user