/** * Comprehensive unit tests for Timeline component * * Tests cover: * - Component rendering * - Time slots display for different view modes (day, week, month) * - Resource rows display with proper heights * - Events positioned correctly on timeline * - Current time indicator visibility and position * - Date navigation controls * - View mode switching * - Zoom functionality * - Drag and drop interactions * - Scroll synchronization between sidebar and timeline */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import Timeline from '../Timeline'; import * as apiClient from '../../../api/client'; // Mock modules vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, fallback?: string) => fallback || key, }), })); vi.mock('../../../api/client', () => ({ default: { get: vi.fn(), }, })); // Mock DnD Kit - simplified for testing vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children }: { children: React.ReactNode }) =>
{children}
, useSensor: vi.fn(), useSensors: vi.fn(() => []), PointerSensor: vi.fn(), useDroppable: vi.fn(() => ({ setNodeRef: vi.fn(), isOver: false, })), useDraggable: vi.fn(() => ({ attributes: {}, listeners: {}, setNodeRef: vi.fn(), isDragging: false, })), DragOverlay: ({ children }: { children: React.ReactNode }) =>
{children}
, })); // Mock child components vi.mock('../../Timeline/TimelineRow', () => ({ default: ({ resourceId, events, height }: any) => (
{events.map((event: any) => (
{event.title}
))}
), })); vi.mock('../../Timeline/CurrentTimeIndicator', () => ({ default: ({ startTime, hourWidth }: any) => (
), })); vi.mock('../Sidebar', () => ({ default: ({ resourceLayouts, pendingAppointments }: any) => (
{resourceLayouts.length}
{pendingAppointments.length}
), })); // Test data const mockResources = [ { id: 1, name: 'Resource 1', type: 'STAFF' }, { id: 2, name: 'Resource 2', type: 'ROOM' }, { id: 3, name: 'Resource 3', type: 'EQUIPMENT' }, ]; const mockAppointments = [ { id: 1, resource: 1, customer: 101, service: 201, customer_name: 'John Doe', service_name: 'Haircut', start_time: new Date('2025-12-07T10:00:00').toISOString(), end_time: new Date('2025-12-07T11:00:00').toISOString(), status: 'CONFIRMED' as const, is_paid: false, }, { id: 2, resource: 1, customer: 102, service: 202, customer_name: 'Jane Smith', service_name: 'Coloring', start_time: new Date('2025-12-07T11:30:00').toISOString(), end_time: new Date('2025-12-07T13:00:00').toISOString(), status: 'CONFIRMED' as const, is_paid: true, }, { id: 3, resource: undefined, // Pending appointment - no resource assigned customer: 103, service: 203, customer_name: 'Bob Johnson', service_name: 'Massage', start_time: new Date('2025-12-07T14:00:00').toISOString(), end_time: new Date('2025-12-07T15:00:00').toISOString(), status: 'PENDING' as const, is_paid: false, }, ]; // Test wrapper with Query Client const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return ({ children }: { children: React.ReactNode }) => ( {children} ); }; describe('Timeline Component', () => { let mockGet: any; beforeEach(() => { vi.clearAllMocks(); mockGet = vi.mocked(apiClient.default.get); // Default API responses mockGet.mockImplementation((url: string) => { if (url === '/resources/') { return Promise.resolve({ data: mockResources }); } if (url === '/appointments/') { return Promise.resolve({ data: mockAppointments }); } return Promise.reject(new Error('Unknown endpoint')); }); }); afterEach(() => { vi.restoreAllMocks(); }); describe('Component Rendering', () => { it('should render the timeline component', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByTestId('sidebar')).toBeInTheDocument(); }); }); it('should display header bar with controls', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByTitle('Previous')).toBeInTheDocument(); expect(screen.getByTitle('Next')).toBeInTheDocument(); expect(screen.getByText('+ New Appointment')).toBeInTheDocument(); }); }); it('should fetch resources from API', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { expect(mockGet).toHaveBeenCalledWith('/resources/'); }); }); it('should fetch appointments from API', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { expect(mockGet).toHaveBeenCalledWith('/appointments/'); }); }); }); describe('Time Slots Rendering', () => { it('should render 24 hour slots in day view', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { // Check for some time labels expect(screen.getByText('12 AM')).toBeInTheDocument(); expect(screen.getByText('6 AM')).toBeInTheDocument(); expect(screen.getByText('12 PM')).toBeInTheDocument(); expect(screen.getByText('6 PM')).toBeInTheDocument(); }); }); it('should render all 24 hours with correct spacing in day view', async () => { const { container } = render(, { wrapper: createWrapper() }); await waitFor(() => { const headerRow = container.querySelector('.sticky.top-0'); expect(headerRow).toBeInTheDocument(); // Should have 24 time slots const timeSlots = headerRow?.querySelectorAll('[style*="width"]'); expect(timeSlots?.length).toBeGreaterThan(0); }); }); it('should render day headers in week view', async () => { const user = userEvent.setup(); render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByText('day')).toBeInTheDocument(); }); const weekButton = screen.getByRole('button', { name: /week/i }); await user.click(weekButton); await waitFor(() => { // Week view should show day names const container = screen.getByRole('button', { name: /week/i }).closest('div')?.parentElement?.parentElement?.parentElement; expect(container).toBeInTheDocument(); }); }); it('should display date range label for current view', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { // Should show day view date format const dateLabel = screen.getByText(/December/i); expect(dateLabel).toBeInTheDocument(); }); }); }); describe('Resource Rows Display', () => { it('should render resource rows for all resources', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByTestId('timeline-row-1')).toBeInTheDocument(); expect(screen.getByTestId('timeline-row-2')).toBeInTheDocument(); expect(screen.getByTestId('timeline-row-3')).toBeInTheDocument(); }); }); it('should display correct number of resources in sidebar', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { const resourceCount = screen.getByTestId('resource-count'); expect(resourceCount).toHaveTextContent('3'); }); }); it('should calculate row heights based on event lanes', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { const row1 = screen.getByTestId('timeline-row-1'); // Row 1 has 2 events, should have calculated height expect(row1).toHaveAttribute('style'); }); }); it('should handle resources with no events', async () => { mockGet.mockImplementation((url: string) => { if (url === '/resources/') { return Promise.resolve({ data: mockResources }); } if (url === '/appointments/') { return Promise.resolve({ data: [] }); } return Promise.reject(new Error('Unknown endpoint')); }); render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByTestId('timeline-row-1')).toBeInTheDocument(); expect(screen.getByTestId('timeline-row-1')).toHaveAttribute('data-event-count', '0'); }); }); }); describe('Events Positioning', () => { it('should render events on their assigned resources', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { const row1 = screen.getByTestId('timeline-row-1'); expect(row1).toHaveAttribute('data-event-count', '2'); }); }); it('should display event titles correctly', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByText('John Doe')).toBeInTheDocument(); expect(screen.getByText('Jane Smith')).toBeInTheDocument(); }); }); it('should filter events by resource', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { const row1 = screen.getByTestId('timeline-row-1'); const row2 = screen.getByTestId('timeline-row-2'); expect(row1).toHaveAttribute('data-event-count', '2'); expect(row2).toHaveAttribute('data-event-count', '0'); }); }); it('should handle overlapping events with lane calculation', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { // Both events are on resource 1, should be in timeline expect(screen.getByTestId('event-1')).toBeInTheDocument(); expect(screen.getByTestId('event-2')).toBeInTheDocument(); }); }); }); describe('Current Time Indicator', () => { it('should render current time indicator', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByTestId('current-time-indicator')).toBeInTheDocument(); }); }); it('should pass correct props to current time indicator', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { const indicator = screen.getByTestId('current-time-indicator'); expect(indicator).toHaveAttribute('data-start-time'); expect(indicator).toHaveAttribute('data-hour-width'); }); }); it('should have correct id for auto-scroll', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { const indicator = screen.getByTestId('current-time-indicator'); expect(indicator).toHaveAttribute('id', 'current-time-indicator'); }); }); }); describe('Date Navigation', () => { it('should have previous and next navigation buttons', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByTitle('Previous')).toBeInTheDocument(); expect(screen.getByTitle('Next')).toBeInTheDocument(); }); }); it('should navigate to previous day when clicking previous button', async () => { const user = userEvent.setup(); render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByTitle('Previous')).toBeInTheDocument(); }); const previousButton = screen.getByTitle('Previous'); await user.click(previousButton); // Date should change (we can't easily test exact date without exposing state) expect(previousButton).toBeInTheDocument(); }); it('should navigate to next day when clicking next button', async () => { const user = userEvent.setup(); render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByTitle('Next')).toBeInTheDocument(); }); const nextButton = screen.getByTitle('Next'); await user.click(nextButton); expect(nextButton).toBeInTheDocument(); }); it('should display current date range', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { // Should show a date with calendar icon const dateDisplay = screen.getByText(/2025/); expect(dateDisplay).toBeInTheDocument(); }); }); }); describe('View Mode Switching', () => { it('should render view mode buttons (day, week, month)', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument(); }); }); it('should highlight active view mode (day by default)', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { const dayButton = screen.getByRole('button', { name: /day/i }); expect(dayButton).toHaveClass('bg-blue-500'); }); }); it('should switch to week view when clicking week button', async () => { const user = userEvent.setup(); render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument(); }); const weekButton = screen.getByRole('button', { name: /week/i }); await user.click(weekButton); await waitFor(() => { expect(weekButton).toHaveClass('bg-blue-500'); }); }); it('should switch to month view when clicking month button', async () => { const user = userEvent.setup(); render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument(); }); const monthButton = screen.getByRole('button', { name: /month/i }); await user.click(monthButton); await waitFor(() => { expect(monthButton).toHaveClass('bg-blue-500'); }); }); it('should only have one active view mode at a time', async () => { const user = userEvent.setup(); render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument(); }); const weekButton = screen.getByRole('button', { name: /week/i }); await user.click(weekButton); await waitFor(() => { const dayButton = screen.getByRole('button', { name: /day/i }); expect(weekButton).toHaveClass('bg-blue-500'); expect(dayButton).not.toHaveClass('bg-blue-500'); }); }); }); describe('Zoom Functionality', () => { it('should render zoom in and zoom out buttons', async () => { const { container } = render(, { wrapper: createWrapper() }); await waitFor(() => { // Look for Zoom label and buttons expect(screen.getByText('Zoom')).toBeInTheDocument(); }); // Zoom buttons are rendered via Lucide icons const zoomSection = screen.getByText('Zoom').parentElement; expect(zoomSection).toBeInTheDocument(); }); it('should increase zoom when clicking zoom in button', async () => { const user = userEvent.setup(); const { container } = render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByText('Zoom')).toBeInTheDocument(); }); // Find zoom in button (second button after Zoom label) const zoomSection = screen.getByText('Zoom').parentElement; const buttons = zoomSection?.querySelectorAll('button'); const zoomInButton = buttons?.[1]; if (zoomInButton) { await user.click(zoomInButton); // Component should still be rendered expect(screen.getByText('Zoom')).toBeInTheDocument(); } }); it('should decrease zoom when clicking zoom out button', async () => { const user = userEvent.setup(); render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByText('Zoom')).toBeInTheDocument(); }); const zoomSection = screen.getByText('Zoom').parentElement; const buttons = zoomSection?.querySelectorAll('button'); const zoomOutButton = buttons?.[0]; if (zoomOutButton) { await user.click(zoomOutButton); expect(screen.getByText('Zoom')).toBeInTheDocument(); } }); }); describe('Pending Appointments', () => { it('should display pending appointments in sidebar', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { const pendingCount = screen.getByTestId('pending-count'); expect(pendingCount).toHaveTextContent('1'); }); }); it('should filter pending appointments from events', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { // Should not render pending appointment as event expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument(); }); }); }); describe('Accessibility', () => { it('should have accessible button labels', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /new appointment/i })).toBeInTheDocument(); }); }); it('should have title attributes on navigation buttons', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByTitle('Previous')).toBeInTheDocument(); expect(screen.getByTitle('Next')).toBeInTheDocument(); }); }); }); describe('Undo/Redo Controls', () => { it('should render undo and redo buttons', async () => { const { container } = render(, { wrapper: createWrapper() }); await waitFor(() => { // Undo/redo buttons exist but are disabled const buttons = container.querySelectorAll('button[disabled]'); expect(buttons.length).toBeGreaterThan(0); }); }); it('should have undo and redo buttons disabled by default', async () => { const { container } = render(, { wrapper: createWrapper() }); await waitFor(() => { const disabledButtons = container.querySelectorAll('button[disabled]'); expect(disabledButtons.length).toBeGreaterThanOrEqual(2); }); }); }); describe('Error Handling', () => { it('should handle API errors gracefully for resources', async () => { mockGet.mockImplementation((url: string) => { if (url === '/resources/') { return Promise.reject(new Error('Network error')); } if (url === '/appointments/') { return Promise.resolve({ data: [] }); } return Promise.reject(new Error('Unknown endpoint')); }); render(, { wrapper: createWrapper() }); await waitFor(() => { // Should still render even with error expect(screen.getByText('+ New Appointment')).toBeInTheDocument(); }); }); it('should handle API errors gracefully for appointments', async () => { mockGet.mockImplementation((url: string) => { if (url === '/resources/') { return Promise.resolve({ data: mockResources }); } if (url === '/appointments/') { return Promise.reject(new Error('Network error')); } return Promise.reject(new Error('Unknown endpoint')); }); render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByText('+ New Appointment')).toBeInTheDocument(); }); }); it('should handle empty resources array', async () => { mockGet.mockImplementation((url: string) => { if (url === '/resources/') { return Promise.resolve({ data: [] }); } if (url === '/appointments/') { return Promise.resolve({ data: [] }); } return Promise.reject(new Error('Unknown endpoint')); }); render(, { wrapper: createWrapper() }); await waitFor(() => { const resourceCount = screen.getByTestId('resource-count'); expect(resourceCount).toHaveTextContent('0'); }); }); it('should handle empty appointments array', async () => { mockGet.mockImplementation((url: string) => { if (url === '/resources/') { return Promise.resolve({ data: mockResources }); } if (url === '/appointments/') { return Promise.resolve({ data: [] }); } return Promise.reject(new Error('Unknown endpoint')); }); render(, { wrapper: createWrapper() }); await waitFor(() => { const pendingCount = screen.getByTestId('pending-count'); expect(pendingCount).toHaveTextContent('0'); }); }); }); describe('Dark Mode Support', () => { it('should apply dark mode classes', async () => { const { container } = render(, { wrapper: createWrapper() }); await waitFor(() => { const mainContainer = container.querySelector('.bg-white'); expect(mainContainer).toHaveClass('dark:bg-gray-900'); }); }); it('should apply dark mode to header', async () => { const { container } = render(, { wrapper: createWrapper() }); await waitFor(() => { const header = container.querySelector('.border-b'); expect(header).toHaveClass('dark:bg-gray-800'); }); }); }); describe('Integration', () => { it('should render complete timeline with all features', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { // Header controls expect(screen.getByTitle('Previous')).toBeInTheDocument(); expect(screen.getByTitle('Next')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument(); expect(screen.getByText('Zoom')).toBeInTheDocument(); expect(screen.getByText('+ New Appointment')).toBeInTheDocument(); // Sidebar expect(screen.getByTestId('sidebar')).toBeInTheDocument(); // Current time indicator expect(screen.getByTestId('current-time-indicator')).toBeInTheDocument(); // Resources expect(screen.getByTestId('resource-count')).toHaveTextContent('3'); // Events expect(screen.getByText('John Doe')).toBeInTheDocument(); }); }); }); });