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