From 4991189995baeb0e040e83f71061ccfe94420ee6 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 11 Nov 2025 09:02:09 +0000 Subject: [PATCH] Add Comprehensive Test Coverage for Utility Functions and Typed Chart Components --- test/typedCharts.test.tsx | 420 ++++++++++++++++++++++++++++++++ test/utils.test.tsx | 497 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 917 insertions(+) create mode 100644 test/typedCharts.test.tsx create mode 100644 test/utils.test.tsx diff --git a/test/typedCharts.test.tsx b/test/typedCharts.test.tsx new file mode 100644 index 000000000..689a9a990 --- /dev/null +++ b/test/typedCharts.test.tsx @@ -0,0 +1,420 @@ +import { vi, describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import 'chart.js/auto'; +import { Chart as ChartJS } from 'chart.js'; +import { + Line, + Bar, + Radar, + Doughnut, + PolarArea, + Bubble, + Pie, + Scatter, +} from '../src/index.js'; + +describe('Typed Chart Components', () => { + const basicData = { + labels: ['Red', 'Blue', 'Yellow'], + datasets: [ + { + label: 'Dataset 1', + data: [12, 19, 3], + backgroundColor: [ + 'rgba(255, 99, 132, 0.2)', + 'rgba(54, 162, 235, 0.2)', + 'rgba(255, 206, 86, 0.2)', + ], + }, + ], + }; + + const options = { + responsive: false, + }; + + let chart: ChartJS | null = null; + + const ref = (el: ChartJS | null) => { + chart = el; + }; + + afterEach(() => { + if (chart) { + chart.destroy(); + chart = null; + } + cleanup(); + }); + + describe('Line Chart', () => { + it('should render Line chart', () => { + render(); + + expect(chart).toBeTruthy(); + expect(chart instanceof ChartJS).toBe(true); + expect(chart?.config.type).toBe('line'); + }); + + it('should pass data to Line chart', () => { + render(); + + expect(chart?.config.data.labels).toEqual(basicData.labels); + expect(chart?.config.data.datasets).toHaveLength(1); + expect(chart?.config.data.datasets[0].label).toBe('Dataset 1'); + }); + + it('should pass options to Line chart', () => { + render(); + + expect(chart?.options.responsive).toBe(false); + }); + + it('should update Line chart on data change', () => { + const newData = { + labels: ['Green', 'Purple'], + datasets: [ + { + label: 'Updated Dataset', + data: [5, 10], + }, + ], + }; + + const { rerender } = render( + + ); + + const updateSpy = vi.spyOn(chart!, 'update'); + + rerender(); + + expect(updateSpy).toHaveBeenCalled(); + expect(chart?.config.data.labels).toEqual(newData.labels); + }); + }); + + describe('Bar Chart', () => { + it('should render Bar chart', () => { + render(); + + expect(chart).toBeTruthy(); + expect(chart instanceof ChartJS).toBe(true); + expect(chart?.config.type).toBe('bar'); + }); + + it('should pass data to Bar chart', () => { + render(); + + expect(chart?.config.data.labels).toEqual(basicData.labels); + expect(chart?.config.data.datasets).toHaveLength(1); + }); + + it('should handle horizontal bar options', () => { + const horizontalOptions = { + indexAxis: 'y' as const, + responsive: false, + }; + + render(); + + expect(chart?.options.indexAxis).toBe('y'); + }); + }); + + describe('Radar Chart', () => { + it('should render Radar chart', () => { + render(); + + expect(chart).toBeTruthy(); + expect(chart instanceof ChartJS).toBe(true); + expect(chart?.config.type).toBe('radar'); + }); + + it('should pass data to Radar chart', () => { + render(); + + expect(chart?.config.data.labels).toEqual(basicData.labels); + expect(chart?.config.data.datasets).toHaveLength(1); + }); + }); + + describe('Doughnut Chart', () => { + it('should render Doughnut chart', () => { + render(); + + expect(chart).toBeTruthy(); + expect(chart instanceof ChartJS).toBe(true); + expect(chart?.config.type).toBe('doughnut'); + }); + + it('should pass data to Doughnut chart', () => { + render(); + + expect(chart?.config.data.labels).toEqual(basicData.labels); + expect(chart?.config.data.datasets).toHaveLength(1); + }); + + it('should handle doughnut-specific options', () => { + const doughnutOptions = { + cutout: '70%', + responsive: false, + }; + + render( + + ); + + expect(chart?.options.cutout).toBe('70%'); + }); + }); + + describe('PolarArea Chart', () => { + it('should render PolarArea chart', () => { + render(); + + expect(chart).toBeTruthy(); + expect(chart instanceof ChartJS).toBe(true); + expect(chart?.config.type).toBe('polarArea'); + }); + + it('should pass data to PolarArea chart', () => { + render(); + + expect(chart?.config.data.labels).toEqual(basicData.labels); + expect(chart?.config.data.datasets).toHaveLength(1); + }); + }); + + describe('Bubble Chart', () => { + const bubbleData = { + datasets: [ + { + label: 'Bubble Dataset', + data: [ + { x: 20, y: 30, r: 15 }, + { x: 40, y: 10, r: 10 }, + ], + backgroundColor: 'rgba(255, 99, 132, 0.2)', + }, + ], + }; + + it('should render Bubble chart', () => { + render(); + + expect(chart).toBeTruthy(); + expect(chart instanceof ChartJS).toBe(true); + expect(chart?.config.type).toBe('bubble'); + }); + + it('should pass data to Bubble chart', () => { + render(); + + expect(chart?.config.data.datasets).toHaveLength(1); + expect(chart?.config.data.datasets[0].data).toHaveLength(2); + }); + }); + + describe('Pie Chart', () => { + it('should render Pie chart', () => { + render(); + + expect(chart).toBeTruthy(); + expect(chart instanceof ChartJS).toBe(true); + expect(chart?.config.type).toBe('pie'); + }); + + it('should pass data to Pie chart', () => { + render(); + + expect(chart?.config.data.labels).toEqual(basicData.labels); + expect(chart?.config.data.datasets).toHaveLength(1); + }); + }); + + describe('Scatter Chart', () => { + const scatterData = { + datasets: [ + { + label: 'Scatter Dataset', + data: [ + { x: -10, y: 0 }, + { x: 0, y: 10 }, + { x: 10, y: 5 }, + ], + backgroundColor: 'rgba(255, 99, 132, 0.2)', + }, + ], + }; + + it('should render Scatter chart', () => { + render(); + + expect(chart).toBeTruthy(); + expect(chart instanceof ChartJS).toBe(true); + expect(chart?.config.type).toBe('scatter'); + }); + + it('should pass data to Scatter chart', () => { + render(); + + expect(chart?.config.data.datasets).toHaveLength(1); + expect(chart?.config.data.datasets[0].data).toHaveLength(3); + }); + }); + + describe('Common functionality across all typed charts', () => { + const charts = [ + { name: 'Line', Component: Line, type: 'line' }, + { name: 'Bar', Component: Bar, type: 'bar' }, + { name: 'Radar', Component: Radar, type: 'radar' }, + { name: 'Doughnut', Component: Doughnut, type: 'doughnut' }, + { name: 'PolarArea', Component: PolarArea, type: 'polarArea' }, + { name: 'Pie', Component: Pie, type: 'pie' }, + ]; + + charts.forEach(({ name, Component, type }) => { + describe(`${name} common features`, () => { + it('should forward ref correctly', () => { + render(); + + expect(chart).toBeTruthy(); + expect(chart?.config.type).toBe(type); + }); + + it('should destroy chart on unmount', () => { + const { unmount } = render( + + ); + + expect(chart).toBeTruthy(); + const destroySpy = vi.spyOn(chart!, 'destroy'); + + unmount(); + + expect(destroySpy).toHaveBeenCalled(); + }); + + it('should accept className prop', () => { + render( + + ); + + expect(chart?.canvas).toHaveClass('custom-chart'); + }); + + it('should accept plugins prop', () => { + const customPlugin = { + id: 'customPlugin', + beforeDraw: vi.fn(), + }; + + render( + + ); + + expect(chart?.config.plugins).toContain(customPlugin); + }); + + it('should handle redraw prop', () => { + const newData = { + labels: ['A', 'B'], + datasets: [{ label: 'New', data: [1, 2] }], + }; + + const { rerender } = render( + + ); + + const originalChart = chart; + const destroySpy = vi.spyOn(originalChart!, 'destroy'); + + rerender( + + ); + + expect(destroySpy).toHaveBeenCalled(); + }); + + it('should pass through aria-label', () => { + const ariaLabel = `${name} Chart`; + + render( + + ); + + expect(chart?.canvas.getAttribute('aria-label')).toBe(ariaLabel); + }); + + it('should handle height and width props', () => { + render( + + ); + + expect(chart?.canvas.height).toBe(400); + expect(chart?.canvas.width).toBe(600); + }); + }); + }); + }); + + describe('Type safety and registration', () => { + it('should not require type prop for typed components', () => { + // This test verifies that typed components work without explicit type prop + render(); + expect(chart?.config.type).toBe('line'); + + if (chart) chart.destroy(); + chart = null; + + render(); + expect(chart?.config.type).toBe('bar'); + }); + + it('should have controllers registered', () => { + // Verify that creating each chart type registers its controller + const chartTypes = [ + { Component: Line, type: 'line' }, + { Component: Bar, type: 'bar' }, + { Component: Radar, type: 'radar' }, + { Component: Doughnut, type: 'doughnut' }, + { Component: PolarArea, type: 'polarArea' }, + { Component: Bubble, type: 'bubble' }, + { Component: Pie, type: 'pie' }, + { Component: Scatter, type: 'scatter' }, + ]; + + chartTypes.forEach(({ Component, type }) => { + render(); + expect(chart?.config.type).toBe(type); + + if (chart) { + chart.destroy(); + chart = null; + } + }); + }); + }); +}); diff --git a/test/utils.test.tsx b/test/utils.test.tsx new file mode 100644 index 000000000..29d976a4e --- /dev/null +++ b/test/utils.test.tsx @@ -0,0 +1,497 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { fireEvent, render } from '@testing-library/react'; +import 'chart.js/auto'; +import { Chart as ChartJS } from 'chart.js'; +import { Chart } from '../src/index.js'; +import { + reforwardRef, + setOptions, + setLabels, + setDatasets, + cloneData, + getDatasetAtEvent, + getElementAtEvent, + getElementsAtEvent, +} from '../src/utils.js'; +import type { ChartData, ChartOptions } from 'chart.js'; + +describe('Utils', () => { + describe('reforwardRef', () => { + it('should call function ref with value', () => { + const ref = vi.fn(); + const value = { test: 'value' }; + + reforwardRef(ref, value); + + expect(ref).toHaveBeenCalledWith(value); + expect(ref).toHaveBeenCalledTimes(1); + }); + + it('should set current property on object ref', () => { + const ref = { current: null }; + const value = { test: 'value' }; + + reforwardRef(ref, value); + + expect(ref.current).toBe(value); + }); + + it('should handle null ref gracefully', () => { + expect(() => reforwardRef(null, { test: 'value' })).not.toThrow(); + }); + + it('should handle undefined ref gracefully', () => { + expect(() => reforwardRef(undefined, { test: 'value' })).not.toThrow(); + }); + }); + + describe('setOptions', () => { + it('should update chart options', () => { + const chart = { + options: { + responsive: false, + maintainAspectRatio: true, + }, + } as any; + + const newOptions: ChartOptions = { + responsive: true, + plugins: { + legend: { + display: false, + }, + }, + }; + + setOptions(chart, newOptions); + + expect(chart.options.responsive).toBe(true); + expect(chart.options.plugins?.legend?.display).toBe(false); + expect(chart.options.maintainAspectRatio).toBe(true); + }); + + it('should handle empty options', () => { + const chart = { + options: {}, + } as any; + + setOptions(chart, {}); + + expect(chart.options).toEqual({}); + }); + + it('should not throw when chart has no options', () => { + const chart = {} as any; + const newOptions: ChartOptions = { responsive: true }; + + expect(() => setOptions(chart, newOptions)).not.toThrow(); + }); + }); + + describe('setLabels', () => { + it('should update labels in chart data', () => { + const currentData: ChartData = { + labels: ['old1', 'old2'], + datasets: [], + }; + + const newLabels = ['new1', 'new2', 'new3']; + + setLabels(currentData, newLabels); + + expect(currentData.labels).toEqual(newLabels); + }); + + it('should handle undefined labels', () => { + const currentData: ChartData = { + labels: ['old1', 'old2'], + datasets: [], + }; + + setLabels(currentData, undefined); + + expect(currentData.labels).toBeUndefined(); + }); + + it('should handle empty labels array', () => { + const currentData: ChartData = { + labels: ['old1', 'old2'], + datasets: [], + }; + + setLabels(currentData, []); + + expect(currentData.labels).toEqual([]); + }); + }); + + describe('setDatasets', () => { + it('should update datasets with matching labels', () => { + const currentData: ChartData = { + labels: ['A', 'B'], + datasets: [ + { label: 'Dataset 1', data: [1, 2] }, + { label: 'Dataset 2', data: [3, 4] }, + ], + }; + + const newDatasets = [ + { label: 'Dataset 1', data: [5, 6] }, + { label: 'Dataset 2', data: [7, 8] }, + ]; + + setDatasets(currentData, newDatasets); + + expect(currentData.datasets[0].data).toEqual([5, 6]); + expect(currentData.datasets[1].data).toEqual([7, 8]); + }); + + it('should add new datasets when no match is found', () => { + const currentData: ChartData = { + labels: ['A', 'B'], + datasets: [{ label: 'Dataset 1', data: [1, 2] }], + }; + + const newDatasets = [ + { label: 'Dataset 1', data: [5, 6] }, + { label: 'Dataset 3', data: [9, 10] }, + ]; + + setDatasets(currentData, newDatasets); + + expect(currentData.datasets).toHaveLength(2); + expect(currentData.datasets[1].label).toBe('Dataset 3'); + expect(currentData.datasets[1].data).toEqual([9, 10]); + }); + + it('should use custom datasetIdKey', () => { + const currentData: ChartData = { + labels: ['A', 'B'], + datasets: [ + { id: 'ds1', label: 'Dataset 1', data: [1, 2] } as any, + { id: 'ds2', label: 'Dataset 2', data: [3, 4] } as any, + ], + }; + + const newDatasets = [ + { id: 'ds1', label: 'Updated 1', data: [5, 6] } as any, + { id: 'ds2', label: 'Updated 2', data: [7, 8] } as any, + ]; + + setDatasets(currentData, newDatasets, 'id'); + + expect(currentData.datasets[0].label).toBe('Updated 1'); + expect(currentData.datasets[1].label).toBe('Updated 2'); + }); + + it('should handle datasets with no data', () => { + const currentData: ChartData = { + labels: ['A', 'B'], + datasets: [{ label: 'Dataset 1', data: [1, 2] }], + }; + + const newDatasets = [{ label: 'Dataset 2' } as any]; + + setDatasets(currentData, newDatasets); + + expect(currentData.datasets).toHaveLength(1); + expect(currentData.datasets[0].label).toBe('Dataset 2'); + }); + + it('should prevent duplicate dataset references', () => { + const currentData: ChartData = { + labels: ['A', 'B'], + datasets: [{ label: 'Dataset 1', data: [1, 2] }], + }; + + const newDatasets = [ + { label: 'Dataset 1', data: [5, 6] }, + { label: 'Dataset 1', data: [7, 8] }, + ]; + + setDatasets(currentData, newDatasets); + + expect(currentData.datasets).toHaveLength(2); + expect(currentData.datasets[0]).not.toBe(currentData.datasets[1]); + }); + }); + + describe('cloneData', () => { + it('should clone chart data with labels and datasets', () => { + const originalData: ChartData = { + labels: ['A', 'B', 'C'], + datasets: [ + { label: 'Dataset 1', data: [1, 2, 3] }, + { label: 'Dataset 2', data: [4, 5, 6] }, + ], + }; + + const clonedData = cloneData(originalData); + + expect(clonedData.labels).toEqual(originalData.labels); + expect(clonedData.datasets).toHaveLength(2); + expect(clonedData.datasets[0].label).toBe('Dataset 1'); + expect(clonedData.datasets[1].label).toBe('Dataset 2'); + }); + + it('should create independent copy of data', () => { + const originalData: ChartData = { + labels: ['A', 'B'], + datasets: [{ label: 'Dataset 1', data: [1, 2] }], + }; + + const clonedData = cloneData(originalData); + + // Modify cloned data + clonedData.labels = ['X', 'Y']; + clonedData.datasets[0].data = [99, 100]; + + // Original should remain unchanged + expect(originalData.labels).toEqual(['A', 'B']); + expect(originalData.datasets[0].data).toEqual([1, 2]); + }); + + it('should use custom datasetIdKey', () => { + const originalData: ChartData = { + labels: ['A', 'B'], + datasets: [ + { id: 'ds1', label: 'Dataset 1', data: [1, 2] } as any, + { id: 'ds2', label: 'Dataset 2', data: [3, 4] } as any, + ], + }; + + const clonedData = cloneData(originalData, 'id'); + + expect(clonedData.datasets).toHaveLength(2); + expect(clonedData.datasets[0]).toHaveProperty('id', 'ds1'); + expect(clonedData.datasets[1]).toHaveProperty('id', 'ds2'); + }); + + it('should handle empty datasets', () => { + const originalData: ChartData = { + labels: ['A', 'B'], + datasets: [], + }; + + const clonedData = cloneData(originalData); + + expect(clonedData.datasets).toEqual([]); + expect(clonedData.labels).toEqual(['A', 'B']); + }); + }); + + describe('Event handler functions', () => { + let chart: ChartJS | null = null; + + const data = { + labels: ['January', 'February', 'March'], + datasets: [ + { + label: 'Sales', + data: [10, 20, 30], + backgroundColor: 'rgba(255, 99, 132, 0.2)', + }, + { + label: 'Revenue', + data: [15, 25, 35], + backgroundColor: 'rgba(54, 162, 235, 0.2)', + }, + ], + }; + + const options = { + responsive: false, + }; + + const ref = (el: ChartJS | null) => { + chart = el; + }; + + beforeEach(() => { + chart = null; + }); + + describe('getDatasetAtEvent', () => { + it('should return dataset elements at event location', () => { + const { container } = render( + + ); + + const canvas = container.querySelector('canvas'); + expect(canvas).toBeTruthy(); + expect(chart).toBeTruthy(); + + if (canvas && chart) { + const event = new MouseEvent('click', { + clientX: 100, + clientY: 100, + bubbles: true, + }); + + Object.defineProperty(event, 'nativeEvent', { + value: event, + writable: false, + }); + + const elements = getDatasetAtEvent(chart, event as any); + + expect(Array.isArray(elements)).toBe(true); + } + }); + + it('should call getElementsAtEventForMode with correct parameters', () => { + const { container } = render( + + ); + + const canvas = container.querySelector('canvas'); + + if (canvas && chart) { + const spy = vi.spyOn(chart, 'getElementsAtEventForMode'); + + const event = new MouseEvent('click', { + clientX: 100, + clientY: 100, + bubbles: true, + }); + + Object.defineProperty(event, 'nativeEvent', { + value: event, + writable: false, + }); + + getDatasetAtEvent(chart, event as any); + + expect(spy).toHaveBeenCalledWith( + event, + 'dataset', + { intersect: true }, + false + ); + } + }); + }); + + describe('getElementAtEvent', () => { + it('should return single element at event location', () => { + const { container } = render( + + ); + + const canvas = container.querySelector('canvas'); + expect(canvas).toBeTruthy(); + expect(chart).toBeTruthy(); + + if (canvas && chart) { + const event = new MouseEvent('click', { + clientX: 100, + clientY: 100, + bubbles: true, + }); + + Object.defineProperty(event, 'nativeEvent', { + value: event, + writable: false, + }); + + const elements = getElementAtEvent(chart, event as any); + + expect(Array.isArray(elements)).toBe(true); + } + }); + + it('should call getElementsAtEventForMode with nearest mode', () => { + const { container } = render( + + ); + + const canvas = container.querySelector('canvas'); + + if (canvas && chart) { + const spy = vi.spyOn(chart, 'getElementsAtEventForMode'); + + const event = new MouseEvent('click', { + clientX: 100, + clientY: 100, + bubbles: true, + }); + + Object.defineProperty(event, 'nativeEvent', { + value: event, + writable: false, + }); + + getElementAtEvent(chart, event as any); + + expect(spy).toHaveBeenCalledWith( + event, + 'nearest', + { intersect: true }, + false + ); + } + }); + }); + + describe('getElementsAtEvent', () => { + it('should return all elements at event location', () => { + const { container } = render( + + ); + + const canvas = container.querySelector('canvas'); + expect(canvas).toBeTruthy(); + expect(chart).toBeTruthy(); + + if (canvas && chart) { + const event = new MouseEvent('click', { + clientX: 100, + clientY: 100, + bubbles: true, + }); + + Object.defineProperty(event, 'nativeEvent', { + value: event, + writable: false, + }); + + const elements = getElementsAtEvent(chart, event as any); + + expect(Array.isArray(elements)).toBe(true); + } + }); + + it('should call getElementsAtEventForMode with index mode', () => { + const { container } = render( + + ); + + const canvas = container.querySelector('canvas'); + + if (canvas && chart) { + const spy = vi.spyOn(chart, 'getElementsAtEventForMode'); + + const event = new MouseEvent('click', { + clientX: 100, + clientY: 100, + bubbles: true, + }); + + Object.defineProperty(event, 'nativeEvent', { + value: event, + writable: false, + }); + + getElementsAtEvent(chart, event as any); + + expect(spy).toHaveBeenCalledWith( + event, + 'index', + { intersect: true }, + false + ); + } + }); + }); + }); +});