175 lines
5.6 KiB
TypeScript
175 lines
5.6 KiB
TypeScript
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<ReturnType<typeof createMemoryRouter>> {
|
|
const routes: RouteObject[] = [
|
|
{ path: '/projects', element: <p>Projects index target</p> },
|
|
{ path: '/projects/:id', element: <ProjectDetailPage /> },
|
|
];
|
|
const router = createMemoryRouter(routes, { initialEntries: ['/projects/project-1'] });
|
|
container = document.createElement('div');
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(<RouterProvider router={router} />);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|