feat(confidence-engine): v0.60g1 list investigation summaries

Recover to clean v0.60f then implement only the storage listing contract.

- Add listInvestigations() to provider: enumerate by prefix, project lightweight summary (id, scenario, updatedAt, investigationRevision, reportExists, reportGeneratedFromRevision), sort by updatedAt desc
- Add application-facing wrapper in investigation-storage.js
- Add 8 deterministic tests covering all listing invariants (coexistence, correct IDs, lightweight projection, legacy exclusion, unrelated exclusion, independent update, ordering, malformed skip)
- Fix MockStorageMap WebStorage API compatibility (.length + .key(i))
- Portfolio NOT migrated — that is v0.60g2
This commit is contained in:
2026-09-04 06:45:26 +01:00
parent 4b55ad1eae
commit 7ba1771bcf
4 changed files with 298 additions and 21 deletions
+188 -19
View File
@@ -9,11 +9,16 @@ import { describe, it, expect, vi } from "vitest";
// ── test-time storage mock (injected into globalThis.window) ───────────
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, String(v)); }
removeItem(k){ this._data.delete(k); }
clear() { this._data.clear(); }
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() {
@@ -23,20 +28,18 @@ function installMockStorage() {
writable: true,
configurable: true,
});
if (!globalThis.window.localStorage) {
Object.defineProperty(globalThis.window, "localStorage", {
value: new MockStorageMap(),
writable: true,
configurable: true,
});
}
if (!globalThis.window.sessionStorage) {
Object.defineProperty(globalThis.window, "sessionStorage", {
value: new MockStorageMap(),
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() {
@@ -431,3 +434,169 @@ describe("investigation-storage v0.60d canonical identity", () => {
});
});
// ── 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);
});
});