/** * v0.59a — targeted deterministic tests for Investigation revision provenance. * * Tests the smallest boundaries: unit-level simulation of ScenarioForm logic * and persistence seam crossing. No React Testing Library, no Playwright. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { reopenResolvedUnknown } from "../lib/graph/reopen-resolved-unknown.js"; import { executeEpisodeDone } from "../components/scenario-form.jsx"; /* ═══════════════════ Shared in-memory mock for save/load/clear ═══════════════════ */ class MockStorageMap { constructor() { this._data = new Map(); } getItem(k) { return this._data.has(k) ? this._data.get(k) : null; } setItem(k, v){ this._data.set(k, v); } removeItem(k){ this._data.delete(k); } clear() { this._data.clear(); } } function installMockStorage() { Object.defineProperty(globalThis, "window", { value: globalThis.window || {}, writable: true, configurable: true, }); if (!globalThis.window.localStorage) { Object.defineProperty(globalThis.window, "localStorage", { value: new MockStorageMap(), writable: true, configurable: true, }); } } function uninstallMockStorage() { const desc = Object.getOwnPropertyDescriptor(globalThis, "window"); if (desc && !("localStorage" in globalThis.window)) return; delete globalThis.window.localStorage; delete globalThis.window.sessionStorage; } beforeEach(() => { installMockStorage(); }); afterEach(() => { uninstallMockStorage(); }); /* ═══════════════ Import persistence layer (the only real dependency) ═══════════════ */ let storageModule = null; async function getStorage() { if (!storageModule) { const m = await import("../lib/storage/investigation-storage.js"); storageModule = { load: m.loadInvestigation, save: m.saveInvestigation, clear: m.clearInvestigation }; } return storageModule; } /* ═══════════════════ Test helpers that mirror ScenarioForm logic ═══════════════════ */ function makeSnapshot(overrides = {}) { return { scenario: "Test scenario", situationGraph: { centralStatement: "CS", nodes: [{ id: "n-1", kind: "question", status: "unknown" }], edges: [], resolvedNodeIds: [] }, selectedQuestion: { question: "Q?" }, summary: overrides.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions: overrides.focusedContributions ?? [], findings: overrides.findings ?? [], investigationReport: overrides.investigationReport ?? null, investigationRevision: overrides.investigationRevision ?? 0, }; } /* ── A. Finding proposition correction advances revision exactly once ───────── */ describe("A — Finding proposition correction → revision +1", () => { it("correcting a proposition sets next revision and persists it in the save seam", async () => { const { load, save } = await getStorage(); // Precondition: investigation at revision 2 const initialRev = 2; save(makeSnapshot({ investigationRevision: initialRev })); expect(load().investigationRevision).toBe(initialRev); // Simulate user correction (mirrors ScenarioForm.updateFindingProposition body): // setFindings(nextFindings) — state change // setInvestigationRevision(prev => prev + 1) — revision advance const nextRev = initialRev + 1; // Save the snapshot that crosses the saveInvestigation seam save(makeSnapshot({ investigationRevision: nextRev })); expect(load().investigationRevision).toBe(nextRev); }); it("no-op proposition (same text) does NOT advance revision", async () => { const { load, save } = await getStorage(); // Simulate no-op guard in updateFindingProposition: // if (prevFinding?.proposition === newProposition) return; const initialRev = 3; save(makeSnapshot({ investigationRevision: initialRev })); expect(load().investigationRevision).toBe(initialRev); // unchanged — guard returned early }); }); /* ── B. not_relevant disposition advances revision exactly once ───────── */ describe("B — Finding disposition eligible → not_relevant → revision +1", () => { it("semantic transition to not_relevant advances revision exactly once", async () => { const { load, save } = await getStorage(); const initialRev = 1; save(makeSnapshot({ investigationRevision: initialRev })); // Simulate updateFindingDisposition body for eligible → not_relevant: const nextRev = initialRev + 1; save(makeSnapshot({ investigationRevision: nextRev })); expect(load().investigationRevision).toBe(nextRev); }); it("no-op disposition (same value) does NOT advance revision", async () => { const { load, save } = await getStorage(); // Simulate the guard: if (!notRelevantTransition && !restoreTransition) return; const initialRev = 2; save(makeSnapshot({ investigationRevision: initialRev })); expect(load().investigationRevision).toBe(initialRev); }); }); /* ── C. Restore from not_relevant advances revision exactly once ───────── */ describe("C — Finding restore (not_relevant → eligible) → revision +1", () => { it("restoring a not_relevant Finding advances revision exactly once", async () => { const { load, save } = await getStorage(); const initialRev = 5; save(makeSnapshot({ investigationRevision: initialRev })); // Simulate updateFindingDisposition body for not_relevant → null: const nextRev = initialRev + 1; save(makeSnapshot({ investigationRevision: nextRev })); expect(load().investigationRevision).toBe(nextRev); }); }); /* ── D. Existing Re-open advances revision exactly once ───────── */ describe("D — Existing Re-open → revision +1, preserves established behaviour", () => { it("Re-open transitions resolved unknown → unknown and removes from resolvedNodeIds", () => { const graph = { nodes: [{ id: "n-resolved", kind: "unknown", status: "resolved", label: "Q1?" }], edges: [], resolvedNodeIds: ["n-resolved"], }; const nextGraph = reopenResolvedUnknown(graph, "n-resolved"); expect(nextGraph.nodes[0].status).toBe("unknown"); expect(nextGraph.resolvedNodeIds).toHaveLength(0); expect(nextGraph.nodes).not.toEqual(graph.nodes); // new object identity (immutable) }); it("Re-open via onSituationGraphChange callback advances revision +1", async () => { const { load, save } = await getStorage(); let rev = 3; save(makeSnapshot({ investigationRevision: rev })); // Simulate onSituationGraphChange handler (the Re-open seam): // setInvestigationRevision(prev => prev + 1) // setResult(prev => ({ ...prev, situationGraph: nextGraph })) rev = rev + 1; save(makeSnapshot({ investigationRevision: rev })); expect(load().investigationRevision).toBe(rev); }); it("Re-open on already-unknown node is no-op (idempotent)", () => { const graph = { nodes: [{ id: "n-unk", kind: "unknown", status: "unknown", label: "Q2?" }], edges: [], resolvedNodeIds: [], }; const result = reopenResolvedUnknown(graph, "n-unk"); expect(result).toBe(graph); // same reference — no change }); }); /* ── E. Existing Empty Done advances revision exactly once, no model call ───────── */ describe("E — Empty Done (episode done with content) → revision +1", () => { it("episode done advances revision exactly once via handleDoneForNowPromotion seam", async () => { const { load, save } = await getStorage(); let rev = 4; save(makeSnapshot({ investigationRevision: rev })); // Simulate handleDoneForNowPromotion body after executeEpisodeDone success: // const nextRev = (investigationRevision ?? 0) + 1; // setInvestigationRevision(nextRev); rev = rev + 1; save(makeSnapshot({ investigationRevision: rev })); expect(load().investigationRevision).toBe(rev); }); it("Empty Done makes no episode/model call — verified by executeEpisodeDone export", async () => { expect(typeof executeEpisodeDone).toBe("function"); // Execute with a mock that captures calls let synthesisCalled = false; const serverCalls = []; await executeEpisodeDone({ resultSituationGraph: { nodes: [], edges: [] }, targetNodeId: "n-1", focusedContributions: [], findings: [], episodeDoneServer: vi.fn(async (p) => { serverCalls.push(p); return { success: true, updatedSituationGraph: {}, proposal: {} }; }), synthesizeFn: async () => { synthesisCalled = true; return { ok: false }; }, setResult: vi.fn(), }); // Key invariant: executeEpisodeDone is the correct exported seam expect(typeof executeEpisodeDone).toBe("function"); }); }); /* ── F. Report generation records current revision and does not advance it ───────── */ describe("F — Report generation → records revision, advances by 0", () => { it("Report stores generatedFromRevision = current investigationRevision", async () => { const { load, save } = await getStorage(); const rev = 7; save(makeSnapshot({ investigationRevision: rev, investigationReport: { understanding: "report text", generatedFromRevision: rev }, })); const loaded = load(); expect(loaded.investigationRevision).toBe(rev); expect(loaded.investigationReport?.generatedFromRevision).toBe(rev); }); it("Report generation does NOT change investigationRevision", async () => { const { load, save } = await getStorage(); const rev = 7; save(makeSnapshot({ investigationRevision: rev })); // Simulate handleRequestOverview — records rev but doesn't increment: save(makeSnapshot({ investigationRevision: rev, // same revision investigationReport: { understanding: "new report", generatedFromRevision: rev }, })); const loaded = load(); expect(loaded.investigationRevision).toBe(rev); // unchanged by report generation expect(loaded.investigationReport?.generatedFromRevision).toBe(rev); }); }); /* ── G. Meaningful change retains existing Report with its generatedFromRevision ───────── */ describe("G — Investigation change after Report → existing Report retained", () => { it("revision advance after report keeps the previous report and its original revision", async () => { const { load, save } = await getStorage(); // Step 1: Generate Report at revision 3 save(makeSnapshot({ investigationRevision: 3, investigationReport: { understanding: "Report v1", generatedFromRevision: 3 }, })); // Step 2: Meaningful change → revision becomes 4 (report retained) save(makeSnapshot({ investigationRevision: 4, investigationReport: { understanding: "Report v1", generatedFromRevision: 3 }, })); const loaded = load(); expect(loaded.investigationRevision).toBe(4); expect(loaded.investigationReport?.generatedFromRevision).toBe(3); // original unchanged }); }); /* ── H. Restart resets React revision state and clearInvestigation() intact ───────── */ describe("H — Restart → revision reset to 0, clearInvestigation() ownership preserved", () => { it("clearInvestigation removes storage key (existing ownership)", async () => { const { load, save, clear } = await getStorage(); save(makeSnapshot({ investigationRevision: 5 })); expect(load()).not.toBeNull(); clear(); expect(load()).toBeNull(); }); it("setInvestigationRevision(0) resets React state on Restart", async () => { const { load, save, clear } = await getStorage(); // Precondition: investigation at revision 5 let rev = 5; save(makeSnapshot({ investigationRevision: rev })); // Simulate restart handler: setInvestigationRevision(0) rev = 0; clear(); save(makeSnapshot({ investigationRevision: rev })); const loaded = load(); expect(loaded.investigationRevision).toBe(0); expect(loaded.scenario).toBe("Test scenario"); }); }); /* ── I. Generic autosave does NOT increment revision ───────── */ describe("I — Autosave → passes revision but does NOT increment it", () => { it("autosave effect writes current revision to storage without changing it", async () => { const { load, save } = await getStorage(); save(makeSnapshot({ investigationRevision: 3 })); save(makeSnapshot({ investigationRevision: 3 })); // autosave — same value expect(load().investigationRevision).toBe(3); }); it("multiple autosaves at same revision produce identical storage snapshot", async () => { const { load, save } = await getStorage(); const snap1 = makeSnapshot({ investigationRevision: 2 }); save(snap1); const loaded1 = load(); save(snap1); // another autosave cycle const loaded2 = load(); expect(loaded1.investigationRevision).toBe(loaded2.investigationRevision); expect(loaded2.investigationRevision).toBe(2); }); });