/** * Unit tests for Sidebar component * * Tests cover: * - Component rendering * - Resources list display * - Pending appointments list * - Empty state handling * - Drag source setup with @dnd-kit * - Scrolling reference setup * - Multi-lane resource badges * - Archive drop zone display * - Internationalization (i18n) */ import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; import { render, screen, within } from '@testing-library/react'; import { DndContext } from '@dnd-kit/core'; import React from 'react'; import Sidebar, { PendingAppointment, ResourceLayout } from '../Sidebar'; // Setup proper mocks for @dnd-kit beforeAll(() => { // Mock IntersectionObserver properly as a constructor class IntersectionObserverMock { observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); constructor() { return this; } } global.IntersectionObserver = IntersectionObserverMock as any; // Mock ResizeObserver properly as a constructor class ResizeObserverMock { observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); constructor() { return this; } } global.ResizeObserver = ResizeObserverMock as any; }); // Mock react-i18next vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => { const translations: Record = { 'scheduler.resources': 'Resources', 'scheduler.resource': 'Resource', 'scheduler.lanes': 'lanes', 'scheduler.pendingRequests': 'Pending Requests', 'scheduler.noPendingRequests': 'No pending requests', 'scheduler.dropToArchive': 'Drop here to archive', 'scheduler.min': 'min', }; return translations[key] || key; }, }), })); // Helper function to create a wrapper with DndContext const createDndWrapper = () => { return ({ children }: { children: React.ReactNode }) => ( {children} ); }; describe('Sidebar', () => { const mockScrollRef = { current: null } as React.RefObject; const mockResourceLayouts: ResourceLayout[] = [ { resourceId: 1, resourceName: 'Dr. Smith', height: 100, laneCount: 1, }, { resourceId: 2, resourceName: 'Conference Room A', height: 120, laneCount: 2, }, { resourceId: 3, resourceName: 'Equipment Bay', height: 100, laneCount: 3, }, ]; const mockPendingAppointments: PendingAppointment[] = [ { id: 1, customerName: 'John Doe', serviceName: 'Consultation', durationMinutes: 30, }, { id: 2, customerName: 'Jane Smith', serviceName: 'Follow-up', durationMinutes: 15, }, { id: 3, customerName: 'Bob Johnson', serviceName: 'Initial Assessment', durationMinutes: 60, }, ]; beforeEach(() => { vi.clearAllMocks(); }); describe('Rendering', () => { it('should render the sidebar container', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const sidebar = container.firstChild as HTMLElement; expect(sidebar).toBeInTheDocument(); expect(sidebar).toHaveClass('flex', 'flex-col', 'bg-white'); }); it('should render with fixed width of 250px', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const sidebar = container.firstChild as HTMLElement; expect(sidebar).toHaveStyle({ width: '250px' }); }); it('should render resources header', () => { render( , { wrapper: createDndWrapper() } ); const header = screen.getByText('Resources'); expect(header).toBeInTheDocument(); }); it('should render pending requests section', () => { render( , { wrapper: createDndWrapper() } ); const pendingHeader = screen.getByText(/Pending Requests/); expect(pendingHeader).toBeInTheDocument(); }); it('should render archive drop zone', () => { render( , { wrapper: createDndWrapper() } ); const dropZone = screen.getByText('Drop here to archive'); expect(dropZone).toBeInTheDocument(); }); }); describe('Resources List', () => { it('should render all resources from resourceLayouts', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('Dr. Smith')).toBeInTheDocument(); expect(screen.getByText('Conference Room A')).toBeInTheDocument(); expect(screen.getByText('Equipment Bay')).toBeInTheDocument(); }); it('should apply correct height to each resource row', () => { render( , { wrapper: createDndWrapper() } ); // The height style is on the resource row container (3 levels up from the text) const drSmith = screen.getByText('Dr. Smith').closest('[style*="height"]'); const confRoom = screen.getByText('Conference Room A').closest('[style*="height"]'); expect(drSmith).toHaveStyle({ height: '100px' }); expect(confRoom).toHaveStyle({ height: '120px' }); }); it('should display "Resource" label for each resource', () => { render( , { wrapper: createDndWrapper() } ); const resourceLabels = screen.getAllByText('Resource'); expect(resourceLabels.length).toBeGreaterThan(0); }); it('should render grip icons for resources', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const gripIcons = container.querySelectorAll('svg'); expect(gripIcons.length).toBeGreaterThan(0); }); it('should not render lane count badge for single-lane resources', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.queryByText(/lanes/)).not.toBeInTheDocument(); }); it('should render lane count badge for multi-lane resources', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('2 lanes')).toBeInTheDocument(); }); it('should render all multi-lane badges correctly', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('2 lanes')).toBeInTheDocument(); expect(screen.getByText('3 lanes')).toBeInTheDocument(); }); it('should apply correct styling to multi-lane badges', () => { render( , { wrapper: createDndWrapper() } ); const badge = screen.getByText('2 lanes'); expect(badge).toHaveClass('text-blue-600', 'bg-blue-50'); }); it('should attach scroll ref to resource list container', () => { const testRef = React.createRef(); render( , { wrapper: createDndWrapper() } ); expect(testRef.current).toBeInstanceOf(HTMLDivElement); }); it('should render empty resources list when no resources provided', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.queryByText('Dr. Smith')).not.toBeInTheDocument(); }); }); describe('Pending Appointments List', () => { it('should render all pending appointments', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('John Doe')).toBeInTheDocument(); expect(screen.getByText('Jane Smith')).toBeInTheDocument(); expect(screen.getByText('Bob Johnson')).toBeInTheDocument(); }); it('should display customer names correctly', () => { render( , { wrapper: createDndWrapper() } ); mockPendingAppointments.forEach((apt) => { expect(screen.getByText(apt.customerName)).toBeInTheDocument(); }); }); it('should display service names correctly', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('Consultation')).toBeInTheDocument(); expect(screen.getByText('Follow-up')).toBeInTheDocument(); expect(screen.getByText('Initial Assessment')).toBeInTheDocument(); }); it('should display duration in minutes for each appointment', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('30 min')).toBeInTheDocument(); expect(screen.getByText('15 min')).toBeInTheDocument(); expect(screen.getByText('60 min')).toBeInTheDocument(); }); it('should display clock icon for each appointment', () => { const { container } = render( , { wrapper: createDndWrapper() } ); // Clock icons are SVGs const clockIcons = container.querySelectorAll('svg'); expect(clockIcons.length).toBeGreaterThan(0); }); it('should display grip vertical icon for drag handle', () => { render( , { wrapper: createDndWrapper() } ); // Navigate up to the draggable container which has the svg const appointment = screen.getByText('John Doe').closest('.cursor-grab'); const svg = appointment?.querySelector('svg'); expect(svg).toBeInTheDocument(); }); it('should show appointment count in header', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument(); }); it('should update count when appointments change', () => { const { rerender } = render( , { wrapper: createDndWrapper() } ); expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument(); rerender( ); expect(screen.getByText(/Pending Requests \(1\)/)).toBeInTheDocument(); }); }); describe('Empty State', () => { it('should display empty message when no pending appointments', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('No pending requests')).toBeInTheDocument(); }); it('should show count of 0 in header when empty', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText(/Pending Requests \(0\)/)).toBeInTheDocument(); }); it('should apply italic styling to empty message', () => { render( , { wrapper: createDndWrapper() } ); const emptyMessage = screen.getByText('No pending requests'); expect(emptyMessage).toHaveClass('italic'); }); it('should not render appointment items when empty', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument(); }); }); describe('Drag and Drop Setup', () => { it('should setup draggable for each pending appointment', () => { const { container } = render( , { wrapper: createDndWrapper() } ); // Each appointment should have drag cursor classes const appointments = container.querySelectorAll('[class*="cursor-grab"]'); expect(appointments.length).toBe(mockPendingAppointments.length); }); it('should apply cursor-grab class to draggable items', () => { render( , { wrapper: createDndWrapper() } ); // Use the specific class selector since .closest('div') returns the inner div const appointmentCard = screen.getByText('John Doe').closest('.cursor-grab'); expect(appointmentCard).toBeInTheDocument(); }); it('should apply active cursor-grabbing class to draggable items', () => { render( , { wrapper: createDndWrapper() } ); // Verify the draggable container has the active:cursor-grabbing class const appointmentCard = screen.getByText('John Doe').closest('[class*="active:cursor-grabbing"]'); expect(appointmentCard).toBeInTheDocument(); }); it('should render pending items with orange left border', () => { render( , { wrapper: createDndWrapper() } ); // Use the specific class selector const appointmentCard = screen.getByText('John Doe').closest('.border-l-orange-400'); expect(appointmentCard).toBeInTheDocument(); }); it('should apply shadow on hover for draggable items', () => { render( , { wrapper: createDndWrapper() } ); // Use the specific class selector const appointmentCard = screen.getByText('John Doe').closest('[class*="hover:shadow-md"]'); expect(appointmentCard).toBeInTheDocument(); }); }); describe('Archive Drop Zone', () => { it('should render drop zone with trash icon', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const dropZone = screen.getByText('Drop here to archive').parentElement; const trashIcon = dropZone?.querySelector('svg'); expect(trashIcon).toBeInTheDocument(); }); it('should apply dashed border to drop zone', () => { render( , { wrapper: createDndWrapper() } ); const dropZone = screen.getByText('Drop here to archive').parentElement; expect(dropZone).toHaveClass('border-dashed'); }); it('should apply opacity to drop zone container', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const dropZoneContainer = screen .getByText('Drop here to archive') .closest('.opacity-50'); expect(dropZoneContainer).toBeInTheDocument(); }); }); describe('Layout and Styling', () => { it('should apply fixed height to resources header', () => { const { container } = render( , { wrapper: createDndWrapper() } ); // The height style is on the header div itself const header = screen.getByText('Resources').closest('[style*="height"]'); expect(header).toHaveStyle({ height: '48px' }); }); it('should apply fixed height to pending requests section', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const pendingSection = screen .getByText(/Pending Requests/) .closest('.h-80'); expect(pendingSection).toBeInTheDocument(); }); it('should have overflow-hidden on resource list', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const resourceList = container.querySelector('.overflow-hidden'); expect(resourceList).toBeInTheDocument(); }); it('should have overflow-y-auto on pending appointments list', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const pendingList = container.querySelector('.overflow-y-auto'); expect(pendingList).toBeInTheDocument(); }); it('should apply border-right to sidebar', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const sidebar = container.firstChild as HTMLElement; expect(sidebar).toHaveClass('border-r'); }); it('should apply shadow to sidebar', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const sidebar = container.firstChild as HTMLElement; expect(sidebar).toHaveClass('shadow-lg'); }); it('should have dark mode classes', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const sidebar = container.firstChild as HTMLElement; expect(sidebar).toHaveClass('dark:bg-gray-800'); expect(sidebar).toHaveClass('dark:border-gray-700'); }); }); describe('Internationalization', () => { it('should use translation for resources header', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('Resources')).toBeInTheDocument(); }); it('should use translation for pending requests header', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText(/Pending Requests/)).toBeInTheDocument(); }); it('should use translation for empty state message', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('No pending requests')).toBeInTheDocument(); }); it('should use translation for drop zone text', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('Drop here to archive')).toBeInTheDocument(); }); it('should use translation for duration units', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('30 min')).toBeInTheDocument(); }); it('should use translation for resource label', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('Resource')).toBeInTheDocument(); }); it('should use translation for lanes label', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('2 lanes')).toBeInTheDocument(); }); }); describe('Integration', () => { it('should render correctly with all props together', () => { render( , { wrapper: createDndWrapper() } ); // Verify resources expect(screen.getByText('Dr. Smith')).toBeInTheDocument(); expect(screen.getByText('Conference Room A')).toBeInTheDocument(); expect(screen.getByText('Equipment Bay')).toBeInTheDocument(); // Verify pending appointments expect(screen.getByText('John Doe')).toBeInTheDocument(); expect(screen.getByText('Jane Smith')).toBeInTheDocument(); expect(screen.getByText('Bob Johnson')).toBeInTheDocument(); // Verify count expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument(); // Verify archive drop zone expect(screen.getByText('Drop here to archive')).toBeInTheDocument(); }); it('should handle empty resources with full pending appointments', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.queryByText('Dr. Smith')).not.toBeInTheDocument(); expect(screen.getByText('John Doe')).toBeInTheDocument(); expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument(); }); it('should handle full resources with empty pending appointments', () => { render( , { wrapper: createDndWrapper() } ); expect(screen.getByText('Dr. Smith')).toBeInTheDocument(); expect(screen.getByText('No pending requests')).toBeInTheDocument(); expect(screen.getByText(/Pending Requests \(0\)/)).toBeInTheDocument(); }); it('should maintain structure with resources and pending sections', () => { const { container } = render( , { wrapper: createDndWrapper() } ); const sidebar = container.firstChild as HTMLElement; // Should have header, resources list, and pending section const sections = sidebar.querySelectorAll( '.border-b, .border-t, .flex-col' ); expect(sections.length).toBeGreaterThan(0); }); }); });