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); }); });