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);
});
});