Semantic revision tracking ensures every meaningful persisted Investigation change advances investigationRevision exactly once, while Report generation records (but does not advance) the current revision as generatedFromRevision for provenance integrity. Corrections: - updateFindingDisposition: add setInvestigationRevision(+1) for semantic transitions (eligible→not_relevant, restore) - updateFindingProposition: add no-op guard + setInvestigationRevision(+1) - onRestart/ContinueLaterBanner/reset button: add setInvestigationRevision(0) - onSituationGraphChange (Re-open seam): already had revision +1 in dirty impl Established behaviour preserved: - Re-open via reopenResolvedUnknown → onSituationGraphChange → revision +1 - Empty Done via handleDoneForNowPromotion → revision +1 - Report generation records generatedFromRevision, advances by 0 - Autosave passes revision but does not increment it - clearInvestigation() ownership intact Tests: targeted Vitest suite (17 tests) covering all provenance boundaries. Durable rule documented in current-handoff.md §v0.59a.
350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|