feat(confidence-engine): v0.60g2 render investigation portfolio

This commit is contained in:
2026-09-04 07:58:04 +01:00
parent 7ba1771bcf
commit df142ca76c
3 changed files with 473 additions and 91 deletions
@@ -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);
});
});