359 lines
12 KiB
React
359 lines
12 KiB
React
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);
|
|
});
|
|
});
|