import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { missionFixtures, projectFixtures, taskFixtures } from './page-fixtures'; const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn(), })); vi.mock('@/lib/api', () => ({ api: apiMock, })); import { ProjectDetailPage } from './project-detail'; let root: Root | null = null; let container: HTMLDivElement; beforeAll(() => { Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true, }); }); afterAll(() => { Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); }); afterEach(async () => { await act(async () => { root?.unmount(); }); document.body.replaceChildren(); root = null; apiMock.mockReset(); }); async function renderProjectDetailPage(): Promise> { const routes: RouteObject[] = [ { path: '/projects', element:

Projects index target

}, { path: '/projects/:id', element: }, ]; const router = createMemoryRouter(routes, { initialEntries: ['/projects/project-1'] }); container = document.createElement('div'); document.body.append(container); root = createRoot(container); await act(async () => { root?.render(); }); return router; } function clickButtonByText(text: string): void { const button = [...container.querySelectorAll('button')].find((candidate) => candidate.textContent?.includes(text), ); if (!button) { throw new Error(`Button containing "${text}" not found`); } button.dispatchEvent(new MouseEvent('click', { bubbles: true })); } describe('ProjectDetailPage', () => { it('loads the project, tasks, missions, and optional PRD content for the active project', async () => { apiMock .mockResolvedValueOnce(projectFixtures[0]) .mockResolvedValueOnce(missionFixtures) .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1')); await renderProjectDetailPage(); expect(apiMock.mock.calls).toEqual([ ['/api/projects/project-1'], ['/api/missions'], ['/api/tasks?projectId=project-1'], ]); expect(container.textContent).toContain('Mosaic Stack'); expect(container.textContent).toContain('Route /projects/:id'); expect(container.textContent).toContain('Tasks'); expect(container.textContent).toContain('Done'); expect(container.textContent).toContain('Blocked'); await act(async () => { clickButtonByText('Missions (1)'); }); expect(container.textContent).toContain('Ship web parity'); expect(container.textContent).not.toContain('Unrelated mission'); await act(async () => { clickButtonByText('PRD'); }); expect(container.textContent).toContain('Mosaic Stack PRD'); expect(container.textContent).toContain('Ship the SPA route parity pages.'); }); it('opens and closes the existing read-only task modal from the tasks tab', async () => { apiMock .mockResolvedValueOnce(projectFixtures[0]) .mockResolvedValueOnce(missionFixtures) .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1')); await renderProjectDetailPage(); await act(async () => { clickButtonByText('Tasks (3)'); }); const row = [...container.querySelectorAll('tr')].find((candidate) => candidate.textContent?.includes('Route /projects'), ); expect(row).toBeTruthy(); await act(async () => { row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(container.querySelector('[role="dialog"]')).toBeTruthy(); expect(container.textContent).toContain('Read-only modal content should remain intact.'); const closeButton = container.querySelector('button[aria-label="Close task details"]'); expect(closeButton).toBeTruthy(); await act(async () => { closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(container.querySelector('[role="dialog"]')).toBeNull(); }); it('renders the project with an empty missions tab when the missions request fails', async () => { apiMock .mockResolvedValueOnce(projectFixtures[0]) .mockRejectedValueOnce(new Error('Missions request failed')) .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1')); await renderProjectDetailPage(); expect(container.textContent).toContain('Mosaic Stack'); expect(container.querySelector('[role="alert"]')).toBeNull(); await act(async () => { clickButtonByText('Missions (0)'); }); expect(container.textContent).toContain('No missions for this project'); }); it('renders a visible alert when the project request fails and lets the user navigate back', async () => { apiMock .mockRejectedValueOnce(new Error('Project request failed')) .mockResolvedValueOnce(missionFixtures) .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1')); const router = await renderProjectDetailPage(); const alert = container.querySelector('[role="alert"]'); expect(alert).toBeTruthy(); expect(alert?.textContent).toContain('Project request failed'); expect(container.textContent).not.toContain('Mosaic Stack'); await act(async () => { clickButtonByText('Back to projects'); }); expect(router.state.location.pathname).toBe('/projects'); }); });