feat(confidence-engine): v0.59a — correct Investigation revision provenance

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.
This commit is contained in:
2026-09-03 13:39:40 +01:00
parent 37a9a12f93
commit 99b3d26817
5 changed files with 437 additions and 12 deletions
+349
View File
@@ -0,0 +1,349 @@
/**
* 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);
});
});