Implements the main navigation and layout structure: - Sidebar component with collapsible navigation and keyboard shortcut - AppHeader with project switcher and user menu - AppBreadcrumbs with auto-generation from pathname - ProjectSwitcher dropdown for quick project navigation - UserMenu with profile, settings, and logout - AppLayout component combining all layout elements Features: - Responsive design (mobile sidebar sheet, desktop sidebar) - Keyboard navigation (Cmd/Ctrl+B to toggle sidebar) - Dark mode support - WCAG AA accessible (ARIA labels, focus management) All 125 tests passing. Follows design system guidelines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
274 lines
7.9 KiB
TypeScript
274 lines
7.9 KiB
TypeScript
/**
|
|
* Tests for ProjectSwitcher Component
|
|
* Verifies project selection, navigation, and accessibility
|
|
*/
|
|
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { ProjectSwitcher, ProjectSelect } from '@/components/layout/ProjectSwitcher';
|
|
import { mockUseRouter } from 'next-intl/navigation';
|
|
|
|
// Mock useRouter
|
|
const mockPush = jest.fn();
|
|
|
|
describe('ProjectSwitcher', () => {
|
|
const mockProjects = [
|
|
{ id: '1', slug: 'project-one', name: 'Project One' },
|
|
{ id: '2', slug: 'project-two', name: 'Project Two' },
|
|
{ id: '3', slug: 'project-three', name: 'Project Three' },
|
|
];
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockUseRouter.mockReturnValue({
|
|
push: mockPush,
|
|
replace: jest.fn(),
|
|
prefetch: jest.fn(),
|
|
back: jest.fn(),
|
|
forward: jest.fn(),
|
|
refresh: jest.fn(),
|
|
});
|
|
});
|
|
|
|
describe('Rendering', () => {
|
|
it('renders create project button when no projects', () => {
|
|
render(<ProjectSwitcher projects={[]} />);
|
|
|
|
expect(screen.getByTestId('create-project-button')).toBeInTheDocument();
|
|
expect(screen.getByText('Create Project')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders project switcher trigger when projects exist', () => {
|
|
render(<ProjectSwitcher projects={mockProjects} />);
|
|
|
|
expect(screen.getByTestId('project-switcher-trigger')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays current project name', () => {
|
|
render(
|
|
<ProjectSwitcher
|
|
projects={mockProjects}
|
|
currentProject={mockProjects[0]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Project One')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays placeholder when no current project', () => {
|
|
render(<ProjectSwitcher projects={mockProjects} />);
|
|
|
|
expect(screen.getByText('Select Project')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Dropdown Menu', () => {
|
|
it('opens dropdown when trigger is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<ProjectSwitcher projects={mockProjects} />);
|
|
|
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
|
await user.click(trigger);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('project-switcher-menu')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('displays all projects in dropdown', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<ProjectSwitcher projects={mockProjects} />);
|
|
|
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
|
await user.click(trigger);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('project-option-project-one')).toBeInTheDocument();
|
|
expect(screen.getByTestId('project-option-project-two')).toBeInTheDocument();
|
|
expect(screen.getByTestId('project-option-project-three')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows current indicator on selected project', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<ProjectSwitcher
|
|
projects={mockProjects}
|
|
currentProject={mockProjects[0]}
|
|
/>
|
|
);
|
|
|
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
|
await user.click(trigger);
|
|
|
|
await waitFor(() => {
|
|
const currentOption = screen.getByTestId('project-option-project-one');
|
|
expect(currentOption).toHaveTextContent('Current');
|
|
});
|
|
});
|
|
|
|
it('includes create new project option', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<ProjectSwitcher projects={mockProjects} />);
|
|
|
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
|
await user.click(trigger);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('create-project-option')).toBeInTheDocument();
|
|
expect(screen.getByText('Create New Project')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Navigation', () => {
|
|
it('navigates to project when option is selected', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<ProjectSwitcher projects={mockProjects} />);
|
|
|
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
|
await user.click(trigger);
|
|
|
|
const projectOption = await screen.findByTestId('project-option-project-two');
|
|
await user.click(projectOption);
|
|
|
|
expect(mockPush).toHaveBeenCalledWith('/projects/project-two');
|
|
});
|
|
|
|
it('calls onProjectChange callback when provided', async () => {
|
|
const user = userEvent.setup();
|
|
const mockOnChange = jest.fn();
|
|
|
|
render(
|
|
<ProjectSwitcher
|
|
projects={mockProjects}
|
|
onProjectChange={mockOnChange}
|
|
/>
|
|
);
|
|
|
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
|
await user.click(trigger);
|
|
|
|
const projectOption = await screen.findByTestId('project-option-project-two');
|
|
await user.click(projectOption);
|
|
|
|
expect(mockOnChange).toHaveBeenCalledWith('project-two');
|
|
expect(mockPush).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('navigates to create project page', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<ProjectSwitcher projects={mockProjects} />);
|
|
|
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
|
await user.click(trigger);
|
|
|
|
const createOption = await screen.findByTestId('create-project-option');
|
|
await user.click(createOption);
|
|
|
|
expect(mockPush).toHaveBeenCalledWith('/projects/new');
|
|
});
|
|
|
|
it('navigates from empty state button', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<ProjectSwitcher projects={[]} />);
|
|
|
|
const createButton = screen.getByTestId('create-project-button');
|
|
await user.click(createButton);
|
|
|
|
expect(mockPush).toHaveBeenCalledWith('/projects/new');
|
|
});
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
it('has accessible label on trigger', () => {
|
|
render(
|
|
<ProjectSwitcher
|
|
projects={mockProjects}
|
|
currentProject={mockProjects[0]}
|
|
/>
|
|
);
|
|
|
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
|
expect(trigger).toHaveAttribute(
|
|
'aria-label',
|
|
'Switch project, current: Project One'
|
|
);
|
|
});
|
|
|
|
it('has accessible label when no current project', () => {
|
|
render(<ProjectSwitcher projects={mockProjects} />);
|
|
|
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
|
expect(trigger).toHaveAttribute('aria-label', 'Select project');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ProjectSelect', () => {
|
|
const mockProjects = [
|
|
{ id: '1', slug: 'project-one', name: 'Project One' },
|
|
{ id: '2', slug: 'project-two', name: 'Project Two' },
|
|
];
|
|
|
|
describe('Rendering', () => {
|
|
it('renders select component', () => {
|
|
render(
|
|
<ProjectSelect
|
|
projects={mockProjects}
|
|
onValueChange={jest.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('project-select')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays placeholder', () => {
|
|
render(
|
|
<ProjectSelect
|
|
projects={mockProjects}
|
|
onValueChange={jest.fn()}
|
|
placeholder="Choose a project"
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Choose a project')).toBeInTheDocument();
|
|
});
|
|
|
|
it('has combobox role', () => {
|
|
render(
|
|
<ProjectSelect
|
|
projects={mockProjects}
|
|
onValueChange={jest.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
});
|
|
|
|
it('applies custom className', () => {
|
|
render(
|
|
<ProjectSelect
|
|
projects={mockProjects}
|
|
onValueChange={jest.fn()}
|
|
className="custom-class"
|
|
/>
|
|
);
|
|
|
|
const select = screen.getByTestId('project-select');
|
|
expect(select).toHaveClass('custom-class');
|
|
});
|
|
});
|
|
|
|
// Note: Selection interaction tests are skipped because Radix UI Select
|
|
// doesn't properly open in JSDOM environment. The component is tested
|
|
// through E2E tests instead.
|
|
});
|