forked from cardosofelipe/fast-next-template
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>
387 lines
9.8 KiB
TypeScript
387 lines
9.8 KiB
TypeScript
/**
|
|
* Tests for AppLayout and related components
|
|
* Verifies layout structure, responsive behavior, and component integration
|
|
*/
|
|
|
|
import { render, screen } from '@testing-library/react';
|
|
import { AppLayout, PageContainer, PageHeader } from '@/components/layout/AppLayout';
|
|
import { useAuth } from '@/lib/auth/AuthContext';
|
|
import { useLogout } from '@/lib/api/hooks/useAuth';
|
|
import { mockUsePathname } from 'next-intl/navigation';
|
|
import type { User } from '@/lib/stores/authStore';
|
|
|
|
// Mock dependencies
|
|
jest.mock('@/lib/auth/AuthContext', () => ({
|
|
useAuth: jest.fn(),
|
|
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
jest.mock('@/lib/api/hooks/useAuth', () => ({
|
|
useLogout: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('@/components/theme', () => ({
|
|
ThemeToggle: () => <div data-testid="theme-toggle">Theme Toggle</div>,
|
|
}));
|
|
|
|
jest.mock('@/components/i18n', () => ({
|
|
LocaleSwitcher: () => <div data-testid="locale-switcher">Locale Switcher</div>,
|
|
}));
|
|
|
|
// Helper to create mock user
|
|
function createMockUser(overrides: Partial<User> = {}): User {
|
|
return {
|
|
id: 'user-123',
|
|
email: 'test@example.com',
|
|
first_name: 'Test',
|
|
last_name: 'User',
|
|
phone_number: null,
|
|
is_active: true,
|
|
is_superuser: false,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('AppLayout', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockUsePathname.mockReturnValue('/projects');
|
|
|
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
|
user: createMockUser(),
|
|
});
|
|
|
|
(useLogout as jest.Mock).mockReturnValue({
|
|
mutate: jest.fn(),
|
|
isPending: false,
|
|
});
|
|
});
|
|
|
|
describe('Rendering', () => {
|
|
it('renders layout container', () => {
|
|
render(
|
|
<AppLayout>
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
expect(screen.getByTestId('app-layout')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders children', () => {
|
|
render(
|
|
<AppLayout>
|
|
<div data-testid="test-content">Test Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
expect(screen.getByTestId('test-content')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders header', () => {
|
|
render(
|
|
<AppLayout>
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
expect(screen.getByTestId('app-header')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders sidebar', () => {
|
|
render(
|
|
<AppLayout>
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders breadcrumbs', () => {
|
|
render(
|
|
<AppLayout>
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders main content area', () => {
|
|
render(
|
|
<AppLayout>
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
const main = screen.getByRole('main');
|
|
expect(main).toBeInTheDocument();
|
|
expect(main).toHaveAttribute('id', 'main-content');
|
|
});
|
|
});
|
|
|
|
describe('Configuration Options', () => {
|
|
it('hides sidebar when hideSidebar is true', () => {
|
|
render(
|
|
<AppLayout hideSidebar>
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('hides breadcrumbs when hideBreadcrumbs is true', () => {
|
|
render(
|
|
<AppLayout hideBreadcrumbs>
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
expect(screen.queryByTestId('breadcrumbs')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('passes custom breadcrumbs to AppBreadcrumbs', () => {
|
|
const customBreadcrumbs = [
|
|
{ label: 'Custom', href: '/custom', current: true },
|
|
];
|
|
|
|
render(
|
|
<AppLayout breadcrumbs={customBreadcrumbs}>
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
expect(screen.getByTestId('breadcrumb-custom')).toBeInTheDocument();
|
|
});
|
|
|
|
it('passes project slug to sidebar', () => {
|
|
const currentProject = { id: '1', slug: 'test-project', name: 'Test' };
|
|
|
|
render(
|
|
<AppLayout currentProject={currentProject}>
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
// Sidebar should show project-specific navigation
|
|
expect(screen.getByTestId('nav-dashboard')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Custom ClassNames', () => {
|
|
it('applies className to main content', () => {
|
|
render(
|
|
<AppLayout className="custom-main">
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
const main = screen.getByRole('main');
|
|
expect(main).toHaveClass('custom-main');
|
|
});
|
|
|
|
it('applies wrapperClassName to outer container', () => {
|
|
render(
|
|
<AppLayout wrapperClassName="custom-wrapper">
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
const layout = screen.getByTestId('app-layout');
|
|
expect(layout).toHaveClass('custom-wrapper');
|
|
});
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
it('main content has tabIndex for skip link support', () => {
|
|
render(
|
|
<AppLayout>
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
const main = screen.getByRole('main');
|
|
expect(main).toHaveAttribute('tabIndex', '-1');
|
|
});
|
|
|
|
it('layout has min-h-screen for full viewport', () => {
|
|
render(
|
|
<AppLayout>
|
|
<div>Content</div>
|
|
</AppLayout>
|
|
);
|
|
|
|
const layout = screen.getByTestId('app-layout');
|
|
expect(layout).toHaveClass('min-h-screen');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('PageContainer', () => {
|
|
describe('Rendering', () => {
|
|
it('renders container', () => {
|
|
render(
|
|
<PageContainer>
|
|
<div>Content</div>
|
|
</PageContainer>
|
|
);
|
|
|
|
expect(screen.getByTestId('page-container')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders children', () => {
|
|
render(
|
|
<PageContainer>
|
|
<div data-testid="test-content">Test Content</div>
|
|
</PageContainer>
|
|
);
|
|
|
|
expect(screen.getByTestId('test-content')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Max Width', () => {
|
|
it('defaults to max-w-6xl', () => {
|
|
render(
|
|
<PageContainer>
|
|
<div>Content</div>
|
|
</PageContainer>
|
|
);
|
|
|
|
const container = screen.getByTestId('page-container');
|
|
expect(container).toHaveClass('max-w-6xl');
|
|
});
|
|
|
|
it('applies custom max width', () => {
|
|
render(
|
|
<PageContainer maxWidth="md">
|
|
<div>Content</div>
|
|
</PageContainer>
|
|
);
|
|
|
|
const container = screen.getByTestId('page-container');
|
|
expect(container).toHaveClass('max-w-md');
|
|
});
|
|
|
|
it('applies full width', () => {
|
|
render(
|
|
<PageContainer maxWidth="full">
|
|
<div>Content</div>
|
|
</PageContainer>
|
|
);
|
|
|
|
const container = screen.getByTestId('page-container');
|
|
expect(container).toHaveClass('max-w-full');
|
|
});
|
|
});
|
|
|
|
describe('Styling', () => {
|
|
it('has container and centering classes', () => {
|
|
render(
|
|
<PageContainer>
|
|
<div>Content</div>
|
|
</PageContainer>
|
|
);
|
|
|
|
const container = screen.getByTestId('page-container');
|
|
expect(container).toHaveClass('container', 'mx-auto');
|
|
});
|
|
|
|
it('has responsive padding', () => {
|
|
render(
|
|
<PageContainer>
|
|
<div>Content</div>
|
|
</PageContainer>
|
|
);
|
|
|
|
const container = screen.getByTestId('page-container');
|
|
expect(container).toHaveClass('px-4', 'py-6', 'lg:px-6', 'lg:py-8');
|
|
});
|
|
|
|
it('applies custom className', () => {
|
|
render(
|
|
<PageContainer className="custom-class">
|
|
<div>Content</div>
|
|
</PageContainer>
|
|
);
|
|
|
|
const container = screen.getByTestId('page-container');
|
|
expect(container).toHaveClass('custom-class');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('PageHeader', () => {
|
|
describe('Rendering', () => {
|
|
it('renders page header', () => {
|
|
render(<PageHeader title="Test Title" />);
|
|
|
|
expect(screen.getByTestId('page-header')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders title', () => {
|
|
render(<PageHeader title="Test Title" />);
|
|
|
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Test Title');
|
|
});
|
|
|
|
it('renders description when provided', () => {
|
|
render(<PageHeader title="Title" description="Test description" />);
|
|
|
|
expect(screen.getByText('Test description')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not render description when not provided', () => {
|
|
render(<PageHeader title="Title" />);
|
|
|
|
const header = screen.getByTestId('page-header');
|
|
expect(header.querySelectorAll('p')).toHaveLength(0);
|
|
});
|
|
|
|
it('renders actions when provided', () => {
|
|
render(
|
|
<PageHeader
|
|
title="Title"
|
|
actions={<button data-testid="action-button">Action</button>}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('action-button')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Styling', () => {
|
|
it('has responsive flex layout', () => {
|
|
render(<PageHeader title="Title" />);
|
|
|
|
const header = screen.getByTestId('page-header');
|
|
expect(header).toHaveClass('flex', 'flex-col', 'sm:flex-row');
|
|
});
|
|
|
|
it('title has responsive text size', () => {
|
|
render(<PageHeader title="Title" />);
|
|
|
|
const title = screen.getByRole('heading', { level: 1 });
|
|
expect(title).toHaveClass('text-2xl', 'sm:text-3xl');
|
|
});
|
|
|
|
it('description has muted styling', () => {
|
|
render(<PageHeader title="Title" description="Description" />);
|
|
|
|
const description = screen.getByText('Description');
|
|
expect(description).toHaveClass('text-muted-foreground');
|
|
});
|
|
|
|
it('applies custom className', () => {
|
|
render(<PageHeader title="Title" className="custom-class" />);
|
|
|
|
const header = screen.getByTestId('page-header');
|
|
expect(header).toHaveClass('custom-class');
|
|
});
|
|
});
|
|
});
|