249 lines
8.3 KiB
React
249 lines
8.3 KiB
React
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
|
import React from "react";
|
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
import "@testing-library/jest-dom";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Minimal isolated mocks for Report page only.
|
|
// No Portfolio, no restart behaviour, no storage provider, no module-cache hacks.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let mockLastLoadedId = undefined;
|
|
let mockLastSavedSnapshot = null;
|
|
|
|
function setMockSnapshot(snap) {
|
|
if (snap) {
|
|
localStorage.setItem(
|
|
"confidence-engine-investigation",
|
|
JSON.stringify(snap),
|
|
);
|
|
mockLoadResult = snap;
|
|
} else {
|
|
localStorage.removeItem("confidence-engine-investigation");
|
|
mockLoadResult = null;
|
|
}
|
|
}
|
|
|
|
let mockLoadResult = null;
|
|
|
|
vi.mock("@/lib/storage/investigation-storage", () => ({
|
|
listInvestigations: () => [],
|
|
loadInvestigation: (id) => {
|
|
mockLastLoadedId = id;
|
|
return mockLoadResult;
|
|
},
|
|
saveInvestigation: (snapshot) => {
|
|
mockLastSavedSnapshot = snapshot;
|
|
},
|
|
clearInvestigation: () => {},
|
|
}));
|
|
|
|
function makeSnapshot(overrides = {}) {
|
|
const situationGraph = {
|
|
evidence: [
|
|
{ id: "e1", claim: "Complaints rose.", type: "finding" },
|
|
{ id: "e2", claim: "Production increased.", type: "finding" },
|
|
],
|
|
reconstruction: {
|
|
plausibleInterpretations: ["The denominator may have been narrowed."],
|
|
},
|
|
};
|
|
|
|
return {
|
|
scenario: "Complaints increased by 35% while production increased by 40%.",
|
|
situationGraph,
|
|
selectedQuestion: null,
|
|
summary: "Production quality declined.",
|
|
updatedAt: new Date().toISOString(),
|
|
schemaVersion: 1,
|
|
investigationReport: null,
|
|
investigationRevision: 0,
|
|
findings: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function cleanup() {
|
|
localStorage.removeItem("confidence-engine-investigation");
|
|
}
|
|
|
|
/* ── v0.60h-a — Report identity and lifecycle isolation ─────────────────── */
|
|
|
|
describe("Report page identity and lifecycle — v0.60h-a", () => {
|
|
let ReportPage;
|
|
|
|
beforeEach(async () => {
|
|
mockLastLoadedId = undefined;
|
|
mockLastSavedSnapshot = null;
|
|
setMockSnapshot(null);
|
|
global.fetch = vi.fn();
|
|
const mod = await import("@/app/investigations/[id]/report/page.jsx");
|
|
ReportPage = mod.default;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
cleanup();
|
|
});
|
|
|
|
/* ── A: route identity on initial load (no existing report → first generation) ── */
|
|
|
|
it("route identity verified on first generation", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
id: "inv-report-a",
|
|
investigationReport: null,
|
|
investigationRevision: 4,
|
|
findings: [],
|
|
}));
|
|
|
|
global.fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, understanding: "Summary.", plausibleInterpretations: "None." }),
|
|
});
|
|
|
|
render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
|
|
await screen.findByText(/Summary\./i);
|
|
|
|
// A — loadInvestigation invoked with route ID
|
|
expect(mockLastLoadedId).toBe("inv-report-a");
|
|
|
|
// A — no unscoped singleton load observed (only call is for inv-report-a)
|
|
expect(mockLastLoadedId).toBe("inv-report-a");
|
|
|
|
// C/D — exactly one overview POST
|
|
const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
|
|
expect(calls).toHaveLength(1);
|
|
|
|
// C — saved snapshot retains route id
|
|
expect(mockLastSavedSnapshot.id).toBe("inv-report-a");
|
|
|
|
// D — generatedFromRevision correct
|
|
expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(4);
|
|
});
|
|
|
|
/* ── B: existing persisted Report (no new generation) ── */
|
|
|
|
it("renders existing report without generation", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
id: "inv-report-a",
|
|
investigationReport: { understanding: "Persisted summary.", hasPlausibleInterpretations: false, generatedFromRevision: 4 },
|
|
investigationRevision: 4,
|
|
}));
|
|
|
|
render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
|
|
await screen.findByText(/Persisted summary\./i);
|
|
|
|
// B — loadInvestigation invoked with route ID
|
|
expect(mockLastLoadedId).toBe("inv-report-a");
|
|
|
|
// E — no overview call when report exists and revisions match
|
|
const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
/* ── C: first generation with empty findings ── */
|
|
|
|
it("first generation with findings=[] posts exactly once and saves correctly", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
id: "inv-report-a",
|
|
investigationReport: null,
|
|
situationGraph: { evidence: [], reconstruction: {} },
|
|
investigationRevision: 4,
|
|
findings: [],
|
|
}));
|
|
|
|
global.fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, understanding: "Generated.", plausibleInterpretations: null }),
|
|
});
|
|
|
|
render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
|
|
await screen.findByText(/Generated\./i);
|
|
|
|
// C — exactly one POST /api/cases/overview
|
|
const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
|
|
expect(calls).toHaveLength(1);
|
|
|
|
// C — crossed storage mock: id === "inv-report-a"
|
|
expect(mockLastSavedSnapshot.id).toBe("inv-report-a");
|
|
|
|
// D — generatedFromRevision === investigationRevision
|
|
expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(4);
|
|
});
|
|
|
|
/* ── D: manual Update report ── */
|
|
|
|
it("manual update reloads same id and saves back", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
id: "inv-report-a",
|
|
investigationReport: { understanding: "R1.", hasPlausibleInterpretations: false, generatedFromRevision: 4 },
|
|
investigationRevision: 5, // mismatch → "Update available"
|
|
findings: [{ id: "f-1", proposition: "P1" }],
|
|
}));
|
|
|
|
global.fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, understanding: "R2.", plausibleInterpretations: null }),
|
|
});
|
|
|
|
render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
|
|
await screen.findByText(/Update available/i);
|
|
|
|
const updateBtn = screen.getByRole("button", { name: /Update report/i });
|
|
fireEvent.click(updateBtn);
|
|
await screen.findByText(/R2\./i);
|
|
|
|
// D — reload by same id
|
|
expect(mockLastLoadedId).toBe("inv-report-a");
|
|
|
|
// D — save back to same ID with updated revision
|
|
expect(mockLastSavedSnapshot.id).toBe("inv-report-a");
|
|
expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(5);
|
|
|
|
// D — exactly one overview POST for the update
|
|
const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
/* ── E: generation failure preserves existing Report ── */
|
|
|
|
it("generation failure preserves existing Report", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
id: "inv-report-a",
|
|
investigationReport: null,
|
|
situationGraph: { evidence: [], reconstruction: {} },
|
|
investigationRevision: 1,
|
|
findings: [],
|
|
}));
|
|
|
|
global.fetch.mockResolvedValue({ ok: false });
|
|
|
|
render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
|
|
await screen.findByText(/Report generation failed/i);
|
|
|
|
// E — no partial save on failure
|
|
expect(mockLastSavedSnapshot).toBeNull();
|
|
});
|
|
|
|
/* ── F: update failure preserves existing Report ── */
|
|
|
|
it("update failure preserves existing report", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
id: "inv-report-a",
|
|
investigationReport: { understanding: "R1.", hasPlausibleInterpretations: false, generatedFromRevision: 4 },
|
|
investigationRevision: 5, // mismatch → "Update available"
|
|
findings: [{ id: "f-1", proposition: "P1" }],
|
|
}));
|
|
|
|
global.fetch.mockResolvedValue({ ok: false });
|
|
|
|
render(React.createElement(ReportPage, { params: { id: "inv-report-a" } }));
|
|
await screen.findByText(/Update available/i);
|
|
|
|
const updateBtn = screen.getByRole("button", { name: /Update report/i });
|
|
fireEvent.click(updateBtn);
|
|
|
|
// F — no save on update failure (saved snapshot unchanged from initial)
|
|
expect(mockLastSavedSnapshot).toBeNull();
|
|
});
|
|
});
|