refactor(confidence-engine): add investigation storage provider
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
// investigation-storage — generic persistence boundary
|
||||||
|
// Exposes loadInvestigation / saveInvestigation / clearInvestigation
|
||||||
|
// and delegates internally to the concrete localStorage provider.
|
||||||
|
|
||||||
|
export { loadInvestigation, saveInvestigation, clearInvestigation } from "./providers/local-storage.js";
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// ── canonical identifiers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CANONICAL_KEY = "confidence-engine-investigation";
|
||||||
|
const LEGACY_KEY = "confidence-engine-session";
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function safeGet(storage, key) {
|
||||||
|
if (!storage || typeof storage.getItem !== "function") return null;
|
||||||
|
try { return storage.getItem(key); } catch (_) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
Object.prototype.toString.call(value) === "[object Object]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── loadInvestigation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the persisted investigation snapshot (normalised to current schema)
|
||||||
|
* or null when no usable state exists.
|
||||||
|
*/
|
||||||
|
export function loadInvestigation() {
|
||||||
|
const storage = _getTargetStorage();
|
||||||
|
if (!storage) return null;
|
||||||
|
|
||||||
|
// 1 – canonical key first
|
||||||
|
let raw = safeGet(storage, CANONICAL_KEY);
|
||||||
|
if (raw !== null) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (isPlainObject(parsed)) {
|
||||||
|
if (!("schemaVersion" in parsed)) parsed.schemaVersion = SCHEMA_VERSION;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (_) { /* malformed — fall through to legacy */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 – legacy fallback
|
||||||
|
const legacyStorage = _getLegacyStorage();
|
||||||
|
if (!legacyStorage) return null;
|
||||||
|
|
||||||
|
raw = safeGet(legacyStorage, LEGACY_KEY);
|
||||||
|
if (raw === null) return null;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (_) { return null; }
|
||||||
|
|
||||||
|
if (!isPlainObject(parsed)) return null;
|
||||||
|
|
||||||
|
// Normalise schemaVersion for legacy data
|
||||||
|
if (!("schemaVersion" in parsed)) parsed.schemaVersion = SCHEMA_VERSION;
|
||||||
|
|
||||||
|
// Migrate into canonical key
|
||||||
|
_persist(storage, CANONICAL_KEY, JSON.stringify(parsed));
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── saveInvestigation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the supplied snapshot to the canonical localStorage key.
|
||||||
|
* The caller's object is never mutated.
|
||||||
|
*/
|
||||||
|
export function saveInvestigation(snapshot) {
|
||||||
|
const storage = _getTargetStorage();
|
||||||
|
if (!storage) return; // silently no-op in non-browser
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(JSON.stringify(snapshot));
|
||||||
|
record.schemaVersion = SCHEMA_VERSION;
|
||||||
|
_persist(storage, CANONICAL_KEY, JSON.stringify(record));
|
||||||
|
} catch (_) { /* storage errors must not crash caller */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── clearInvestigation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Removes only the canonical investigation key. */
|
||||||
|
export function clearInvestigation() {
|
||||||
|
const storage = _getTargetStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
try { storage.removeItem(CANONICAL_KEY); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _persist(storage, key, value) {
|
||||||
|
try { storage.setItem(key, value); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getTargetStorage() {
|
||||||
|
if (typeof globalThis.window === "undefined") return null;
|
||||||
|
const s = globalThis.window.localStorage;
|
||||||
|
if (!s || typeof s.getItem !== "function") return null;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getLegacyStorage() {
|
||||||
|
if (typeof globalThis.window === "undefined") return null;
|
||||||
|
const s = globalThis.window.sessionStorage;
|
||||||
|
if (!s || typeof s.getItem !== "function") return null;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* 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(); }
|
||||||
|
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(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function installMockStorage() {
|
||||||
|
// Define window with mock localStorage and sessionStorage as properties
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!globalThis.window.sessionStorage) {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user