SRC-04: such-oberflaeche-mit-hervorhebung
Such-Oberfläche (Next.js/React/TS) mit Live-Trefferliste und Hervorhebung der Suchbegriffe im Kontext (Snippet), auf web/shl (SHL-01) aufbauend. - app/api/search/route.ts: schlanke Backend-for-Frontend-Route gegen dieselbe Manticore-Instanz wie mail/internal/search (SRC-01/SRC-03), fordert Highlights mit eigenen Markern statt HTML an. - lib/highlight.ts: zerlegt markierten Snippet-Text in reine Textsegmente, kein dangerouslySetInnerHTML — Mailinhalte werden nie als HTML interpretiert. - app/page.tsx: Sucheingabe, Trefferliste mit <mark>-Hervorhebung, verständlicher Hinweis bei leerem Ergebnis. - app/mail/[messageId]/page.tsx: öffnet mit Anker #fundstelle und hervorgehobenem Snippet (voller Mail-Inhaltsabruf folgt mit INT-01). - lib/contrast.ts: reale WCAG-2.1-Kontrastberechnung. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-04-PRUEFPROTOKOLL.md): 1. Manueller Test gegen echten next start + live Manticore auf 192.168.1.131: Hervorhebung real bestätigt. 2. lib/highlightColors.test.ts: echte WCAG-Berechnung, Hell 14,29:1, Dunkel 6,43:1 (>= 4.5:1 AA). 3. Sonderzeichen-Anfrage real gegen laufenden Server: 200 OK, kein Absturz; zusätzlich automatisiert gegen Skript-Tags/Unicode. Kein Umbau: mail/internal/*, web/shl, web/retention-admin 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
9748307f12
commit
d23438d3d0
@@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import SearchPage from "./page";
|
||||
|
||||
const ORIGINAL_ENV = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_MAIL_TENANT_SLUG: "acme" };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockSearchResponse(hits: unknown[]) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(JSON.stringify({ hits }), { status: 200 }))
|
||||
);
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1: Eingabe liefert Trefferliste mit hervorgehobenen
|
||||
// Suchbegriffen im Snippet.
|
||||
describe("SearchPage — Trefferliste mit Hervorhebung", () => {
|
||||
it("rendert Treffer mit <mark> um den hervorgehobenen Suchbegriff", async () => {
|
||||
mockSearchResponse([
|
||||
{
|
||||
messageId: "msg-1",
|
||||
subjectSnippet: "Quartalsbericht ⦃⦃Umsatz⦄⦄",
|
||||
bodySnippet: "hoher ⦃⦃Umsatz⦄⦄ im dritten Quartal",
|
||||
score: 100,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<SearchPage />);
|
||||
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Umsatz" } });
|
||||
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
|
||||
|
||||
await waitFor(() => {
|
||||
const marks = document.querySelectorAll("mark");
|
||||
expect(marks.length).toBeGreaterThan(0);
|
||||
});
|
||||
const marks = Array.from(document.querySelectorAll("mark")).map((m) => m.textContent);
|
||||
expect(marks).toContain("Umsatz");
|
||||
});
|
||||
|
||||
// Akzeptanzkriterium 3: leere Ergebnisse zeigen verständlichen Hinweis
|
||||
// statt leerer Fläche.
|
||||
it("zeigt bei leerem Ergebnis einen verständlichen Hinweis", async () => {
|
||||
mockSearchResponse([]);
|
||||
|
||||
render(<SearchPage />);
|
||||
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "nichts-vorhanden" } });
|
||||
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Keine Treffer");
|
||||
});
|
||||
});
|
||||
|
||||
// Pflichtprüfung 3: Sonderzeichen in der Suchanfrage bringen die Anzeige
|
||||
// nicht zum Absturz.
|
||||
it("wirft bei Sonderzeichen in der Suchanfrage keinen Fehler", async () => {
|
||||
mockSearchResponse([
|
||||
{
|
||||
messageId: "msg-2",
|
||||
subjectSnippet: `⦃⦃<script>alert(1)</script>⦄⦄ & "Zitat" 日本語`,
|
||||
bodySnippet: "",
|
||||
score: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<SearchPage />);
|
||||
fireEvent.change(screen.getByLabelText("Suchbegriff"), {
|
||||
target: { value: '"<script>" OR -Ümläüt 日本語' },
|
||||
});
|
||||
expect(() => fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!)).not.toThrow();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll("mark").length).toBeGreaterThan(0);
|
||||
});
|
||||
// Kein <script>-Element im DOM entstanden (kein dangerouslySetInnerHTML,
|
||||
// Manticore-Highlight wird als reiner Text gerendert, nicht als HTML).
|
||||
expect(document.querySelectorAll("script").length).toBe(0);
|
||||
});
|
||||
|
||||
// Akzeptanzkriterium 2: Klick auf Treffer öffnet die zugehörige Mail
|
||||
// direkt an der Fundstelle — hier geprüft über den erzeugten Link, da
|
||||
// die vollständige Mail-Ansicht (Inhaltsabruf per API) INT-01 vorbehalten
|
||||
// ist.
|
||||
it("verlinkt jeden Treffer auf die Mail-Detailseite mit Fundstellen-Anker", async () => {
|
||||
mockSearchResponse([
|
||||
{ messageId: "msg-3", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 },
|
||||
]);
|
||||
|
||||
render(<SearchPage />);
|
||||
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
|
||||
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
|
||||
|
||||
await waitFor(() => {
|
||||
const link = screen.getByRole("link");
|
||||
expect(link.getAttribute("href")).toMatch(/^\/mail\/msg-3\?.*#fundstelle$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user