818 lines
28 KiB
JavaScript
818 lines
28 KiB
JavaScript
/**
|
|
* Focused provider-level contract tests for investigation-storage.
|
|
*
|
|
* Deterministic in-memory mocks — no React, no Playwright, no LLM calls.
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
|
|
// ── test-time storage mock (injected into globalThis.window) ───────────
|
|
|
|
class MockStorageMap {
|
|
constructor() {
|
|
this._data = new Map();
|
|
this._keys = []; // ordered key list for WebStorage API compatibility
|
|
}
|
|
get length() { return this._data.size; }
|
|
getItem(k) { return this._data.has(k) ? this._data.get(k) : null; }
|
|
setItem(k, v) { this._data.set(k, String(v)); if (!this._keys.includes(k)) this._keys.push(k); }
|
|
removeItem(k) { this._data.delete(k); const idx = this._keys.indexOf(k); if (idx !== -1) this._keys.splice(idx, 1); }
|
|
clear() { this._data.clear(); this._keys.length = 0; }
|
|
key(n) { return this._keys[n] ?? null; }
|
|
}
|
|
|
|
function installMockStorage() {
|
|
// Define window with mock localStorage and sessionStorage as properties
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: globalThis.window || {},
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
// Always force-install mock storage (previous uninstall may have left window
|
|
// without own properties but with inherited ones in some environments)
|
|
Object.defineProperty(globalThis.window, "localStorage", {
|
|
value: new MockStorageMap(),
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(globalThis.window, "sessionStorage", {
|
|
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;
|
|
if (Object.getOwnPropertyNames(globalThis.window).length === 0) {
|
|
delete globalThis.window;
|
|
}
|
|
}
|
|
|
|
// ── lifecycle ──────────────────────────────────────────────────────────
|
|
|
|
beforeEach(() => { installMockStorage(); });
|
|
afterEach (() => { uninstallMockStorage(); });
|
|
|
|
// ── import under test (fresh module each time) ────────────────────────
|
|
|
|
async function getModule() {
|
|
// Clear all cached modules to get fresh bindings per test
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/providers/local-storage.js");
|
|
return { load: m.loadInvestigation, save: m.saveInvestigation, clear: m.clearInvestigation };
|
|
}
|
|
|
|
// ── shared snapshot factories ──────────────────────────────────────────
|
|
|
|
function makeSnapshot(overrides = {}) {
|
|
return {
|
|
schemaVersion: overrides.schemaVersion ?? 1,
|
|
situationGraph: { nodes: [{ id: "n1", label: "A" }], edges: [] },
|
|
openQuestions: ["q1"],
|
|
assumptions: [],
|
|
observations: [],
|
|
uncertainties: [],
|
|
relationships: [],
|
|
messages: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function legacySnapshot() {
|
|
return { situationGraph: { nodes: [{ id: "old" }], edges: [] }, legacy: true };
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// Tests
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
describe("investigation-storage provider", () => {
|
|
|
|
// ── 1. save → load round trip ───────────────────────────────────
|
|
|
|
it("save then load returns the same snapshot (round-trip)", async () => {
|
|
const { load, save } = await getModule();
|
|
const snap = makeSnapshot({ situationGraph: { nodes: [{ id: "x1" }], edges: [] }, openQuestions: ["why?"] });
|
|
|
|
save(snap);
|
|
const loaded = load();
|
|
|
|
expect(loaded).not.toBeNull();
|
|
expect(loaded.situationGraph.nodes[0].id).toBe("x1");
|
|
expect(loaded.openQuestions).toEqual(["why?"]);
|
|
});
|
|
|
|
// ── 2. schemaVersion = 1 persisted ──────────────────────────────
|
|
|
|
it("persisted record includes schemaVersion = 1", async () => {
|
|
const { load, save } = await getModule();
|
|
const snap = makeSnapshot({ schemaVersion: undefined });
|
|
save(snap);
|
|
const loaded = load();
|
|
expect(loaded.schemaVersion).toBe(1);
|
|
});
|
|
|
|
// ── 3. caller snapshot object not mutated ───────────────────────
|
|
|
|
it("save does not mutate the caller's snapshot object", async () => {
|
|
const { save } = await getModule();
|
|
const snap = { nodes: [{ id: "a" }] };
|
|
const before = JSON.stringify(snap);
|
|
save(snap);
|
|
expect(JSON.stringify(snap)).toBe(before);
|
|
});
|
|
|
|
// ── 4. clear → load returns null ────────────────────────────────
|
|
|
|
it("clear then load returns null", async () => {
|
|
const { load, save, clear } = await getModule();
|
|
save(makeSnapshot());
|
|
expect(load()).not.toBeNull();
|
|
clear();
|
|
expect(load()).toBeNull();
|
|
});
|
|
|
|
// ── 5. malformed localStorage → null, no throw ─────────────────
|
|
|
|
it("malformed localStorage returns null without throwing", async () => {
|
|
const { load } = await getModule();
|
|
globalThis.window.localStorage.setItem(
|
|
"confidence-engine-investigation",
|
|
"not valid json {{{"
|
|
);
|
|
expect(() => load()).not.toThrow();
|
|
expect(load()).toBeNull();
|
|
});
|
|
|
|
// ── 6. localStorage takes precedence over sessionStorage ────────
|
|
|
|
it("localStorage value used even when legacy sessionStorage exists", async () => {
|
|
const { load, save } = await getModule();
|
|
const localSnap = makeSnapshot({ source: "local" });
|
|
const sessionSnap = legacySnapshot(); // no schemaVersion
|
|
|
|
save(localSnap);
|
|
globalThis.window.sessionStorage.setItem(
|
|
"confidence-engine-session",
|
|
JSON.stringify(sessionSnap)
|
|
);
|
|
|
|
const loaded = load();
|
|
expect(loaded).not.toBeNull();
|
|
expect(loaded.source).toBe("local"); // from localStorage, not sessionStorage
|
|
});
|
|
|
|
// ── 7. empty localStorage + valid legacy sessionStorage → legacy returned ──
|
|
|
|
it("empty canonical key returns legacy snapshot when no local state", async () => {
|
|
const { load, save } = await getModule();
|
|
save(makeSnapshot({ source: "local" })); // populate a value
|
|
globalThis.window.localStorage.clear(); // remove it (canonical key gone)
|
|
globalThis.window.sessionStorage.setItem(
|
|
"confidence-engine-session",
|
|
JSON.stringify(legacySnapshot())
|
|
);
|
|
|
|
const loaded = load();
|
|
expect(loaded).not.toBeNull();
|
|
expect(loaded.legacy).toBe(true); // came from sessionStorage
|
|
});
|
|
|
|
// ── 8. legacy snapshot copied into localStorage ─────────────────
|
|
|
|
it("legacy migration copies snapshot into canonical key", async () => {
|
|
const { load } = await getModule();
|
|
globalThis.window.localStorage.clear();
|
|
globalThis.window.sessionStorage.setItem(
|
|
"confidence-engine-session",
|
|
JSON.stringify(legacySnapshot())
|
|
);
|
|
|
|
load(); // triggers migration
|
|
|
|
const migrated = globalThis.window.localStorage.getItem("confidence-engine-investigation");
|
|
expect(migrated).not.toBeNull();
|
|
expect(JSON.parse(migrated).legacy).toBe(true);
|
|
expect(JSON.parse(migrated).schemaVersion).toBe(1);
|
|
});
|
|
|
|
// ── 9. unrelated keys untouched ─────────────────────────────────
|
|
|
|
it("unrelated storage keys remain untouched", async () => {
|
|
const { load, save } = await getModule();
|
|
globalThis.window.localStorage.setItem("unrelated-key", '{"data":42}');
|
|
globalThis.window.sessionStorage.setItem("unrelated-session", "hello");
|
|
|
|
save(makeSnapshot());
|
|
load();
|
|
|
|
expect(globalThis.window.localStorage.getItem("unrelated-key")).toBe('{"data":42}');
|
|
expect(globalThis.window.sessionStorage.getItem("unrelated-session")).toBe("hello");
|
|
});
|
|
|
|
// ── 10. non-browser environment safely returns null / no-op ─────
|
|
|
|
it("non-browser environment returns null load, safe no-op save/clear", async () => {
|
|
uninstallMockStorage(); // removes window entirely
|
|
|
|
const { load, save, clear } = await getModule();
|
|
|
|
expect(load()).toBeNull(); // no browser → null
|
|
expect(() => save(makeSnapshot())).not.toThrow(); // no-op without crash
|
|
expect(() => clear()).not.toThrow(); // no-op without crash
|
|
|
|
installMockStorage(); // restore for remaining tests
|
|
});
|
|
|
|
});
|
|
|
|
// ── v0.60c — identity-aware multi-investigation persistence ────────────
|
|
|
|
describe("investigation-storage v0.60c identity-aware", () => {
|
|
|
|
function newId() {
|
|
return `inv-${Math.random().toString(36).slice(2, 9)}`;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
globalThis.window.localStorage.clear();
|
|
});
|
|
|
|
// ── A: saveInvestigation(A) persists A under A.id ───────────────
|
|
|
|
it("save with id persists under that id key", async () => {
|
|
const { load, save } = await getModule();
|
|
const idA = newId();
|
|
const snapA = makeSnapshot({ id: idA, title: "Alpha" });
|
|
|
|
save(snapA, idA);
|
|
|
|
const loaded = load(idA);
|
|
expect(loaded).not.toBeNull();
|
|
expect(loaded.id).toBe(idA);
|
|
expect(loaded.title).toBe("Alpha");
|
|
});
|
|
|
|
// ── B: saveInvestigation(B) persists B independently under B.id ──
|
|
|
|
it("save two investigations with different ids are independent", async () => {
|
|
const { load, save } = await getModule();
|
|
const idA = newId();
|
|
const idB = newId();
|
|
const snapA = makeSnapshot({ id: idA, title: "Alpha" });
|
|
const snapB = makeSnapshot({ id: idB, title: "Beta" });
|
|
|
|
save(snapA, idA);
|
|
save(snapB, idB);
|
|
|
|
// A survives B save
|
|
const loadedA = load(idA);
|
|
expect(loadedA).not.toBeNull();
|
|
expect(loadedA.id).toBe(idA);
|
|
expect(loadedA.title).toBe("Alpha");
|
|
});
|
|
|
|
// ── A loads by A.id ──────────────────────────────────────────────
|
|
|
|
it("loadInvestigation(A.id) returns A", async () => {
|
|
const { load, save } = await getModule();
|
|
const idA = newId();
|
|
const snapA = makeSnapshot({ id: idA, title: "Alpha" });
|
|
save(snapA, idA);
|
|
|
|
const loaded = load(idA);
|
|
expect(loaded).not.toBeNull();
|
|
expect(loaded.id).toBe(idA);
|
|
expect(loaded.title).toBe("Alpha");
|
|
});
|
|
|
|
// ── B loads by B.id ──────────────────────────────────────────────
|
|
|
|
it("loadInvestigation(B.id) returns B", async () => {
|
|
const { load, save } = await getModule();
|
|
const idA = newId();
|
|
const idB = newId();
|
|
const snapA = makeSnapshot({ id: idA, title: "Alpha" });
|
|
const snapB = makeSnapshot({ id: idB, title: "Beta" });
|
|
|
|
save(snapA, idA);
|
|
save(snapB, idB);
|
|
|
|
const loaded = load(idB);
|
|
expect(loaded).not.toBeNull();
|
|
expect(loaded.id).toBe(idB);
|
|
expect(loaded.title).toBe("Beta");
|
|
});
|
|
|
|
// ── unknown ID returns null ──────────────────────────────────────
|
|
|
|
it("loadInvestigation(unknownId) returns null", async () => {
|
|
const { load } = await getModule();
|
|
const loaded = load("non-existent-id-xyz");
|
|
expect(loaded).toBeNull();
|
|
});
|
|
|
|
// ── save does not invent or change the supplied ID ───────────────
|
|
|
|
it("saveInvestigation does not invent or replace the supplied id", async () => {
|
|
const { save } = await getModule();
|
|
const originalId = `inv-unique-77`;
|
|
const snapA = makeSnapshot({ id: originalId, title: "Alpha" });
|
|
|
|
save(snapA, originalId);
|
|
|
|
// Verify the persisted record still carries exactly the supplied id
|
|
// (the deep clone should not have mutated it)
|
|
const raw = globalThis.window.localStorage.getItem(
|
|
`confidence-engine-investigation:${originalId}`
|
|
);
|
|
expect(raw).not.toBeNull();
|
|
const parsed = JSON.parse(raw);
|
|
expect(parsed.id).toBe(originalId);
|
|
});
|
|
|
|
});
|
|
|
|
// ── v0.60d — canonical save identity contract (snapshot.id is sole authority) ─
|
|
|
|
describe("investigation-storage v0.60d canonical identity", () => {
|
|
|
|
function newId() {
|
|
return `inv-${Math.random().toString(36).slice(2, 9)}`;
|
|
}
|
|
|
|
// Import the application-facing wrapper (not the bare provider)
|
|
async function getWrapper() {
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/investigation-storage.js");
|
|
return { load: m.loadInvestigation, save: m.saveInvestigation, clear: m.clearInvestigation };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
globalThis.window.localStorage.clear();
|
|
});
|
|
|
|
// ── 1. saveInvestigation({ id: "inv-a", ... }) persists under inv-a ──
|
|
|
|
it("save with snapshot.id persists under that id key", async () => {
|
|
const { save } = await getWrapper();
|
|
const snap = makeSnapshot({ id: newId(), title: "Alpha" });
|
|
save(snap);
|
|
const raw = globalThis.window.localStorage.getItem(`confidence-engine-investigation:${snap.id}`);
|
|
expect(raw).not.toBeNull();
|
|
});
|
|
|
|
// ── 2. loadInvestigation("inv-a") returns inv-a ───────────────
|
|
|
|
it("save with snapshot.id and load by same id round-trips", async () => {
|
|
const { load, save } = await getWrapper();
|
|
const snap = makeSnapshot({ id: newId(), title: "Alpha" });
|
|
save(snap);
|
|
const loaded = load(snap.id);
|
|
expect(loaded).not.toBeNull();
|
|
expect(loaded.id).toBe(snap.id);
|
|
expect(loaded.title).toBe("Alpha");
|
|
});
|
|
|
|
// ── 3. provider does not allocate/change the ID ────────────────
|
|
|
|
it("saveInvestigation does not invent or replace snapshot.id", async () => {
|
|
const { save } = await getWrapper();
|
|
const snap = makeSnapshot({ id: "inv-audit-99", title: "Audit" });
|
|
save(snap);
|
|
const raw = globalThis.window.localStorage.getItem(`confidence-engine-investigation:${snap.id}`);
|
|
expect(JSON.parse(raw).id).toBe("inv-audit-99");
|
|
});
|
|
|
|
// ── 4. canonical save has no second competing identity authority ─
|
|
|
|
it("explicit id argument is ignored when snapshot has an id", async () => {
|
|
const { load, save } = await getWrapper();
|
|
const snap = makeSnapshot({ id: newId(), title: "Alpha" });
|
|
const fakeSecondId = "fake-override";
|
|
save(snap, fakeSecondId);
|
|
// Should NOT appear under fakeSecondId — identity comes from snapshot.id only
|
|
const fakeRaw = globalThis.window.localStorage.getItem(`confidence-engine-investigation:${fakeSecondId}`);
|
|
expect(fakeRaw).toBeNull();
|
|
});
|
|
|
|
// ── 5. A and B remain independently addressable ────────────────
|
|
|
|
it("save two identified investigations are independent", async () => {
|
|
const { load, save } = await getWrapper();
|
|
const idA = newId();
|
|
const idB = newId();
|
|
const snapA = makeSnapshot({ id: idA, title: "Alpha" });
|
|
const snapB = makeSnapshot({ id: idB, title: "Beta" });
|
|
save(snapA);
|
|
save(snapB);
|
|
expect((await load(idA)).id).toBe(idA);
|
|
expect((await load(idA)).title).toBe("Alpha");
|
|
expect((await load(idB)).id).toBe(idB);
|
|
expect((await load(idB)).title).toBe("Beta");
|
|
});
|
|
|
|
// ── 6. unknown ID remains null ────────────────────────────────
|
|
|
|
it("loadInvestigation(unknownId) returns null", async () => {
|
|
const { load } = await getWrapper();
|
|
expect(await load("non-existent-id-xyz")).toBeNull();
|
|
});
|
|
|
|
// ── 7. legacy singleton compatibility preserved ───────────────
|
|
|
|
it("unidentified snapshot falls back to CANONICAL_KEY for legacy callers", async () => {
|
|
const { load, save } = await getWrapper();
|
|
save(makeSnapshot({ legacy: true })); // no id on snapshot
|
|
const loaded = load(); // no id argument
|
|
expect(loaded).not.toBeNull();
|
|
expect(loaded.legacy).toBe(true);
|
|
});
|
|
|
|
});
|
|
|
|
// ── v0.60g1 — listInvestigations lightweight summaries ───────────────
|
|
|
|
describe("investigation-storage v0.60g1 listing", () => {
|
|
|
|
function newId() {
|
|
return `inv-${Math.random().toString(36).slice(2, 9)}`;
|
|
}
|
|
|
|
async function getWrapper() {
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/investigation-storage.js");
|
|
return {
|
|
list: m.listInvestigations,
|
|
save: m.saveInvestigation,
|
|
load: m.loadInvestigation,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
if (globalThis.window && globalThis.window.localStorage) {
|
|
try { globalThis.window.localStorage.clear(); } catch(e) {}
|
|
}
|
|
});
|
|
|
|
// ── 1. Two durable-ID Investigations coexist ────────────────────
|
|
|
|
it("listInvestigations returns summaries for two durable-ID Investigations", async () => {
|
|
const { list, save } = await getWrapper();
|
|
save(makeSnapshot({ id: newId(), scenario: "Scenario A" }));
|
|
save(makeSnapshot({ id: newId(), scenario: "Scenario B" }));
|
|
|
|
const summaries = list();
|
|
expect(summaries.length).toBe(2);
|
|
});
|
|
|
|
// ── 2. Correct identities ───────────────────────────────────────
|
|
|
|
it("each summary carries its correct durable ID", async () => {
|
|
const { list, save } = await getWrapper();
|
|
const idA = newId();
|
|
const idB = newId();
|
|
save(makeSnapshot({ id: idA, scenario: "Scenario A" }));
|
|
save(makeSnapshot({ id: idB, scenario: "Scenario B" }));
|
|
|
|
const summaries = list();
|
|
expect(summaries.map(s => s.id)).toContain(idA);
|
|
expect(summaries.map(s => s.id)).toContain(idB);
|
|
});
|
|
|
|
// ── 3. Lightweight projection ───────────────────────────────────
|
|
|
|
it("summary contains portfolio fields but not canonical payload", async () => {
|
|
const { list, save } = await getWrapper();
|
|
save(makeSnapshot({ id: newId(), scenario: "Scenario A" }));
|
|
|
|
const summaries = list();
|
|
const summary = summaries[0];
|
|
|
|
// Portfolio-required fields present
|
|
expect(summary).toHaveProperty("id");
|
|
expect(summary).toHaveProperty("scenario");
|
|
expect(summary).toHaveProperty("updatedAt");
|
|
expect(summary).toHaveProperty("investigationRevision");
|
|
expect(summary).toHaveProperty("reportExists");
|
|
expect(summary).toHaveProperty("reportGeneratedFromRevision");
|
|
|
|
// Canonical payload fields NOT exposed
|
|
expect(summary.situationGraph).toBeUndefined();
|
|
expect(summary.findings).toBeUndefined();
|
|
expect(summary.investigationReport).toBeUndefined();
|
|
});
|
|
|
|
// ── 4. Legacy singleton exclusion ───────────────────────────────
|
|
|
|
it("legacy singleton is not included in listing", async () => {
|
|
const { list } = await getWrapper();
|
|
|
|
globalThis.window.localStorage.setItem(
|
|
"confidence-engine-investigation",
|
|
JSON.stringify(makeSnapshot({ legacy: true }))
|
|
);
|
|
|
|
const summaries = list();
|
|
expect(summaries.length).toBe(0);
|
|
});
|
|
|
|
// ── 5. Unrelated storage exclusion ──────────────────────────────
|
|
|
|
it("unrelated localStorage record is not included", async () => {
|
|
const { list, save } = await getWrapper();
|
|
|
|
globalThis.window.localStorage.setItem(
|
|
"confidence-engine-investigation-metadata",
|
|
'{"data":42}'
|
|
);
|
|
save(makeSnapshot({ id: newId(), scenario: "Scenario X" }));
|
|
|
|
const summaries = list();
|
|
expect(summaries.length).toBe(1);
|
|
});
|
|
|
|
// ── 6. Independent update preserves other Investigation ─────────
|
|
|
|
it("updating inv-a does not replace or remove inv-b", async () => {
|
|
const { list, save } = await getWrapper();
|
|
|
|
const idA = newId();
|
|
const idB = newId();
|
|
|
|
save(makeSnapshot({ id: idA, scenario: "Scenario A" }));
|
|
save(makeSnapshot({ id: idB, scenario: "Scenario B" }));
|
|
|
|
// Update inv-a
|
|
save(makeSnapshot({ id: idA, scenario: "Updated Scenario A" }));
|
|
|
|
const summaries = list();
|
|
expect(summaries.length).toBe(2);
|
|
|
|
const aSummary = summaries.find(s => s.id === idA);
|
|
const bSummary = summaries.find(s => s.id === idB);
|
|
|
|
expect(aSummary.scenario).toBe("Updated Scenario A");
|
|
expect(bSummary.scenario).toBe("Scenario B");
|
|
});
|
|
|
|
// ── 7. Deterministic ordering by updatedAt descending ───────────
|
|
|
|
it("most recently updated Investigation listed first", async () => {
|
|
const { list, save } = await getWrapper();
|
|
|
|
const idA = newId();
|
|
const idB = newId();
|
|
|
|
save(makeSnapshot({
|
|
id: idA,
|
|
scenario: "Scenario A",
|
|
updatedAt: "2026-09-01T10:00:00.000Z",
|
|
}));
|
|
save(makeSnapshot({
|
|
id: idB,
|
|
scenario: "Scenario B",
|
|
updatedAt: "2026-09-02T10:00:00.000Z",
|
|
}));
|
|
|
|
const summaries = list();
|
|
expect(summaries[0].id).toBe(idB);
|
|
expect(summaries[1].id).toBe(idA);
|
|
});
|
|
|
|
// ── 8. Malformed durable-ID entry skipped ──────────────────────
|
|
|
|
it("malformed durable-ID entry does not prevent listing valid records", async () => {
|
|
globalThis.window.localStorage.setItem(
|
|
"confidence-engine-investigation:inv-malformed",
|
|
"not valid json {{{"
|
|
);
|
|
|
|
const { list, save } = await getWrapper();
|
|
save(makeSnapshot({ id: newId(), scenario: "Valid Scenario" }));
|
|
|
|
const summaries = list();
|
|
expect(summaries.length).toBe(1);
|
|
});
|
|
|
|
});
|
|
|
|
// ── v0.60j — restartInvestigation semantic reset ─────────────────────
|
|
|
|
describe("investigation-storage v0.60j restart", () => {
|
|
|
|
function newId() {
|
|
return `inv-${Math.random().toString(36).slice(2, 9)}`;
|
|
}
|
|
|
|
async function getWrapper() {
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/investigation-storage.js");
|
|
return {
|
|
restart: m.restartInvestigation,
|
|
load: m.loadInvestigation,
|
|
list: m.listInvestigations,
|
|
save: m.saveInvestigation,
|
|
};
|
|
}
|
|
|
|
function makeRestartedSnapshot(overrides = {}) {
|
|
return {
|
|
id: overrides.id ?? newId(),
|
|
scenario: "Test scenario text",
|
|
situationGraph: { nodes: [{ id: "n1" }], edges: [] },
|
|
selectedQuestion: { question: "Why?" },
|
|
summary: "Previous understanding",
|
|
focusedContributions: [{ evidence: "some evidence" }],
|
|
findings: [{ id: "f1", proposition: "A finding" }],
|
|
investigationReport: { understanding: "report text", generatedFromRevision: 5 },
|
|
investigationRevision: 3,
|
|
updatedAt: "2026-09-01T10:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
if (globalThis.window && globalThis.window.localStorage) {
|
|
try { globalThis.window.localStorage.clear(); } catch(e) {}
|
|
}
|
|
});
|
|
|
|
// ── A — restart preserves identity ──────────────────────────────
|
|
|
|
it("restart preserves the Investigation durable id", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
const snap = makeRestartedSnapshot({ id: "inv-a" });
|
|
save(snap);
|
|
restart("inv-a");
|
|
|
|
const loaded = load("inv-a");
|
|
expect(loaded).not.toBeNull();
|
|
expect(loaded.id).toBe("inv-a");
|
|
});
|
|
|
|
// ── B — restart preserves container-required state ──────────────
|
|
|
|
it("restart preserves scenario framing", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
const snap = makeRestartedSnapshot({ id: "inv-b" });
|
|
save(snap);
|
|
restart("inv-b");
|
|
|
|
const loaded = load("inv-b");
|
|
expect(loaded.scenario).toBe("Test scenario text");
|
|
});
|
|
|
|
it("restart preserves schemaVersion", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
const snap = makeRestartedSnapshot({ id: "inv-c" });
|
|
save(snap);
|
|
restart("inv-c");
|
|
|
|
const loaded = load("inv-c");
|
|
expect(loaded.schemaVersion).toBe(1);
|
|
});
|
|
|
|
// ── C — restart removes previous reasoning/report state ────────
|
|
|
|
it("restart clears situationGraph", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
const snap = makeRestartedSnapshot({ id: "inv-d" });
|
|
save(snap);
|
|
restart("inv-d");
|
|
|
|
const loaded = load("inv-d");
|
|
expect(loaded.situationGraph).toBe(null);
|
|
});
|
|
|
|
it("restart clears selectedQuestion", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
const snap = makeRestartedSnapshot({ id: "inv-e" });
|
|
save(snap);
|
|
restart("inv-e");
|
|
|
|
const loaded = load("inv-e");
|
|
expect(loaded.selectedQuestion).toBe(null);
|
|
});
|
|
|
|
it("restart clears summary (current understanding)", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
const snap = makeRestartedSnapshot({ id: "inv-f" });
|
|
save(snap);
|
|
restart("inv-f");
|
|
|
|
const loaded = load("inv-f");
|
|
expect(loaded.summary).toBe(null);
|
|
});
|
|
|
|
it("restart clears focusedContributions", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
const snap = makeRestartedSnapshot({ id: "inv-g" });
|
|
save(snap);
|
|
restart("inv-g");
|
|
|
|
const loaded = load("inv-g");
|
|
expect(loaded.focusedContributions).toEqual([]);
|
|
});
|
|
|
|
it("restart clears findings", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
const snap = makeRestartedSnapshot({ id: "inv-h" });
|
|
save(snap);
|
|
restart("inv-h");
|
|
|
|
const loaded = load("inv-h");
|
|
expect(loaded.findings).toEqual([]);
|
|
});
|
|
|
|
it("restart clears investigationReport (stale Report must not survive)", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
const snap = makeRestartedSnapshot({ id: "inv-i" });
|
|
save(snap);
|
|
restart("inv-i");
|
|
|
|
const loaded = load("inv-i");
|
|
expect(loaded.investigationReport).toBe(null);
|
|
});
|
|
|
|
it("restart resets investigationRevision to 0", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
const snap = makeRestartedSnapshot({ id: "inv-j" });
|
|
save(snap);
|
|
restart("inv-j");
|
|
|
|
const loaded = load("inv-j");
|
|
expect(loaded.investigationRevision).toBe(0);
|
|
});
|
|
|
|
// ── D — restart is isolated ────────────────────────────────────
|
|
|
|
it("restart A does not affect B", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
save(makeRestartedSnapshot({ id: "inv-a" }));
|
|
save(makeRestartedSnapshot({ id: "inv-b" }));
|
|
|
|
restart("inv-a");
|
|
|
|
const bLoaded = load("inv-b");
|
|
expect(bLoaded.id).toBe("inv-b");
|
|
expect(bLoaded.scenario).toBe("Test scenario text");
|
|
expect(bLoaded.situationGraph).not.toBeNull(); // B untouched
|
|
expect(bLoaded.findings.length).toBe(1); // B findings preserved
|
|
});
|
|
|
|
// ── E — listing remains after restart ──────────────────────────
|
|
|
|
it("restart does not remove Investigation from listInvestigations", async () => {
|
|
const { save, load, restart, list } = await getWrapper();
|
|
|
|
save(makeRestartedSnapshot({ id: "inv-k" }));
|
|
const before = list();
|
|
expect(before.length).toBe(1);
|
|
|
|
restart("inv-k");
|
|
|
|
const after = list();
|
|
expect(after.length).toBe(1);
|
|
expect(after[0].id).toBe("inv-k");
|
|
});
|
|
|
|
// ── F — missing identity does not restart singleton ────────────
|
|
|
|
it("restart with undefined id is silently no-op (no singleton fallback)", async () => {
|
|
const { save, load, restart } = await getWrapper();
|
|
|
|
// Set up a legacy singleton entry
|
|
if (globalThis.window && globalThis.window.localStorage) {
|
|
globalThis.window.localStorage.setItem(
|
|
"confidence-engine-investigation",
|
|
JSON.stringify(makeRestartedSnapshot({ id: "legacy-singleton" }))
|
|
);
|
|
}
|
|
|
|
restart(undefined); // should be no-op, not touch singleton
|
|
|
|
// Singleton entry still exists (unchanged)
|
|
const raw = globalThis.window?.localStorage.getItem("confidence-engine-investigation");
|
|
expect(raw).not.toBeNull();
|
|
const parsed = JSON.parse(raw);
|
|
expect(parsed.id).toBe("legacy-singleton");
|
|
});
|
|
|
|
});
|