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(); }); });