import { describe, it, expect, vi, afterEach } from "vitest"; import { actionableWarnings, clarificationError, clarificationReviewed, recoverReview, parseWorkspace, writeWorkspace, readWorkspace, } from "../src/workspace"; import { extractLocal } from "../server/context"; import { CASES } from "../server/cases"; import type { Source } from "../src/types"; const scenario = CASES[0]; const draft = extractLocal(scenario.sources); const review = Object.fromEntries( draft.facts.map((f) => [f.id, "include" as const]) ); describe("workspace recovery", () => { it("restores decisions after server IDs change", () => { const next = { ...draft, facts: draft.facts.map((f) => ({ ...f, id: `new-${f.id}` })), }; expect(Object.keys(recoverReview(draft, next, review))).toHaveLength( draft.facts.length ); }); it("leaves changed facts and new facts unreviewed", () => { const next = { ...draft, facts: draft.facts.map((f, i) => i === 0 ? { ...f, label: "Different person" } : f ), }; expect( recoverReview(draft, next, review)[next.facts[0].id] ).toBeUndefined(); }); it("never restores decisions across changed source fingerprints", () => { expect( recoverReview(draft, { ...draft, fingerprint: "changed" }, review) ).toEqual({}); }); it("does not transfer contradictory duplicate decisions", () => { const duplicate = { ...draft.facts[0], id: "duplicate" }; expect( recoverReview({ ...draft, facts: [...draft.facts, duplicate] }, draft, { ...review, duplicate: "exclude", })[draft.facts[0].id] ).toBeUndefined(); }); it("round-trips edited sources, review and an unfinished source edit", () => { let raw = ""; const storage = { getItem: () => raw, setItem: (_: string, v: string) => { raw = v; }, }; writeWorkspace(storage, { version: 1, savedAt: "now", scenario, sources: scenario.sources, sourceId: scenario.sources[0].id, draft, review, useModel: true, editing: true, editText: "Unsaved note", report: { synthetic: true }, }); expect(readWorkspace(storage)?.editText).toBe("Unsaved note"); expect(readWorkspace(storage)?.review).toEqual(review); }); it("saves personal factor facts such as weight, sex and potassium", () => { const grace = CASES.find((c) => c.id === "personal-factors")!; const graceDraft = extractLocal(grace.sources); expect(graceDraft.facts.map((f) => f.kind)).toEqual( expect.arrayContaining(["weight", "sex"]) ); const saved = parseWorkspace( JSON.stringify({ version: 1, savedAt: "now", scenario: grace, sources: grace.sources, sourceId: grace.sources[0].id, draft: graceDraft, review: {}, useModel: false, editing: false, editText: "", report: null, }) ); expect(saved.draft?.facts).toHaveLength(graceDraft.facts.length); }); it("rejects malformed backups before replacing work", () => { expect(() => parseWorkspace('{"version":1}')).toThrow(); expect(() => parseWorkspace("broken")).toThrow(); }); it("surfaces quota failure instead of claiming a save", () => { expect(() => writeWorkspace( { setItem: () => { throw new Error("quota"); }, }, { version: 1, savedAt: "now", scenario, sources: scenario.sources, sourceId: "x", draft, review, useModel: true, editing: false, editText: "", report: null, } ) ).toThrow("quota"); }); it("suppresses only redundant partial-identity warnings", () => { const sourceId = draft.facts.find((f) => f.kind === "patient")!.sourceId; const warnings = [ { sourceId, line: 1, reason: "A partial name cannot replace the patient identity already recorded in this source.", }, { sourceId, line: 2, reason: "Unknown strength" }, ]; expect(actionableWarnings({ ...draft, warnings })).toEqual([warnings[1]]); expect(actionableWarnings({ ...draft, facts: [], warnings })).toHaveLength( 2 ); }); it("validates overlong and empty clarification fields explicitly", () => { expect(clarificationError("statement", "x".repeat(121))).toContain("120"); expect(clarificationError("", "author")).not.toBe(""); expect(clarificationError("statement", "author")).toBe(""); }); const original: Source = { id: "original", title: "Original", kind: "note", date: "2026-10-23", text: "Patient: Nora Test\nDose needs clarification", }; const correction: Source = { id: "clarified", title: "Clarification", kind: "note", date: "2026-10-23", text: "Patient: Nora Test\nMedication: Amlodipine; strength: 2.5 mg", clarification: { sourceId: "original", line: 2, quote: "Dose needs clarification", reason: "Unknown dose", confirmedBy: "Synthetic author", }, }; const sources = [original, correction]; const clarified = extractLocal(sources); const reviewed = Object.fromEntries( clarified.facts.map((f) => [f.id, "include" as const]) ); it("marks a clarification reviewed only after all its facts are reviewed", () => { expect(clarificationReviewed(sources, clarified, {}, "original", 2)).toBe( false ); expect( clarificationReviewed(sources, clarified, reviewed, "original", 2) ).toBe(true); }); it("does not call an identity-only clarification resolved", () => { const onlyIdentity = { ...clarified, facts: clarified.facts.filter((f) => f.kind === "patient"), }; expect( clarificationReviewed(sources, onlyIdentity, reviewed, "original", 2) ).toBe(false); }); it("does not mark excluded or conflicting clarification facts resolved", () => { expect( clarificationReviewed( sources, clarified, Object.fromEntries( clarified.facts.map((f) => [f.id, "exclude" as const]) ), "original", 2 ) ).toBe(false); const med = clarified.facts.find((f) => f.kind === "medicine")!; const conflict = { ...med, id: "conflict", sourceId: "original", detail: "5 mg", quote: "Medication: Amlodipine; strength: 5 mg", }; expect( clarificationReviewed( sources, { ...clarified, facts: [...clarified.facts, conflict] }, { ...reviewed, conflict: "include" }, "original", 2 ) ).toBe(false); }); }); describe("recovery after server expiry", () => { afterEach(() => vi.useRealTimers()); it("retains exact review decisions after the two-hour server expiry", async () => { const { keepDraft, getDraft } = await import("../server/sessions"); vi.useFakeTimers(); const old = keepDraft(draft, scenario.sources); vi.advanceTimersByTime(2 * 60 * 60 * 1000); expect(() => getDraft(old.id!, scenario.sources)).toThrow("expired"); const fresh = keepDraft(extractLocal(scenario.sources), scenario.sources); expect(fresh.id).not.toBe(old.id); expect(recoverReview(old, fresh, review)).toEqual(review); }); it("recovers after capacity eviction without trusting the expired draft ID", async () => { const { keepDraft, getDraft } = await import("../server/sessions"); const old = keepDraft(draft, scenario.sources); for (let i = 0; i < 50; i++) keepDraft(draft, scenario.sources); expect(() => getDraft(old.id!, scenario.sources)).toThrow("expired"); const fresh = keepDraft(extractLocal(scenario.sources), scenario.sources); expect(recoverReview(old, fresh, review)).toEqual(review); }); });