diff --git a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx new file mode 100644 index 0000000000..84b2d3ec72 --- /dev/null +++ b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx @@ -0,0 +1,623 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { ThemeProvider, useTheme } from 'next-themes' +import React from 'react' +import ContributionHeatmap from 'components/ContributionHeatmap' +import '@testing-library/jest-dom' + +jest.mock('react-apexcharts', () => { + return function MockChart(props: { + options: unknown + series: unknown + height: string | number + type: string + }) { + const mockSeries = props.series as Array<{ + name: string + data: Array<{ x: string; y: number; date: string }> + }> + const mockOptions = props.options as Record + + if (mockOptions.tooltip && typeof mockOptions.tooltip === 'object') { + const tooltip = mockOptions.tooltip as { custom?: (...args: unknown[]) => unknown } + if (tooltip.custom) { + if (mockSeries[0]?.data.length > 0) { + tooltip.custom({ + seriesIndex: 0, + dataPointIndex: 0, + w: { config: { series: mockSeries } }, + }) + } + tooltip.custom({ + seriesIndex: 0, + dataPointIndex: 999, + w: { config: { series: mockSeries } }, + }) + } + } + + return ( +
+ {mockSeries.map((series) => ( +
+ {series.name}: {series.data.length} data points +
+ ))} +
+ ) + } +}) + +jest.mock('next-themes', () => ({ + useTheme: jest.fn(), + ThemeProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +const renderWithTheme = (ui: React.ReactElement, theme: 'light' | 'dark' = 'light') => { + ;(useTheme as jest.Mock).mockReturnValue({ theme, setTheme: jest.fn() }) + return render({ui}) +} + +describe('ContributionHeatmap', () => { + const mockData: Record = { + '2024-01-01': 5, + '2024-01-02': 8, + '2024-01-03': 12, + '2024-01-04': 15, + '2024-01-05': 0, + '2024-01-08': 3, + '2024-01-15': 20, + } + const defaultProps = { + contributionData: mockData, + startDate: '2024-01-01', + endDate: '2024-01-31', + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering & Props', () => { + it('renders with minimal and all optional props', async () => { + const { rerender } = renderWithTheme() + await waitFor(() => expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument()) + expect(screen.queryByRole('heading')).not.toBeInTheDocument() + + rerender( + + + + ) + expect(screen.getByText('Activity')).toBeInTheDocument() + }) + + it('renders all 7 day series and correct chart type', () => { + renderWithTheme() + ;['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach((day) => + expect(screen.getByTestId(`series-${day}`)).toBeInTheDocument() + ) + expect(screen.getByTestId('mock-heatmap-chart')).toHaveAttribute('data-type', 'heatmap') + }) + + it('applies custom unit and handles undefined title', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + expect(screen.queryByRole('heading')).not.toBeInTheDocument() + }) + }) + + describe('Data Processing & Edge Cases', () => { + it('handles empty, zero, and high values', () => { + const testCases = [ + { data: {}, desc: 'empty' }, + { data: { '2024-01-01': 0, '2024-01-02': 0 }, desc: 'zero' }, + { data: { '2024-01-01': 1000, '2024-01-02': 9999 }, desc: 'high' }, + ] + testCases.forEach(({ data }) => { + const { unmount } = renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + unmount() + }) + }) + + it('handles various date ranges', () => { + const ranges = [ + { start: '2024-01-01', end: '2024-01-01', data: { '2024-01-01': 5 } }, + { start: '2024-01-25', end: '2024-02-05', data: { '2024-01-25': 5, '2024-02-05': 10 } }, + { start: '2023-12-25', end: '2024-01-05', data: { '2023-12-25': 5, '2024-01-05': 10 } }, + ] + ranges.forEach(({ start, end, data }) => { + const { unmount } = renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + unmount() + }) + }) + + it('handles mid-week start and sparse data', () => { + const props = { + contributionData: { '2024-01-03': 5, '2024-01-15': 10, '2024-01-31': 3 }, + startDate: '2024-01-03', + endDate: '2024-01-31', + } + renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles large datasets (365 days)', () => { + const largeData: Record = {} + for (let i = 0; i < 365; i++) { + const date = new Date('2024-01-01') + date.setDate(date.getDate() + i) + largeData[date.toISOString().split('T')[0]] = i % 20 + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Theme & Styling', () => { + it('renders in light and dark mode with correct classes', () => { + const { rerender } = renderWithTheme( + , + 'light' + ) + expect(screen.getByText('Light').parentElement).toHaveClass('text-gray-700') + ;(useTheme as jest.Mock).mockReturnValue({ theme: 'dark', setTheme: jest.fn() }) + rerender( + + + + ) + expect(screen.getByText('Dark').parentElement).toHaveClass('text-gray-700') + }) + + it('applies correct container and style classes', () => { + const { container } = renderWithTheme() + expect(container.querySelector('.heatmap-container')).toBeInTheDocument() + expect(container.querySelector('style')).toBeInTheDocument() + expect(container.querySelector('.w-full')).toBeInTheDocument() + }) + + it('includes responsive media queries', () => { + const { container } = renderWithTheme() + const styleContent = container.querySelector('style')?.textContent + expect(styleContent).toContain('@media (max-width: 768px)') + expect(styleContent).toContain('@media (max-width: 480px)') + }) + }) + + describe('Content & Accessibility', () => { + it('renders title with correct styling and semantic HTML', () => { + const { container } = renderWithTheme( + + ) + const title = screen.getByText('Activity') + expect(title).toHaveClass('font-semibold') + expect(title.parentElement).toHaveClass('mb-1', 'text-sm') + expect(container.querySelector('h4')).toBeInTheDocument() + }) + + it('has accessible heading structure', () => { + renderWithTheme() + expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Accessible') + }) + }) + + describe('Chart Configuration & Performance', () => { + it('sets correct dimensions and series count', () => { + renderWithTheme() + const chart = screen.getByTestId('mock-heatmap-chart') + expect(chart).toHaveAttribute('data-height', '100%') + expect(chart).toHaveAttribute('data-series-length', '7') + }) + + it('re-renders correctly when props change', () => { + const { rerender } = renderWithTheme() + const newProps = { + contributionData: { '2024-02-01': 10 }, + startDate: '2024-02-01', + endDate: '2024-02-28', + } + rerender( + + + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles dynamic import with SSR disabled', async () => { + renderWithTheme() + await waitFor(() => expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument()) + }) + }) + + describe('Tooltip Behavior', () => { + it('generates correct tooltip with date formatting', () => { + renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles singular and plural unit labels in tooltip', () => { + const { rerender } = renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + + const singleData = { '2024-01-01': 1 } + rerender( + + + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('tooltip respects theme colors', () => { + const { rerender } = renderWithTheme(, 'light') + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + ;(useTheme as jest.Mock).mockReturnValue({ theme: 'dark', setTheme: jest.fn() }) + rerender( + + + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles missing data in tooltip gracefully', () => { + const { container } = renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + + const styleTag = container.querySelector('style') + expect(styleTag).toBeInTheDocument() + }) + }) + + describe('Week Number Calculation', () => { + it('correctly calculates week numbers starting from Monday', () => { + const data = { + '2024-01-01': 5, + '2024-01-08': 10, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles week transitions correctly', () => { + const data = { + '2024-01-07': 5, + '2024-01-08': 10, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Color Scale Logic', () => { + it('applies correct color ranges for different activity levels', () => { + const activityData = { + '2024-01-01': 0, + '2024-01-02': 2, + '2024-01-03': 6, + '2024-01-04': 10, + '2024-01-05': 15, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles boundary values in color ranges', () => { + const boundaryData = { + '2024-01-01': 0, + '2024-01-02': 1, + '2024-01-03': 4, + '2024-01-04': 5, + '2024-01-05': 8, + '2024-01-06': 9, + '2024-01-07': 12, + '2024-01-08': 13, + '2024-01-09': 1000, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Date Range Edge Cases', () => { + it('handles leap year dates', () => { + const leapYearData = { + '2024-02-28': 5, + '2024-02-29': 10, + '2024-03-01': 3, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles year boundaries correctly', () => { + const yearBoundaryData = { + '2023-12-30': 5, + '2023-12-31': 10, + '2024-01-01': 8, + '2024-01-02': 12, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles dates in reverse chronological order in data object', () => { + const reversedData: Record = {} + for (let i = 30; i >= 1; i--) { + const date = new Date('2024-01-01') + date.setDate(date.getDate() + i - 1) + reversedData[date.toISOString().split('T')[0]] = i + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Extreme Values & Data Quality', () => { + it('handles negative contribution values gracefully', () => { + const negativeData = { + '2024-01-01': -5, + '2024-01-02': 10, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles floating point contribution values', () => { + const floatData = { + '2024-01-01': 5.5, + '2024-01-02': 10.99, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles extremely large contribution counts', () => { + const extremeData = { + '2024-01-01': 999999, + '2024-01-02': 1000000, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Component State & Lifecycle', () => { + it('maintains consistent rendering across multiple updates', () => { + const { rerender } = renderWithTheme() + + for (let i = 0; i < 5; i++) { + const newData = { [`2024-01-${i + 1}`]: i * 5 } + rerender( + + + + ) + } + + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles unmounting and remounting', () => { + const { unmount } = renderWithTheme() + unmount() + renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('String Formatting & Localization', () => { + it('formats date strings correctly in different formats', () => { + const dateFormatData = { + '2024-01-01': 5, + '2024-1-2': 10, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles custom unit strings with special characters', () => { + const { unmount } = renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + unmount() + + const { rerender } = renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + + rerender( + + + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Chart Options Validation', () => { + it('configures chart with correct options structure', () => { + renderWithTheme() + const chart = screen.getByTestId('mock-heatmap-chart') + expect(chart).toHaveAttribute('data-type', 'heatmap') + }) + + it('renders heatmap chart', () => { + renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Responsive Design', () => { + it('applies responsive container classes', () => { + const { container } = renderWithTheme() + const heatmapContainer = container.querySelector('.heatmap-container') + expect(heatmapContainer).toBeInTheDocument() + }) + + it('maintains aspect ratio on different screen sizes', () => { + const { container } = renderWithTheme() + const styleContent = container.querySelector('style')?.textContent + expect(styleContent).toContain('aspect-ratio: 4 / 1') + expect(styleContent).toContain('min-height: 132px') + }) + }) + + describe('Integration & Real-world Scenarios', () => { + it('renders complete heatmap with realistic GitHub contribution data', () => { + const githubLikeData: Record = { + '2024-01-01': 5, + '2024-01-03': 12, + '2024-01-05': 3, + '2024-01-08': 8, + '2024-01-10': 15, + '2024-01-12': 7, + '2024-01-15': 20, + '2024-01-18': 4, + '2024-01-20': 9, + '2024-01-22': 11, + '2024-01-25': 6, + '2024-01-28': 13, + } + renderWithTheme( + + ) + + expect(screen.getByText('GitHub Contributions')).toBeInTheDocument() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + ;['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach((day) => + expect(screen.getByTestId(`series-${day}`)).toBeInTheDocument() + ) + }) + + it('handles complete absence of data gracefully', () => { + renderWithTheme( + + ) + + expect(screen.getByText('No Activity')).toBeInTheDocument() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('renders correctly with all edge cases combined', () => { + const complexData: Record = { + '2024-02-29': 100, + '2024-12-31': 50, + '2024-01-01': 0, + '2024-06-15': 1, + } + + renderWithTheme( + + ) + + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) +})