feat(confidence-engine): v0.60g2 render investigation portfolio
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
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. No module-cache manipulation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mockList = [];
|
||||
let mockClearTarget = null;
|
||||
|
||||
vi.mock("@/lib/storage/investigation-storage", () => ({
|
||||
listInvestigations: () => [...mockList],
|
||||
clearInvestigation: (id) => {
|
||||
mockClearTarget = id;
|
||||
},
|
||||
}));
|
||||
|
||||
function setMockSummaries(snapshots) {
|
||||
mockList = snapshots ?? [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
function summaryNoReport() {
|
||||
return {
|
||||
id: "inv-no-report",
|
||||
scenario: "Scenario No Report",
|
||||
updatedAt: "2026-09-01T10:00:00.000Z",
|
||||
investigationRevision: 1,
|
||||
reportExists: false,
|
||||
reportGeneratedFromRevision: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A — zero summaries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("v0.60g2 — zero summaries", () => {
|
||||
let Portfolio;
|
||||
|
||||
beforeEach(async () => {
|
||||
pushRef.push = vi.fn();
|
||||
setMockSummaries([]);
|
||||
const mod = await import("@/app/page.jsx");
|
||||
Portfolio = mod.default;
|
||||
});
|
||||
|
||||
it('shows "No investigations yet."', async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
expect(await screen.findByText(/No investigations yet\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show any Continue links", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
expect(screen.queryByText(/Continue investigation/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B — one summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("v0.60g2 — one summary", () => {
|
||||
let Portfolio;
|
||||
|
||||
beforeEach(async () => {
|
||||
pushRef.push = vi.fn();
|
||||
setMockSummaries([summaryA()]);
|
||||
const mod = await import("@/app/page.jsx");
|
||||
Portfolio = mod.default;
|
||||
});
|
||||
|
||||
it("renders exactly one Investigation card", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
const section = await screen.findByRole("heading", { name: /investigations/i, level: 2 });
|
||||
// Card renders as a div with border within the section — verify via scenario text
|
||||
expect(screen.getByText(/Scenario A/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Scenario B/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Continue investigation link for the card", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
expect(await screen.findByText(/Continue investigation/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Continue links to /investigations/{own-id}", async () => {
|
||||
pushRef.push = vi.fn();
|
||||
render(React.createElement(Portfolio));
|
||||
const continueLink = await screen.findByRole("link", { name: /Continue investigation/i });
|
||||
expect(continueLink).toHaveAttribute("href", "/investigations/inv-a");
|
||||
});
|
||||
|
||||
it("renders View report link when reportExists is true", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
expect(await screen.findByText(/View report/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("View report links to /investigations/{own-id}/report", async () => {
|
||||
pushRef.push = vi.fn();
|
||||
render(React.createElement(Portfolio));
|
||||
const viewLink = await screen.findByRole("link", { name: /View report/i });
|
||||
expect(viewLink).toHaveAttribute("href", "/investigations/inv-a/report");
|
||||
});
|
||||
|
||||
it('shows "Current" when revisions match', async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
expect(await screen.findByText(/Current/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C — two summaries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("v0.60g2 — two summaries", () => {
|
||||
let Portfolio;
|
||||
|
||||
beforeEach(async () => {
|
||||
pushRef.push = vi.fn();
|
||||
setMockSummaries([summaryA(), summaryB()]);
|
||||
const mod = await import("@/app/page.jsx");
|
||||
Portfolio = mod.default;
|
||||
});
|
||||
|
||||
it("renders both scenarios", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
expect(screen.getByText(/Scenario A/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Scenario B/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders two distinct Continue investigation links", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
const continueLinks = screen.getAllByText(/Continue investigation/i);
|
||||
expect(continueLinks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("first Continue href contains inv-a", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
const links = document.querySelectorAll('a[href]');
|
||||
const continueHrefs = Array.from(links)
|
||||
.filter((l) => l.textContent.includes("Continue investigation"))
|
||||
.map((l) => l.getAttribute("href"));
|
||||
expect(continueHrefs).toContain("/investigations/inv-a");
|
||||
});
|
||||
|
||||
it("second Continue href contains inv-b", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
const links = document.querySelectorAll('a[href]');
|
||||
const continueHrefs = Array.from(links)
|
||||
.filter((l) => l.textContent.includes("Continue investigation"))
|
||||
.map((l) => l.getAttribute("href"));
|
||||
expect(continueHrefs).toContain("/investigations/inv-b");
|
||||
});
|
||||
|
||||
it("preserves listing order (A before B)", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
const cards = document.querySelectorAll('[class*="bg-gradient-to-b"]');
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards[0]).toContainElement(screen.getByText(/Scenario A/));
|
||||
expect(cards[1]).toContainElement(screen.getByText(/Scenario B/));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D — per-card Report link
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("v0.60g2 — per-card Report visibility", () => {
|
||||
let Portfolio;
|
||||
|
||||
beforeEach(async () => {
|
||||
pushRef.push = vi.fn();
|
||||
setMockSummaries([summaryNoReport()]);
|
||||
const mod = await import("@/app/page.jsx");
|
||||
Portfolio = mod.default;
|
||||
});
|
||||
|
||||
it("does not show View report when reportExists is false", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
expect(screen.queryByText(/View report/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still shows Continue investigation when no report', async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
expect(await screen.findByText(/Continue investigation/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show freshness label when no report", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
expect(screen.queryByText(/Current/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Update available/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E — per-card freshness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("v0.60g2 — per-card freshness", () => {
|
||||
let Portfolio;
|
||||
|
||||
it('shows "Current" when revisions match', async () => {
|
||||
setMockSummaries([summaryA()]);
|
||||
const mod = await import("@/app/page.jsx");
|
||||
Portfolio = mod.default;
|
||||
render(React.createElement(Portfolio));
|
||||
expect(await screen.findByText(/Current/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Update available" when revisions differ', async () => {
|
||||
setMockSummaries([summaryB()]);
|
||||
const mod = await import("@/app/page.jsx");
|
||||
Portfolio = mod.default;
|
||||
render(React.createElement(Portfolio));
|
||||
expect(await screen.findByText(/Update available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows neither when reportExists is false", async () => {
|
||||
setMockSummaries([summaryNoReport()]);
|
||||
const mod = await import("@/app/page.jsx");
|
||||
Portfolio = mod.default;
|
||||
render(React.createElement(Portfolio));
|
||||
expect(screen.queryByText(/Current/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Update available/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("per-card labels are independent", async () => {
|
||||
setMockSummaries([summaryA(), summaryB()]);
|
||||
const mod = await import("@/app/page.jsx");
|
||||
Portfolio = mod.default;
|
||||
render(React.createElement(Portfolio));
|
||||
expect(screen.getByText(/Current/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Update available/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F — no singleton routing (case-1 must never appear in persisted card hrefs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("v0.60g2 — no singleton routing", () => {
|
||||
let Portfolio;
|
||||
|
||||
beforeEach(async () => {
|
||||
pushRef.push = vi.fn();
|
||||
setMockSummaries([summaryA(), summaryB()]);
|
||||
const mod = await import("@/app/page.jsx");
|
||||
Portfolio = mod.default;
|
||||
});
|
||||
|
||||
it("no persisted card href contains case-1", async () => {
|
||||
render(React.createElement(Portfolio));
|
||||
const links = document.querySelectorAll('a[href]');
|
||||
const allHrefs = Array.from(links).map((l) => l.getAttribute("href"));
|
||||
for (const href of allHrefs) {
|
||||
expect(href).not.toContain("case-1");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G — Create New regression
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("v0.60g2 — Create New regression", () => {
|
||||
let Portfolio;
|
||||
let cryptoRandomUUID;
|
||||
|
||||
beforeEach(async () => {
|
||||
cryptoRandomUUID = vi.fn();
|
||||
pushRef.push = vi.fn();
|
||||
setMockSummaries([]);
|
||||
Object.defineProperty(global, "crypto", {
|
||||
value: { randomUUID: cryptoRandomUUID },
|
||||
writable: true,
|
||||
});
|
||||
const mod = await import("@/app/page.jsx");
|
||||
Portfolio = mod.default;
|
||||
});
|
||||
|
||||
it("Create New remains visible even with summaries", async () => {
|
||||
setMockSummaries([summaryA()]);
|
||||
const mod2 = await import("@/app/page.jsx");
|
||||
Portfolio = mod2.default;
|
||||
render(React.createElement(Portfolio));
|
||||
expect(await screen.findByText(/Create new investigation/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Create New allocates fixed UUID and navigates", async () => {
|
||||
setMockSummaries([]);
|
||||
cryptoRandomUUID.mockReturnValue("11111111-2222-4333-8444-555555555555");
|
||||
const mod2 = await import("@/app/page.jsx");
|
||||
Portfolio = mod2.default;
|
||||
render(React.createElement(Portfolio));
|
||||
|
||||
const createBtn = await screen.findByRole("button", { name: /Create new investigation/i });
|
||||
fireEvent.click(createBtn);
|
||||
|
||||
expect(cryptoRandomUUID).toHaveBeenCalledTimes(1);
|
||||
expect(pushRef.push).toHaveBeenCalledWith("/investigations/11111111-2222-4333-8444-555555555555");
|
||||
});
|
||||
|
||||
it("Create New does not invoke any storage save", async () => {
|
||||
setMockSummaries([]);
|
||||
cryptoRandomUUID.mockReturnValue("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
|
||||
const mod2 = await import("@/app/page.jsx");
|
||||
Portfolio = mod2.default;
|
||||
|
||||
// Replace clearInvestigation mock to also detect any listInvestigations call count change
|
||||
let originalListLength = mockList.length;
|
||||
|
||||
render(React.createElement(Portfolio));
|
||||
|
||||
const createBtn = await screen.findByRole("button", { name: /Create new investigation/i });
|
||||
fireEvent.click(createBtn);
|
||||
|
||||
// The mock list should not have changed (no save happens)
|
||||
expect(mockList).toHaveLength(originalListLength);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user