diff --git a/src/components/carousel/__tests__/CarouselControls.test.tsx b/src/components/carousel/__tests__/CarouselControls.test.tsx new file mode 100644 index 00000000..5e05d8bc --- /dev/null +++ b/src/components/carousel/__tests__/CarouselControls.test.tsx @@ -0,0 +1,324 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ChakraProvider } from '@chakra-ui/react'; +import { CarouselControls } from '../components/CarouselControls'; +import { CarouselControlsProps } from '../types'; + +const renderWithChakra = (ui: React.ReactElement) => { + return render({ui}); +}; + +describe('CarouselControls', () => { + let mockHandleDecrementClick = jest.fn(); + let mockHandleIncrementClick = jest.fn(); + let mockHandleDotClick = jest.fn(); + let mockHandleFocus = jest.fn(); + + const getDefaultProps = (): CarouselControlsProps => ({ + activeItem: 0, + maxActiveItem: 3, + constraint: 2, + totalDots: 3, + colorScheme: 'primary', + gap: 32, + childrenLength: 5, + showProgressBar: false, + progressPercentage: 0, + isLoading: false, + handleDecrementClick: mockHandleDecrementClick, + handleIncrementClick: mockHandleIncrementClick, + handleDotClick: mockHandleDotClick, + handleFocus: mockHandleFocus, + }); + + beforeEach(() => { + mockHandleDecrementClick = jest.fn(); + mockHandleIncrementClick = jest.fn(); + mockHandleDotClick = jest.fn(); + mockHandleFocus = jest.fn(); + }); + + it('renders navigation buttons and dots correctly', () => { + // Render the component with default props + renderWithChakra(); + + // Check that navigation buttons exist and have correct labels + const prevButton = screen.getByLabelText('previous carousel item'); + expect(prevButton).toBeInTheDocument(); + + const nextButton = screen.getByLabelText('next carousel item'); + expect(nextButton).toBeInTheDocument(); + + // Count all interactive elements (navigation buttons and dots) + const allButtons = screen.getAllByRole('button'); + expect(allButtons).toHaveLength(5); + + // Check that dots (carousel indicators) exist and have correct labels + expect( + screen.getByLabelText('carousel indicator 1 of 3 (current)'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 2 of 3'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 3 of 3'), + ).toBeInTheDocument(); + }); + + it('renders nothing when childrenLength is not greater than constraint', () => { + const props = { + ...getDefaultProps(), + childrenLength: 2, + constraint: 2, + }; + + renderWithChakra(); + + // There should be no interactive elements when navigation isn't needed + const buttons = screen.queryAllByRole('button'); + expect(buttons).toHaveLength(0); + }); + + it('calls handleDecrementClick when previous button is clicked', async () => { + const user = userEvent.setup(); + + // Create a specific mock function for this test + // activeItem is set to 1 so that the previous button is enabled + const mockDecrement = jest.fn(); + const props = { + ...getDefaultProps(), + activeItem: 1, + handleDecrementClick: mockDecrement, + }; + + renderWithChakra(); + + const prevButton = screen.getByLabelText('previous carousel item'); + + // Verify that the previous button is clickable + expect(prevButton).not.toBeDisabled(); + + // Simulate user clicking the previous button + await user.click(prevButton); + + // Verify that the function was called only once + expect(mockDecrement).toHaveBeenCalledTimes(1); + }); + + it('calls handleIncrementClick when next button is clicked', async () => { + const user = userEvent.setup(); + + const mockIncrement = jest.fn(); + const props = { + ...getDefaultProps(), + handleIncrementClick: mockIncrement, + }; + + renderWithChakra(); + + const nextButton = screen.getByLabelText('next carousel item'); + + await user.click(nextButton); + + expect(mockIncrement).toHaveBeenCalledTimes(1); + }); + + it('calls handleDotClick with correct index when dot is clicked', async () => { + const user = userEvent.setup(); + + const mockDotClick = jest.fn(); + const props = { + ...getDefaultProps(), + handleDotClick: mockDotClick, + }; + + renderWithChakra(); + + // Click the second dot + const secondDot = screen.getByLabelText('carousel indicator 2 of 3'); + await user.click(secondDot); + + // Verify that handler was called with correct index (only once) + expect(mockDotClick).toHaveBeenCalledWith(1); + expect(mockDotClick).toHaveBeenCalledTimes(1); + }); + + it('handles Enter key navigation on dots correctly', async () => { + const user = userEvent.setup(); + + const mockDotClick = jest.fn(); + const props = { + ...getDefaultProps(), + handleDotClick: mockDotClick, + }; + + renderWithChakra(); + + const firstDot = screen.getByLabelText( + 'carousel indicator 1 of 3 (current)', + ); + + // Focus on the dot and test Enter key + firstDot.focus(); + await user.keyboard('{Enter}'); + + expect(mockDotClick).toHaveBeenCalledWith(0); + expect(mockDotClick).toHaveBeenCalledTimes(1); + }); + + it('handles spacebar navigation on dots correctly', async () => { + const user = userEvent.setup(); + + const mockDotClick = jest.fn(); + const props = { + ...getDefaultProps(), + handleDotClick: mockDotClick, + }; + + renderWithChakra(); + + const firstDot = screen.getByLabelText( + 'carousel indicator 1 of 3 (current)', + ); + + // Focus on the dot and test spacebar + firstDot.focus(); + await user.keyboard(' '); + + expect(mockDotClick).toHaveBeenCalledWith(0); + expect(mockDotClick).toHaveBeenCalledTimes(1); + }); + + it('disables previous button when at the beginning', () => { + // Use default props (activeItem = 0) + renderWithChakra(); + + // If activeItem <= 0, the previous button is disabled + const prevButton = screen.getByLabelText('previous carousel item'); + expect(prevButton).toBeDisabled(); + }); + + it('disables next button when at the end', () => { + const props = { + ...getDefaultProps(), + activeItem: 3, // maxActiveItem = 3 + }; + + renderWithChakra(); + + // If activeItem >= maxActiveItem, the next button is disabled + const nextButton = screen.getByLabelText('next carousel item'); + expect(nextButton).toBeDisabled(); + }); + + it('renders progress bar when showProgressBar is true', () => { + const props = { + ...getDefaultProps(), + showProgressBar: true, + progressPercentage: 50, + }; + + renderWithChakra(); + + // Chakra UI Progress component creates nested progressbar elements + const progressBars = screen.getAllByRole('progressbar'); + expect(progressBars.length).toBeGreaterThanOrEqual(1); + + // Check the main progress bar has correct accessibility attributes + const mainProgressBar = progressBars[0]; + expect(mainProgressBar).toHaveAttribute('aria-valuemin', '0'); + expect(mainProgressBar).toHaveAttribute('aria-valuemax', '100'); + expect(mainProgressBar).toHaveAttribute('aria-valuenow', '50'); + expect(mainProgressBar).toHaveAttribute( + 'aria-label', + 'Carousel progress: 50% complete', + ); + + // Verify that navigation buttons are present + const prevButton = screen.getByLabelText('previous carousel item'); + const nextButton = screen.getByLabelText('next carousel item'); + + expect(prevButton).toBeInTheDocument(); + expect(nextButton).toBeInTheDocument(); + + // Verify that dot indicators aren't present + expect( + screen.queryByLabelText(/carousel indicator/), + ).not.toBeInTheDocument(); + + // Verify that only 2 buttons are present (prev + next, no dot buttons) + const allButtons = screen.getAllByRole('button'); + expect(allButtons).toHaveLength(2); + }); + + it('renders skeleton when isLoading is true', () => { + const props = { + ...getDefaultProps(), + isLoading: true, + }; + + renderWithChakra(); + + // Verify the component doesn't break during loading + expect(screen.getByLabelText('previous carousel item')).toBeInTheDocument(); + }); + + it('calls handleFocus when buttons receive focus', async () => { + const mockFocus = jest.fn(); + const props = { + ...getDefaultProps(), + handleFocus: mockFocus, + }; + + renderWithChakra(); + + const prevButton = screen.getByLabelText('previous carousel item'); + + // Test pure focus behavior + fireEvent.focus(prevButton); + + expect(mockFocus).toHaveBeenCalled(); + }); + + it('highlights the correct dot based on activeItem and constraint', () => { + const props = { + ...getDefaultProps(), + activeItem: 2, + constraint: 2, + totalDots: 3, + }; + + renderWithChakra(); + + // The current group should be Math.floor(2 / 2) = 1 + // The second dot should be highlighted + expect( + screen.getByLabelText('carousel indicator 1 of 3'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 2 of 3 (current)'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 3 of 3'), + ).toBeInTheDocument(); + }); + + it('highlights last dot when at the end of carousel', () => { + const props = { + ...getDefaultProps(), + activeItem: 3, + maxActiveItem: 3, + constraint: 2, + childrenLength: 5, + totalDots: 3, + }; + + renderWithChakra(); + + // When at the end of the carousel, the last dot should be highlighted + expect( + screen.getByLabelText('carousel indicator 3 of 3 (current)'), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/carousel/__tests__/Item.test.tsx b/src/components/carousel/__tests__/Item.test.tsx new file mode 100644 index 00000000..d35dfa86 --- /dev/null +++ b/src/components/carousel/__tests__/Item.test.tsx @@ -0,0 +1,255 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ChakraProvider } from '@chakra-ui/react'; +import { Item } from '../components/Item'; +import { ItemProps } from '../types'; + +const renderWithChakra = (ui: React.ReactElement) => { + return render({ui}); +}; + +describe('Item', () => { + let mockSetTrackIsActive: jest.Mock; + let mockSetActiveItem: jest.Mock; + let defaultProps: ItemProps; + + beforeEach(() => { + mockSetTrackIsActive = jest.fn(); + mockSetActiveItem = jest.fn(); + + defaultProps = { + setTrackIsActive: mockSetTrackIsActive, + setActiveItem: mockSetActiveItem, + trackIsActive: false, + constraint: 2, + itemWidth: 200, + positions: [0, -220, -440, -660], + index: 1, + gap: 20, + children:
Test Item Content
, // Content to render inside the item + }; + }); + + it('renders the item with correct content and styling', () => { + renderWithChakra(); + + expect(screen.getByText('Test Item Content')).toBeInTheDocument(); + + const itemElement = screen.getByText('Test Item Content').parentElement; + expect(itemElement).toHaveStyle('width: 200px'); + }); + + it('uses fallback width when itemWidth is not provided', () => { + const propsWithoutWidth = { + ...defaultProps, + itemWidth: 0, // No width provided + }; + + renderWithChakra(); + + const itemElement = screen.getByText('Test Item Content').parentElement; + expect(itemElement).toHaveStyle('width: 200px'); + }); + + it('applies correct margin when not the last item', () => { + const propsWithGap = { + ...defaultProps, + gap: 24, + }; + + renderWithChakra( +
+ +
First Item
+
+ +
Second Item
+
+ +
Last Item
+
+
, + ); + + const firstItem = screen.getByText('First Item').parentElement; + const secondItem = screen.getByText('Second Item').parentElement; + const lastItem = screen.getByText('Last Item').parentElement; + + // Verify that non-last items have the gap margin + expect(firstItem).toHaveStyle('margin-right: 24px'); + expect(secondItem).toHaveStyle('margin-right: 24px'); + + // Verify that last item doesn't have the gap margin + expect(lastItem).not.toHaveStyle('margin-right: 24px'); + }); + + it('handles edge case with single item (no margin needed)', () => { + renderWithChakra( +
+ +
Single Item
+
+
, + ); + + const singleItem = screen.getByText('Single Item').parentElement!; + + // Single item is also the last item, so should not have margin + expect(singleItem).not.toHaveStyle('margin-right: 24px'); + }); + + it('activates track when item receives focus', () => { + renderWithChakra(); + + const itemElement = screen.getByText('Test Item Content').parentElement!; + + // Simulate focusing on the item using fireEvent + fireEvent.focus(itemElement); + + // setTrackIsActive should be called with true + expect(mockSetTrackIsActive).toHaveBeenCalledWith(true); + }); + + it('sets active item when Tab key is pressed and item is within bounds', () => { + // Create props where this item (index 1) is within the valid range + // maxActiveItem = positions.length - constraint = 4 - 2 = 2 + const propsWithValidIndex = { + ...defaultProps, + index: 1, + positions: [0, -220, -440, -660], + constraint: 2, // Show 2 items at once + }; + + renderWithChakra(); + + const itemElement = screen.getByText('Test Item Content').parentElement!; + + // Focus the item and simulate Tab key up event + itemElement.focus(); + fireEvent.keyUp(itemElement, { key: 'Tab' }); + + // When Tab is pressed, setActiveItem should be called with the item's index + expect(mockSetActiveItem).toHaveBeenCalledWith(1); + }); + + it('does not set active item when Tab is pressed and item is beyond maxActiveItem', () => { + // Create props where this item (index 3) is beyond the valid range + // maxActiveItem = 4 - 2 = 2, so index 3 > 2 (invalid) + const propsWithInvalidIndex = { + ...defaultProps, + index: 3, + positions: [0, -220, -440, -660], + constraint: 2, + }; + + renderWithChakra(); + + const itemElement = screen.getByText('Test Item Content').parentElement!; + + itemElement.focus(); + fireEvent.keyUp(itemElement, { key: 'Tab' }); + + // setActiveItem shouldn't be called because index 3 > maxActiveItem (2) + expect(mockSetActiveItem).not.toHaveBeenCalled(); + }); + + it('deactivates track when last item loses focus after Tab navigation', () => { + // Create props for the last item in the positions array + const propsForLastItem = { + ...defaultProps, + index: 3, // Last item (positions.length - 1) + positions: [0, -220, -440, -660], // 4 positions (0, 1, 2, 3) + }; + + renderWithChakra(); + + const itemElement = screen.getByText('Test Item Content').parentElement!; + + // First, simulate Tab keydown to set the userDidTab flag + itemElement.focus(); + fireEvent.keyDown(itemElement, { key: 'Tab' }); + + // Then simulate blur (losing focus) + fireEvent.blur(itemElement); + + // For the last item after Tab navigation, track should be deactivated + expect(mockSetTrackIsActive).toHaveBeenCalledWith(false); + }); + + it('does not deactivate track when non-last item loses focus', async () => { + renderWithChakra(); // index: 1 (not last) + + const itemElement = screen.getByText('Test Item Content').parentElement!; + + itemElement.focus(); + fireEvent.keyDown(itemElement, { key: 'Tab' }); + fireEvent.blur(itemElement); + + // Track shouldn't be deactivated because this is not the last item + expect(mockSetTrackIsActive).not.toHaveBeenCalledWith(false); + }); + + it('does not deactivate track when item loses focus without Tab navigation', async () => { + const propsForLastItem = { + ...defaultProps, + index: 3, + positions: [0, -220, -440, -660], + }; + + renderWithChakra(); + + const itemElement = screen.getByText('Test Item Content').parentElement!; + + // Focus and blur without pressing Tab first + itemElement.focus(); + fireEvent.blur(itemElement); + + // Track shouldn't be deactivated because userDidTab is false + expect(mockSetTrackIsActive).not.toHaveBeenCalledWith(false); + }); + + it('has proper accessibility structure', () => { + renderWithChakra(); + + const itemElement = screen.getByText('Test Item Content').parentElement!; + + // Item should be focusable (for keyboard navigation) + expect(itemElement).toHaveProperty('tabIndex'); + + // Should have the correct CSS class + expect(itemElement).toHaveClass('item'); + }); + + it('ignores non-Tab keyboard events', () => { + renderWithChakra(); + + const itemElement = screen.getByText('Test Item Content').parentElement!; + + itemElement.focus(); + fireEvent.keyUp(itemElement, { key: 'Enter' }); + + // setActiveItem shouldn't be called for non-Tab keys + expect(mockSetActiveItem).not.toHaveBeenCalled(); + }); + + it('renders different types of children correctly', () => { + const complexChild = ( +
+

Card Title

+

Card description

+ +
+ ); + + const propsWithComplexChild = { + ...defaultProps, + children: complexChild, + }; + + renderWithChakra(); + + expect(screen.getByText('Card Title')).toBeInTheDocument(); + expect(screen.getByText('Card description')).toBeInTheDocument(); + expect(screen.getByText('Action Button')).toBeInTheDocument(); + }); +}); diff --git a/src/components/carousel/__tests__/Track.test.tsx b/src/components/carousel/__tests__/Track.test.tsx new file mode 100644 index 00000000..30fd828a --- /dev/null +++ b/src/components/carousel/__tests__/Track.test.tsx @@ -0,0 +1,569 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ChakraProvider } from '@chakra-ui/react'; +import { Track } from '../components/Track'; +import { TrackProps, DragEndInfo } from '../types'; + +// Mock framer-motion to avoid complex animation testing and focus on logic +jest.mock('framer-motion', () => ({ + motion: (Component: any) => { + return React.forwardRef( + ({ children, onDragStart, onDragEnd, ...props }, ref) => { + // Filter out framer-motion specific props that shouldn't be passed to DOM + const { + dragConstraints, + animate, + style, + drag, + _active, + minWidth, + flexWrap, + cursor, + ...domProps + } = props; + return ( +
{ + // Simulate drag start + if (onDragStart) { + onDragStart(); + } + }} + onMouseUp={(e: React.MouseEvent) => { + // Simulate drag end with proper mock data + if (onDragEnd) { + const mockInfo: DragEndInfo = { + point: { x: 100, y: 0 }, + delta: { x: -150, y: 0 }, + offset: { x: -150, y: 0 }, + velocity: { x: -500, y: 0 }, + }; + onDragEnd(e.nativeEvent, mockInfo); + } + }} + > + {children} +
+ ); + }, + ); + }, + // Mock animation controls + useAnimation: () => ({ + start: jest.fn(), + }), + // Mock motion value + useMotionValue: (initialValue: number) => ({ + get: jest.fn(() => initialValue), + set: jest.fn(), + }), +})); + +// Mock Chakra UI's components to render as divs without Chakra-specific props +jest.mock('@chakra-ui/react', () => ({ + ...jest.requireActual('@chakra-ui/react'), + // Mock Flex to filter out Chakra-specific props + Flex: React.forwardRef( + ({ children, spacing, alignItems, ...props }, ref) => { + const { minWidth, flexWrap, cursor, _active, ...domProps } = props; + return ( +
+ {children} +
+ ); + }, + ), + // Mock VStack to filter out Chakra-specific props + VStack: React.forwardRef( + ({ children, spacing, alignItems, ...props }, ref) => { + const { minWidth, flexWrap, cursor, _active, ...domProps } = props; + return ( +
+ {children} +
+ ); + }, + ), +})); + +const renderWithChakra = (ui: React.ReactElement) => { + return render({ui}); +}; + +describe('Track', () => { + let mockSetTrackIsActive: jest.Mock; + let mockSetActiveItem: jest.Mock; + + // Mock children components for the carousel + const mockChildren = [ +
Item 1
, +
Item 2
, +
Item 3
, +
Item 4
, + ]; + + // Helper function to get default props with current mock functions + const getDefaultProps = (): TrackProps => ({ + setTrackIsActive: mockSetTrackIsActive, + setActiveItem: mockSetActiveItem, + trackIsActive: false, + activeItem: 0, + constraint: 2, // Show 2 items at once + multiplier: 0.35, + positions: [0, -250, -500, -750], + children: mockChildren, + maxActiveItem: 2, // 4 (items) - 2 (constraint) = 2 + }); + + // Set up fresh mocks before each test + beforeEach(() => { + mockSetTrackIsActive = jest.fn(); + mockSetActiveItem = jest.fn(); + + // Clear any existing event listeners from previous tests + jest.clearAllMocks(); + + // Clear any DOM event listeners that might persist + document.removeEventListener('keydown', jest.fn() as any); + document.removeEventListener('mousedown', jest.fn() as any); + document.removeEventListener('wheel', jest.fn() as any); + }); + + // Clean up event listeners after each test + afterEach(() => { + document.removeEventListener('keydown', jest.fn()); + document.removeEventListener('mousedown', jest.fn()); + document.removeEventListener('wheel', jest.fn()); + }); + + it('renders track with all children', () => { + renderWithChakra(); + + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 2')).toBeInTheDocument(); + expect(screen.getByText('Item 3')).toBeInTheDocument(); + expect(screen.getByText('Item 4')).toBeInTheDocument(); + }); + + it('renders correctly with different numbers of children', () => { + // Test with single child + const singleChild = [
Only Item
]; + const propsWithOneChild = { + ...getDefaultProps(), + children: singleChild, + positions: [0], + maxActiveItem: 0, + }; + + const { rerender } = renderWithChakra(); + expect(screen.getByText('Only Item')).toBeInTheDocument(); + + // Test with many children + const manyChildren = Array.from({ length: 8 }, (_, i) => ( +
Item {i + 1}
+ )); + const propsWithManyChildren = { + ...getDefaultProps(), + children: manyChildren, + positions: Array.from({ length: 8 }, (_, i) => -i * 250), + maxActiveItem: 6, // 8 - 2 = 6 + }; + + rerender(); + + // All items should be rendered + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 8')).toBeInTheDocument(); + }); + + it('sets up drag constraints correctly', () => { + renderWithChakra(); + + // Component should render without errors + expect(screen.getByText('Item 1')).toBeInTheDocument(); + }); + + it('updates position when activeItem changes', () => { + // Animation calls are not tested due to mocking + // Only tests that the component responds to activeItem changes + const { rerender } = renderWithChakra(); + + // Change activeItem and re-render + const updatedProps = { + ...getDefaultProps(), + activeItem: 1, // Move to second position + }; + + rerender(); + + // Component should still render correctly with new activeItem + expect(screen.getByText('Item 1')).toBeInTheDocument(); + }); + + it('sets track active state based on click target', () => { + const { container } = renderWithChakra(); + + // Get the track element (should be the container) + const trackElement = container.firstChild as HTMLElement; + + // Simulate clicking inside the track + fireEvent.mouseDown(trackElement); + + // setTrackIsActive should be called + expect(mockSetTrackIsActive).toHaveBeenCalled(); + }); + + it('handles clicks outside the track element', () => { + renderWithChakra(); + + // Create a click event on a different element + const outsideElement = document.createElement('div'); + document.body.appendChild(outsideElement); + + // Simulate clicking outside the track + fireEvent.mouseDown(outsideElement); + + // setTrackIsActive should be called with false (clicked outside) + expect(mockSetTrackIsActive).toHaveBeenCalledWith(false); + + // Clean up + document.body.removeChild(outsideElement); + }); + + it('handles drag start correctly', () => { + const props = { + ...getDefaultProps(), + activeItem: 1, // Start at position 1 + }; + + renderWithChakra(); + + // Find the draggable element using the test ID + const trackElement = screen.getByTestId('draggable-track'); + + // Simulate drag start + fireEvent.mouseDown(trackElement); + + // The component should store the starting position internally + expect(trackElement).toBeInTheDocument(); + }); + + it('calculates correct target position on drag end', () => { + renderWithChakra(); + + // Find the draggable element using the test ID added + const trackElement = screen.getByTestId('draggable-track'); + + // First simulate drag start with mouseDown + fireEvent.mouseDown(trackElement); + + // Then simulate drag end with mouseUp - this will trigger the mock drag end handler + fireEvent.mouseUp(trackElement); + + // The component should calculate the closest position and set activeItem + expect(mockSetActiveItem).toHaveBeenCalled(); + }); + + it('handles keyboard navigation with arrow keys', () => { + const props = { + ...getDefaultProps(), + trackIsActive: true, // Track must be active for keyboard nav to work + activeItem: 1, // Start at position 1 + }; + + renderWithChakra(); + + // Simulate pressing the right arrow key + fireEvent.keyDown(document, { key: 'ArrowRight' }); + + // Should call setActiveItem with a function that increments position + expect(mockSetActiveItem).toHaveBeenCalledWith(expect.any(Function)); + + // Test the function that was passed to setActiveItem + const updateFunction = mockSetActiveItem.mock.calls[0][0]; + expect(updateFunction(1)).toBe(2); // Math.min(1 + 2, 2) = 2 + }); + + it('handles up and down arrow keys same as left and right', () => { + const props = { + ...getDefaultProps(), + trackIsActive: true, + activeItem: 1, + }; + + renderWithChakra(); + + // Test ArrowUp (should work like ArrowRight) + fireEvent.keyDown(document, { key: 'ArrowUp' }); + expect(mockSetActiveItem).toHaveBeenCalledWith(expect.any(Function)); + + // Reset and test ArrowDown (should work like ArrowLeft) + mockSetActiveItem.mockClear(); + fireEvent.keyDown(document, { key: 'ArrowDown' }); + expect(mockSetActiveItem).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('ignores keyboard events when track is inactive', () => { + const props = { + ...getDefaultProps(), + trackIsActive: false, // Track is not active + }; + + renderWithChakra(); + + // Try keyboard navigation + fireEvent.keyDown(document, { key: 'ArrowRight' }); + fireEvent.keyDown(document, { key: 'ArrowLeft' }); + + // Should not respond to keyboard when inactive + expect(mockSetActiveItem).not.toHaveBeenCalled(); + }); + + it('responds differently based on trackIsActive state', () => { + // Test with active track + const { unmount } = renderWithChakra( + , + ); + + // Keyboard events should work when track is active + fireEvent.keyDown(document, { key: 'ArrowRight' }); + expect(mockSetActiveItem).toHaveBeenCalled(); + + // Unmount the first component to clean up its event listeners + unmount(); + + // Reset and test with inactive track + mockSetActiveItem.mockClear(); + + renderWithChakra( + , + ); + + // Keyboard events should be ignored when track is inactive + fireEvent.keyDown(document, { key: 'ArrowRight' }); + expect(mockSetActiveItem).not.toHaveBeenCalled(); + }); + + it('prevents default behavior for relevant keyboard events', () => { + const propsWithActiveTrack = { + ...getDefaultProps(), + trackIsActive: true, + activeItem: 1, // In middle, so both directions are valid + }; + + renderWithChakra(); + + // Create events with preventDefault mock + const rightArrowEvent = new KeyboardEvent('keydown', { + key: 'ArrowRight', + }); + const preventDefaultSpy = jest.spyOn(rightArrowEvent, 'preventDefault'); + + fireEvent(document, rightArrowEvent); + + // Should prevent default browser behavior for arrow keys + expect(preventDefaultSpy).toHaveBeenCalled(); + + preventDefaultSpy.mockRestore(); + }); + + it('respects boundaries when navigating with keyboard', () => { + const props = { + ...getDefaultProps(), + trackIsActive: true, + activeItem: 2, // At the end (maxActiveItem) + }; + + renderWithChakra(); + + // Try to go right when already at the end + fireEvent.keyDown(document, { key: 'ArrowRight' }); + + // As activeItem (2) >= maxActiveItem (2), it shouldn't call setActiveItem + expect(mockSetActiveItem).not.toHaveBeenCalled(); + + // Reset and test left arrow + mockSetActiveItem.mockClear(); + + // Test left arrow (should work) + fireEvent.keyDown(document, { key: 'ArrowLeft' }); + expect(mockSetActiveItem).toHaveBeenCalledWith(expect.any(Function)); + + const updateFunction = mockSetActiveItem.mock.calls[0][0]; + expect(updateFunction(2)).toBe(0); // Math.max(2 - 2, 0) = 0 + }); + + it('prevents navigation beyond boundaries with keyboard', () => { + const propsAtStart = { + ...getDefaultProps(), + trackIsActive: true, + activeItem: 0, + }; + + renderWithChakra(); + + // Try to go left when already at the beginning + fireEvent.keyDown(document, { key: 'ArrowLeft' }); + + // Should not call setActiveItem because activeItem (0) <= 0 + expect(mockSetActiveItem).not.toHaveBeenCalled(); + }); + + it('handles keyboard navigation when constraint exceeds remaining items', () => { + const propsWithLargeConstraint = { + ...getDefaultProps(), + trackIsActive: true, + activeItem: 1, + constraint: 5, // Larger than number of remaining items + maxActiveItem: 2, + }; + + renderWithChakra(); + + // Press right arrow + fireEvent.keyDown(document, { key: 'ArrowRight' }); + + expect(mockSetActiveItem).toHaveBeenCalledWith(expect.any(Function)); + + // Test the update function + const updateFunction = mockSetActiveItem.mock.calls[0][0]; + // Should be limited by maxActiveItem: Math.min(1 + 5, 2) = 2 + expect(updateFunction(1)).toBe(2); + }); + + it('handles horizontal wheel events for navigation', () => { + renderWithChakra(); + + // Create a horizontal wheel event (like trackpad horizontal scroll) + const wheelEvent = new WheelEvent('wheel', { + deltaX: 100, + deltaY: 10, + bubbles: true, + }); + + // Dispatch the wheel event on the document + fireEvent(document, wheelEvent); + + // Should update activeItem based on scroll direction + expect(mockSetActiveItem).toHaveBeenCalled(); + }); + + it('ignores wheel events that are primarily vertical', () => { + renderWithChakra(); + + // Create a mostly vertical wheel event (normal page scrolling) + const wheelEvent = new WheelEvent('wheel', { + deltaX: 10, + deltaY: 100, + bubbles: true, + }); + + fireEvent(document, wheelEvent); + + // Should ignore this event since |deltaY| > |deltaX| + expect(mockSetActiveItem).not.toHaveBeenCalled(); + }); + + it('prevents default behavior for horizontal wheel events', () => { + renderWithChakra(); + + // Create wheel event with preventDefault spy + const wheelEvent = new WheelEvent('wheel', { + deltaX: 100, + deltaY: 10, + }); + const preventDefaultSpy = jest.spyOn(wheelEvent, 'preventDefault'); + const stopPropagationSpy = jest.spyOn(wheelEvent, 'stopPropagation'); + + fireEvent(document, wheelEvent); + + // Should prevent default and stop propagation for horizontal scrolling + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + + preventDefaultSpy.mockRestore(); + stopPropagationSpy.mockRestore(); + }); + + it('works with custom drag multiplier', () => { + const propsWithCustomMultiplier = { + ...getDefaultProps(), + multiplier: 0.5, + }; + + renderWithChakra(); + + // Component should render normally with custom multiplier + expect(screen.getByText('Item 1')).toBeInTheDocument(); + }); + + it('handles empty positions array gracefully', () => { + const propsWithEmptyPositions = { + ...getDefaultProps(), + positions: [], // No positions + children: [], // No children + maxActiveItem: 0, + }; + + renderWithChakra(); + + // Should render without crashing + expect(mockSetTrackIsActive).toBeDefined(); + }); + + it('removes event listeners on unmount', () => { + // Spy on document event listener methods to verify cleanup + const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); + + const { unmount } = renderWithChakra(); + + // Verify that event listeners were added + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'keydown', + expect.any(Function), + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'mousedown', + expect.any(Function), + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'wheel', + expect.any(Function), + { passive: false }, + ); + + unmount(); + + // Verify that event listeners were removed + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'keydown', + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'mousedown', + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'wheel', + expect.any(Function), + ); + + // Clean up spies + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); +}); diff --git a/src/components/carousel/__tests__/constants.test.ts b/src/components/carousel/__tests__/constants.test.ts new file mode 100644 index 00000000..59f6b920 --- /dev/null +++ b/src/components/carousel/__tests__/constants.test.ts @@ -0,0 +1,38 @@ +import { TRANSITION_PROPS, PROGRESS_BAR_THRESHOLDS } from '../constants'; + +describe('Carousel Constants', () => { + it('has correct transition properties for spring animation', () => { + expect(TRANSITION_PROPS.spring).toEqual({ + type: 'spring', + stiffness: 200, + damping: 60, + mass: 3, + }); + }); + + it('has correct transition properties for ease animation', () => { + expect(TRANSITION_PROPS.ease).toEqual({ + type: 'ease', + ease: 'easeInOut', + duration: 0.4, + }); + }); + + it('has correct progress bar threshold values', () => { + expect(PROGRESS_BAR_THRESHOLDS).toEqual({ + SINGLE_ITEM: 10, + TWO_ITEMS: 18, + THREE_OR_MORE: 25, + MIN_WIDTH: 300, + }); + }); + + it('has logical progression in threshold values', () => { + expect(PROGRESS_BAR_THRESHOLDS.SINGLE_ITEM).toBeLessThan( + PROGRESS_BAR_THRESHOLDS.TWO_ITEMS, + ); + expect(PROGRESS_BAR_THRESHOLDS.TWO_ITEMS).toBeLessThan( + PROGRESS_BAR_THRESHOLDS.THREE_OR_MORE, + ); + }); +}); diff --git a/src/components/carousel/__tests__/index.test.tsx b/src/components/carousel/__tests__/index.test.tsx new file mode 100644 index 00000000..6b4ffaa1 --- /dev/null +++ b/src/components/carousel/__tests__/index.test.tsx @@ -0,0 +1,538 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ChakraProvider } from '@chakra-ui/react'; +import { Carousel } from '../index'; + +// Mock the useResizeObserver hook (it's used to detect container width) +jest.mock('usehooks-ts', () => ({ + useResizeObserver: jest.fn(() => ({ + width: 800, + height: 400, + })), +})); + +// Mock Chakra UI's useMediaQuery (employed in useCarouselState) +jest.mock('@chakra-ui/react', () => ({ + ...jest.requireActual('@chakra-ui/react'), + useMediaQuery: jest.fn(() => [false]), +})); + +// Mock the theme import with non-overlapping breakpoints +jest.mock('src/theme', () => ({ + theme: { + breakpoints: { + base: '0px', + sm: '480px', + md: '768px', + lg: '992px', + xl: '1280px', + }, + }, +})); + +// Mock framer-motion to simplify animation testing +jest.mock('framer-motion', () => ({ + motion: (Component: any) => { + const MotionComponent = React.forwardRef( + ({ children, ...props }, ref) => { + const { + animate, + initial, + exit, + transition, + variants, + drag, + dragConstraints, + dragElastic, + dragMomentum, + dragTransition, + whileHover, + whileTap, + whileFocus, + whileDrag, + whileInView, + onAnimationStart, + onAnimationComplete, + onHoverStart, + onHoverEnd, + onTap, + onTapStart, + onTapCancel, + onDrag, + onDragStart, + onDragEnd, + onDirectionLock, + onViewportEnter, + onViewportLeave, + style, + ...domProps + } = props; + + return ( + + {children} + + ); + }, + ); + MotionComponent.displayName = `Motion${ + Component.displayName || Component.name || 'Component' + }`; + return MotionComponent; + }, + useAnimation: () => ({ + start: jest.fn(), + }), + useMotionValue: (initialValue: number) => ({ + get: jest.fn(() => initialValue), + set: jest.fn(), + }), +})); + +const renderWithChakra = (ui: React.ReactElement) => { + return render({ui}); +}; + +// Mock media queries for different screen sizes with non-overlapping ranges +const mockMediaQueries = (screenSize: 'mobile' | 'tablet' | 'desktop') => { + const { useMediaQuery } = require('@chakra-ui/react'); + + switch (screenSize) { + case 'mobile': + // Mobile: 0px to 767px (below md breakpoint) - constraint: 1 + (useMediaQuery as jest.Mock) + .mockReturnValueOnce([true]) // isBetweenBaseAndMd: (max-width: 767px) + .mockReturnValueOnce([false]) // isBetweenMdAndXl: (min-width: 768px) and (max-width: 1279px) + .mockReturnValueOnce([false]); // isGreaterThanXL: (min-width: 1280px) + break; + + case 'tablet': + // Tablet: 768px to 1279px - constraint: 2 + (useMediaQuery as jest.Mock) + .mockReturnValueOnce([false]) + .mockReturnValueOnce([true]) + .mockReturnValueOnce([false]); + break; + + case 'desktop': + // Desktop: 1280px and above - constraint: 3 + (useMediaQuery as jest.Mock) + .mockReturnValueOnce([false]) + .mockReturnValueOnce([false]) + .mockReturnValueOnce([true]); + break; + } +}; + +describe('Carousel', () => { + // Sample carousel items for testing + const mockChildren = [ +
Carousel Item 1
, +
Carousel Item 2
, +
Carousel Item 3
, +
Carousel Item 4
, +
Carousel Item 5
, + ]; + + const defaultProps = { + colorScheme: 'primary', + gap: 32, + isLoading: false, + } as const; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset useMediaQuery to default state + const { useMediaQuery } = require('@chakra-ui/react'); + (useMediaQuery as jest.Mock).mockReturnValue([false]); + }); + + it('renders carousel with all children and controls', () => { + mockMediaQueries('tablet'); + + renderWithChakra({mockChildren}); + + // All carousel items should be rendered + expect(screen.getByText('Carousel Item 1')).toBeInTheDocument(); + expect(screen.getByText('Carousel Item 2')).toBeInTheDocument(); + expect(screen.getByText('Carousel Item 3')).toBeInTheDocument(); + expect(screen.getByText('Carousel Item 4')).toBeInTheDocument(); + expect(screen.getByText('Carousel Item 5')).toBeInTheDocument(); + + // Navigation controls should be rendered + expect(screen.getByLabelText('previous carousel item')).toBeInTheDocument(); + expect(screen.getByLabelText('next carousel item')).toBeInTheDocument(); + + // Should have pagination dots (5 items, constraint = 2 => 3 dots total) + expect( + screen.getByLabelText('carousel indicator 1 of 3 (current)'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 2 of 3'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 3 of 3'), + ).toBeInTheDocument(); + }); + + it('works with minimal props using default values', () => { + mockMediaQueries('mobile'); + + // Only provide required children, no other props + renderWithChakra({mockChildren}); + + expect(screen.getByText('Carousel Item 1')).toBeInTheDocument(); + expect(screen.getByLabelText('previous carousel item')).toBeInTheDocument(); + }); + + it('has correct DOM structure and CSS classes', () => { + mockMediaQueries('tablet'); + + const { container } = renderWithChakra( + {mockChildren}, + ); + + // Should have the padded-carousel class + const paddedCarousel = container.querySelector('.padded-carousel'); + expect(paddedCarousel).toBeInTheDocument(); + + // Should have items with the 'item' class + const items = container.querySelectorAll('.item'); + expect(items).toHaveLength(5); + }); + + it('renders complex children content correctly', () => { + mockMediaQueries('tablet'); + + // Create complex carousel items + const complexChildren = [ +
+

Card 1 Title

+

Card 1 description

+ +
, +
+

Card 2 Title

+

Card 2 description

+ +
, +
+
Test Image Placeholder
+ Image caption +
, + ]; + + renderWithChakra({complexChildren}); + + // All complex content should be rendered + expect(screen.getByText('Card 1 Title')).toBeInTheDocument(); + expect(screen.getByText('Card 1 description')).toBeInTheDocument(); + expect(screen.getByText('Action 1')).toBeInTheDocument(); + expect(screen.getByText('Card 2 Title')).toBeInTheDocument(); + expect(screen.getByText('Test Image Placeholder')).toBeInTheDocument(); + expect(screen.getByText('Image caption')).toBeInTheDocument(); + }); + + it('allows navigation between items using controls', async () => { + const user = userEvent.setup(); + mockMediaQueries('tablet'); // Tablet (constraint: 2) + + renderWithChakra({mockChildren}); + + // Previous button should be disabled (at start) + const prevButton = screen.getByLabelText('previous carousel item'); + const nextButton = screen.getByLabelText('next carousel item'); + + expect(prevButton).toBeDisabled(); + expect(nextButton).not.toBeDisabled(); + + // Click next button to advance + await user.click(nextButton); + + // After clicking next, the previous button shouldn't be disabled + await waitFor(() => { + expect(prevButton).not.toBeDisabled(); + }); + }); + + it('allows navigation using pagination dots', async () => { + const user = userEvent.setup(); + mockMediaQueries('tablet'); // Tablet: (5 items, constraint = 2 => 3 dots total) + + renderWithChakra({mockChildren}); + + // Initially, first dot should be active (current) + expect( + screen.getByLabelText('carousel indicator 1 of 3 (current)'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 2 of 3'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 3 of 3'), + ).toBeInTheDocument(); + + // Previous button should be disabled at start + const prevButton = screen.getByLabelText('previous carousel item'); + const nextButton = screen.getByLabelText('next carousel item'); + expect(prevButton).toBeDisabled(); + expect(nextButton).not.toBeDisabled(); + + // Click on the second dot to navigate + const secondDot = screen.getByLabelText('carousel indicator 2 of 3'); + await user.click(secondDot); + + // Wait for state updates and verify navigation occurred + await waitFor(() => { + // Second dot should now be active + expect( + screen.getByLabelText('carousel indicator 2 of 3 (current)'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 1 of 3'), + ).toBeInTheDocument(); + + // Navigation buttons should both be enabled + expect(prevButton).not.toBeDisabled(); + expect(nextButton).not.toBeDisabled(); + }); + + // Click on the third (last) dot + const thirdDot = screen.getByLabelText('carousel indicator 3 of 3'); + await user.click(thirdDot); + + await waitFor(() => { + // Third dot should now be active + expect( + screen.getByLabelText('carousel indicator 3 of 3 (current)'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 2 of 3'), + ).toBeInTheDocument(); + + // Next button should be disabled + expect(prevButton).not.toBeDisabled(); + expect(nextButton).toBeDisabled(); + }); + + // Navigate back to first dot + const firstDot = screen.getByLabelText('carousel indicator 1 of 3'); + await user.click(firstDot); + + await waitFor(() => { + // First dot should be active again + expect( + screen.getByLabelText('carousel indicator 1 of 3 (current)'), + ).toBeInTheDocument(); + + // Should be back at start, so previous button disabled + expect(prevButton).toBeDisabled(); + expect(nextButton).not.toBeDisabled(); + }); + }); + + it('shows controls when items exceed constraint', () => { + mockMediaQueries('mobile'); // Mobile layout (constraint: 1) + + renderWithChakra( + + {[
Item 1
,
Item 2
]} +
, + ); + + // Should show controls because 2 items > 1 constraint + expect(screen.getByLabelText('previous carousel item')).toBeInTheDocument(); + expect(screen.getByLabelText('next carousel item')).toBeInTheDocument(); + }); + + it('hides controls when all items fit in viewport', () => { + mockMediaQueries('desktop'); // Desktop layout (constraint: 3) + + // Create 3 items for desktop layout + const exactFitChildren = [ +
Item 1
, +
Item 2
, +
Item 3
, // items = constraint = 3 + ]; + + renderWithChakra({exactFitChildren}); + + // Should render all items + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 2')).toBeInTheDocument(); + expect(screen.getByText('Item 3')).toBeInTheDocument(); + + // Should not show navigation controls + expect( + screen.queryByLabelText('previous carousel item'), + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('next carousel item'), + ).not.toBeInTheDocument(); + }); + + it('shows progress bar when there are many items', () => { + mockMediaQueries('mobile'); + + // Create many items to trigger progress bar + const manyChildren = Array.from({ length: 15 }, (_, i) => ( +
Item {i + 1}
+ )); + + renderWithChakra({manyChildren}); + + // Should show progress bar instead of dots - get the main progress bar + const progressBars = screen.getAllByRole('progressbar'); + expect(progressBars.length).toBeGreaterThan(0); + + // Should not show individual dots when progress bar is shown + expect( + screen.queryByLabelText(/carousel indicator \d+ of/), + ).not.toBeInTheDocument(); + }); + + it('integrates responsive behavior correctly across breakpoints with boundary testing', () => { + const threeChildren = [ +
Item 1
, +
Item 2
, +
Item 3
, + ]; + + // Mobile: 3 items, constraint = 1 => 3 dots + mockMediaQueries('mobile'); + const { rerender } = renderWithChakra( + {threeChildren}, + ); + + expect(screen.getByLabelText('previous carousel item')).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 1 of 3 (current)'), + ).toBeInTheDocument(); + + // Tablet: 3 items, constraint = 2 => 2 dots + jest.clearAllMocks(); + mockMediaQueries('tablet'); + rerender({threeChildren}); + + expect(screen.getByLabelText('previous carousel item')).toBeInTheDocument(); + expect( + screen.getByLabelText('carousel indicator 1 of 2 (current)'), + ).toBeInTheDocument(); + expect( + screen.queryByLabelText('carousel indicator 3 of'), + ).not.toBeInTheDocument(); + + // Desktop: 3 items, constraint = 3 => no controls needed + jest.clearAllMocks(); + mockMediaQueries('desktop'); + rerender({threeChildren}); + + expect( + screen.queryByLabelText('previous carousel item'), + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText(/carousel indicator/), + ).not.toBeInTheDocument(); + }); + + it('provides proper accessibility attributes', () => { + mockMediaQueries('tablet'); + + renderWithChakra({mockChildren}); + + // Check navigation button accessibility + const prevButton = screen.getByLabelText('previous carousel item'); + const nextButton = screen.getByLabelText('next carousel item'); + + expect(prevButton).toHaveAttribute('aria-label', 'previous carousel item'); + expect(nextButton).toHaveAttribute('aria-label', 'next carousel item'); + + // Check dot accessibility + const firstDot = screen.getByLabelText( + 'carousel indicator 1 of 3 (current)', + ); + expect(firstDot).toHaveAttribute('role', 'button'); + expect(firstDot).toHaveAttribute('tabindex', '0'); + }); + + it('provides proper accessibility for progress bar and updates correctly', async () => { + const user = userEvent.setup(); + mockMediaQueries('mobile'); + + // Create enough items to trigger progress bar + const manyChildren = Array.from({ length: 12 }, (_, i) => ( +
Item {i + 1}
+ )); + + renderWithChakra({manyChildren}); + + // Check initial progress bar accessibility + let progressBar = screen.getByLabelText('Carousel progress: 0% complete'); + expect(progressBar).toHaveAttribute('aria-valuemin', '0'); + expect(progressBar).toHaveAttribute('aria-valuemax', '100'); + expect(progressBar).toHaveAttribute('aria-valuenow', '0'); + expect(progressBar).toHaveAttribute('role', 'progressbar'); + + // Navigate forward once + const nextButton = screen.getByLabelText('next carousel item'); + await user.click(nextButton); + + // Progress should update + await waitFor(() => { + const updatedProgressBar = screen.getByLabelText( + /Carousel progress: \d+% complete/, + ); + expect(updatedProgressBar).toBeInTheDocument(); + const ariaNow = updatedProgressBar.getAttribute('aria-valuenow'); + expect(parseInt(ariaNow!)).toBeGreaterThan(0); + }); + }); + + it('displays loading state correctly', () => { + mockMediaQueries('tablet'); + + renderWithChakra( + + {mockChildren} + , + ); + + // Content should still render (skeleton handles the loading display) + expect(screen.getByText('Carousel Item 1')).toBeInTheDocument(); + + // Controls should show loading state (skeleton) + const prevButton = screen.getByLabelText('previous carousel item'); + expect(prevButton).toBeInTheDocument(); + }); + + it('handles single carousel item correctly', () => { + mockMediaQueries('mobile'); + + renderWithChakra( + {[
Only Item
]}
, + ); + + // Should render the single item + expect(screen.getByText('Only Item')).toBeInTheDocument(); + + // Should not render controls (childrenLength <= constraint) + expect( + screen.queryByLabelText('previous carousel item'), + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('next carousel item'), + ).not.toBeInTheDocument(); + }); + + it('handles empty children array gracefully', () => { + mockMediaQueries('mobile'); + + renderWithChakra({[]}); + + // Should not crash, and should not show controls + expect( + screen.queryByLabelText('previous carousel item'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/carousel/__tests__/useCarouselNavigation.test.ts b/src/components/carousel/__tests__/useCarouselNavigation.test.ts new file mode 100644 index 00000000..ec988743 --- /dev/null +++ b/src/components/carousel/__tests__/useCarouselNavigation.test.ts @@ -0,0 +1,201 @@ +import { renderHook, act } from '@testing-library/react'; +import { useCarouselNavigation } from '../hooks/useCarouselNavigation'; + +describe('useCarouselNavigation', () => { + // Set up some mock functions + let mockSetActiveItem: jest.Mock; + let mockSetTrackIsActive: jest.Mock; + + // Helper function to create default props with optional overrides + const createDefaultProps = (overrides = {}) => ({ + setActiveItem: mockSetActiveItem, + setTrackIsActive: mockSetTrackIsActive, + constraint: 2, + maxActiveItem: 10, + ...overrides, + }); + + beforeEach(() => { + mockSetActiveItem = jest.fn(); + mockSetTrackIsActive = jest.fn(); + }); + + it('should return navigation functions', () => { + const { result } = renderHook(() => + useCarouselNavigation(createDefaultProps()), + ); + + expect(result.current).toEqual({ + handleFocus: expect.any(Function), + handleDecrementClick: expect.any(Function), + handleIncrementClick: expect.any(Function), + handleDotClick: expect.any(Function), + }); + }); + + it('should activate track when handleFocus is called', () => { + const { result } = renderHook(() => + useCarouselNavigation(createDefaultProps()), + ); + + act(() => { + result.current.handleFocus(); + }); + + // Check that setTrackIsActive was called with true + expect(mockSetTrackIsActive).toHaveBeenCalledWith(true); + // Check that it was called exactly once + expect(mockSetTrackIsActive).toHaveBeenCalledTimes(1); + // setActiveItem should NOT have been called for focus + expect(mockSetActiveItem).not.toHaveBeenCalled(); + }); + + it('should handle decrement click correctly', () => { + const { result } = renderHook(() => + useCarouselNavigation(createDefaultProps()), + ); + + act(() => { + result.current.handleDecrementClick(); + }); + + // When decrement is clicked: + // a. Track should be activated + expect(mockSetTrackIsActive).toHaveBeenCalledWith(true); + // b. setActiveItem should be called with a function + expect(mockSetActiveItem).toHaveBeenCalledWith(expect.any(Function)); + + // Get the function passed to setActiveItem + const updateFunction = mockSetActiveItem.mock.calls[0][0]; + + // Test the function with different values (using default constraint: 2) + expect(updateFunction(4)).toBe(2); + expect(updateFunction(1)).toBe(0); + expect(updateFunction(0)).toBe(0); + }); + + it('should handle increment click correctly', () => { + const { result } = renderHook(() => + useCarouselNavigation( + createDefaultProps({ + constraint: 3, + maxActiveItem: 15, + }), + ), + ); + + act(() => { + result.current.handleIncrementClick(); + }); + + expect(mockSetTrackIsActive).toHaveBeenCalledWith(true); + expect(mockSetActiveItem).toHaveBeenCalledWith(expect.any(Function)); + + // Get the function passed to setActiveItem + const updateFunction = mockSetActiveItem.mock.calls[0][0]; + + expect(updateFunction(5)).toBe(8); + expect(updateFunction(14)).toBe(15); + expect(updateFunction(15)).toBe(15); + }); + + it('should handle dot click correctly', () => { + const { result } = renderHook(() => + useCarouselNavigation(createDefaultProps()), + ); + + // Test clicking on dot index 2 + act(() => { + result.current.handleDotClick(2); + }); + + // Dot click should calculate: index * constraint = 2 * 2 = 4 (using default constraint: 2) + expect(mockSetActiveItem).toHaveBeenCalledWith(4); + }); + + it('should handle dot click at boundaries correctly', () => { + const { result } = renderHook(() => + useCarouselNavigation( + createDefaultProps({ + constraint: 3, + maxActiveItem: 7, + }), + ), + ); + + // Test clicking a dot that would go beyond the maximum + act(() => { + result.current.handleDotClick(3); // 3 * 3 = 9, but max is 7 + }); + + expect(mockSetActiveItem).toHaveBeenCalledWith(7); + }); + + it('should return stable function references', () => { + const { result, rerender } = renderHook(() => + useCarouselNavigation(createDefaultProps()), + ); + + // Get the functions from the first render + const firstRenderFunctions = result.current; + + // Force a re-render with the same props + rerender(); + + // Get the functions from the second render + const secondRenderFunctions = result.current; + + // Function references should be the same + expect(firstRenderFunctions.handleFocus).toBe( + secondRenderFunctions.handleFocus, + ); + expect(firstRenderFunctions.handleDecrementClick).toBe( + secondRenderFunctions.handleDecrementClick, + ); + expect(firstRenderFunctions.handleIncrementClick).toBe( + secondRenderFunctions.handleIncrementClick, + ); + expect(firstRenderFunctions.handleDotClick).toBe( + secondRenderFunctions.handleDotClick, + ); + }); + + it('should update functions when dependencies change', () => { + let constraint = 2; + let maxActiveItem = 10; + + // Create a renderHook setup + const { result, rerender } = renderHook(() => + useCarouselNavigation({ + setActiveItem: mockSetActiveItem, + setTrackIsActive: mockSetTrackIsActive, + constraint, + maxActiveItem, + }), + ); + + // Test with initial values + act(() => { + result.current.handleIncrementClick(); + }); + + const firstUpdateFunction = mockSetActiveItem.mock.calls[0][0]; + expect(firstUpdateFunction(0)).toBe(2); + + // Clear mock calls + mockSetActiveItem.mockClear(); + + // Change props and re-render + constraint = 5; + maxActiveItem = 20; + rerender(); + + // Test with new values + act(() => { + result.current.handleIncrementClick(); + }); + + const secondUpdateFunction = mockSetActiveItem.mock.calls[0][0]; + expect(secondUpdateFunction(0)).toBe(5); + }); +}); diff --git a/src/components/carousel/__tests__/useCarouselState.test.ts b/src/components/carousel/__tests__/useCarouselState.test.ts new file mode 100644 index 00000000..5c8ed947 --- /dev/null +++ b/src/components/carousel/__tests__/useCarouselState.test.ts @@ -0,0 +1,336 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { useCarouselState } from '../hooks/useCarouselState'; + +// Mock Chakra UI's useMediaQuery hook +jest.mock('@chakra-ui/react', () => ({ + ...jest.requireActual('@chakra-ui/react'), + useMediaQuery: jest.fn(), +})); + +// Mock the theme import with non-overlapping breakpoints +jest.mock('src/theme', () => ({ + theme: { + breakpoints: { + base: '0px', + sm: '480px', + md: '768px', + lg: '992px', + xl: '1280px', + }, + }, +})); + +describe('useCarouselState', () => { + const mockChildren = [ + React.createElement('div', { key: '1' }, 'Item 1'), + React.createElement('div', { key: '2' }, 'Item 2'), + React.createElement('div', { key: '3' }, 'Item 3'), + React.createElement('div', { key: '4' }, 'Item 4'), + React.createElement('div', { key: '5' }, 'Item 5'), + ]; + + const defaultProps = { + children: mockChildren, + width: 800, + gap: 32, + }; + + // Mock media queries for different screen sizes with non-overlapping ranges + const mockMediaQueries = (screenSize: 'mobile' | 'tablet' | 'desktop') => { + const { useMediaQuery } = require('@chakra-ui/react'); + + switch (screenSize) { + case 'mobile': + // Mobile: 0px to 767px (below md breakpoint) - constraint: 1 + (useMediaQuery as jest.Mock) + .mockReturnValueOnce([true]) // isBetweenBaseAndMd: (max-width: 767px) + .mockReturnValueOnce([false]) // isBetweenMdAndXl: (min-width: 768px) and (max-width: 1279px) + .mockReturnValueOnce([false]); // isGreaterThanXL: (min-width: 1280px) + break; + + case 'tablet': + // Tablet: 768px to 1279px - constraint: 2 + (useMediaQuery as jest.Mock) + .mockReturnValueOnce([false]) + .mockReturnValueOnce([true]) + .mockReturnValueOnce([false]); + break; + + case 'desktop': + // Desktop: 1280px and above - constraint: 3 + (useMediaQuery as jest.Mock) + .mockReturnValueOnce([false]) + .mockReturnValueOnce([false]) + .mockReturnValueOnce([true]); + break; + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Set default media query behavior (no breakpoint matched) + const { useMediaQuery } = require('@chakra-ui/react'); + (useMediaQuery as jest.Mock).mockReturnValue([false]); + }); + + it('should return initial state values correctly', () => { + mockMediaQueries('tablet'); + + const { result } = renderHook(() => useCarouselState(defaultProps)); + + expect(result.current).toHaveProperty('itemWidth'); + expect(result.current).toHaveProperty('activeItem'); + expect(result.current).toHaveProperty('setActiveItem'); + expect(result.current).toHaveProperty('trackIsActive'); + expect(result.current).toHaveProperty('setTrackIsActive'); + expect(result.current).toHaveProperty('constraint'); + expect(result.current).toHaveProperty('controlsWidth'); + expect(result.current).toHaveProperty('showProgressBar'); + expect(result.current).toHaveProperty('positions'); + expect(result.current).toHaveProperty('maxActiveItem'); + expect(result.current).toHaveProperty('totalDots'); + expect(result.current).toHaveProperty('progressPercentage'); + + // Verify initial state values + expect(result.current.activeItem).toBe(0); + expect(result.current.trackIsActive).toBe(false); + expect(result.current.progressPercentage).toBe(0); + }); + + it('should configure mobile layout correctly (constraint: 1)', () => { + mockMediaQueries('mobile'); + + const { result } = renderHook(() => useCarouselState(defaultProps)); + + // Mobile should show 1 item at a time + expect(result.current.constraint).toBe(1); + // Item width should be container width minus gap + expect(result.current.itemWidth).toBe(800 - 32); // 768px + // maxActiveItem = 5 items - 1 (constraint) = 4 + expect(result.current.maxActiveItem).toBe(4); + // Total dots should be 5 (one for each item) + expect(result.current.totalDots).toBe(5); + }); + + it('should configure tablet layout correctly (constraint: 2)', () => { + mockMediaQueries('tablet'); + + const { result } = renderHook(() => useCarouselState(defaultProps)); + + expect(result.current.constraint).toBe(2); + expect(result.current.itemWidth).toBe(800 / 2 - 32); + expect(result.current.maxActiveItem).toBe(3); + expect(result.current.totalDots).toBe(3); + }); + + it('should configure desktop layout correctly (constraint: 3)', () => { + mockMediaQueries('desktop'); + + const { result } = renderHook(() => useCarouselState(defaultProps)); + + expect(result.current.constraint).toBe(3); + expect(result.current.itemWidth).toBe(800 / 3 - 32); + expect(result.current.maxActiveItem).toBe(2); + expect(result.current.totalDots).toBe(2); + }); + + it('should limit desktop constraint to 2 when there are fewer than 3 items', () => { + mockMediaQueries('desktop'); + + // Test with only 2 children + const propsWithFewItems = { + ...defaultProps, + children: [ + React.createElement('div', { key: '1' }, 'Item 1'), + React.createElement('div', { key: '2' }, 'Item 2'), + ], + }; + + const { result } = renderHook(() => useCarouselState(propsWithFewItems)); + + expect(result.current.constraint).toBe(2); + expect(result.current.itemWidth).toBe(368); + expect(result.current.maxActiveItem).toBe(0); + }); + + it('should calculate item positions correctly', () => { + mockMediaQueries('tablet'); + + const { result } = renderHook(() => useCarouselState(defaultProps)); + + const itemWidth = result.current.itemWidth; + const itemWidthPlusGap = itemWidth + 32; + + expect(result.current.positions[0]).toBe(-Math.abs(itemWidthPlusGap * 0)); + expect(result.current.positions[1]).toBe(-Math.abs(itemWidthPlusGap * 1)); + expect(result.current.positions[2]).toBe(-Math.abs(itemWidthPlusGap * 2)); + expect(result.current.positions[3]).toBe(-Math.abs(itemWidthPlusGap * 3)); + expect(result.current.positions[4]).toBe(-Math.abs(itemWidthPlusGap * 4)); + }); + + it('should recalculate positions when layout changes', () => { + mockMediaQueries('mobile'); + + const { result, rerender } = renderHook(() => + useCarouselState(defaultProps), + ); + + const initialPositions = result.current.positions; + const initialItemWidth = result.current.itemWidth; + + // Switch to tablet layout to change itemWidth + jest.clearAllMocks(); + mockMediaQueries('tablet'); + + rerender(); + + // Verify positions changed due to itemWidth change + expect(result.current.positions).not.toEqual(initialPositions); + expect(result.current.itemWidth).not.toBe(initialItemWidth); + + // Verify positions are calculated correctly with the current itemWidth + const currentItemWidth = result.current.itemWidth; + const expectedPositions = mockChildren.map( + (_, index) => -Math.abs((currentItemWidth + 32) * index), + ); + + expectedPositions.forEach((expectedPos, index) => { + expect(result.current.positions[index]).toBe(expectedPos); + }); + }); + + it('should allow updating active item through setter', () => { + mockMediaQueries('tablet'); + + const { result } = renderHook(() => useCarouselState(defaultProps)); + + // Initial active item should be 0 + expect(result.current.activeItem).toBe(0); + + // Update active item + act(() => { + result.current.setActiveItem(2); + }); + + // Active item should be updated + expect(result.current.activeItem).toBe(2); + }); + + it('should calculate progress percentage correctly', () => { + mockMediaQueries('tablet'); + + const { result } = renderHook(() => useCarouselState(defaultProps)); + + // Initial progress should be 0% (at start) + expect(result.current.progressPercentage).toBe(0); + + // Move to middle position + act(() => { + result.current.setActiveItem(1); // Move to index 1 + }); + + // Progress calculation: (1 / 3) * 100 ≈ 33.33% + // maxActiveItem for 5 items with constraint 2 is 3 + expect(result.current.progressPercentage).toBeCloseTo(33.33, 1); + + // Move to end + act(() => { + result.current.setActiveItem(3); + }); + + // Progress should be 100% at the end + expect(result.current.progressPercentage).toBe(100); + }); + + it('should show progress bar when thresholds are exceeded', () => { + mockMediaQueries('mobile'); // constraint: 1 + + // Create enough items to exceed SINGLE_ITEM threshold (10) + const manyChildren = Array.from({ length: 12 }, (_, i) => + React.createElement('div', { key: i }, `Item ${i + 1}`), + ); + + const propsWithManyItems = { + ...defaultProps, + children: manyChildren, + }; + + const { result } = renderHook(() => useCarouselState(propsWithManyItems)); + + // Should show progress bar as totalDots (12) > SINGLE_ITEM threshold (10) + expect(result.current.totalDots).toBe(12); + expect(result.current.showProgressBar).toBe(true); + }); + + it('should force progress bar when available width is below minimum', () => { + mockMediaQueries('tablet'); + + // Use very narrow width (below MIN_WIDTH threshold of 300) + const propsWithNarrowWidth = { + ...defaultProps, + width: 250, + }; + + const { result } = renderHook(() => useCarouselState(propsWithNarrowWidth)); + + // Should show progress bar since width < MIN_WIDTH + expect(result.current.showProgressBar).toBe(true); + }); + + it('should update values when screen size changes', () => { + mockMediaQueries('tablet'); + + const { result, rerender } = renderHook(() => + useCarouselState(defaultProps), + ); + + const initialItemWidth = result.current.itemWidth; + expect(initialItemWidth).toBe(800 / 2 - 32); // 368 + + // Switch to mobile layout to trigger recalculation + jest.clearAllMocks(); + mockMediaQueries('mobile'); + + rerender(); + + const newItemWidth = result.current.itemWidth; + + // Verify the itemWidth was recalculated + expect(newItemWidth).toBe(800 - 32); // 768 + expect(newItemWidth).not.toBe(initialItemWidth); + }); + + it('should handle edge cases with empty or single children', () => { + mockMediaQueries('mobile'); + + // Test with single child + const propsWithSingleChild = { + ...defaultProps, + children: [React.createElement('div', { key: '1' }, 'Only Item')], + }; + + const { result } = renderHook(() => useCarouselState(propsWithSingleChild)); + + // Single item configuration + expect(result.current.constraint).toBe(1); + expect(result.current.maxActiveItem).toBe(0); + expect(result.current.totalDots).toBe(1); + expect(result.current.progressPercentage).toBe(0); + + // Test with empty children + const propsWithNoChildren = { + ...defaultProps, + children: [], + }; + + const { result: emptyResult } = renderHook(() => + useCarouselState(propsWithNoChildren), + ); + + // Empty children configuration + expect(emptyResult.current.maxActiveItem).toBeGreaterThanOrEqual(0); + expect(emptyResult.current.totalDots).toBe(1); + }); +});