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:
@@ -48,10 +48,12 @@ export default function ReportPage() {
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setExisting((prev) => {
|
||||
const updated = { ...prev, investigationReport: { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true } };
|
||||
saveInvestigation(updated);
|
||||
return updated;
|
||||
/* ── v0.59a — provenance: record generation revision (does NOT change Investigation revision) ── */
|
||||
const rev = existing?.investigationRevision ?? 0;
|
||||
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
|
||||
setExisting((p) => {
|
||||
saveInvestigation({ ...p, investigationReport: reportData });
|
||||
return { ...p, investigationReport: reportData };
|
||||
});
|
||||
} else {
|
||||
setGenerationError(true);
|
||||
|
||||
@@ -1329,6 +1329,8 @@ export default function ReasoningWorkspace({
|
||||
onImmediateGraphChange,
|
||||
/* ── canonical graph-replacement seam (future Re-open) ── */
|
||||
onSituationGraphChange,
|
||||
/* ── v0.59a — provenance revision ──────────────────── */
|
||||
investigationRevision,
|
||||
/* ── test init seam (no effect → immediate state) ───────── */
|
||||
initialPostAnalyseStatus,
|
||||
/* ── v0.54b — investigation overview transient state ─── */
|
||||
|
||||
@@ -292,6 +292,9 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
/* ── v0.55 — persisted investigation report (derived artefact) ── */
|
||||
const [investigationReport, setInvestigationReport] = useState(null);
|
||||
|
||||
/* ── v0.59a — provenance: Investigation revision tracking ── */
|
||||
const [investigationRevision, setInvestigationRevision] = useState(0);
|
||||
|
||||
/* ── in-flight gate for episode reconsideration on Done ──── */
|
||||
const doneInProgressRef = useRef(false);
|
||||
|
||||
@@ -326,6 +329,9 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
|
||||
if (!notRelevantTransition && !restoreTransition) return;
|
||||
|
||||
/* ── v0.59a — provenance: eligible evidence set changed ── */
|
||||
setInvestigationRevision((prev) => (prev ?? 0) + 1);
|
||||
|
||||
const currentGraph = result?.situationGraph;
|
||||
if (!currentGraph) return;
|
||||
|
||||
@@ -345,8 +351,15 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
f.id === findingId ? { ...f, proposition: newProposition, userDisposition: null } : f,
|
||||
);
|
||||
|
||||
/* ── v0.59a — provenance: no-op guard ── */
|
||||
const prevFinding = (findings ?? []).find((f) => f.id === findingId);
|
||||
if (prevFinding?.proposition === newProposition) return; // no semantic change
|
||||
|
||||
setFindings(() => nextFindings);
|
||||
|
||||
/* ── v0.59a — provenance: corrected Finding changes evidence ── */
|
||||
setInvestigationRevision((prev) => (prev ?? 0) + 1);
|
||||
|
||||
// ── Synthesis trigger: corrected Finding → one reconstruction ──
|
||||
const currentGraph = result?.situationGraph;
|
||||
if (!currentGraph) return;
|
||||
@@ -408,6 +421,10 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
setResult,
|
||||
});
|
||||
|
||||
/* ── v0.59a — provenance: episode done is meaningful evidence change ── */
|
||||
const nextRev = (investigationRevision ?? 0) + 1;
|
||||
setInvestigationRevision(nextRev);
|
||||
|
||||
/* CU synthesis — install only on success */
|
||||
if (doneResult?.synthesisResult?.ok && doneResult.synthesisResult.data?.currentUnderstanding) {
|
||||
setCurrentUnderstanding(doneResult.synthesisResult.data.currentUnderstanding);
|
||||
@@ -448,10 +465,12 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
setOverviewState(res);
|
||||
|
||||
// Persist as a derived artefact of this investigation
|
||||
const rev = investigationRevision ?? 0;
|
||||
const report = {
|
||||
understanding: res.understanding,
|
||||
plausibleInterpretations: hasPlausibleInput ? res.plausibleInterpretations ?? "" : "",
|
||||
hasPlausibleInterpretations: hasPlausibleInput,
|
||||
generatedFromRevision: rev,
|
||||
};
|
||||
setInvestigationReport(report);
|
||||
|
||||
@@ -465,6 +484,7 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
focusedContributions,
|
||||
findings,
|
||||
investigationReport: report,
|
||||
investigationRevision: rev,
|
||||
});
|
||||
}
|
||||
// On failure: do not clear existing CU, do not block further attempts
|
||||
@@ -539,6 +559,9 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
setInvestigationReport(saved.investigationReport);
|
||||
}
|
||||
|
||||
/* ── v0.59a — hydrate provenance revision ─────────── */
|
||||
setInvestigationRevision(saved.investigationRevision ?? 0);
|
||||
|
||||
// Partial sessions (present but no graph) must NOT suppress the
|
||||
// scenario-entry form. Only promote to success when there is actual
|
||||
// investigation data to render.
|
||||
@@ -565,6 +588,7 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
focusedContributions,
|
||||
findings,
|
||||
investigationReport,
|
||||
investigationRevision,
|
||||
});
|
||||
}, [
|
||||
scenario,
|
||||
@@ -574,6 +598,7 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
focusedContributions,
|
||||
findings,
|
||||
investigationReport,
|
||||
investigationRevision,
|
||||
]);
|
||||
|
||||
/* Restore facilitator dismiss preference (Experiment 05) ─── */
|
||||
@@ -651,7 +676,9 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
setCurrentUnderstanding(data.summary ?? null);
|
||||
const normalised = normaliseStartResult(data);
|
||||
setResult(normalised);
|
||||
saveInvestigation({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [], investigationReport });
|
||||
/* ── v0.59a — provenance: first meaningful change sets revision to 1 ── */
|
||||
setInvestigationRevision(1);
|
||||
saveInvestigation({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [], investigationReport, investigationRevision: 1 });
|
||||
} else {
|
||||
setStatus("error");
|
||||
setCurrentUnderstanding(data.summary ?? null);
|
||||
@@ -735,7 +762,10 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
|
||||
setAnswer("");
|
||||
// Persist after successful update turn — include explicit next state
|
||||
saveInvestigation({ scenario, situationGraph: nextGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: nextFindings, investigationReport });
|
||||
/* ── v0.59a — provenance: meaningful change advances revision ── */
|
||||
const nextRev = (investigationRevision ?? 0) + 1;
|
||||
setInvestigationRevision(nextRev);
|
||||
saveInvestigation({ scenario, situationGraph: nextGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: nextFindings, investigationReport, investigationRevision: nextRev });
|
||||
} else {
|
||||
setUpdateStatus("error");
|
||||
setUpdateError(outcome);
|
||||
@@ -907,10 +937,16 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
onSummaryUpdate={handleDoneForNowPromotion}
|
||||
/* ── immediate graph transition (Done acknowledged before async) ── */
|
||||
onImmediateGraphChange={(nextGraph) => setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }))}
|
||||
/* ── canonical graph-replacement seam (future Re-open) ── */
|
||||
onSituationGraphChange={(nextGraph) => setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }))}
|
||||
/* ── v0.59a — provenance tracking ─────────────────── */
|
||||
investigationRevision={investigationRevision}
|
||||
onSituationGraphChange={(nextGraph) => {
|
||||
const nextRev = (investigationRevision ?? 0) + 1;
|
||||
setInvestigationRevision(nextRev);
|
||||
setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }));
|
||||
}}
|
||||
onRestart={() => {
|
||||
clearInvestigation();
|
||||
setInvestigationRevision(0);
|
||||
setStatus("idle");
|
||||
setResult(null);
|
||||
setAnswer("");
|
||||
@@ -930,7 +966,7 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
|
||||
{/* ── Continue later banner when session was restored ── */}
|
||||
{status === "success" && result?.updatedAt && (
|
||||
<ContinueLaterBanner onRestart={() => { clearInvestigation(); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); setFindings([]); }} />
|
||||
<ContinueLaterBanner onRestart={() => { clearInvestigation(); setInvestigationRevision(0); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); setFindings([]); }} />
|
||||
)}
|
||||
|
||||
{/* Reset button after successful analysis */}
|
||||
@@ -939,6 +975,7 @@ export default function ScenarioForm({ onNavigateToReport }) {
|
||||
<button
|
||||
onClick={() => {
|
||||
clearInvestigation();
|
||||
setInvestigationRevision(0);
|
||||
setScenario("");
|
||||
setStatus("idle");
|
||||
setResult(null);
|
||||
|
||||
+38
-3
@@ -6,8 +6,8 @@
|
||||
## Repository checkpoint
|
||||
|
||||
- **Branch:** `feature/investigation-report-v0.55`
|
||||
- **HEAD:** `7db28c8` — first Report generation lifecycle verified
|
||||
- **Working tree:** clean (documented)
|
||||
- **HEAD:** `37a9a12` — route design evolution provenance through archive index
|
||||
- **Working tree:** will be clean after v0.59a commit
|
||||
|
||||
## Current product architecture
|
||||
|
||||
@@ -71,6 +71,39 @@ RAW USER EVIDENCE
|
||||
- Re-open returns the question to Open Questions and removes from `doneForNowIds`.
|
||||
- Older stale development localStorage states (pre-v0.53 shape) may be discarded during current dev phase. No migration required.
|
||||
|
||||
### v0.59a — Investigation revision provenance rule
|
||||
|
||||
- Investigation has a semantic revision (`investigationRevision`).
|
||||
- Meaningful persisted Investigation changes advance it by exactly 1.
|
||||
- Persistence activity itself (autosave, save) does NOT advance revision.
|
||||
- Report generation records `generatedFromRevision: investigationRevision`.
|
||||
- Report generation advances revision by 0.
|
||||
- Equal revisions mean Report reflects current Investigation.
|
||||
- Different revisions mean the Investigation has changed since Report generation.
|
||||
- Existing Report remains available (not invalidated).
|
||||
- Report update/regeneration remains manual (user-triggered).
|
||||
- Restart clears Investigation + Report via `clearInvestigation()` + `setInvestigationRevision(0)`.
|
||||
- Report history/comparison remains deferred beyond MVP.
|
||||
|
||||
**Semantic transitions that advance revision:**
|
||||
```
|
||||
Episode Done (with content) → +1
|
||||
Re-open (resolved → unknown) → +1
|
||||
Finding proposition correction → +1
|
||||
Eligible → not_relevant → +1
|
||||
not_relevant → eligible (restore) → +1
|
||||
First meaningful change → =1 (from initial 0)
|
||||
```
|
||||
|
||||
**Transitions that do NOT advance revision:**
|
||||
```
|
||||
Autosave → 0
|
||||
Report generation → 0
|
||||
Hydration → 0 (reads value)
|
||||
Empty proposition correction (no-op) → 0
|
||||
null → null disposition (no-op) → 0
|
||||
```
|
||||
|
||||
### Zero Open Questions milestone (v0.51)
|
||||
|
||||
When all unknowns are resolved and clarified questions exist: "You've now worked through all of the questions we surfaced. Would you like to see an overview of what we understand so far?" with "Review current understanding" button. This occupies the former Open Questions position. The invitation is a milestone, not a readiness/completion judgement.
|
||||
@@ -103,9 +136,11 @@ Reconstruct CU when canonical meaning or eligible evidence set changes — NOT w
|
||||
|
||||
- Multi-investigation portfolio (search/tag/archive/group)
|
||||
- Durable investigation identities beyond `case-1`
|
||||
- Report freshness/versioning after investigation changes
|
||||
- Visible Report freshness UI (revision comparison display — backend tracking in place)
|
||||
- "Update report" action (manual regeneration only)
|
||||
- Export/copy of Reports to Jira or external document
|
||||
- Portfolio expansion beyond one canonical investigation
|
||||
- Report history / comparison
|
||||
|
||||
**Known boundaries:**
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user