diff --git a/__tests__/components/ui/EmptyState.test.tsx b/__tests__/components/ui/EmptyState.test.tsx new file mode 100644 index 0000000..0d4a154 --- /dev/null +++ b/__tests__/components/ui/EmptyState.test.tsx @@ -0,0 +1,95 @@ +/** + * Tests for the EmptyState component + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { EmptyState } from '@/components/ui/EmptyState'; + +describe('EmptyState', () => { + it('should render message', () => { + render(); + expect(screen.getByText('No items found')).toBeInTheDocument(); + }); + + it('should render default icon', () => { + render(); + expect(screen.getByText('📝')).toBeInTheDocument(); + }); + + it('should render custom icon', () => { + render(); + expect(screen.getByText('🔍')).toBeInTheDocument(); + }); + + it('should render title when provided', () => { + render(); + expect(screen.getByText('Empty List')).toBeInTheDocument(); + }); + + it('should not render title when not provided', () => { + render(); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); + + it('should render action button when action is provided', () => { + const mockClick = jest.fn(); + render( + + ); + expect(screen.getByText('Add Item')).toBeInTheDocument(); + }); + + it('should call action onClick when button is clicked', () => { + const mockClick = jest.fn(); + render( + + ); + fireEvent.click(screen.getByText('Add Item')); + expect(mockClick).toHaveBeenCalledTimes(1); + }); + + it('should not render action button when action is not provided', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('should have centered layout', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('text-center'); + expect(container.firstChild).toHaveClass('py-16'); + }); + + it('should style title correctly', () => { + render(); + const title = screen.getByText('Empty'); + expect(title).toHaveClass('text-lg'); + expect(title).toHaveClass('font-semibold'); + }); + + it('should style message correctly', () => { + render(); + const message = screen.getByText('No items found'); + expect(message).toHaveClass('text-gray-600'); + }); + + it('should style action button correctly', () => { + const mockClick = jest.fn(); + render( + + ); + const button = screen.getByText('Click Me'); + expect(button).toHaveClass('bg-blue-600'); + expect(button).toHaveClass('text-white'); + }); +}); diff --git a/__tests__/components/ui/LoadingState.test.tsx b/__tests__/components/ui/LoadingState.test.tsx new file mode 100644 index 0000000..8e461bf --- /dev/null +++ b/__tests__/components/ui/LoadingState.test.tsx @@ -0,0 +1,99 @@ +/** + * Tests for the LoadingState component + * @jest-environment jsdom + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { LoadingState } from '@/components/ui/LoadingState'; + +describe('LoadingState', () => { + it('should render page type by default', () => { + const { container } = render(); + expect(container.querySelector('.p-8')).toBeInTheDocument(); + }); + + it('should render card type skeleton', () => { + const { container } = render(); + expect(container.querySelector('.h-32')).toBeInTheDocument(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + + it('should render table type skeleton', () => { + const { container } = render(); + expect(container.querySelector('.h-10')).toBeInTheDocument(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + + it('should render table type with custom rows', () => { + const { container } = render(); + const rowElements = container.querySelectorAll('.h-12'); + expect(rowElements).toHaveLength(6); + }); + + it('should render list type skeleton', () => { + const { container } = render(); + expect(container.querySelector('.rounded-full')).toBeInTheDocument(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + + it('should render list type with custom rows', () => { + const { container } = render(); + const avatars = container.querySelectorAll('.rounded-full'); + expect(avatars).toHaveLength(3); + }); + + it('should render page type with default columns', () => { + const { container } = render(); + const cards = container.querySelectorAll('.h-32'); + expect(cards).toHaveLength(4); + }); + + it('should render page type with custom columns', () => { + const { container } = render(); + const cards = container.querySelectorAll('.h-32'); + expect(cards).toHaveLength(6); + }); + + it('should have animate-pulse class for card type', () => { + const { container } = render(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + + it('should have animate-pulse class for table type', () => { + const { container } = render(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + + it('should have animate-pulse class for list type', () => { + const { container } = render(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + + it('should have animate-pulse class for page type', () => { + const { container } = render(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + + it('should render with rounded corners for card type', () => { + const { container } = render(); + expect(container.querySelector('.rounded-lg')).toBeInTheDocument(); + }); + + it('should render with gray backgrounds', () => { + const { container } = render(); + expect(container.querySelector('.bg-gray-200')).toBeInTheDocument(); + }); + + it('should default to 4 rows for table type', () => { + const { container } = render(); + const rowElements = container.querySelectorAll('.h-12'); + expect(rowElements).toHaveLength(4); + }); + + it('should default to 4 rows for list type', () => { + const { container } = render(); + const avatars = container.querySelectorAll('.rounded-full'); + expect(avatars).toHaveLength(4); + }); +}); diff --git a/__tests__/components/ui/Modal.test.tsx b/__tests__/components/ui/Modal.test.tsx new file mode 100644 index 0000000..e00200d --- /dev/null +++ b/__tests__/components/ui/Modal.test.tsx @@ -0,0 +1,200 @@ +/** + * Tests for the Modal component + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Modal } from '@/components/ui/Modal'; + +describe('Modal', () => { + const mockOnClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not render when isOpen is false', () => { + const { container } = render( + +
Modal content
+
+ ); + expect(container.firstChild).toBeNull(); + }); + + it('should render when isOpen is true', () => { + render( + +
Modal content
+
+ ); + expect(screen.getByText('Modal content')).toBeInTheDocument(); + }); + + it('should render title when provided', () => { + render( + +
Content
+
+ ); + expect(screen.getByText('Test Title')).toBeInTheDocument(); + }); + + it('should render close button by default', () => { + render( + +
Content
+
+ ); + expect(screen.getByLabelText('Fermer')).toBeInTheDocument(); + }); + + it('should not render close button when showCloseButton is false', () => { + render( + +
Content
+
+ ); + expect(screen.queryByLabelText('Fermer')).not.toBeInTheDocument(); + }); + + it('should call onClose when close button is clicked', () => { + render( + +
Content
+
+ ); + fireEvent.click(screen.getByLabelText('Fermer')); + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when backdrop is clicked', () => { + render( + +
Content
+
+ ); + // The dialog role is on the backdrop element itself + const backdrop = screen.getByRole('dialog'); + fireEvent.click(backdrop); + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should not call onClose when modal content is clicked', () => { + render( + +
Content
+
+ ); + fireEvent.click(screen.getByText('Content')); + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('should call onClose when Escape key is pressed', () => { + render( + +
Content
+
+ ); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should have dialog role', () => { + render( + +
Content
+
+ ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('should have aria-modal attribute', () => { + render( + +
Content
+
+ ); + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true'); + }); + + it('should apply sm size class', () => { + const { container } = render( + +
Content
+
+ ); + expect(container.querySelector('.max-w-md')).toBeInTheDocument(); + }); + + it('should apply md size class by default', () => { + const { container } = render( + +
Content
+
+ ); + expect(container.querySelector('.max-w-lg')).toBeInTheDocument(); + }); + + it('should apply lg size class', () => { + const { container } = render( + +
Content
+
+ ); + expect(container.querySelector('.max-w-2xl')).toBeInTheDocument(); + }); + + it('should apply xl size class', () => { + const { container } = render( + +
Content
+
+ ); + expect(container.querySelector('.max-w-4xl')).toBeInTheDocument(); + }); + + it('should set body overflow to hidden when open', () => { + render( + +
Content
+
+ ); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('should reset body overflow when closed', () => { + const { rerender } = render( + +
Content
+
+ ); + rerender( + +
Content
+
+ ); + expect(document.body.style.overflow).toBe('unset'); + }); + + it('should have aria-labelledby when title is provided', () => { + render( + +
Content
+
+ ); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title'); + }); + + it('should not have aria-labelledby when title is not provided', () => { + render( + +
Content
+
+ ); + const dialog = screen.getByRole('dialog'); + expect(dialog).not.toHaveAttribute('aria-labelledby'); + }); +}); diff --git a/__tests__/components/ui/Pagination.test.tsx b/__tests__/components/ui/Pagination.test.tsx new file mode 100644 index 0000000..f04a58b --- /dev/null +++ b/__tests__/components/ui/Pagination.test.tsx @@ -0,0 +1,174 @@ +/** + * Tests for the Pagination component + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Pagination } from '@/components/ui/Pagination'; + +describe('Pagination', () => { + const mockOnPageChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return null when totalPages is 1', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('should return null when totalPages is 0', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('should render previous and next buttons', () => { + render( + + ); + expect(screen.getByText('Précédent')).toBeInTheDocument(); + expect(screen.getByText('Suivant')).toBeInTheDocument(); + }); + + it('should disable previous button on first page', () => { + render( + + ); + const prevButton = screen.getByText('Précédent').closest('button'); + expect(prevButton).toBeDisabled(); + }); + + it('should disable next button on last page', () => { + render( + + ); + const nextButton = screen.getByText('Suivant').closest('button'); + expect(nextButton).toBeDisabled(); + }); + + it('should call onPageChange with previous page when clicking previous', () => { + render( + + ); + fireEvent.click(screen.getByText('Précédent')); + expect(mockOnPageChange).toHaveBeenCalledWith(2); + }); + + it('should call onPageChange with next page when clicking next', () => { + render( + + ); + fireEvent.click(screen.getByText('Suivant')); + expect(mockOnPageChange).toHaveBeenCalledWith(4); + }); + + it('should not call onPageChange when clicking disabled previous', () => { + render( + + ); + fireEvent.click(screen.getByText('Précédent')); + expect(mockOnPageChange).not.toHaveBeenCalled(); + }); + + it('should not call onPageChange when clicking disabled next', () => { + render( + + ); + fireEvent.click(screen.getByText('Suivant')); + expect(mockOnPageChange).not.toHaveBeenCalled(); + }); + + it('should show page numbers when showPageNumbers is true', () => { + render( + + ); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('should show page text when showPageNumbers is false', () => { + render( + + ); + expect(screen.getByText('Page 2 sur 5')).toBeInTheDocument(); + }); + + it('should call onPageChange when clicking a page number', () => { + render( + + ); + fireEvent.click(screen.getByText('3')); + expect(mockOnPageChange).toHaveBeenCalledWith(3); + }); + + it('should highlight current page', () => { + render( + + ); + const currentPageButton = screen.getByText('2'); + expect(currentPageButton).toHaveClass('bg-blue-600'); + expect(currentPageButton).toHaveClass('text-white'); + }); + + it('should show ellipsis for many pages when at start', () => { + render( + + ); + expect(screen.getByText('...')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + }); + + it('should show ellipsis for many pages when at end', () => { + render( + + ); + expect(screen.getByText('...')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('should show two ellipsis when in middle of many pages', () => { + render( + + ); + const ellipsis = screen.getAllByText('...'); + expect(ellipsis).toHaveLength(2); + }); + + it('should apply custom className', () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('should have flex layout', () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass('flex'); + expect(container.firstChild).toHaveClass('items-center'); + expect(container.firstChild).toHaveClass('justify-center'); + }); +});