630 lines
24 KiB
React
630 lines
24 KiB
React
import { describe, expect, it, beforeEach, vi } from "vitest";
|
|
import React from "react";
|
|
import { render, screen, fireEvent, within } from "@testing-library/react";
|
|
import "@testing-library/jest-dom";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock next/navigation — file-level. Uses shared mutable `pushRef`.
|
|
// vi.mock factories are called on first import (in beforeEach), so by that
|
|
// time pushRef is always initialized from the preceding let binding.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let pushRef = { push: () => {} };
|
|
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => pushRef,
|
|
}));
|
|
|
|
let cryptoRandomUUID = vi.fn();
|
|
Object.defineProperty(global, "crypto", {
|
|
value: { randomUUID: cryptoRandomUUID },
|
|
writable: true,
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock investigation-storage — shared for entire test file
|
|
// (vi.mock hoists; all tests share this instance)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let mockClearStorage = vi.fn();
|
|
let mockLoadResult = null;
|
|
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;
|
|
}
|
|
}
|
|
|
|
vi.mock("@/lib/storage/investigation-storage", () => ({
|
|
listInvestigations: () => [],
|
|
loadInvestigation: (id) => {
|
|
mockLastLoadedId = id;
|
|
return mockLoadResult;
|
|
},
|
|
saveInvestigation: (snapshot) => {
|
|
mockLastSavedSnapshot = snapshot;
|
|
},
|
|
clearInvestigation: () => {
|
|
localStorage.removeItem("confidence-engine-investigation");
|
|
mockClearStorage();
|
|
},
|
|
}));
|
|
|
|
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 while production increased.",
|
|
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");
|
|
}
|
|
|
|
describe("Portfolio page (pre-confirmation v0.55/v0.56)", () => {
|
|
let Portfolio;
|
|
|
|
beforeEach(async () => {
|
|
pushRef.push = vi.fn();
|
|
cryptoRandomUUID.mockClear();
|
|
Object.defineProperty(global, "crypto", {
|
|
value: { randomUUID: cryptoRandomUUID },
|
|
writable: true,
|
|
});
|
|
const mod = await import("@/app/page.jsx");
|
|
Portfolio = mod.default;
|
|
});
|
|
|
|
it("shows + Create new investigation when no investigation exists", async () => {
|
|
setMockSnapshot(null);
|
|
render(React.createElement(Portfolio));
|
|
expect(await screen.findByText(/Create new investigation/i)).toBeInTheDocument();
|
|
expect(screen.queryByText(/Open investigation/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/Continue investigation/i)).not.toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
|
|
it("does not show Restart investigation when no investigation exists", async () => {
|
|
setMockSnapshot(null);
|
|
render(React.createElement(Portfolio));
|
|
expect(screen.queryByText(/Restart investigation/i)).not.toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
|
|
it("shows Continue investigation card when one persisted investigation exists", async () => {
|
|
setMockSnapshot(makeSnapshot());
|
|
render(React.createElement(Portfolio));
|
|
expect(await screen.findByText(/Continue investigation/i)).toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
|
|
it("does not show Open investigation text when one persisted investigation exists", async () => {
|
|
setMockSnapshot(makeSnapshot());
|
|
render(React.createElement(Portfolio));
|
|
expect(screen.queryByText(/Open investigation/i)).not.toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
|
|
it("shows View report only when a persisted report exists", async () => {
|
|
setMockSnapshot(
|
|
makeSnapshot({
|
|
investigationReport: {
|
|
understanding: "We understand complaints rose.",
|
|
hasPlausibleInterpretations: false,
|
|
generatedFromRevision: 0,
|
|
},
|
|
}),
|
|
);
|
|
render(React.createElement(Portfolio));
|
|
expect(await screen.findByText(/View report/i)).toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
|
|
it("does not show View report when no report persists", async () => {
|
|
setMockSnapshot(makeSnapshot());
|
|
render(React.createElement(Portfolio));
|
|
expect(screen.queryByText(/View report/i)).not.toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
|
|
it("shows Restart investigation button when one persisted investigation exists", async () => {
|
|
setMockSnapshot(makeSnapshot());
|
|
render(React.createElement(Portfolio));
|
|
expect(await screen.findByText(/Restart investigation/i)).toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
|
|
it("card does not contain Create new investigation when one persisted investigation exists", async () => {
|
|
setMockSnapshot(makeSnapshot());
|
|
render(React.createElement(Portfolio));
|
|
expect(await screen.findByText(/Create new investigation/i)).toBeInTheDocument();
|
|
const card = document.querySelector("section.mb-10");
|
|
const btns = Array.from(card.querySelectorAll('a,button'));
|
|
expect(btns.some(el => /Create new investigation/i.test(el.textContent))).toBe(false);
|
|
await cleanup();
|
|
});
|
|
|
|
it("View / Continue / Restart semantics unchanged before confirmation (no report)", async () => {
|
|
setMockSnapshot(makeSnapshot());
|
|
render(React.createElement(Portfolio));
|
|
// View report absent because investigationReport is null
|
|
expect(screen.queryByText(/View report/i)).not.toBeInTheDocument();
|
|
expect(await screen.findByText(/Continue investigation/i)).toBeInTheDocument();
|
|
expect(await screen.findByRole("button", { name: /Restart investigation/i })).toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
|
|
it("View report present when a persisted report exists", async () => {
|
|
setMockSnapshot(
|
|
makeSnapshot({
|
|
investigationReport: {
|
|
understanding: "We understand complaints rose.",
|
|
hasPlausibleInterpretations: false,
|
|
generatedFromRevision: 0,
|
|
},
|
|
}),
|
|
);
|
|
render(React.createElement(Portfolio));
|
|
expect(await screen.findByText(/View report/i)).toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
|
|
it("Create New allocates a fresh ID and navigates without calling saveInvestigation", async () => {
|
|
setMockSnapshot(null);
|
|
pushRef.push = vi.spyOn(pushRef, "push");
|
|
cryptoRandomUUID.mockReturnValue(
|
|
"11111111-2222-4333-8444-555555555555",
|
|
);
|
|
Object.defineProperty(global, "crypto", {
|
|
value: { randomUUID: cryptoRandomUUID },
|
|
writable: true,
|
|
});
|
|
|
|
render(React.createElement(Portfolio));
|
|
|
|
const createLink = await screen.findByRole("button", {
|
|
name: /Create new investigation/i,
|
|
});
|
|
fireEvent.click(createLink);
|
|
|
|
expect(cryptoRandomUUID).toHaveBeenCalledTimes(1);
|
|
expect(pushRef.push).toHaveBeenCalledWith(
|
|
"/investigations/11111111-2222-4333-8444-555555555555",
|
|
);
|
|
await cleanup();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Restart confirmation flow (v0.57)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("Restart confirmation flow (v0.57)", () => {
|
|
let Portfolio;
|
|
|
|
beforeEach(async () => {
|
|
mockClearStorage = vi.fn();
|
|
setMockSnapshot(makeSnapshot());
|
|
const mod = await import("@/app/page.jsx");
|
|
Portfolio = mod.default;
|
|
});
|
|
|
|
it("first Restart click does not call clearInvestigation", async () => {
|
|
render(React.createElement(Portfolio));
|
|
const restartBtn = await screen.findByRole("button", { name: /Restart investigation/i });
|
|
fireEvent.click(restartBtn);
|
|
expect(mockClearStorage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("warning dialog title appears on first Restart click", async () => {
|
|
render(React.createElement(Portfolio));
|
|
const restartBtn = await screen.findByRole("button", { name: /Restart investigation/i });
|
|
fireEvent.click(restartBtn);
|
|
expect(await screen.findByRole("heading", { name: /Restart this investigation\?/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("warning body accurately describes loss on first Restart click", async () => {
|
|
render(React.createElement(Portfolio));
|
|
const restartBtn = await screen.findByRole("button", { name: /Restart investigation/i });
|
|
fireEvent.click(restartBtn);
|
|
expect(await screen.findByText(/Your current investigation, findings, clarified questions, and report will be lost/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("dialog has accessible role and semantics", async () => {
|
|
render(React.createElement(Portfolio));
|
|
const restartBtn = await screen.findByRole("button", { name: /Restart investigation/i });
|
|
fireEvent.click(restartBtn);
|
|
const dialog = await screen.findByRole("dialog");
|
|
expect(dialog).toHaveAttribute("aria-modal", "true");
|
|
expect(dialog).toHaveAttribute("aria-labelledby");
|
|
});
|
|
|
|
it("Cancel closes the dialog and does not call clearInvestigation", async () => {
|
|
render(React.createElement(Portfolio));
|
|
const restartBtn = await screen.findByRole("button", { name: /Restart investigation/i });
|
|
fireEvent.click(restartBtn);
|
|
expect(await screen.findByRole("dialog")).toBeInTheDocument();
|
|
const cancelBtn = screen.getByRole("button", { name: "Cancel" });
|
|
fireEvent.click(cancelBtn);
|
|
expect(mockClearStorage).not.toHaveBeenCalled();
|
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("persisted card remains after Cancel", async () => {
|
|
render(React.createElement(Portfolio));
|
|
const restartBtn = await screen.findByRole("button", { name: /Restart investigation/i });
|
|
fireEvent.click(restartBtn);
|
|
const cancelBtn = screen.getByRole("button", { name: "Cancel" });
|
|
fireEvent.click(cancelBtn);
|
|
expect(await screen.findByText(/Continue investigation/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("confirmed Restart calls clearInvestigation exactly once", async () => {
|
|
render(React.createElement(Portfolio));
|
|
const restartBtn = await screen.findByRole("button", { name: /Restart investigation/i });
|
|
fireEvent.click(restartBtn);
|
|
const dialog = await screen.findByRole("dialog");
|
|
const dialogRestartBtn = within(dialog).getByRole("button", { name: "Restart investigation" });
|
|
fireEvent.click(dialogRestartBtn);
|
|
expect(mockClearStorage).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("confirmed Restart removes the card from Portfolio state", async () => {
|
|
render(React.createElement(Portfolio));
|
|
expect(await screen.findByText(/Continue investigation/i)).toBeInTheDocument();
|
|
const restartBtn = await screen.findByRole("button", { name: /Restart investigation/i });
|
|
fireEvent.click(restartBtn);
|
|
const dialog = await screen.findByRole("dialog");
|
|
const dialogRestartBtn = within(dialog).getByRole("button", { name: "Restart investigation" });
|
|
fireEvent.click(dialogRestartBtn);
|
|
expect(screen.queryByText(/Continue investigation/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("Portfolio-level + Create new investigation remains after confirmed Restart (but no card)", async () => {
|
|
render(React.createElement(Portfolio));
|
|
expect(await screen.findByText(/Create new investigation/i)).toBeInTheDocument();
|
|
const restartBtn = await screen.findByRole("button", { name: /Restart investigation/i });
|
|
fireEvent.click(restartBtn);
|
|
const dialog = await screen.findByRole("dialog");
|
|
const dialogRestartBtn = within(dialog).getByRole("button", { name: "Restart investigation" });
|
|
fireEvent.click(dialogRestartBtn);
|
|
// card is gone (no Continue investigation) but the portfolio-level link remains
|
|
// After restart, existing=null so "Investigations" section disappears, leaving only the standalone + Create new investigation link
|
|
});
|
|
|
|
it("mocked clear does not call original storage during tests", async () => {
|
|
render(React.createElement(Portfolio));
|
|
const restartBtn = await screen.findByRole("button", { name: /Restart investigation/i });
|
|
fireEvent.click(restartBtn);
|
|
expect(mockClearStorage).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Report lifecycle — v0.58 first Report generation (no report → generate → persist)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("Report page lifecycle — v0.58", () => {
|
|
let ReportPage;
|
|
|
|
beforeEach(async () => {
|
|
setMockSnapshot(makeSnapshot());
|
|
global.fetch = vi.fn();
|
|
const mod = await import("@/app/investigations/[id]/report/page.jsx");
|
|
ReportPage = mod.default;
|
|
});
|
|
|
|
afterEach(async () => cleanup());
|
|
|
|
// 1. persisted Report → 0 overview requests
|
|
it("renders persisted report without calling /api/cases/overview", async () => {
|
|
const persistSnap = makeSnapshot({
|
|
investigationReport: { understanding: "Persisted summary.", hasPlausibleInterpretations: false },
|
|
});
|
|
setMockSnapshot(persistSnap);
|
|
|
|
render(React.createElement(ReportPage));
|
|
await screen.findByText(/Persisted summary\./i);
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// 2. no Report → skeleton / loading state appears
|
|
it("shows What we understand heading when no report persists", async () => {
|
|
setMockSnapshot(makeSnapshot());
|
|
render(React.createElement(ReportPage));
|
|
expect(await screen.findByText(/What we understand/i)).toBeInTheDocument();
|
|
});
|
|
|
|
// 3. no Report → exactly 1 overview request
|
|
it("makes exactly one POST /api/cases/overview when report absent", async () => {
|
|
global.fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, understanding: "Summary.", plausibleInterpretations: "None." }),
|
|
});
|
|
|
|
setMockSnapshot(makeSnapshot());
|
|
render(React.createElement(ReportPage));
|
|
await screen.findByText(/Summary\./i);
|
|
const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
// 4. success → understanding renders
|
|
it("renders understanding content after successful generation", async () => {
|
|
global.fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, understanding: "First report summary.", plausibleInterpretations: "None." }),
|
|
});
|
|
|
|
setMockSnapshot(makeSnapshot());
|
|
render(React.createElement(ReportPage));
|
|
expect(await screen.findByText(/First report summary\./i)).toBeInTheDocument();
|
|
});
|
|
|
|
// 5. success → investigationReport persists
|
|
it("persists investigationReport via canonical storage after generation", async () => {
|
|
setMockSnapshot(makeSnapshot());
|
|
|
|
global.fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, understanding: "Persisted via save.", plausibleInterpretations: "None." }),
|
|
});
|
|
|
|
render(React.createElement(ReportPage));
|
|
await screen.findByText(/Persisted via save\./i);
|
|
|
|
// saveInvestigation mock does not write to localStorage.
|
|
// We verify persistence indirectly: the report rendered on screen confirms
|
|
// the component received data AND the useEffect callback invoked saveInvestigation.
|
|
// The storage unit tests (investigation-storage.test.js) verify saveInvestigation
|
|
// writes correctly to localStorage — here we verify the integration path works.
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
"/api/cases/overview",
|
|
expect.objectContaining({ method: "POST" }),
|
|
);
|
|
});
|
|
|
|
// 6. existing canonical fields preserved after generation persist
|
|
it("preserves canonical investigation fields after report persists", async () => {
|
|
setMockSnapshot(makeSnapshot());
|
|
const scenarioOriginal = makeSnapshot().scenario;
|
|
global.fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, understanding: "Field check.", plausibleInterpretations: "None." }),
|
|
});
|
|
|
|
render(React.createElement(ReportPage));
|
|
await screen.findByText(/Field check\./i);
|
|
|
|
// Verify the snapshot in localStorage still contains original fields
|
|
const storedRaw = localStorage.getItem("confidence-engine-investigation");
|
|
expect(storedRaw).toBeTruthy();
|
|
const stored = JSON.parse(storedRaw);
|
|
expect(stored.scenario).toBe(scenarioOriginal);
|
|
});
|
|
|
|
// 7. absent plausible interpretations → section omitted
|
|
it("omits What remains plausible when hasPlausibleInterpretations is false", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
investigationReport: { understanding: "Summary.", hasPlausibleInterpretations: false },
|
|
}));
|
|
|
|
render(React.createElement(ReportPage));
|
|
await screen.findByText(/Summary\./i);
|
|
expect(screen.queryByText(/What remains plausible/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
// 8. present plausible interpretations → separate section renders
|
|
it("renders What remains plausible when hasPlausibleInterpretations is true and content exists", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
investigationReport: { understanding: "Summary.", hasPlausibleInterpretations: true, plausibleInterpretations: "One alternative explanation." },
|
|
}));
|
|
|
|
render(React.createElement(ReportPage));
|
|
await screen.findByText(/Summary\./i);
|
|
expect(await screen.findByText(/What remains plausible/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/One alternative explanation\./i)).toBeInTheDocument();
|
|
});
|
|
|
|
// 9. failed request → no partial report persists
|
|
it("does not persist investigationReport on generation failure", async () => {
|
|
setMockSnapshot(makeSnapshot());
|
|
global.fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: false, stage: "internal", error: "Internal server error" }),
|
|
});
|
|
|
|
render(React.createElement(ReportPage));
|
|
await screen.findByText(/Report generation failed/i);
|
|
|
|
// Verify no report was saved — investigationReport should remain null
|
|
const storedRaw = localStorage.getItem("confidence-engine-investigation");
|
|
expect(storedRaw).toBeTruthy();
|
|
const stored = JSON.parse(storedRaw);
|
|
expect(stored.investigationReport).toBeNull();
|
|
});
|
|
|
|
// 10. failed request → no automatic retry
|
|
it("does not re-generate on state update after failure", async () => {
|
|
let fetchCallCount = 0;
|
|
global.fetch.mockImplementation(async (...args) => {
|
|
fetchCallCount++;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
return {
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: false }),
|
|
};
|
|
});
|
|
|
|
setMockSnapshot(makeSnapshot());
|
|
render(React.createElement(ReportPage));
|
|
await screen.findByText(/Report generation failed/i);
|
|
// Allow time for any potential re-trigger
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
/* ── v0.60h — Report route identity assertions ─────────────────────────── */
|
|
|
|
describe("Report route identity — v0.60h", () => {
|
|
let ReportPage;
|
|
|
|
beforeEach(async () => {
|
|
mockLastLoadedId = undefined;
|
|
mockLastSavedSnapshot = null;
|
|
setMockSnapshot(makeSnapshot());
|
|
global.fetch = vi.fn();
|
|
const mod = await import("@/app/investigations/[id]/report/page.jsx");
|
|
ReportPage = mod.default;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
cleanup();
|
|
});
|
|
|
|
it("inv-a identified load + first generation", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
id: "inv-a",
|
|
investigationReport: null,
|
|
investigationRevision: 3,
|
|
findings: [],
|
|
}));
|
|
|
|
global.fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, understanding: "Summary.", plausibleInterpretations: "None." }),
|
|
});
|
|
|
|
render(React.createElement(ReportPage, { params: { id: "inv-a" } }));
|
|
await screen.findByText(/Summary\./i);
|
|
|
|
// A — identified load
|
|
expect(mockLastLoadedId).toBe("inv-a");
|
|
|
|
// C — first generation reaches overview exactly once
|
|
const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
|
|
expect(calls).toHaveLength(1);
|
|
|
|
// C — saved snapshot retains durable id
|
|
expect(mockLastSavedSnapshot.id).toBe("inv-a");
|
|
|
|
// C — generatedFromRevision correct
|
|
expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(3);
|
|
});
|
|
|
|
it("existing report for inv-b renders without generation", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
id: "inv-b",
|
|
investigationReport: { understanding: "Already generated.", hasPlausibleInterpretations: false, generatedFromRevision: 5 },
|
|
investigationRevision: 5,
|
|
}));
|
|
|
|
render(React.createElement(ReportPage, { params: { id: "inv-b" } }));
|
|
await screen.findByText(/Already generated\./i);
|
|
|
|
// A — identified load even for existing report
|
|
expect(mockLastLoadedId).toBe("inv-b");
|
|
|
|
// E — no overview call
|
|
const calls = global.fetch.mock.calls.filter((c) => c[0] === "/api/cases/overview");
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it("manual update reloads by same id and saves back", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
id: "inv-c",
|
|
investigationReport: { understanding: "R1.", hasPlausibleInterpretations: false, generatedFromRevision: 2 },
|
|
investigationRevision: 4, // revision mismatch → "Update available"
|
|
findings: [{ id: "f-1", proposition: "P1" }],
|
|
}));
|
|
|
|
global.fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, understanding: "R2.", plausibleInterpretations: "None." }),
|
|
});
|
|
|
|
render(React.createElement(ReportPage, { params: { id: "inv-c" } }));
|
|
await screen.findByText(/Update available/i);
|
|
|
|
// Find and click Update report button
|
|
const updateBtn = screen.getByRole("button", { name: /Update report/i });
|
|
fireEvent.click(updateBtn);
|
|
await screen.findByText(/R2\./i);
|
|
|
|
// D — manual update uses same durable id
|
|
expect(mockLastLoadedId).toBe("inv-c");
|
|
|
|
// D — save back to same identified Investigation
|
|
expect(mockLastSavedSnapshot.id).toBe("inv-c");
|
|
expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(4);
|
|
|
|
// 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);
|
|
});
|
|
|
|
it("existing report hydration retains zero-call behaviour", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
id: "inv-d",
|
|
investigationReport: { understanding: "R.", hasPlausibleInterpretations: false, generatedFromRevision: 3 },
|
|
investigationRevision: 3, // matching → Current
|
|
}));
|
|
|
|
render(React.createElement(ReportPage, { params: { id: "inv-d" } }));
|
|
await screen.findByText(/Current/i);
|
|
|
|
// E — no singleton/unscoped load happens during Report lifecycle
|
|
expect(mockLastLoadedId).toBe("inv-d");
|
|
|
|
// 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);
|
|
});
|
|
|
|
it("generation failure preserves existing Report", async () => {
|
|
setMockSnapshot(makeSnapshot({
|
|
id: "inv-e",
|
|
investigationReport: null,
|
|
situationGraph: { evidence: [], reconstruction: {} },
|
|
investigationRevision: 1,
|
|
findings: [],
|
|
}));
|
|
|
|
global.fetch.mockResolvedValue({ ok: false });
|
|
|
|
render(React.createElement(ReportPage, { params: { id: "inv-e" } }));
|
|
await screen.findByText(/Report generation failed/i);
|
|
|
|
// F — failure semantics: no partial Report persisted
|
|
expect(mockLastSavedSnapshot).toBeNull();
|
|
});
|
|
});
|