From 5e093e8523918bddcf3cdbf63479bcbeb8a3e134 Mon Sep 17 00:00:00 2001 From: SiddharthJiyani Date: Mon, 17 Nov 2025 12:17:56 +0530 Subject: [PATCH 1/5] test: add ContributionHeatmap component unit tests --- .../components/ContributionHeatmap.test.tsx | 516 ++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 frontend/__tests__/unit/components/ContributionHeatmap.test.tsx diff --git a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx new file mode 100644 index 0000000000..a9ee0ba37c --- /dev/null +++ b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx @@ -0,0 +1,516 @@ +import { useQuery } from '@apollo/client/react' +import { screen, waitFor } from '@testing-library/react' +import { render } from 'wrappers/testUtil' +import UserDetailsPage from 'app/members/[memberKey]/page' +import { drawContributions, fetchHeatmapData } from 'utils/helpers/githubHeatmap' +import { mockUserDetailsData } from '@unit/data/mockUserDetails' + +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useQuery: jest.fn(), +})) + +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useParams: () => ({ memberKey: 'test-user' }), + useRouter: () => ({ + push: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + }), +})) + +jest.mock('utils/helpers/githubHeatmap', () => ({ + fetchHeatmapData: jest.fn(), + drawContributions: jest.fn(() => {}), +})) + +// Render Next/Image as a regular to inspect src/alt +jest.mock('next/image', () => ({ + __esModule: true, + default: (props: any) => { + // eslint-disable-next-line jsx-a11y/alt-text + return + }, +})) + +// Theme mock that we can toggle between tests and rerenders +let currentTheme: 'light' | 'dark' = 'light' +jest.mock('next-themes', () => ({ + useTheme: () => ({ resolvedTheme: currentTheme }), +})) + +// Minimal canvas stubs for jsdom +beforeAll(() => { + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + value: jest.fn(() => ({ + // 2d context stubs if needed later + fillRect: jest.fn(), + clearRect: jest.fn(), + getImageData: jest.fn(() => ({ data: [] })), + putImageData: jest.fn(), + createImageData: jest.fn(() => []), + setTransform: jest.fn(), + drawImage: jest.fn(), + save: jest.fn(), + fillText: jest.fn(), + restore: jest.fn(), + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + closePath: jest.fn(), + stroke: jest.fn(), + translate: jest.fn(), + scale: jest.fn(), + rotate: jest.fn(), + arc: jest.fn(), + fill: jest.fn(), + measureText: jest.fn(() => ({ width: 0 })), + })), + writable: false, + }) + + Object.defineProperty(HTMLCanvasElement.prototype, 'toDataURL', { + value: jest.fn(() => 'data:image/png;base64,heatmap'), + }) +}) + +describe('ContributionHeatmap behavior (via UserDetailsPage)', () => { + beforeEach(() => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: mockUserDetailsData, + loading: false, + error: null, + }) + ;(drawContributions as jest.Mock).mockClear() + ;(fetchHeatmapData as jest.Mock).mockReset() + currentTheme = 'light' + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering with minimal required props', () => { + test('renders fallback background when no contributions data', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({}) + + render() + + await waitFor(() => { + const bg = screen.getByAltText('Heatmap Background') + expect(bg).toBeInTheDocument() + const container = bg.closest('div.hidden.lg\\:block') + expect(container).toBeInTheDocument() + }) + expect(drawContributions).not.toHaveBeenCalled() + }) + + test('renders successfully with user data but no heatmap data', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({}) + + render() + + await waitFor(() => { + expect(screen.getByText('Test User')).toBeInTheDocument() + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + }) + }) + + describe('Conditional rendering logic', () => { + test('does not render heatmap section when user is private contributor (null response)', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue(null) + + render() + + await waitFor(() => { + expect(screen.queryByAltText('Heatmap Background')).toBeNull() + expect(screen.queryByAltText('Contribution Heatmap')).toBeNull() + }) + }) + + test('shows heatmap container only when not private contributor', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 5, intensity: 2 }], + years: [{ year: '2025' }], + }) + + render() + + await waitFor(() => { + const container = screen.getByAltText('Heatmap Background').closest('div.hidden.lg\\:block') + expect(container).toBeInTheDocument() + }) + }) + }) + + describe('Prop-based behavior', () => { + test('uses dark placeholder image in dark mode without data', async () => { + currentTheme = 'dark' + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({}) + + render() + + await waitFor(() => { + const bg = screen.getByAltText('Heatmap Background') as HTMLImageElement + expect(bg).toBeInTheDocument() + expect(bg.src).toContain('heatmap-background-dark.png') + }) + }) + + test('uses light placeholder image in light mode without data', async () => { + currentTheme = 'light' + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({}) + + render() + + await waitFor(() => { + const bg = screen.getByAltText('Heatmap Background') as HTMLImageElement + expect(bg).toBeInTheDocument() + expect(bg.src).toContain('heatmap-background-light.png') + }) + }) + + test('passes correct theme parameter to drawContributions', async () => { + currentTheme = 'light' + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 1, intensity: 1 }], + years: [{ year: '2025' }], + }) + + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + + currentTheme = 'light' + rerender() + + await waitFor(() => { + if ((drawContributions as jest.Mock).mock.calls.length > 0) { + const lastCallArgs = (drawContributions as jest.Mock).mock.calls.at(-1) + expect(lastCallArgs?.[1]?.themeName).toBe('light') + } + }) + }) + }) + + describe('State changes and internal logic', () => { + test('draws and shows contribution heatmap image after theme change when data available', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 1, intensity: 1 }], + years: [{ year: '2025' }], + }) + + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + + currentTheme = 'dark' + rerender() + + await waitFor(() => { + const img = screen.getByAltText('Contribution Heatmap') + expect(img).toBeInTheDocument() + }) + + expect(drawContributions).toHaveBeenCalled() + const lastCallArgs = (drawContributions as jest.Mock).mock.calls.at(-1) + expect(lastCallArgs?.[1]?.themeName).toBe('dark') + }) + + test('transitions from fallback to rendered heatmap on theme toggle', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 10, intensity: 3 }], + years: [{ year: '2025' }], + }) + + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + + currentTheme = 'dark' + rerender() + + await waitFor(() => { + expect(screen.getByAltText('Contribution Heatmap')).toBeInTheDocument() + expect(screen.queryByAltText('Heatmap Background')).toBeNull() + }) + }) + }) + + describe('Default values and fallbacks', () => { + test('shows loading placeholder when heatmap data exists but image not generated yet', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [], + years: [], + }) + + render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + }) + + test('handles empty contributions array gracefully', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [], + years: [], + }) + + render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + expect(drawContributions).not.toHaveBeenCalled() + }) + }) + + describe('Text and content rendering', () => { + test('renders with correct alt text for background image', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({}) + + render() + + await waitFor(() => { + const bg = screen.getByAltText('Heatmap Background') + expect(bg).toHaveAttribute('alt', 'Heatmap Background') + }) + }) + + test('renders with correct alt text for generated heatmap image', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 1, intensity: 1 }], + years: [{ year: '2025' }], + }) + + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + + currentTheme = 'dark' + rerender() + + await waitFor(() => { + const img = screen.getByAltText('Contribution Heatmap') + expect(img).toHaveAttribute('alt', 'Contribution Heatmap') + }) + }) + }) + + describe('Edge cases and invalid inputs', () => { + test('handles undefined contributions data', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue(undefined) + + render() + + await waitFor(() => { + expect(screen.queryByAltText('Heatmap Background')).toBeNull() + expect(screen.queryByAltText('Contribution Heatmap')).toBeNull() + }) + }) + + test('handles malformed heatmap data structure', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: 'invalid', + years: null, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Test User')).toBeInTheDocument() + }) + }) + + test('handles empty years array', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 5, intensity: 2 }], + years: [], + }) + + render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + }) + }) + + describe('Accessibility', () => { + test('canvas element is hidden from accessibility tree', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 1, intensity: 1 }], + years: [{ year: '2025' }], + }) + + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + + currentTheme = 'dark' + rerender() + + await waitFor(() => { + const canvas = document.querySelector('canvas') + expect(canvas).toHaveAttribute('aria-hidden', 'true') + }) + }) + + test('heatmap image has proper alt text for screen readers', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 1, intensity: 1 }], + years: [{ year: '2025' }], + }) + + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + + currentTheme = 'dark' + rerender() + + await waitFor(() => { + const img = screen.getByAltText('Contribution Heatmap') + expect(img).toHaveAccessibleName() + }) + }) + }) + + describe('DOM structure and styling', () => { + test('heatmap container has correct responsive classes', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 1, intensity: 1 }], + years: [{ year: '2025' }], + }) + + render() + + await waitFor(() => { + const container = screen.getByAltText('Heatmap Background').closest('div.hidden.lg\\:block') + expect(container).toHaveClass('hidden') + expect(container).toHaveClass('lg:block') + }) + }) + + test('heatmap background has correct styling classes', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({}) + + render() + + await waitFor(() => { + const bg = screen.getByAltText('Heatmap Background') as HTMLImageElement + expect(bg).toHaveClass('heatmap-background-loader') + expect(bg).toHaveClass('h-full') + expect(bg).toHaveClass('w-full') + expect(bg).toHaveClass('object-cover') + }) + }) + + test('rendered heatmap image has correct styling classes', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 1, intensity: 1 }], + years: [{ year: '2025' }], + }) + + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + + currentTheme = 'dark' + rerender() + + await waitFor(() => { + const img = screen.getByAltText('Contribution Heatmap') as HTMLImageElement + expect(img).toHaveClass('h-full') + expect(img).toHaveClass('w-full') + expect(img).toHaveClass('object-cover') + }) + }) + + test('canvas is hidden with display none', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 1, intensity: 1 }], + years: [{ year: '2025' }], + }) + + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + + currentTheme = 'dark' + rerender() + + await waitFor(() => { + const canvas = document.querySelector('canvas') + expect(canvas).toHaveStyle({ display: 'none' }) + }) + }) + }) + + describe('Integration with drawContributions', () => { + test('calls drawContributions with canvas reference', async () => { + ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ + contributions: [{ date: '2025-01-01', count: 1, intensity: 1 }], + years: [{ year: '2025' }], + }) + + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + + currentTheme = 'dark' + rerender() + + await waitFor(() => { + expect(drawContributions).toHaveBeenCalled() + const firstArg = (drawContributions as jest.Mock).mock.calls[0][0] + expect(firstArg).toBeInstanceOf(HTMLCanvasElement) + }) + }) + + test('calls drawContributions with correct data object', async () => { + const mockData = { + contributions: [{ date: '2025-01-01', count: 5, intensity: 2 }], + years: [{ year: '2025' }], + } + ;(fetchHeatmapData as jest.Mock).mockResolvedValue(mockData) + + const { rerender } = render() + + await waitFor(() => { + expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() + }) + + currentTheme = 'dark' + rerender() + + await waitFor(() => { + expect(drawContributions).toHaveBeenCalled() + const callArgs = (drawContributions as jest.Mock).mock.calls[0][1] + expect(callArgs.data).toBeDefined() + expect(callArgs.username).toBe('test-user') + }) + }) + }) +}) From a7bc10acbaea211ca9b38f6f89d7a3b1042de0d6 Mon Sep 17 00:00:00 2001 From: SiddharthJiyani Date: Mon, 17 Nov 2025 12:58:49 +0530 Subject: [PATCH 2/5] fix: resolve ESLint import order and type issues in ContributionHeatmap tests --- .../unit/components/ContributionHeatmap.test.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx index a9ee0ba37c..15696ad4a4 100644 --- a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx +++ b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx @@ -1,9 +1,10 @@ import { useQuery } from '@apollo/client/react' import { screen, waitFor } from '@testing-library/react' +import { mockUserDetailsData } from '@unit/data/mockUserDetails' +import React from 'react' import { render } from 'wrappers/testUtil' import UserDetailsPage from 'app/members/[memberKey]/page' import { drawContributions, fetchHeatmapData } from 'utils/helpers/githubHeatmap' -import { mockUserDetailsData } from '@unit/data/mockUserDetails' jest.mock('@apollo/client/react', () => ({ ...jest.requireActual('@apollo/client/react'), @@ -28,8 +29,8 @@ jest.mock('utils/helpers/githubHeatmap', () => ({ // Render Next/Image as a regular to inspect src/alt jest.mock('next/image', () => ({ __esModule: true, - default: (props: any) => { - // eslint-disable-next-line jsx-a11y/alt-text + default: (props: React.ComponentProps<'img'>) => { + // eslint-disable-next-line jsx-a11y/alt-text, @next/next/no-img-element return }, })) @@ -189,11 +190,11 @@ describe('ContributionHeatmap behavior (via UserDetailsPage)', () => { rerender() await waitFor(() => { - if ((drawContributions as jest.Mock).mock.calls.length > 0) { - const lastCallArgs = (drawContributions as jest.Mock).mock.calls.at(-1) - expect(lastCallArgs?.[1]?.themeName).toBe('light') - } + expect((drawContributions as jest.Mock).mock.calls.length).toBeGreaterThan(0) }) + + const lastCallArgs = (drawContributions as jest.Mock).mock.calls.at(-1) + expect(lastCallArgs?.[1]?.themeName).toBe('light') }) }) From 461517d8c245493297b2bc1d70af11ecda32b191 Mon Sep 17 00:00:00 2001 From: SiddharthJiyani Date: Mon, 17 Nov 2025 13:24:37 +0530 Subject: [PATCH 3/5] fix: restore canvas stubs in afterAll to prevent test interference --- .../components/ContributionHeatmap.test.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx index 15696ad4a4..3a924ac896 100644 --- a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx +++ b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx @@ -42,6 +42,10 @@ jest.mock('next-themes', () => ({ })) // Minimal canvas stubs for jsdom +// Store original methods to restore after tests +const originalGetContext = HTMLCanvasElement.prototype.getContext +const originalToDataURL = HTMLCanvasElement.prototype.toDataURL + beforeAll(() => { Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { value: jest.fn(() => ({ @@ -68,11 +72,28 @@ beforeAll(() => { fill: jest.fn(), measureText: jest.fn(() => ({ width: 0 })), })), - writable: false, + writable: true, + configurable: true, }) Object.defineProperty(HTMLCanvasElement.prototype, 'toDataURL', { value: jest.fn(() => 'data:image/png;base64,heatmap'), + writable: true, + configurable: true, + }) +}) + +afterAll(() => { + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + value: originalGetContext, + writable: true, + configurable: true, + }) + + Object.defineProperty(HTMLCanvasElement.prototype, 'toDataURL', { + value: originalToDataURL, + writable: true, + configurable: true, }) }) From 1ce4b57faa5b67fe104b45ae299600baec267a6b Mon Sep 17 00:00:00 2001 From: SiddharthJiyani Date: Thu, 20 Nov 2025 08:53:33 +0530 Subject: [PATCH 4/5] fix: correct theme transition test and use String.raw for CSS selectors --- .../unit/components/ContributionHeatmap.test.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx index 3a924ac896..bfb7403177 100644 --- a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx +++ b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx @@ -122,7 +122,7 @@ describe('ContributionHeatmap behavior (via UserDetailsPage)', () => { await waitFor(() => { const bg = screen.getByAltText('Heatmap Background') expect(bg).toBeInTheDocument() - const container = bg.closest('div.hidden.lg\\:block') + const container = bg.closest(String.raw`div.hidden.lg\:block`) expect(container).toBeInTheDocument() }) expect(drawContributions).not.toHaveBeenCalled() @@ -161,7 +161,9 @@ describe('ContributionHeatmap behavior (via UserDetailsPage)', () => { render() await waitFor(() => { - const container = screen.getByAltText('Heatmap Background').closest('div.hidden.lg\\:block') + const container = screen + .getByAltText('Heatmap Background') + .closest(String.raw`div.hidden.lg\:block`) expect(container).toBeInTheDocument() }) }) @@ -207,7 +209,7 @@ describe('ContributionHeatmap behavior (via UserDetailsPage)', () => { expect(screen.getByAltText('Heatmap Background')).toBeInTheDocument() }) - currentTheme = 'light' + currentTheme = 'dark' rerender() await waitFor(() => { @@ -215,7 +217,7 @@ describe('ContributionHeatmap behavior (via UserDetailsPage)', () => { }) const lastCallArgs = (drawContributions as jest.Mock).mock.calls.at(-1) - expect(lastCallArgs?.[1]?.themeName).toBe('light') + expect(lastCallArgs?.[1]?.themeName).toBe('dark') }) }) @@ -423,7 +425,9 @@ describe('ContributionHeatmap behavior (via UserDetailsPage)', () => { render() await waitFor(() => { - const container = screen.getByAltText('Heatmap Background').closest('div.hidden.lg\\:block') + const container = screen + .getByAltText('Heatmap Background') + .closest(String.raw`div.hidden.lg\:block`) expect(container).toHaveClass('hidden') expect(container).toHaveClass('lg:block') }) From f14562596f747c69ea6d54d4a22743984545ea26 Mon Sep 17 00:00:00 2001 From: SiddharthJiyani Date: Thu, 20 Nov 2025 09:38:14 +0530 Subject: [PATCH 5/5] test: strengthen absence assertions by waiting for page to settle --- .../__tests__/unit/components/ContributionHeatmap.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx index bfb7403177..262f8892b1 100644 --- a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx +++ b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx @@ -147,6 +147,8 @@ describe('ContributionHeatmap behavior (via UserDetailsPage)', () => { render() await waitFor(() => { + // Ensure the page has finished loading user data first + expect(screen.getByText('Test User')).toBeInTheDocument() expect(screen.queryByAltText('Heatmap Background')).toBeNull() expect(screen.queryByAltText('Contribution Heatmap')).toBeNull() }) @@ -339,6 +341,8 @@ describe('ContributionHeatmap behavior (via UserDetailsPage)', () => { render() await waitFor(() => { + // Ensure the page has finished loading user data first + expect(screen.getByText('Test User')).toBeInTheDocument() expect(screen.queryByAltText('Heatmap Background')).toBeNull() expect(screen.queryByAltText('Contribution Heatmap')).toBeNull() })