marmar-context / web / context / test / workspace.test.ts
workspace.test.ts
Raw
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);
  });
});