/** * CE-WP-0002-T09 — end-to-end test of the PRD slice-1 scenario. * * Steps verified (per the workplan): * 1. Open the app. * 2. Pick a fixture PDF. * 3. Programmatically inject a selection for the manifest's known-good * quote (the actual drag-select interaction is verified manually — * see ADR-0004 for the headless-Chromium limitation). * 4. Save an evidence item with a comment. * 5. The item appears in the sidebar. * 6. Reload the page (unmount + remount with the same localStorage). * 7. Click the evidence item. * 8. The viewer is asked to scroll to the correct annotation * (the visual highlight render is not exercised here — see ADR-0004). * * The PDF.js viewer + ingest pipeline are mocked. Both have dedicated * coverage elsewhere (`src/source/pdf/ingest.test.ts`, * `tests/integration/anchor-source-roundtrip.test.ts`, * `src/anchor/pdf-selector-math.test.ts`). T09 owns the *wiring* of the * full UX flow; the PDF-rendering correctness lives in those layers. */ // @vitest-environment happy-dom import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Selector } from "@shared/selector"; import type { PdfSelectionCapture } from "@anchor/index"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- interface ViewerProps { pdfUrl: string; storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[]; scrollToAnnotationId?: string; onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void; } interface ViewerSnapshot { pdfUrl: string | null; storedAnnotationIds: string[]; scrollToAnnotationId: string | null; onSelectionCaptured: ViewerProps["onSelectionCaptured"] | null; } const viewerSnapshot: ViewerSnapshot = { pdfUrl: null, storedAnnotationIds: [], scrollToAnnotationId: null, onSelectionCaptured: null, }; vi.mock("@anchor/index", async (importOriginal) => { const original = await importOriginal(); const MockPdfSpikeViewer = (props: ViewerProps) => { viewerSnapshot.pdfUrl = props.pdfUrl; viewerSnapshot.storedAnnotationIds = props.storedAnnotations.map((a) => a.id); viewerSnapshot.scrollToAnnotationId = props.scrollToAnnotationId ?? null; viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured; return (
); }; return { ...original, PdfSpikeViewer: MockPdfSpikeViewer, }; }); // Mock ingestPdf so the click-to-load flow doesn't pull PDF.js into jsdom. // The synthetic representation includes the manifest's known-good quote in // its canonical text so createSelectors/resolveSelectors run for real. const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!; const SYNTHETIC_CANONICAL = [ "Header boilerplate that comes before the quote.", FIXTURE.known_good_quote, "Trailing prose that comes after the quote.", ].join(" "); // CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed. import { seedSessionWithDoc, type SeedResult } from "./helpers/seed-session"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function resetViewerSnapshot() { viewerSnapshot.pdfUrl = null; viewerSnapshot.storedAnnotationIds = []; viewerSnapshot.scrollToAnnotationId = null; viewerSnapshot.onSelectionCaptured = null; } function syntheticCaptureFor(fixturePage: number, text: string): PdfSelectionCapture { return { kind: "pdf", text, page: fixturePage, rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.04 }], boundingRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.04 }, }; } async function loadApp() { // Late import so the vi.mock calls take effect before the module graph // pulls in @anchor / @source. const { App } = await import("@app/App"); return render(); } // --------------------------------------------------------------------------- // Test // --------------------------------------------------------------------------- describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => { let seeded: SeedResult; beforeEach(() => { resetViewerSnapshot(); // Each test starts with empty localStorage. globalThis.localStorage?.clear(); if (typeof window !== "undefined") { history.replaceState(null, "", window.location.pathname); } seeded = seedSessionWithDoc({ sessionName: "Demo", documentTitle: FIXTURE.filename, canonicalText: SYNTHETIC_CANONICAL, }); }); afterEach(() => { vi.restoreAllMocks(); }); it("walks the full slice-1 scenario: load → select → save → reload → click → scroll", { timeout: 15000 }, async () => { const user = userEvent.setup(); // Step 1: open the app. CE-WP-0005: a pre-seeded session boots // straight into the active layout (no empty state). const { unmount } = await loadApp(); expect(screen.queryByTestId("empty-state")).toBeNull(); expect(screen.getByTestId("session-menu-toggle").textContent).toContain("Demo"); // Step 2: the fixture doc is pre-seeded into the active session, so // the viewer mounts automatically — no fixture-button click needed. await waitFor(() => { expect(viewerSnapshot.onSelectionCaptured).not.toBeNull(); }); // Step 3: programmatically inject a selection for the known-good quote. expect(viewerSnapshot.onSelectionCaptured).not.toBeNull(); await act(async () => { viewerSnapshot.onSelectionCaptured!( syntheticCaptureFor(FIXTURE.known_good_quote_page, FIXTURE.known_good_quote), [{ type: "TextQuoteSelector", exact: FIXTURE.known_good_quote }], ); }); // The inline capture form should appear with the quoted text preview. await screen.findByTestId("inline-capture-form"); await screen.findByText(/New evidence/); // Step 4: add a comment and save. await user.type( screen.getByTestId("inline-capture-commentary"), "Important deadline clause", ); await user.click(screen.getByTestId("inline-capture-save")); // Step 5: the item appears in the sidebar. The commentary text is // unique to the right pane (the collection list never echoes it back). await screen.findByText(/Important deadline clause/); // The mock viewer should now know about the stored annotation. await waitFor(() => { expect(viewerSnapshot.storedAnnotationIds.length).toBe(1); }); const savedAnnotationId = viewerSnapshot.storedAnnotationIds[0]!; // Per-session snapshot key from CE-WP-0005 — implementation detail // but worth asserting once at the integration layer. const stored = globalThis.localStorage?.getItem( `citation-evidence:session:${seeded.sessionId}:engine-snapshot:v1`, ); expect(stored).toBeTruthy(); // Step 6: reload — unmount and remount the App. The same localStorage is // still in place because we did not clear it. unmount(); resetViewerSnapshot(); await loadApp(); // The viewer should re-mount automatically because the active document // was persisted. await waitFor(() => { expect(viewerSnapshot.onSelectionCaptured).not.toBeNull(); }); // The sidebar should show the restored item. const restoredItem = await screen.findByText(/Important deadline clause/); // The viewer must already know about the restored annotation. expect(viewerSnapshot.storedAnnotationIds).toContain(savedAnnotationId); // Step 7: click the evidence item in the sidebar. The commentary text // is rendered inside the sidebar's