v0.59b — Report page freshness UI: - Shows Current / Update available beside the generated report - Manual Update report action with duplicate prevention guard - Explanation copy about investigation changes since generation - Persists generatedFromRevision during update flow v0.59c — Portfolio Report freshness state: - Surfaces Current / Update available alongside existing View report link - Derives solely from revision provenance (zero model calls) - No Update report action on Portfolio (manual update owned by Report page) - Neither state shown when no Report exists - Updated makeSnapshot with investigationRevision for realistic test data
173 lines
5.9 KiB
React
173 lines
5.9 KiB
React
/**
|
|
* v0.59c — targeted deterministic tests for Portfolio Report freshness UI.
|
|
*
|
|
* Exercises four cases on the Portfolio:
|
|
* 1. Report + matching revisions → "Current"
|
|
* 2. Report + differing revisions → "Update available"
|
|
* 3. differing revisions still render View report action
|
|
* 4. no Report → neither Current nor Update available
|
|
* 5. zero model/fetch calls during render
|
|
* 6. Portfolio does not mutate Investigation storage
|
|
* 7. existing Continue / Restart actions unchanged
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import React from "react";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import "@testing-library/jest-dom";
|
|
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({ push: () => {} }),
|
|
}));
|
|
|
|
let mockClearStorage = vi.fn();
|
|
let mockLoadResult = 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", () => ({
|
|
loadInvestigation: () => mockLoadResult,
|
|
saveInvestigation: vi.fn(),
|
|
clearInvestigation: () => {
|
|
localStorage.removeItem("confidence-engine-investigation");
|
|
mockClearStorage();
|
|
},
|
|
}));
|
|
|
|
/** Creates a snapshot with report. revisionMatch controls freshness state. */
|
|
function makeFreshSnapshot(investigationRevision, revisionMatch) {
|
|
return {
|
|
scenario: "Test investigation",
|
|
situationGraph: { centralStatement: "CS", nodes: [], edges: [] },
|
|
selectedQuestion: null,
|
|
summary: "Test summary.",
|
|
updatedAt: new Date().toISOString(),
|
|
schemaVersion: 1,
|
|
investigationReport: {
|
|
understanding: "We understand complaints rose.",
|
|
hasPlausibleInterpretations: false,
|
|
generatedFromRevision: revisionMatch ? investigationRevision : (investigationRevision - 2),
|
|
},
|
|
investigationRevision,
|
|
findings: [],
|
|
};
|
|
}
|
|
|
|
/** Creates a snapshot with NO report. */
|
|
function makeNoReportSnapshot(investigationRevision) {
|
|
return {
|
|
scenario: "Test investigation",
|
|
situationGraph: { centralStatement: "CS", nodes: [], edges: [] },
|
|
selectedQuestion: null,
|
|
summary: "Test summary.",
|
|
updatedAt: new Date().toISOString(),
|
|
schemaVersion: 1,
|
|
investigationReport: null,
|
|
investigationRevision,
|
|
findings: [],
|
|
};
|
|
}
|
|
|
|
async function cleanup() {
|
|
localStorage.removeItem("confidence-engine-investigation");
|
|
}
|
|
|
|
describe("Portfolio freshness — Report + matching revisions", () => {
|
|
it("renders Current alongside View report", async () => {
|
|
setMockSnapshot(makeFreshSnapshot(3, true));
|
|
const mod = await import("@/app/page.jsx");
|
|
render(React.createElement(mod.default));
|
|
expect(await screen.findByText(/View report/i)).toBeInTheDocument();
|
|
expect(screen.getByText("Current")).toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
|
|
it("makes zero fetch calls during render", async () => {
|
|
global.fetch = vi.fn();
|
|
setMockSnapshot(makeFreshSnapshot(3, true));
|
|
const mod = await import("@/app/page.jsx");
|
|
render(React.createElement(mod.default));
|
|
|
|
try {
|
|
await waitFor(() => screen.findByText(/View report/i), { timeout: 5000 });
|
|
} catch {}
|
|
|
|
// Portfolio should not call fetch at all — derives state from localStorage only
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
global.fetch = undefined;
|
|
await cleanup();
|
|
});
|
|
});
|
|
|
|
describe("Portfolio freshness — Report + differing revisions", () => {
|
|
it("renders Update available alongside View report", async () => {
|
|
setMockSnapshot(makeFreshSnapshot(5, false));
|
|
const mod = await import("@/app/page.jsx");
|
|
render(React.createElement(mod.default));
|
|
expect(await screen.findByText(/View report/i)).toBeInTheDocument();
|
|
expect(screen.getByText("Update available")).toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
|
|
it("View report still present as link for differing revisions", async () => {
|
|
setMockSnapshot(makeFreshSnapshot(5, false));
|
|
const mod = await import("@/app/page.jsx");
|
|
render(React.createElement(mod.default));
|
|
const link = await screen.findByRole("link", { name: /View report/i });
|
|
expect(link).toBeInTheDocument();
|
|
expect(link).toHaveAttribute("href", "/investigations/case-1/report");
|
|
await cleanup();
|
|
});
|
|
});
|
|
|
|
describe("Portfolio freshness — no Report", () => {
|
|
it("renders neither Current nor Update available when no report exists", async () => {
|
|
setMockSnapshot(makeNoReportSnapshot(0));
|
|
const mod = await import("@/app/page.jsx");
|
|
render(React.createElement(mod.default));
|
|
expect(screen.queryByText(/View report/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Current")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Update available")).not.toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
});
|
|
|
|
describe("Portfolio existing actions preserved", () => {
|
|
it("Continue investigation and Restart remain when a report exists", async () => {
|
|
setMockSnapshot(makeFreshSnapshot(3, true));
|
|
const mod = await import("@/app/page.jsx");
|
|
render(React.createElement(mod.default));
|
|
expect(await screen.findByText(/Continue investigation/i)).toBeInTheDocument();
|
|
expect(await screen.findByText(/Restart investigation/i)).toBeInTheDocument();
|
|
await cleanup();
|
|
});
|
|
});
|
|
|
|
describe("Portfolio freshness — differing revisions no-additional-calls", () => {
|
|
it("refreshing with differing data does not trigger additional API calls", async () => {
|
|
global.fetch = vi.fn();
|
|
setMockSnapshot(makeFreshSnapshot(5, false));
|
|
const mod = await import("@/app/page.jsx");
|
|
|
|
render(React.createElement(mod.default));
|
|
try {
|
|
await waitFor(() => screen.findByText(/Update available/i), { timeout: 2000 });
|
|
} catch {}
|
|
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
global.fetch = undefined;
|
|
await cleanup();
|
|
});
|
|
});
|