import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' import { render } from '@testing-library/react' import { EchartUI } from './echart-ui' import type { EChartsOption } from 'echarts' import * as echarts from 'echarts' import { resetSharedResizeObserver } from './shared-resize-observer' // Mock echarts module vi.mock('echarts', () => { const mockChart = { setOption: vi.fn(), dispose: vi.fn(), resize: vi.fn(), on: vi.fn(), off: vi.fn(), getZr: vi.fn(() => ({ setCursorStyle: vi.fn(), })), } return { init: vi.fn(() => mockChart), } }) describe('EchartUI', () => { let mockChart: { setOption: ReturnType dispose: ReturnType resize: ReturnType on: ReturnType off: ReturnType getZr: ReturnType } let mockResizeObserver: { observe: ReturnType disconnect: ReturnType unobserve: ReturnType } let resizeObserverCallback: ResizeObserverCallback | undefined const defaultProps = { id: 'test-echart', } const basicOption: EChartsOption = { title: { text: 'Test Chart', }, series: [ { type: 'bar', data: [1, 2, 3, 4, 5], }, ], } beforeEach(() => { vi.clearAllMocks() // Create a fresh mock chart instance for each test mockChart = { setOption: vi.fn(), dispose: vi.fn(), resize: vi.fn(), on: vi.fn(), off: vi.fn(), getZr: vi.fn(() => ({ setCursorStyle: vi.fn(), })), } // Make echarts.init return our mock chart vi.mocked(echarts.init).mockReturnValue( mockChart as unknown as echarts.ECharts, ) // Reset the shared observer so each test gets a fresh one using the mock resetSharedResizeObserver() // Mock ResizeObserver mockResizeObserver = { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn(), } resizeObserverCallback = undefined global.ResizeObserver = class { constructor(callback: ResizeObserverCallback) { resizeObserverCallback = callback } observe = mockResizeObserver.observe disconnect = mockResizeObserver.disconnect unobserve = mockResizeObserver.unobserve } as unknown as typeof ResizeObserver }) afterEach(() => { vi.restoreAllMocks() }) test('renders div container', () => { const { container } = render( , ) const div = container.querySelector('div') expect(div).toBeTruthy() }) test('initializes echarts with default options', () => { render() expect(echarts.init).toHaveBeenCalledWith( expect.any(HTMLDivElement), null, expect.objectContaining({ renderer: 'svg', height: 304, }), ) }) test('initializes echarts with custom renderer', () => { render( , ) expect(echarts.init).toHaveBeenCalledWith( expect.any(HTMLDivElement), null, expect.objectContaining({ renderer: 'canvas', height: 304, }), ) }) test('initializes echarts with custom height', () => { render( , ) expect(echarts.init).toHaveBeenCalledWith( expect.any(HTMLDivElement), null, expect.objectContaining({ renderer: 'svg', height: 500, }), ) }) test('initializes echarts with custom init options', () => { render( , ) expect(echarts.init).toHaveBeenCalledWith( expect.any(HTMLDivElement), null, expect.objectContaining({ renderer: 'canvas', height: 600, width: 800, }), ) }) test('sets chart options on mount', () => { render() expect(mockChart.setOption).toHaveBeenCalledWith(basicOption, { lazyUpdate: true, notMerge: true, }) }) test('updates chart options when option prop changes', () => { const { rerender } = render( , ) const newOption: EChartsOption = { title: { text: 'Updated Chart', }, series: [ { type: 'line', data: [5, 4, 3, 2, 1], }, ], } rerender() expect(mockChart.setOption).toHaveBeenCalledWith(newOption, { lazyUpdate: true, notMerge: true, }) }) test('applies custom className', () => { const { container } = render( , ) const div = container.querySelector('div') expect(div?.className).toBe('custom-chart') }) test('applies custom style', () => { const customStyle = { width: '100%', height: '400px' } const { container } = render( , ) const div = container.querySelector('div')! expect(div?.style.width).toBe('100%') expect(div?.style.height).toBe('400px') }) test('disposes chart instance on unmount', () => { const { unmount } = render( , ) unmount() expect(mockChart.dispose).toHaveBeenCalled() }) test('sets up ResizeObserver on mount', () => { render() // Verify ResizeObserver was used by checking observe was called expect(mockResizeObserver.observe).toHaveBeenCalledWith( expect.any(HTMLDivElement), ) }) test('unobserves element from shared ResizeObserver on unmount', () => { const { unmount } = render( , ) unmount() // The shared observer calls unobserve() for the specific element, not disconnect() expect(mockResizeObserver.unobserve).toHaveBeenCalled() }) test('calls resize on chart when ResizeObserver triggers', () => { render() // Trigger the shared observer's callback with a mock entry targeting the chart element const chartElement = document.getElementById(defaultProps.id)! const entries: ResizeObserverEntry[] = [ { target: chartElement } as unknown as ResizeObserverEntry, ] resizeObserverCallback!(entries, {} as ResizeObserver) expect(mockChart.resize).toHaveBeenCalled() }) test('accepts ref prop without errors', () => { const chartRef = { current: null, } as unknown as React.RefObject // Component accepts ref as a prop but since it doesn't use forwardRef, // the useImperativeHandle won't properly set the ref. // This test verifies the component doesn't crash when ref is provided. expect(() => { render() }).not.toThrow() }) test('attaches event listeners when onEvents prop is provided', () => { const clickHandler = vi.fn() const hoverHandler = vi.fn() render( , ) expect(mockChart.on).toHaveBeenCalledWith('click', clickHandler) expect(mockChart.on).toHaveBeenCalledWith('mouseover', hoverHandler) }) test('updates event listeners when onEvents prop changes', () => { const clickHandler1 = vi.fn() const clickHandler2 = vi.fn() const { rerender } = render( , ) expect(mockChart.on).toHaveBeenCalledWith('click', clickHandler1) rerender( , ) expect(mockChart.off).toHaveBeenCalledWith('click') expect(mockChart.on).toHaveBeenCalledWith('click', clickHandler2) }) test('sets default cursor style when no click event is provided', () => { const mockGetZr = vi.fn(() => ({ setCursorStyle: vi.fn(), })) mockChart.getZr = mockGetZr render() expect(mockChart.on).toHaveBeenCalledWith('mousemove', expect.any(Function)) }) test('does not set default cursor style when click event is provided', () => { const clickHandler = vi.fn() render( , ) // Should not attach mousemove handler when click is provided const mousemoveCalls = mockChart.on.mock.calls.filter( (call) => call[0] === 'mousemove', ) expect(mousemoveCalls.length).toBe(0) }) test('handles multiple event types simultaneously', () => { const clickHandler = vi.fn() const dblclickHandler = vi.fn() const mouseoverHandler = vi.fn() const mouseoutHandler = vi.fn() render( , ) expect(mockChart.on).toHaveBeenCalledWith('click', clickHandler) expect(mockChart.on).toHaveBeenCalledWith('dblclick', dblclickHandler) expect(mockChart.on).toHaveBeenCalledWith('mouseover', mouseoverHandler) expect(mockChart.on).toHaveBeenCalledWith('mouseout', mouseoutHandler) }) test('reinitializes chart when init options change', () => { vi.clearAllMocks() // Clear mocks from beforeEach const { rerender } = render( , ) expect(echarts.init).toHaveBeenCalledTimes(1) expect(mockChart.dispose).not.toHaveBeenCalled() rerender( , ) expect(mockChart.dispose).toHaveBeenCalled() expect(echarts.init).toHaveBeenCalledTimes(2) }) test('handles empty onEvents object', () => { render() // Should still set default mousemove handler expect(mockChart.on).toHaveBeenCalledWith('mousemove', expect.any(Function)) }) test('generates unique id for container', () => { const { container: container1 } = render( , ) const { container: container2 } = render( , ) const div1 = container1.querySelector('div') const div2 = container2.querySelector('div') expect(div1?.id).toBe('test-echart-1') expect(div2?.id).toBe('test-echart-2') expect(div1?.id).not.toBe(div2?.id) }) test('handles complex chart options', () => { const complexOption: EChartsOption = { title: { text: 'Complex Chart', left: 'center', }, tooltip: { trigger: 'axis', }, legend: { data: ['Series 1', 'Series 2'], bottom: 10, }, xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], }, yAxis: { type: 'value', }, series: [ { name: 'Series 1', type: 'line', data: [120, 200, 150, 80, 70, 110, 130], }, { name: 'Series 2', type: 'bar', data: [220, 180, 190, 234, 290, 330, 310], }, ], } render() expect(mockChart.setOption).toHaveBeenCalledWith(complexOption, { lazyUpdate: true, notMerge: true, }) }) test('does not break when ref is not provided', () => { expect(() => { render() }).not.toThrow() }) test('handles undefined onEvents gracefully', () => { render( , ) expect(mockChart.on).toHaveBeenCalledWith('mousemove', expect.any(Function)) }) test('cleans up properly with multiple rerenders', () => { const { rerender, unmount } = render( , ) const option2: EChartsOption = { series: [{ type: 'line', data: [1, 2, 3] }], } rerender() const option3: EChartsOption = { series: [{ type: 'bar', data: [3, 2, 1] }], } rerender() unmount() expect(mockChart.dispose).toHaveBeenCalled() expect(mockResizeObserver.unobserve).toHaveBeenCalled() }) })