/** * 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 { DocumentId, RepresentationId } from "@shared/ids"; import type { Document, DocumentRepresentation } from "@shared/document"; 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(" "); vi.mock("@source/index", async (importOriginal) => { const original = await importOriginal(); return { ...original, ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => { const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId; const representationId = ("rep_test_" + Math.random().toString(36).slice(2, 10)) as RepresentationId; const document: Document = { id: documentId, mediaType: "application/pdf", ...(options?.filename ? { title: options.filename } : {}), fingerprint: "synthetic-fingerprint-for-test", createdAt: "2026-05-25T00:00:00.000Z", updatedAt: "2026-05-25T00:00:00.000Z", }; const representation: DocumentRepresentation = { id: representationId, documentId, representationType: "pdf-text", contentHash: "synthetic-fingerprint-for-test", canonicalText: SYNTHETIC_CANONICAL, pageMap: [{ page: 1, width: 595, height: 842 }], offsetMap: [ { page: 1, globalStart: 0, globalEnd: SYNTHETIC_CANONICAL.length, pageLength: SYNTHETIC_CANONICAL.length, }, ], generatedAt: "2026-05-25T00:00:00.000Z", }; return { document, representation }; }), }; }); // --------------------------------------------------------------------------- // 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)", () => { beforeEach(() => { resetViewerSnapshot(); // Each test starts with empty localStorage. globalThis.localStorage?.clear(); // The fetch isn't reached (ingestPdf is mocked) — but stub it so that // any accidental call returns gracefully instead of TypeError. globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, { status: 200, headers: { "Content-Type": "application/pdf" }, }), ); }); afterEach(() => { vi.restoreAllMocks(); }); it("walks the full slice-1 scenario: load → select → save → reload → click → scroll", async () => { const user = userEvent.setup(); // Step 1: open the app. const { unmount } = await loadApp(); expect(screen.getByText("Collection")).toBeTruthy(); // Step 2: pick a fixture. const fixtureButton = screen.getByRole("button", { name: new RegExp(FIXTURE.id) }); await user.click(fixtureButton); // The mock viewer should have mounted with our test URL. await waitFor(() => { expect(viewerSnapshot.pdfUrl).toBeTruthy(); }); expect(decodeURIComponent(viewerSnapshot.pdfUrl!)).toContain(FIXTURE.filename); // 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 toolbar should appear with the quoted text preview. const toolbar = await screen.findByText(/New annotation/); expect(toolbar).toBeTruthy(); // Step 4: add a comment and save. const textarea = screen.getByPlaceholderText(/Add a one-line comment/); await user.type(textarea, "Important deadline clause"); await user.click(screen.getByRole("button", { name: /Save evidence/ })); // 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]!; // Snapshot key from EngineContext.STORAGE_KEY — implementation detail // but worth asserting once at the integration layer. const stored = globalThis.localStorage?.getItem("citation-evidence: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.pdfUrl).toBeTruthy(); }); expect(decodeURIComponent(viewerSnapshot.pdfUrl!)).toContain(FIXTURE.filename); // 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