260 lines
11 KiB
JavaScript
260 lines
11 KiB
JavaScript
/**
|
|
* Focused integration contract tests proving the canonical autosave effect in
|
|
* ScenarioForm preserves investigation state across save/load cycles.
|
|
*
|
|
* 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() {
|
|
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(); vi.resetModules(); });
|
|
|
|
// ── helper factories (mirrors ScenarioForm call shapes) ───────────────
|
|
|
|
function makeSnapshot(overrides = {}) {
|
|
return {
|
|
scenario: overrides.scenario ?? "Test investigation",
|
|
situationGraph: overrides.situationGraph ?? { centralStatement: "Test", nodes: [], edges: [] },
|
|
selectedQuestion: overrides.selectedQuestion ?? { question: "What changed?" },
|
|
summary: overrides.summary ?? "Current understanding",
|
|
updatedAt: new Date().toISOString(),
|
|
focusedContributions: overrides.focusedContributions ?? [],
|
|
findings: overrides.findings ?? [],
|
|
schemaVersion: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeContribution(overrides = {}) {
|
|
return {
|
|
id: overrides.id ?? "contrib-0001",
|
|
sequence: overrides.sequence ?? 1,
|
|
targetNodeId: overrides.targetNodeId ?? "nk-001",
|
|
question: overrides.question ?? "Q1?",
|
|
answer: overrides.answer ?? "A1",
|
|
};
|
|
}
|
|
|
|
function makeFinding(overrides = {}) {
|
|
return {
|
|
id: overrides.id ?? "finding-001",
|
|
proposition: overrides.proposition ?? "Test finding",
|
|
status: "provisional",
|
|
evaluation: "considered",
|
|
userDisposition: overrides.userDisposition ?? null,
|
|
contributionId: overrides.contributionId ?? "contrib-0001",
|
|
sourceObservation: overrides.sourceObservation ?? "Observation A",
|
|
originatingTargetNodeId: overrides.originatingTargetNodeId ?? "n-1",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// Proof A — Hydration safety: autosave guard skips on null graph
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
describe("Proof A — Hydration safety", () => {
|
|
it("autosave guard prevents overwrite of existing saved investigation when result.situationGraph is null", async () => {
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/investigation-storage.js");
|
|
|
|
// Simulate: user previously had a valid investigation
|
|
const priorSnap = makeSnapshot({ scenario: "Previous work" });
|
|
m.saveInvestigation(priorSnap);
|
|
expect(m.loadInvestigation().scenario).toBe("Previous work");
|
|
|
|
// Simulate autosave guard condition with null situationGraph (fresh mount)
|
|
const guardedResult = { situationGraph: null };
|
|
if (!guardedResult?.situationGraph) {
|
|
// Autosave effect returns early — existing data preserved
|
|
expect(m.loadInvestigation().scenario).toBe("Previous work");
|
|
}
|
|
});
|
|
|
|
it("autosave guard also skips when result is undefined", async () => {
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/investigation-storage.js");
|
|
m.saveInvestigation(makeSnapshot({ scenario: "Has data" }));
|
|
|
|
if (!undefined?.situationGraph) {
|
|
expect(m.loadInvestigation().scenario).toBe("Has data");
|
|
}
|
|
});
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// Proof B — Focused contributions & derived findings durability
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
describe("Proof B — Focused learning durability", () => {
|
|
it("focused contributions AND derived findings persist through save/load without additional update", async () => {
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/investigation-storage.js");
|
|
|
|
const contribution = makeContribution({ id: "contrib-0001", sequence: 2, question: "What changed?", answer: "Everything" });
|
|
const finding = makeFinding({
|
|
id: "finding-0001",
|
|
proposition: "Everything has changed",
|
|
userDisposition: null,
|
|
contributionId: "contrib-0001",
|
|
sourceObservation: "Everything",
|
|
});
|
|
|
|
m.saveInvestigation(makeSnapshot({
|
|
scenario: "Updated investigation",
|
|
focusedContributions: [contribution],
|
|
findings: [finding],
|
|
}));
|
|
|
|
const loaded = m.loadInvestigation();
|
|
expect(loaded.focusedContributions).toHaveLength(1);
|
|
expect(loaded.focusedContributions[0].id).toBe("contrib-0001");
|
|
expect(loaded.focusedContributions[0].answer).toBe("Everything");
|
|
expect(loaded.findings).toHaveLength(1);
|
|
expect(loaded.findings[0].proposition).toBe("Everything has changed");
|
|
expect(loaded.findings[0].contributionId).toBe("contrib-0001");
|
|
});
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// Proof C/D — Finding disposition & correction durability
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
describe("Proof C/D/E — Finding disposition and correction durability", () => {
|
|
it("not_relevant disposition survives save/load cycle without requiring another update", async () => {
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/investigation-storage.js");
|
|
|
|
m.saveInvestigation(makeSnapshot({
|
|
findings: [makeFinding({ id: "finding-nr", userDisposition: "not_relevant" })],
|
|
}));
|
|
|
|
const loaded = m.loadInvestigation();
|
|
expect(loaded.findings[0].userDisposition).toBe("not_relevant");
|
|
});
|
|
|
|
it("restored finding (null disposition) survives save/load without requiring another update", async () => {
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/investigation-storage.js");
|
|
|
|
m.saveInvestigation(makeSnapshot({
|
|
findings: [makeFinding({ id: "finding-restore", userDisposition: null })],
|
|
}));
|
|
|
|
const loaded = m.loadInvestigation();
|
|
expect(loaded.findings[0].userDisposition).toBeNull();
|
|
});
|
|
|
|
it("corrected proposition preserves finding.id, contributionId, sourceObservation and resets disposition to null", async () => {
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/investigation-storage.js");
|
|
|
|
const corrected = makeFinding({
|
|
id: "finding-correction",
|
|
proposition: "Updated by user correction",
|
|
userDisposition: null,
|
|
contributionId: "contrib-0042",
|
|
sourceObservation: "Key observation X",
|
|
});
|
|
|
|
m.saveInvestigation(makeSnapshot({ findings: [corrected] }));
|
|
const loaded = m.loadInvestigation();
|
|
|
|
const f = loaded.findings[0];
|
|
expect(f.id).toBe("finding-correction");
|
|
expect(f.proposition).toBe("Updated by user correction");
|
|
expect(f.userDisposition).toBeNull();
|
|
expect(f.contributionId).toBe("contrib-0042");
|
|
expect(f.sourceObservation).toBe("Key observation X");
|
|
});
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// Proof F — Intentional clear cannot resurrect legacy state
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
describe("Proof F — Legacy resurrection prevented", () => {
|
|
it("clear removes both canonical localStorage key and legacy sessionStorage key, loadInvestigation returns null", async () => {
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/investigation-storage.js");
|
|
|
|
// Populate both storage areas
|
|
m.saveInvestigation(makeSnapshot({ scenario: "Canonical" }));
|
|
globalThis.window.sessionStorage.setItem(
|
|
"confidence-engine-session",
|
|
JSON.stringify({ scenario: "Legacy session data" })
|
|
);
|
|
|
|
expect(globalThis.window.localStorage.getItem("confidence-engine-investigation")).not.toBeNull();
|
|
expect(globalThis.window.sessionStorage.getItem("confidence-engine-session")).not.toBeNull();
|
|
|
|
m.clearInvestigation();
|
|
|
|
expect(globalThis.window.localStorage.getItem("confidence-engine-investigation")).toBeNull();
|
|
expect(globalThis.window.sessionStorage.getItem("confidence-engine-session")).toBeNull();
|
|
expect(m.loadInvestigation()).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// Proof G — Unrelated storage survives clear
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
describe("Proof G — Unrelated storage survives clear", () => {
|
|
it("unrelated localStorage and sessionStorage keys survive intentional clearInvestigation", async () => {
|
|
vi.resetModules();
|
|
const m = await import("../../lib/storage/investigation-storage.js");
|
|
|
|
globalThis.window.localStorage.setItem("unrelated-key", '{"data":42}');
|
|
globalThis.window.sessionStorage.setItem("unrelated-session", "hello-persisted");
|
|
|
|
m.saveInvestigation(makeSnapshot({ scenario: "Target" }));
|
|
m.clearInvestigation();
|
|
|
|
expect(globalThis.window.localStorage.getItem("unrelated-key")).toBe('{"data":42}');
|
|
expect(globalThis.window.sessionStorage.getItem("unrelated-session")).toBe("hello-persisted");
|
|
});
|
|
});
|