generated from coulomb/repo-seed
Persist capture data per session (field values, schema, links)
Capture mode state lived only in React memory and was lost when reopening a session or remounting EngineProvider. - Add per-session localStorage capture snapshot (schema, values, links) - Restore on session mount; persist on field/schema/link changes - Seed binder links from storage without spurious bus events - Clean up capture key when session is deleted - Integration test for reload persistence
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
EngineProvider,
|
EngineProvider,
|
||||||
SessionProvider,
|
SessionProvider,
|
||||||
useActiveSession,
|
useActiveSession,
|
||||||
|
useActiveSessionId,
|
||||||
useEngine,
|
useEngine,
|
||||||
usePdfByteStore,
|
usePdfByteStore,
|
||||||
useSessionByteStoreRegistry,
|
useSessionByteStoreRegistry,
|
||||||
@@ -33,6 +34,8 @@ import {
|
|||||||
useSessionVersionBumper,
|
useSessionVersionBumper,
|
||||||
} from "@work/index";
|
} from "@work/index";
|
||||||
|
|
||||||
|
import { CaptureLinkPersister } from "./forms/CaptureLinkPersister";
|
||||||
|
import { loadCaptureState } from "./forms/capture-persistence";
|
||||||
import { FormsApp } from "./forms/FormsApp";
|
import { FormsApp } from "./forms/FormsApp";
|
||||||
import { ReviewLayout } from "./ReviewLayout";
|
import { ReviewLayout } from "./ReviewLayout";
|
||||||
|
|
||||||
@@ -158,9 +161,29 @@ function ActiveAppFrame({
|
|||||||
|
|
||||||
function SessionScopedTree({ mode }: { mode: AppMode }) {
|
function SessionScopedTree({ mode }: { mode: AppMode }) {
|
||||||
const engine = useEngine();
|
const engine = useEngine();
|
||||||
|
const sessionId = useActiveSessionId();
|
||||||
|
const restoredCapture = useMemo(
|
||||||
|
() => (sessionId ? loadCaptureState(sessionId) : null),
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BinderProvider bus={engine.bus}>
|
<BinderProvider
|
||||||
{mode === "forms" ? <FormsApp /> : <ReviewLayout upload={<UploadDropzone />} />}
|
bus={engine.bus}
|
||||||
|
initialLinks={restoredCapture?.evidenceLinks}
|
||||||
|
>
|
||||||
|
<CaptureLinkPersister sessionId={sessionId} />
|
||||||
|
{mode === "forms" ? (
|
||||||
|
<FormsApp
|
||||||
|
sessionId={sessionId}
|
||||||
|
initialSchema={restoredCapture?.formSchema}
|
||||||
|
initialFieldValues={restoredCapture?.fieldValues}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReviewLayout upload={<UploadDropzone />} />
|
||||||
|
)}
|
||||||
</BinderProvider>
|
</BinderProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/app/forms/CaptureLinkPersister.tsx
Normal file
29
src/app/forms/CaptureLinkPersister.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Writes evidence links to per-session capture storage whenever the
|
||||||
|
* binder mutates links.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import type { SessionId } from "@shared/ids";
|
||||||
|
|
||||||
|
import { useBinder } from "@binder/index";
|
||||||
|
import { useEngineEventTick } from "@work/index";
|
||||||
|
|
||||||
|
import { persistCapturePatch } from "./capture-persistence";
|
||||||
|
|
||||||
|
export function CaptureLinkPersister({ sessionId }: { sessionId: SessionId }) {
|
||||||
|
const { links } = useBinder();
|
||||||
|
const linkTick = useEngineEventTick("EvidenceLinkCreated");
|
||||||
|
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
|
||||||
|
const updateTick = useEngineEventTick("EvidenceLinkUpdated");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void linkTick;
|
||||||
|
void unlinkTick;
|
||||||
|
void updateTick;
|
||||||
|
persistCapturePatch(sessionId, { evidenceLinks: links.list() });
|
||||||
|
}, [sessionId, links, linkTick, unlinkTick, updateTick]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
useBinder,
|
useBinder,
|
||||||
useRegisterRect,
|
useRegisterRect,
|
||||||
} from "@binder/index";
|
} from "@binder/index";
|
||||||
|
import type { SessionId } from "@shared/ids";
|
||||||
|
|
||||||
import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer";
|
import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer";
|
||||||
import {
|
import {
|
||||||
CollectionList,
|
CollectionList,
|
||||||
@@ -36,6 +38,7 @@ import {
|
|||||||
|
|
||||||
import { FormRenderer, type FieldDefinitionPatch } from "@binder/FormRenderer";
|
import { FormRenderer, type FieldDefinitionPatch } from "@binder/FormRenderer";
|
||||||
|
|
||||||
|
import { persistCapturePatch } from "./capture-persistence";
|
||||||
import { DEMO_SCHEMA } from "./demo-schema";
|
import { DEMO_SCHEMA } from "./demo-schema";
|
||||||
import { HighlightRectBridge } from "./HighlightRectBridge";
|
import { HighlightRectBridge } from "./HighlightRectBridge";
|
||||||
|
|
||||||
@@ -53,18 +56,31 @@ function quotePreview(text: string, max = 80): string {
|
|||||||
return t.length > max ? `${t.slice(0, max)}…` : t;
|
return t.length > max ? `${t.slice(0, max)}…` : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormsApp() {
|
export interface FormsAppProps {
|
||||||
const [schema, setSchema] = useState<FormSchema>(() => ({
|
readonly sessionId: SessionId;
|
||||||
...DEMO_SCHEMA,
|
readonly initialSchema?: FormSchema;
|
||||||
fields: [...DEMO_SCHEMA.fields],
|
readonly initialFieldValues?: Readonly<Record<string, string>>;
|
||||||
}));
|
}
|
||||||
|
|
||||||
|
export function FormsApp({
|
||||||
|
sessionId,
|
||||||
|
initialSchema,
|
||||||
|
initialFieldValues,
|
||||||
|
}: FormsAppProps) {
|
||||||
|
const [schema, setSchema] = useState<FormSchema>(() =>
|
||||||
|
initialSchema
|
||||||
|
? { ...initialSchema, fields: [...initialSchema.fields] }
|
||||||
|
: { ...DEMO_SCHEMA, fields: [...DEMO_SCHEMA.fields] },
|
||||||
|
);
|
||||||
|
|
||||||
const fieldLabels = useMemo(
|
const fieldLabels = useMemo(
|
||||||
() => new Map(schema.fields.map((f) => [f.id, f.label] as const)),
|
() => new Map(schema.fields.map((f) => [f.id, f.label] as const)),
|
||||||
[schema],
|
[schema],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
|
const [fieldValues, setFieldValues] = useState<Record<string, string>>(
|
||||||
|
() => ({ ...(initialFieldValues ?? {}) }),
|
||||||
|
);
|
||||||
const [showAddFieldForm, setShowAddFieldForm] = useState(false);
|
const [showAddFieldForm, setShowAddFieldForm] = useState(false);
|
||||||
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
|
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -72,6 +88,10 @@ export function FormsApp() {
|
|||||||
setFieldValues((prev) => ({ ...prev, [fieldId]: value }));
|
setFieldValues((prev) => ({ ...prev, [fieldId]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
persistCapturePatch(sessionId, { formSchema: schema, fieldValues });
|
||||||
|
}, [sessionId, schema, fieldValues]);
|
||||||
|
|
||||||
const nextFieldId = useCallback((fields: readonly FormFieldSchema[]): string => {
|
const nextFieldId = useCallback((fields: readonly FormFieldSchema[]): string => {
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const f of fields) {
|
for (const f of fields) {
|
||||||
|
|||||||
95
src/app/forms/capture-persistence.test.ts
Normal file
95
src/app/forms/capture-persistence.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* CE-WP-0008 — per-session capture state round-trip.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { SessionId } from "@shared/ids";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CAPTURE_STATE_VERSION,
|
||||||
|
captureStateKey,
|
||||||
|
defaultCaptureState,
|
||||||
|
loadCaptureState,
|
||||||
|
persistCapturePatch,
|
||||||
|
removeCaptureState,
|
||||||
|
saveCaptureState,
|
||||||
|
} from "./capture-persistence";
|
||||||
|
|
||||||
|
function memoryStorage(): Storage {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
get length() {
|
||||||
|
return map.size;
|
||||||
|
},
|
||||||
|
clear: () => map.clear(),
|
||||||
|
getItem: (k) => map.get(k) ?? null,
|
||||||
|
key: (i) => [...map.keys()][i] ?? null,
|
||||||
|
removeItem: (k) => void map.delete(k),
|
||||||
|
setItem: (k, v) => void map.set(k, v),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION = "sess_capture" as SessionId;
|
||||||
|
|
||||||
|
describe("capture-persistence", () => {
|
||||||
|
it("uses a per-session storage key", () => {
|
||||||
|
expect(captureStateKey(SESSION)).toBe(
|
||||||
|
"citation-evidence:session:sess_capture:capture-state:v1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips schema, field values, and links", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const state = defaultCaptureState();
|
||||||
|
const withData = {
|
||||||
|
...state,
|
||||||
|
fieldValues: { summary: "Tenant owes arrears", deadline: "2026-12-15" },
|
||||||
|
evidenceLinks: [
|
||||||
|
{
|
||||||
|
id: "evlink_1",
|
||||||
|
evidenceItemId: "evi_1",
|
||||||
|
targetType: "form-field",
|
||||||
|
targetId: "summary",
|
||||||
|
relation: "supports",
|
||||||
|
status: "candidate",
|
||||||
|
createdAt: "2026-06-08T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-08T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
saveCaptureState(SESSION, withData, storage);
|
||||||
|
const loaded = loadCaptureState(SESSION, storage);
|
||||||
|
|
||||||
|
expect(loaded?.version).toBe(CAPTURE_STATE_VERSION);
|
||||||
|
expect(loaded?.fieldValues).toEqual(withData.fieldValues);
|
||||||
|
expect(loaded?.evidenceLinks).toHaveLength(1);
|
||||||
|
expect(loaded?.formSchema.id).toBe("demo-form");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persistCapturePatch merges without dropping other fields", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
saveCaptureState(
|
||||||
|
SESSION,
|
||||||
|
{
|
||||||
|
...defaultCaptureState(),
|
||||||
|
fieldValues: { amount: "1200" },
|
||||||
|
evidenceLinks: [],
|
||||||
|
},
|
||||||
|
storage,
|
||||||
|
);
|
||||||
|
|
||||||
|
persistCapturePatch(SESSION, { fieldValues: { amount: "1500", summary: "Updated" } }, storage);
|
||||||
|
|
||||||
|
const loaded = loadCaptureState(SESSION, storage);
|
||||||
|
expect(loaded?.fieldValues).toEqual({ amount: "1500", summary: "Updated" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeCaptureState clears the key", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
saveCaptureState(SESSION, defaultCaptureState(), storage);
|
||||||
|
removeCaptureState(SESSION, storage);
|
||||||
|
expect(loadCaptureState(SESSION, storage)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
129
src/app/forms/capture-persistence.ts
Normal file
129
src/app/forms/capture-persistence.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Per-session Capture mode persistence (form schema, field values, links).
|
||||||
|
*
|
||||||
|
* Engine snapshots intentionally omit binder/app UI state. This module
|
||||||
|
* stores capture data beside the engine snapshot under a per-session
|
||||||
|
* localStorage key.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EvidenceLink } from "@shared/evidence-link";
|
||||||
|
import type { SessionId } from "@shared/ids";
|
||||||
|
|
||||||
|
import type { FormSchema } from "@binder/FormRenderer";
|
||||||
|
|
||||||
|
import { DEMO_SCHEMA } from "./demo-schema";
|
||||||
|
|
||||||
|
export const CAPTURE_STATE_VERSION = 1;
|
||||||
|
|
||||||
|
export interface CaptureStateSnapshot {
|
||||||
|
readonly version: number;
|
||||||
|
readonly formSchema: FormSchema;
|
||||||
|
readonly fieldValues: Readonly<Record<string, string>>;
|
||||||
|
readonly evidenceLinks: readonly EvidenceLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureStateKey(sessionId: SessionId): string {
|
||||||
|
return `citation-evidence:session:${sessionId}:capture-state:v1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultCaptureState(): CaptureStateSnapshot {
|
||||||
|
return {
|
||||||
|
version: CAPTURE_STATE_VERSION,
|
||||||
|
formSchema: { ...DEMO_SCHEMA, fields: [...DEMO_SCHEMA.fields] },
|
||||||
|
fieldValues: {},
|
||||||
|
evidenceLinks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFormSchema(value: unknown): value is FormSchema {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const o = value as Record<string, unknown>;
|
||||||
|
if (typeof o.id !== "string" || typeof o.title !== "string") return false;
|
||||||
|
if (!Array.isArray(o.fields)) return false;
|
||||||
|
return o.fields.every((f) => {
|
||||||
|
if (typeof f !== "object" || f === null) return false;
|
||||||
|
const field = f as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof field.id === "string" &&
|
||||||
|
typeof field.label === "string" &&
|
||||||
|
(field.type === "text" || field.type === "textarea" || field.type === "date")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCaptureState(raw: unknown): CaptureStateSnapshot | null {
|
||||||
|
if (typeof raw !== "object" || raw === null) return null;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
if (o.version !== CAPTURE_STATE_VERSION) return null;
|
||||||
|
if (!isFormSchema(o.formSchema)) return null;
|
||||||
|
if (typeof o.fieldValues !== "object" || o.fieldValues === null || Array.isArray(o.fieldValues)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const fieldValues: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(o.fieldValues as Record<string, unknown>)) {
|
||||||
|
if (typeof v === "string") fieldValues[k] = v;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(o.evidenceLinks)) return null;
|
||||||
|
const evidenceLinks = o.evidenceLinks as EvidenceLink[];
|
||||||
|
return {
|
||||||
|
version: CAPTURE_STATE_VERSION,
|
||||||
|
formSchema: o.formSchema,
|
||||||
|
fieldValues,
|
||||||
|
evidenceLinks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCaptureState(
|
||||||
|
sessionId: SessionId,
|
||||||
|
storage: Pick<Storage, "getItem"> = globalThis.localStorage,
|
||||||
|
): CaptureStateSnapshot | null {
|
||||||
|
if (typeof storage?.getItem !== "function") return null;
|
||||||
|
const raw = storage.getItem(captureStateKey(sessionId));
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return parseCaptureState(JSON.parse(raw) as unknown);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCaptureState(
|
||||||
|
sessionId: SessionId,
|
||||||
|
state: CaptureStateSnapshot,
|
||||||
|
storage: Pick<Storage, "setItem"> = globalThis.localStorage,
|
||||||
|
): void {
|
||||||
|
if (typeof storage?.setItem !== "function") return;
|
||||||
|
try {
|
||||||
|
storage.setItem(captureStateKey(sessionId), JSON.stringify(state));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("saveCaptureState: write failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistCapturePatch(
|
||||||
|
sessionId: SessionId,
|
||||||
|
patch: Partial<
|
||||||
|
Pick<CaptureStateSnapshot, "formSchema" | "fieldValues" | "evidenceLinks">
|
||||||
|
>,
|
||||||
|
storage: Pick<Storage, "getItem" | "setItem"> = globalThis.localStorage,
|
||||||
|
): void {
|
||||||
|
const current = loadCaptureState(sessionId, storage) ?? defaultCaptureState();
|
||||||
|
saveCaptureState(
|
||||||
|
sessionId,
|
||||||
|
{
|
||||||
|
version: CAPTURE_STATE_VERSION,
|
||||||
|
formSchema: patch.formSchema ?? current.formSchema,
|
||||||
|
fieldValues: patch.fieldValues ?? current.fieldValues,
|
||||||
|
evidenceLinks: patch.evidenceLinks ?? current.evidenceLinks,
|
||||||
|
},
|
||||||
|
storage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCaptureState(
|
||||||
|
sessionId: SessionId,
|
||||||
|
storage: Pick<Storage, "removeItem"> = globalThis.localStorage,
|
||||||
|
): void {
|
||||||
|
if (typeof storage?.removeItem !== "function") return;
|
||||||
|
storage.removeItem(captureStateKey(sessionId));
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
|
import type { EvidenceLink } from "@shared/evidence-link";
|
||||||
|
|
||||||
import type { EventBus } from "@engine/events";
|
import type { EventBus } from "@engine/events";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -68,9 +70,20 @@ export interface BinderProviderProps {
|
|||||||
* because its observers attach to the current `window`.
|
* because its observers attach to the current `window`.
|
||||||
*/
|
*/
|
||||||
readonly services?: Omit<BinderServices, "rect">;
|
readonly services?: Omit<BinderServices, "rect">;
|
||||||
|
/**
|
||||||
|
* Restored evidence links for this session. Seeded directly into the
|
||||||
|
* repo (no bus events) so reload does not spuriously re-emit
|
||||||
|
* `EvidenceLinkCreated`.
|
||||||
|
*/
|
||||||
|
readonly initialLinks?: readonly EvidenceLink[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BinderProvider({ children, bus, services }: BinderProviderProps) {
|
export function BinderProvider({
|
||||||
|
children,
|
||||||
|
bus,
|
||||||
|
services,
|
||||||
|
initialLinks,
|
||||||
|
}: BinderProviderProps) {
|
||||||
const built = useMemo<BinderServices>(() => {
|
const built = useMemo<BinderServices>(() => {
|
||||||
const links = services?.links ?? createInMemoryLinkRepo();
|
const links = services?.links ?? createInMemoryLinkRepo();
|
||||||
const bindings = services?.bindings ?? createBindingService(links, bus);
|
const bindings = services?.bindings ?? createBindingService(links, bus);
|
||||||
@@ -78,6 +91,15 @@ export function BinderProvider({ children, bus, services }: BinderProviderProps)
|
|||||||
return { links, bindings, rect };
|
return { links, bindings, rect };
|
||||||
}, [bus, services]);
|
}, [bus, services]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialLinks?.length || services?.links) return;
|
||||||
|
for (const link of initialLinks) {
|
||||||
|
if (!built.links.get(link.id)) {
|
||||||
|
built.links.create(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [built.links, initialLinks, services?.links]);
|
||||||
|
|
||||||
// Disconnect rect observers + listeners on unmount.
|
// Disconnect rect observers + listeners on unmount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ export function attachSessionPersister(
|
|||||||
// Also drop the per-session active-document-id key — otherwise
|
// Also drop the per-session active-document-id key — otherwise
|
||||||
// it gets orphaned and accumulates in localStorage forever.
|
// it gets orphaned and accumulates in localStorage forever.
|
||||||
storage.removeItem(`citation-evidence:session:${sessionId}:active-document-id:v1`);
|
storage.removeItem(`citation-evidence:session:${sessionId}:active-document-id:v1`);
|
||||||
|
storage.removeItem(`citation-evidence:session:${sessionId}:capture-state:v1`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("attachSessionPersister: snapshot cleanup failed", err);
|
console.warn("attachSessionPersister: snapshot cleanup failed", err);
|
||||||
}
|
}
|
||||||
|
|||||||
109
tests/integration/capture-session-persist.dom.test.tsx
Normal file
109
tests/integration/capture-session-persist.dom.test.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Capture state survives switching away from and back to a session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @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 { Selector } from "@shared/selector";
|
||||||
|
|
||||||
|
import type { PdfSelectionCapture } from "@anchor/index";
|
||||||
|
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
|
||||||
|
|
||||||
|
import { captureStateKey, loadCaptureState } from "@app/forms/capture-persistence";
|
||||||
|
|
||||||
|
interface ViewerProps {
|
||||||
|
pdfUrl: string;
|
||||||
|
storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[];
|
||||||
|
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("@anchor/index", async (importOriginal) => {
|
||||||
|
const original = await importOriginal<typeof import("@anchor/index")>();
|
||||||
|
const MockPdfSpikeViewer = (_props: ViewerProps) => (
|
||||||
|
<div data-testid="mock-pdf-viewer" />
|
||||||
|
);
|
||||||
|
return { ...original, PdfSpikeViewer: MockPdfSpikeViewer };
|
||||||
|
});
|
||||||
|
|
||||||
|
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
|
||||||
|
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
|
||||||
|
const SESSION_NAME = "capture-persist-session";
|
||||||
|
|
||||||
|
import { seedSessionWithDoc } from "./helpers/seed-session";
|
||||||
|
|
||||||
|
async function loadApp() {
|
||||||
|
const { App } = await import("@app/App");
|
||||||
|
return render(<App />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Capture session persistence", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.localStorage?.clear();
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
history.replaceState(null, "", window.location.pathname);
|
||||||
|
}
|
||||||
|
seedSessionWithDoc({
|
||||||
|
sessionName: SESSION_NAME,
|
||||||
|
documentTitle: FIXTURE.filename,
|
||||||
|
canonicalText: SYNTHETIC_CANONICAL,
|
||||||
|
mode: "forms",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"restores typed field values after leaving and reopening the session",
|
||||||
|
{ timeout: 20000 },
|
||||||
|
async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const first = await loadApp();
|
||||||
|
|
||||||
|
const summary = screen.getByLabelText("Summary of the matter");
|
||||||
|
await user.type(summary, "Persisted summary text");
|
||||||
|
await user.click(screen.getByLabelText("Disputed amount"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const stored = Object.values(globalThis.localStorage ?? {}).join("");
|
||||||
|
expect(stored).toContain("Persisted summary text");
|
||||||
|
});
|
||||||
|
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
await loadApp();
|
||||||
|
|
||||||
|
const restored = screen.getByLabelText("Summary of the matter") as HTMLTextAreaElement;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(restored.value).toBe("Persisted summary text");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"writes capture state under the per-session storage key",
|
||||||
|
{ timeout: 15000 },
|
||||||
|
async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await loadApp();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Disputed amount"), "EUR 500");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const keys = Object.keys(globalThis.localStorage ?? {});
|
||||||
|
const captureKey = keys.find((k) => k.includes(":capture-state:v1"));
|
||||||
|
expect(captureKey).toBeTruthy();
|
||||||
|
const sessionId = captureKey!.split(":")[2];
|
||||||
|
const state = loadCaptureState(sessionId as never);
|
||||||
|
expect(state?.fieldValues.amount).toBe("EUR 500");
|
||||||
|
expect(captureStateKey(sessionId as never)).toBe(captureKey);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user