feat(confidence-engine): v0.60j preserve investigation on restart

This commit is contained in:
2026-09-04 10:18:22 +01:00
parent 4bc998ee3f
commit cc3a5dabd4
7 changed files with 551 additions and 7 deletions
+215
View File
@@ -600,3 +600,218 @@ describe("investigation-storage v0.60g1 listing", () => {
});
});
// ── 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");
});
});
+232
View File
@@ -0,0 +1,232 @@
/**
* Dedicated UI contract tests for v0.60j — restartInvestigation caller behaviour.
*
* Deterministic via static file-level vi.mock — no localStorage, no provider imports,
* no vi.resetModules, no module-cache manipulation, no timing sleeps.
*/
import { describe, expect, it, beforeEach, vi } from "vitest";
import React from "react";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom";
// ---------------------------------------------------------------------------
// Mock next/navigation — static file-level mock
// ---------------------------------------------------------------------------
let pushRef = { push: () => {} };
vi.mock("next/navigation", () => ({
useRouter: () => pushRef,
}));
// ---------------------------------------------------------------------------
// Mock investigation-storage — application-facing contract only
// No localStorage. No provider imports.
// NOTE: All exports must be present because ScenarioForm imports multiple.
// ---------------------------------------------------------------------------
let mockList = [];
let mockRestartTarget = null;
let mockLoadResult = null;
let mockClearTarget = null;
let mockLastSavedSnapshot = null;
vi.mock("@/lib/storage/investigation-storage", () => ({
listInvestigations: () => [...mockList],
restartInvestigation: (id) => {
mockRestartTarget = id;
},
clearInvestigation: (id) => {
mockClearTarget = id;
},
loadInvestigation: (id) => mockLoadResult,
saveInvestigation: (snapshot) => {
mockLastSavedSnapshot = snapshot;
},
}));
function setMockSummaries(snapshots) {
mockList = snapshots ?? [];
}
function setMockLoadResult(snap) {
mockLoadResult = snap;
}
// ---------------------------------------------------------------------------
// Test fixture data — plain InvestigationSummary arrays
// ---------------------------------------------------------------------------
function summaryA() {
return {
id: "inv-a",
scenario: "Scenario A",
updatedAt: "2026-09-03T10:00:00.000Z",
investigationRevision: 2,
reportExists: true,
reportGeneratedFromRevision: 2,
};
}
function summaryB() {
return {
id: "inv-b",
scenario: "Scenario B",
updatedAt: "2026-09-02T10:00:00.000Z",
investigationRevision: 3,
reportExists: true,
reportGeneratedFromRevision: 2,
};
}
// ---------------------------------------------------------------------------
// A — Portfolio restart uses restartInvestigation (not clearInvestigation)
// ---------------------------------------------------------------------------
describe("v0.60j — Portfolio Restart calls restartInvestigation", () => {
let Portfolio;
beforeEach(async () => {
pushRef.push = vi.fn();
mockRestartTarget = null;
mockClearTarget = null;
setMockSummaries([summaryA(), summaryB()]);
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
});
it("restart confirmation calls restartInvestigation with the correct id (inv-a)", async () => {
render(React.createElement(Portfolio));
// Scope to the first card's Restart button (scenario A)
const cards = document.querySelectorAll('[class*="border-teal-300"]');
expect(cards).toHaveLength(2);
const firstCardRestartBtn = within(cards[0]).getByRole("button", { name: /Restart investigation/i });
fireEvent.click(firstCardRestartBtn);
// Confirmation dialog appears — scoped to the aria-labelledby title for inv-a
expect(await screen.findByRole("heading", { name: /Restart this investigation\?/i })).toBeInTheDocument();
// Click the confirm button INSIDE the dialog (scoped), not the card's restart buttons
const dialog = document.querySelector('[role="dialog"]');
const confirmBtn = within(dialog).getByRole("button", { name: "Restart investigation" });
fireEvent.click(confirmBtn);
await waitFor(() => expect(mockRestartTarget).toBe("inv-a"));
});
it("restart does NOT call clearInvestigation", async () => {
render(React.createElement(Portfolio));
const cards = document.querySelectorAll('[class*="border-teal-300"]');
const firstCardRestartBtn = within(cards[0]).getByRole("button", { name: /Restart investigation/i });
fireEvent.click(firstCardRestartBtn);
await screen.findByRole("heading", { name: /Restart this investigation\?/i });
const dialog = document.querySelector('[role="dialog"]');
const confirmBtn = within(dialog).getByRole("button", { name: "Restart investigation" });
fireEvent.click(confirmBtn);
await waitFor(() => expect(mockClearTarget).toBeNull());
});
it("restart of inv-b passes inv-b as id", async () => {
render(React.createElement(Portfolio));
const cards = document.querySelectorAll('[class*="border-teal-300"]');
// Second card (scenario B) — index 1
const secondCardRestartBtn = within(cards[1]).getByRole("button", { name: /Restart investigation/i });
fireEvent.click(secondCardRestartBtn);
await screen.findByRole("heading", { name: /Restart this investigation\?/i });
const dialog = document.querySelector('[role="dialog"]');
const confirmBtn = within(dialog).getByRole("button", { name: "Restart investigation" });
fireEvent.click(confirmBtn);
await waitFor(() => expect(mockRestartTarget).toBe("inv-b"));
});
});
// ---------------------------------------------------------------------------
// B — ScenarioForm restart path calls restartInvestigation with id
// ---------------------------------------------------------------------------
describe("v0.60j — ScenarioForm restart calls restartInvestigation(investigationId)", () => {
let ScenarioForm;
beforeEach(async () => {
pushRef.push = vi.fn();
mockRestartTarget = null;
setMockLoadResult(null); // No persisted session on mount
const mod = await import("@/components/scenario-form.jsx");
ScenarioForm = mod.default;
});
it("ScenarioForm mounts without error under mocked storage", async () => {
const testId = "inv-scenarioform-mock-test";
expect(() => {
render(React.createElement(ScenarioForm, { investigationId: testId }));
}).not.toThrow();
});
it("restartInvestigation is importable from storage layer", async () => {
const m = await import("@/lib/storage/investigation-storage.js");
expect(typeof m.restartInvestigation).toBe("function");
});
// ── Directly observable: ContinueLaterBanner restart ──────────────────────
it("ContinueLaterBanner Restart calls restartInvestigation(\"inv-restart-a\")", async () => {
mockRestartTarget = null;
setMockLoadResult({
id: "inv-restart-a",
scenario: "Test ContinueLaterBanner restart scenario",
situationGraph: { nodes: [], edges: [] }, // hasGraph → status promoted to "success"
updatedAt: "2026-09-03T10:00:00.000Z",
});
const mod = await import("@/components/scenario-form.jsx");
ScenarioForm = mod.default;
render(React.createElement(ScenarioForm, { investigationId: "inv-restart-a" }));
// ContinueLaterBanner renders when status === "success" && result?.updatedAt exists
expect(await screen.findByText("Restart investigation")).toBeInTheDocument();
fireEvent.click(screen.getByText("Restart investigation"));
await waitFor(() => expect(mockRestartTarget).toBe("inv-restart-a"));
});
// ── Directly observable: "Start new investigation" reset control ───────────
it("'Start new investigation' button calls restartInvestigation(\"inv-restart-a\")", async () => {
mockRestartTarget = null;
setMockLoadResult({
id: "inv-restart-a",
scenario: "Test StartNew reset scenario",
situationGraph: { nodes: [], edges: [] }, // hasGraph → status promoted to "success"
updatedAt: "2026-09-03T10:00:00.000Z",
});
const mod = await import("@/components/scenario-form.jsx");
ScenarioForm = mod.default;
render(React.createElement(ScenarioForm, { investigationId: "inv-restart-a" }));
expect(await screen.findByText("Start new investigation")).toBeInTheDocument();
fireEvent.click(screen.getByText("Start new investigation"));
await waitFor(() => expect(mockRestartTarget).toBe("inv-restart-a"));
});
// ── Source-inspected-only: ReasoningWorkspace onRestart path ──────────────
// PATH 1 NOT DIRECTLY OBSERVABLE IN BOUNDED APPARATUS
// ProviderUnavailable / MalformedResponse / UnexpectedState cards require
// error-state reasoning data — not fabricatable in this bounded apparatus.
//
// Source confirmation (components/scenario-form.jsx line ~949):
// onRestart={() => { restartInvestigation(investigationId); ... }}
// This passes investigationId as the first argument to restartInvestigation.
});