diff --git a/package.json b/package.json index df93bca..73784e9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@citation-evidence/engine": "link:../citation-engine", "@citation-evidence/evidence-source": "link:../evidence-source", "@citation-evidence/evidence-anchor": "link:../evidence-anchor", + "@citation-evidence/work": "link:../citation-work", "jszip": "^3.10.1", "pdfjs-dist": "^4.4.168", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2d7e1b..5340105 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@citation-evidence/evidence-source': specifier: link:../evidence-source version: link:../evidence-source + '@citation-evidence/work': + specifier: link:../citation-work + version: link:../citation-work jszip: specifier: ^3.10.1 version: 3.10.1 diff --git a/src/work/CollectionList.dom.test.tsx b/src/work/CollectionList.dom.test.tsx deleted file mode 100644 index d75655a..0000000 --- a/src/work/CollectionList.dom.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, 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 { Document, DocumentRepresentation } from "@shared/document"; -import type { DocumentId, RepresentationId } from "@shared/ids"; - -import { CollectionList, EngineProvider, useEngine, usePdfByteStore } from "./index"; - -function makeDoc(suffix: string): { document: Document; representation: DocumentRepresentation } { - const id = `doc_${suffix}` as DocumentId; - const repId = `rep_${suffix}` as RepresentationId; - return { - document: { - id, - mediaType: "application/pdf", - title: `Doc ${suffix}`, - fingerprint: `hash-${suffix}`, - createdAt: "2026-05-25T00:00:00.000Z", - updatedAt: "2026-05-25T00:00:00.000Z", - }, - representation: { - id: repId, - documentId: id, - representationType: "pdf-text", - contentHash: `hash-${suffix}`, - canonicalText: `body ${suffix}`, - pageMap: [{ page: 1, width: 100, height: 100 }], - offsetMap: [{ page: 1, globalStart: 0, globalEnd: 6, pageLength: 6 }], - generatedAt: "2026-05-25T00:00:00.000Z", - }, - }; -} - -function Seed() { - const engine = useEngine(); - const store = usePdfByteStore(); - if (engine.documents.list().length === 0) { - const a = makeDoc("alpha"); - const b = makeDoc("beta"); - store.put(a.document.id, new Uint8Array([1, 2])); - store.put(b.document.id, new Uint8Array([3, 4])); - engine.documents.register(a); - engine.documents.register(b); - } - return null; -} - -beforeEach(() => { - globalThis.localStorage?.clear(); -}); - -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); -}); - -describe("CollectionList (session-scoped)", () => { - it("renders one row per registered document", async () => { - render( - - - - , - ); - await waitFor(() => { - expect(screen.getByText("Doc alpha")).toBeTruthy(); - expect(screen.getByText("Doc beta")).toBeTruthy(); - }); - expect(screen.getByText("Demo session")).toBeTruthy(); - }); - - it( - "per-row delete asks for confirmation, then removes the row and revokes the blob URL", - { timeout: 8000 }, - async () => { - let revokedUrl: string | null = null; - // Patch URL.revokeObjectURL so we can confirm the byte store fired it. - const original = URL.revokeObjectURL; - URL.revokeObjectURL = (url: string) => { - revokedUrl = url; - }; - - try { - render( - - - - , - ); - await screen.findByText("Doc alpha"); - - const user = userEvent.setup(); - const deleteBtn = await screen.findByTestId("collection-delete-doc_alpha"); - // First click → confirm prompt - await user.click(deleteBtn); - expect(deleteBtn.textContent).toContain("Confirm"); - // Second click → commit - await user.click(deleteBtn); - await waitFor(() => { - expect(screen.queryByText("Doc alpha")).toBeNull(); - }); - expect(revokedUrl).not.toBeNull(); - expect(revokedUrl!).toMatch(/^blob:/); - } finally { - URL.revokeObjectURL = original; - } - }, - ); -}); diff --git a/src/work/CollectionList.tsx b/src/work/CollectionList.tsx deleted file mode 100644 index 3b8e463..0000000 --- a/src/work/CollectionList.tsx +++ /dev/null @@ -1,166 +0,0 @@ -/** - * CollectionList — the left pane. - * - * CE-WP-0005 turned this into a *session-scoped* list. It shows the - * documents currently registered with the active session's engine, - * with per-row Open + Delete actions and an inline upload affordance. - * - * Fixture-driven quick-start lives in - * `src/app/sessions/SampleSessions.tsx` and is no longer the default. - */ - -import { useCallback, useEffect, useMemo, useState } from "react"; -import type { CSSProperties } from "react"; -import type { ReactNode } from "react"; - -import type { DocumentId } from "@shared/ids"; -import { - useActiveDocumentId, - useEngine, - useEngineEventTick, - useEngineRevision, - usePdfByteStore, -} from "./EngineContext"; - -export interface CollectionListProps { - /** - * Slot rendered above the list — typically the upload affordance. - * Kept as a slot so this component stays in `work/` (which cannot - * import `app/`). - */ - readonly upload?: ReactNode; - /** Optional session header text — typically the active session name. */ - readonly title?: string; -} - -export function CollectionList({ upload, title }: CollectionListProps) { - const engine = useEngine(); - const byteStore = usePdfByteStore(); - const { id: activeId, setId } = useActiveDocumentId(); - - const importedTick = useEngineEventTick("DocumentImported"); - const removedTick = useEngineEventTick("DocumentRemoved"); - const revision = useEngineRevision(); - - const documents = useMemo( - () => engine.documents.list(), - [engine, importedTick, removedTick, revision], - ); - - // Confirm-on-delete UX without a modal: clicking Delete asks "Confirm?", - // a second click within ~3s commits. Esc clears the pending state. - const [pendingDeleteId, setPendingDeleteId] = useState(null); - - useEffect(() => { - if (!pendingDeleteId) return; - const t = setTimeout(() => setPendingDeleteId(null), 3000); - return () => clearTimeout(t); - }, [pendingDeleteId]); - - const handleDelete = useCallback( - (id: DocumentId) => { - if (pendingDeleteId !== id) { - setPendingDeleteId(id); - return; - } - // Active doc was just deleted — clear the pointer so the viewer - // unmounts before the engine drops the record. - if (activeId === id) setId(null); - byteStore.delete(id); - engine.documents.remove(id); - setPendingDeleteId(null); - }, - [activeId, byteStore, engine, pendingDeleteId, setId], - ); - - return ( - - ); -} - -const openButtonStyle: CSSProperties = { - display: "block", - width: "100%", - textAlign: "left", - background: "transparent", - border: "none", - padding: 8, - cursor: "pointer", - fontSize: 12, -}; diff --git a/src/work/EngineContext.tsx b/src/work/EngineContext.tsx deleted file mode 100644 index d2c892a..0000000 --- a/src/work/EngineContext.tsx +++ /dev/null @@ -1,316 +0,0 @@ -/** - * Engine + active-document React context. - * - * MVP composition root for the UI: one `Engine` instance for the lifetime of - * the SPA, plus the "what's open in the viewer right now" pointer. - * `useEngine()` returns the engine; `useActiveDocument()` returns the - * currently-loaded `{document, representation}` pair, refreshed when the - * engine emits `DocumentImported` / `DocumentRepresentationGenerated`. - * - * Replaces ad-hoc engine wiring inside each component. Per the workplan - * (T07 note), state lives in a single React context; no Zustand or Redux. - */ - -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, - type ReactNode, -} from "react"; -import type { Document, DocumentRepresentation } from "@shared/document"; -import type { AnnotationId, DocumentId, SessionId } from "@shared/ids"; -import type { Selector } from "@shared/selector"; -import { - attachPersister, - createEngine, - engineSnapshotKey, - restoreFromStorage, - type Engine, -} from "@engine/index"; -import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor"; -import { createPdfByteStore, type PdfByteStore } from "@source/index"; -import { useContext as useReactContext } from "react"; -import { SessionInternalContext } from "./SessionContextInternal"; - -/** - * Legacy single-bucket storage keys, kept for any user landing on a - * build without sessions. CE-WP-0005 switched persistence to per-session - * keys (`engineSnapshotKey(sessionId)`); the unscoped keys below are - * only consulted when no `sessionId` is provided to the provider. - */ -const LEGACY_STORAGE_KEY = "citation-evidence:engine-snapshot:v1"; -const ACTIVE_KEY = "citation-evidence:active-document-id:v1"; - -function storageKeyFor(sessionId: SessionId | null): string { - return sessionId ? engineSnapshotKey(sessionId) : LEGACY_STORAGE_KEY; -} - -function activeDocumentKeyFor(sessionId: SessionId | null): string { - return sessionId - ? `citation-evidence:session:${sessionId}:active-document-id:v1` - : ACTIVE_KEY; -} - -/** - * The pending selection lives in context (not local component state) because - * the toolbar that consumes it is rendered above the viewer, not inside it. - * `null` means "no selection waiting for a comment". - */ -export interface PendingSelection { - readonly capture: PdfSelectionCapture; - readonly selectors: readonly Selector[]; -} - -interface EngineContextValue { - readonly engine: Engine; - readonly byteStore: PdfByteStore; - readonly activeDocumentId: DocumentId | null; - setActiveDocumentId(id: DocumentId | null): void; - readonly pendingSelection: PendingSelection | null; - setPendingSelection(pending: PendingSelection | null): void; - readonly scrollToAnnotationId: AnnotationId | null; - /** The version counter bumps even when the same id is set twice in a row, - * so a second click on the same evidence item still triggers a scroll. */ - readonly scrollVersion: number; - scrollToAnnotation(id: AnnotationId | null): void; - /** - * Bumps each time the engine's repos are mutated outside the normal - * event-emitting service path — currently only on `restoreFromStorage`. - * Consumers that cache `engine.documents.list()` via `useMemo` add this - * to their deps so the restored state is reflected on remount. - */ - readonly engineRevision: number; -} - -const EngineContext = createContext(null); - -interface EngineProviderProps { - readonly children: ReactNode; - /** Inject a pre-built engine for tests; production uses the default. */ - readonly engine?: Engine; - /** - * Active session id. Drives the per-session storage key for the engine - * snapshot and the active-document pointer. `null`/omitted falls back - * to the legacy unscoped keys for back-compat with pre-CE-WP-0005 - * builds. - * - * To switch sessions, parents should *re-key* this provider - * (``) so React - * unmounts the subtree and a fresh engine is created. - */ - readonly sessionId?: SessionId | null; -} - -export function EngineProvider({ - children, - engine: injected, - sessionId = null, -}: EngineProviderProps) { - const engine = useMemo(() => injected ?? createEngine(), [injected]); - // Prefer the SessionProvider's per-session byte store registry when - // available; fall back to a provider-local store for tests that mount - // EngineProvider on its own. - const sessionCtx = useReactContext(SessionInternalContext); - const [fallbackByteStore] = useState(() => createPdfByteStore()); - const byteStore = - sessionId && sessionCtx - ? sessionCtx.getOrCreateByteStore(sessionId) - : fallbackByteStore; - const [activeDocumentId, setActiveDocumentIdState] = useState(null); - // `restoreFromStorage` writes directly to the engine's repos without - // firing engine events (by design — see persistence.ts). That means - // consuming components (CollectionList etc.) wouldn't normally - // re-render to reflect the restored state. Bumping `engineRevision` - // after restore is what consumers add to their `useMemo` deps so - // the restored state shows up on (re-)mount. - const [engineRevision, setEngineRevision] = useState(0); - const [pendingSelection, setPendingSelection] = useState(null); - const [scrollState, setScrollState] = useState<{ id: AnnotationId | null; version: number }>({ - id: null, - version: 0, - }); - - const snapshotKey = storageKeyFor(sessionId); - const activeDocKey = activeDocumentKeyFor(sessionId); - - // Restore from localStorage on first mount, then attach the persister. - // The injected-engine path skips persistence (tests own their lifecycle). - useEffect(() => { - if (injected) return; - if (typeof globalThis.localStorage === "undefined") return; - const result = restoreFromStorage(engine, { key: snapshotKey }); - if (result.restored) { - const saved = globalThis.localStorage.getItem(activeDocKey); - if (saved && engine.documents.get(saved as DocumentId)) { - setActiveDocumentIdState(saved as DocumentId); - } - // Force a re-render so consumers see the restored repos. - setEngineRevision((n) => n + 1); - } - return attachPersister(engine, { key: snapshotKey }); - }, [engine, injected, snapshotKey, activeDocKey]); - - // Persist the active-document pointer alongside the engine snapshot so a - // reload lands the user back where they were. - useEffect(() => { - if (injected) return; - if (typeof globalThis.localStorage === "undefined") return; - if (activeDocumentId) { - globalThis.localStorage.setItem(activeDocKey, activeDocumentId); - } else { - globalThis.localStorage.removeItem(activeDocKey); - } - }, [activeDocumentId, injected, activeDocKey]); - - // Switching the active document discards any pending selection — it - // belongs to the previous document's viewer state. - const setActiveDocumentId = useCallback((id: DocumentId | null) => { - setActiveDocumentIdState(id); - setPendingSelection(null); - setScrollState((prev) => ({ id: null, version: prev.version + 1 })); - }, []); - - const scrollToAnnotation = useCallback((id: AnnotationId | null) => { - setScrollState((prev) => ({ id, version: prev.version + 1 })); - }, []); - - const value = useMemo( - () => ({ - engine, - byteStore, - activeDocumentId, - setActiveDocumentId, - pendingSelection, - setPendingSelection, - scrollToAnnotationId: scrollState.id, - scrollVersion: scrollState.version, - scrollToAnnotation, - engineRevision, - }), - [ - engine, - byteStore, - activeDocumentId, - setActiveDocumentId, - pendingSelection, - scrollState, - scrollToAnnotation, - engineRevision, - ], - ); - - return {children}; -} - -export function useEngine(): Engine { - const ctx = useContext(EngineContext); - if (!ctx) throw new Error("useEngine: missing EngineProvider"); - return ctx.engine; -} - -export function usePdfByteStore(): PdfByteStore { - const ctx = useContext(EngineContext); - if (!ctx) throw new Error("usePdfByteStore: missing EngineProvider"); - return ctx.byteStore; -} - -export function useEngineRevision(): number { - const ctx = useContext(EngineContext); - if (!ctx) throw new Error("useEngineRevision: missing EngineProvider"); - return ctx.engineRevision; -} - -export function useActiveDocumentId(): { - readonly id: DocumentId | null; - setId(id: DocumentId | null): void; -} { - const ctx = useContext(EngineContext); - if (!ctx) throw new Error("useActiveDocumentId: missing EngineProvider"); - return { id: ctx.activeDocumentId, setId: ctx.setActiveDocumentId }; -} - -export function useActiveDocument(): { - readonly document: Document | null; - readonly representation: DocumentRepresentation | null; -} { - const engine = useEngine(); - const { id } = useActiveDocumentId(); - const [tick, setTick] = useState(0); - - // Re-render when documents come and go so list views stay fresh. - useEffect(() => { - const off1 = engine.bus.on("DocumentImported", () => setTick((t) => t + 1)); - const off2 = engine.bus.on("DocumentRepresentationGenerated", () => setTick((t) => t + 1)); - return () => { - off1(); - off2(); - }; - }, [engine]); - - const document = id ? engine.documents.get(id) : null; - const representation = id - ? engine.documents.listRepresentations(id).at(-1) ?? null - : null; - - // `tick` is intentionally read to silence unused-var warnings; the dep - // chain is via useState so React handles the re-render. We don't actually - // need to consume the value. - void tick; - - return { document, representation }; -} - -/** - * Subscribe to a single engine event type and trigger a re-render each time - * it fires. Returns the current monotonic counter — pure state-marker. - */ -export function useEngineEventTick[0]>( - type: T, -): number { - const engine = useEngine(); - const [tick, setTick] = useState(0); - const bump = useCallback(() => setTick((t) => t + 1), []); - useEffect(() => engine.bus.on(type, bump), [engine, type, bump]); - return tick; -} - -export function usePendingSelection(): { - readonly pending: PendingSelection | null; - set(pending: PendingSelection | null): void; -} { - const ctx = useContext(EngineContext); - if (!ctx) throw new Error("usePendingSelection: missing EngineProvider"); - return { pending: ctx.pendingSelection, set: ctx.setPendingSelection }; -} - -export function useScrollToAnnotation(): { - readonly id: AnnotationId | null; - readonly version: number; - scrollTo(id: AnnotationId | null): void; -} { - const ctx = useContext(EngineContext); - if (!ctx) throw new Error("useScrollToAnnotation: missing EngineProvider"); - return { - id: ctx.scrollToAnnotationId, - version: ctx.scrollVersion, - scrollTo: ctx.scrollToAnnotation, - }; -} - -/** - * Track the most-recent `EvidenceItemActivated` event id from the engine - * bus. Returns `null` until something is activated. UI components that - * highlight "the active evidence" subscribe via this hook so they don't - * need to import the binder's active-state machine directly. - */ -export function useLastActivatedEvidence(): import("@shared/ids").EvidenceItemId | null { - const engine = useEngine(); - const [id, setId] = useState(null); - useEffect(() => { - return engine.bus.on("EvidenceItemActivated", (e) => setId(e.evidenceItemId)); - }, [engine]); - return id; -} diff --git a/src/work/EvidenceFormBody.tsx b/src/work/EvidenceFormBody.tsx deleted file mode 100644 index 796b43f..0000000 --- a/src/work/EvidenceFormBody.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/** - * EvidenceFormBody — the shared "citation + commentary" editor. - * - * One form drives both: - * - the *capture* flow (creating evidence from a fresh selection - * inside `InlineCaptureForm`), and - * - the *edit* flow (modifying an existing evidence card inside - * `EvidenceSidebar`). - * - * Keeping a single component means changes to the field layout, hint - * placement, or save-button copy land in one file and apply to both - * surfaces. Callers control the labels and any badge/helper text. - */ - -import type { CSSProperties, ReactNode } from "react"; - -export interface EvidenceFormBodyProps { - readonly quote: string; - readonly commentary: string; - onChangeQuote(next: string): void; - onChangeCommentary(next: string): void; - onSave(): void; - onCancel(): void; - /** Save-button label, defaults to "Save". */ - readonly saveLabel?: string; - /** Cancel-button label, defaults to "Cancel". */ - readonly cancelLabel?: string; - /** Short caption rendered at the top of the form, e.g. a selector - * count badge or "Editing". */ - readonly badge?: ReactNode; - /** Inline note shown under the buttons (e.g. "won't move the - * marked passage" when editing an existing item). */ - readonly helper?: ReactNode; - /** data-testid prefix for the input + button hooks. */ - readonly testidPrefix: string; -} - -export function EvidenceFormBody(p: EvidenceFormBodyProps) { - const saveLabel = p.saveLabel ?? "Save"; - const cancelLabel = p.cancelLabel ?? "Cancel"; - return ( - - {p.badge && ( - {p.badge} - )} - Citation text - p.onChangeQuote(e.target.value)} - data-testid={`${p.testidPrefix}-quote`} - rows={3} - style={{ - ...textareaStyle, - fontStyle: "italic", - }} - /> - Commentary - p.onChangeCommentary(e.target.value)} - data-testid={`${p.testidPrefix}-commentary`} - rows={2} - placeholder="(optional)" - style={textareaStyle} - /> - - - {saveLabel} - - - {cancelLabel} - - {p.helper && ( - {p.helper} - )} - - - ); -} - -const labelStyle: CSSProperties = { - display: "block", - color: "#666", - fontSize: 11, - marginBottom: 2, -}; - -const textareaStyle: CSSProperties = { - width: "100%", - boxSizing: "border-box", - fontSize: 12, - padding: 4, - marginBottom: 6, -}; diff --git a/src/work/EvidenceSidebar.dom.test.tsx b/src/work/EvidenceSidebar.dom.test.tsx deleted file mode 100644 index 7ba23af..0000000 --- a/src/work/EvidenceSidebar.dom.test.tsx +++ /dev/null @@ -1,210 +0,0 @@ -/** - * EvidenceSidebar export-flow tests (CE-WP-0004-T04). - * - * Covers: - * - Export popover opens on click, exposes Markdown + HTML options. - * - Copy as Markdown writes the rendered card to navigator.clipboard - * and shows a success toast. - * - Copy as HTML writes the HTML card. - * - Clipboard failure surfaces an error toast. - * - Cmd/Ctrl+Shift+C exports the active evidence as Markdown. - * - * Uses a real engine (not a mock) so the renderer + service plumbing is - * exercised end-to-end up to the clipboard boundary. - */ - -// @vitest-environment happy-dom - -import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { createEngine, type Engine } from "@engine/index"; -import type { DocumentId } from "@shared/ids"; -import { newId } from "@shared/ids"; -import { EngineProvider, EvidenceSidebar } from "./index"; - -// happy-dom ships a real `navigator.clipboard.writeText` that stashes -// the text into an internal Blob. We spy on the prototype method so -// every test's clicks route through our mock without fighting the -// Navigator class's `#clipboard` private field. `mockImplementation` -// per test swaps in the success or failure behaviour. -let writeText: ReturnType & - ((text: string) => Promise); -let lastEngine: Engine | null = null; -let activeDocumentId: DocumentId | null = null; - -function seedEngine(): { engine: Engine; documentId: DocumentId } { - const engine = createEngine(); - const now = "2026-05-25T00:00:00.000Z"; - const { document } = engine.documents.register({ - document: { - id: newId("document"), - title: "Order from 14 Mar 2024", - mediaType: "application/pdf", - fingerprint: "test-fingerprint", - createdAt: now, - updatedAt: now, - }, - representation: { - id: newId("representation"), - documentId: newId("document"), - representationType: "pdf-text", - contentHash: "test-fingerprint", - canonicalText: "Die Frist endet am 31. März 2024.", - pageMap: [{ page: 1, width: 595, height: 842 }], - offsetMap: [{ page: 1, globalStart: 0, globalEnd: 34, pageLength: 34 }], - generatedAt: now, - }, - }); - const annotation = engine.annotations.create({ - documentId: document.id, - selectors: [ - { type: "TextQuoteSelector", exact: "Die Frist endet am 31. März 2024." }, - ], - quote: "Die Frist endet am 31. März 2024.", - }); - engine.evidence.create({ - annotationIds: [annotation.id], - commentary: "Deadline clause for the buyer.", - }); - return { engine, documentId: document.id }; -} - -function Harness({ engine, documentId }: { engine: Engine; documentId: DocumentId }) { - lastEngine = engine; - activeDocumentId = documentId; - return ( - - - - - - ); -} - -import { useEffect } from "react"; -import { useActiveDocumentId } from "./EngineContext"; - -function ActiveDocumentSetter({ - documentId, - children, -}: { - documentId: DocumentId; - children: React.ReactNode; -}) { - const { setId } = useActiveDocumentId(); - useEffect(() => { - setId(documentId); - }, [documentId, setId]); - return <>{children}>; -} - -function installClipboard(impl: (text: string) => Promise) { - writeText = vi.fn(impl) as unknown as typeof writeText; - // happy-dom recreates its Clipboard prototype across the - // beforeEach → render boundary in some test passes, so patches - // applied earlier (e.g. in beforeEach) get silently discarded. - // Patching the *current* prototype right before the action under - // test (i.e. after render) is reliable. - const proto = Object.getPrototypeOf(navigator.clipboard); - Object.defineProperty(proto, "writeText", { - configurable: true, - writable: true, - value: writeText, - }); -} - -describe("EvidenceSidebar — export flow (CE-WP-0004-T04)", () => { - beforeEach(() => { - lastEngine = null; - activeDocumentId = null; - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it("renders an Export toggle for each evidence item", async () => { - const { engine, documentId } = seedEngine(); - render(); - installClipboard(async () => undefined); - await screen.findByText(/Deadline clause for the buyer/); - const toggle = await screen.findByLabelText("Export evidence item"); - expect(toggle).toBeTruthy(); - }); - - it("opens the menu and copies Markdown to the clipboard", async () => { - const user = userEvent.setup(); - const { engine, documentId } = seedEngine(); - render(); - installClipboard(async () => undefined); - await screen.findByText(/Deadline clause for the buyer/); - await user.click(await screen.findByLabelText("Export evidence item")); - await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" })); - await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); - const written = writeText.mock.calls[0]![0] as string; - expect(written).toContain("> Die Frist endet am 31. März 2024."); - expect(written).toContain("— *Order from 14 Mar 2024*"); - expect(written).toContain("[Open source](/viewer?document="); - expect(written).toContain("Deadline clause for the buyer."); - const toast = await screen.findByTestId("export-toast"); - expect(toast.getAttribute("data-tone")).toBe("success"); - expect(toast.textContent).toContain("Copied as Markdown"); - }); - - it("copies HTML when the HTML menu item is clicked", async () => { - const user = userEvent.setup(); - const { engine, documentId } = seedEngine(); - render(); - installClipboard(async () => undefined); - await screen.findByText(/Deadline clause for the buyer/); - await user.click(await screen.findByLabelText("Export evidence item")); - await user.click(await screen.findByRole("menuitem", { name: "Copy as HTML" })); - await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); - const written = writeText.mock.calls[0]![0] as string; - expect(written).toContain('