import { describe, it, expect, beforeEach, vi } from 'vitest' import { act, render } from '@testing-library/react' import type * as echarts from 'echarts' // ─── Hoisted mocks ────────────────────────────────────────────────────────── // Capture the mock chart so each test can introspect setOption / dispose calls. const mockChart = { setOption: vi.fn(), dispose: vi.fn(), resize: vi.fn(), on: vi.fn(), off: vi.fn(), // Stubbed for the edge-label clamp; tests override per-case. convertToPixel: vi.fn((_finder: unknown, index: number) => index === 0 ? 4 : 296, ), getWidth: vi.fn(() => 300), } const initSpy = vi.fn((...args: unknown[]): typeof mockChart => { void args return mockChart }) vi.mock('echarts', () => ({ __esModule: true, init: (...args: unknown[]) => initSpy(...args), // Fake text measurement: width ∝ character count, enough to make a long // edge label overflow a near-edge tick in the clamp tests. format: { getTextRect: (text: unknown) => ({ width: String(text).length * 10 }), }, })) /** Invoke the `finished` listener the bridge bound on the mock chart. */ function fireFinished(): void { for (const [event, handler] of mockChart.on.mock.calls) { if (event === 'finished') (handler as () => void)() } } // Resize observer mock — capture the callback so tests can trigger it. let resizeCb: (() => void) | null = null const observeResizeSpy = vi.fn((_node: HTMLElement, cb: () => void) => { resizeCb = cb return () => { resizeCb = null } }) vi.mock('./shared-resize-observer', () => ({ observeResize: (node: HTMLElement, cb: () => void) => observeResizeSpy(node, cb), })) import { EchartUI } from './echart-ui' beforeEach(() => { mockChart.setOption.mockReset() mockChart.dispose.mockReset() mockChart.resize.mockReset() mockChart.on.mockReset() mockChart.off.mockReset() mockChart.convertToPixel.mockClear() mockChart.getWidth.mockClear() initSpy.mockClear() observeResizeSpy.mockClear() resizeCb = null }) const categoryOption = { xAxis: { type: 'category', axisLabel: {} }, series: [{ type: 'bar', encode: { x: 'name', y: 'value' }, datasetIndex: 0 }], dataset: [ { source: [ { name: 'California', value: 3 }, { name: 'Texas', value: 2 }, { name: 'Wyoming', value: 1 }, ], }, ], } as unknown as echarts.EChartsOption describe('', () => { it('initialises echarts on mount with merged init opts and notifies onInstance', () => { const onInstance = vi.fn() render( , ) expect(initSpy).toHaveBeenCalledTimes(1) // 3rd arg merges the default `{ renderer: 'svg', height: 304 }` with the // caller's init — canvas wins. const initOpts = initSpy.mock.calls[0]![2] as Record expect(initOpts.renderer).toBe('canvas') expect(initOpts.height).toBe(304) expect(onInstance).toHaveBeenCalledWith(mockChart) }) it('applies setOption on mount and adds `series` to replaceMerge on first paint', () => { render() expect(mockChart.setOption).toHaveBeenCalledTimes(1) const args = mockChart.setOption.mock.calls[0]! expect( (args[1] as { replaceMerge?: readonly string[] }).replaceMerge, ).toContain('series') }) it('merges caller-provided replaceMerge keys with structural fingerprints', () => { render( , ) const augmented = ( mockChart.setOption.mock.calls[0]![1] as { replaceMerge?: readonly string[] } ).replaceMerge! expect(augmented).toContain('legend') expect(augmented).toContain('grid') expect(augmented).toContain('series') }) it('skips `series` in replaceMerge when only style fields change between renders', () => { const onInstance = vi.fn() const { rerender } = render( , ) mockChart.setOption.mockClear() rerender( , ) const replaceMerge = ( mockChart.setOption.mock.calls[0]![1] as { replaceMerge?: readonly string[] } ).replaceMerge // No structural change → no `series` flag added; nothing else passed. expect(replaceMerge).toBeUndefined() }) it('adds `series` to replaceMerge when a series field changes structurally', () => { const { rerender } = render( , ) mockChart.setOption.mockClear() rerender() const replaceMerge = ( mockChart.setOption.mock.calls[0]![1] as { replaceMerge?: readonly string[] } ).replaceMerge! expect(replaceMerge).toContain('series') }) it('adds `dataset` to replaceMerge when dataset length changes', () => { const { rerender } = render( , ) mockChart.setOption.mockClear() rerender( , ) const replaceMerge = ( mockChart.setOption.mock.calls[0]![1] as { replaceMerge?: readonly string[] } ).replaceMerge! expect(replaceMerge).toContain('dataset') }) it('binds onEvents handlers and unbinds them on unmount', () => { const handler = vi.fn() const { unmount } = render( , ) expect(mockChart.on).toHaveBeenCalledWith('click', handler) unmount() expect(mockChart.off).toHaveBeenCalledWith('click', handler) }) it('observes the container for resize and calls chart.resize on resize', () => { render() expect(observeResizeSpy).toHaveBeenCalled() expect(resizeCb).not.toBeNull() act(() => resizeCb?.()) expect(mockChart.resize).toHaveBeenCalledTimes(1) }) it('disposes the chart and calls onInstance(null) on unmount', () => { const onInstance = vi.fn() const { unmount } = render() onInstance.mockClear() unmount() expect(onInstance).toHaveBeenCalledWith(null) expect(mockChart.dispose).toHaveBeenCalledTimes(1) }) it('clamps overflowing edge labels on `finished` (category x-axis)', () => { render() mockChart.setOption.mockClear() // First tick at x=4, 'California' ~100px wide (half 50) → overflows left. act(() => fireFinished()) expect(mockChart.setOption).toHaveBeenCalledTimes(1) const applied = mockChart.setOption.mock.calls[0]![0] as { xAxis: { axisLabel: { alignMinLabel: string | null } } } expect(applied.xAxis.axisLabel.alignMinLabel).toBe('left') }) it('does not clamp again on a second `finished` with the same verdict', () => { render() act(() => fireFinished()) mockChart.setOption.mockClear() act(() => fireFinished()) expect(mockChart.setOption).not.toHaveBeenCalled() }) it('does not clamp a non-category (time) x-axis', () => { render( , ) mockChart.setOption.mockClear() act(() => fireFinished()) expect(mockChart.setOption).not.toHaveBeenCalled() }) it('treats a non-array series as a single-fingerprint signature', () => { // The fingerprint helper handles both array AND object/single-series // forms. Re-render with a *fresh* option object that has the same // single-series shape — the apply effect re-runs (deps changed) but // the fingerprint says nothing structural changed. const { rerender } = render( , ) mockChart.setOption.mockClear() rerender( , ) const replaceMerge = ( mockChart.setOption.mock.calls[0]![1] as { replaceMerge?: readonly string[] } ).replaceMerge expect(replaceMerge).toBeUndefined() }) it('treats a non-array dataset as a single-fingerprint signature', () => { const { rerender } = render( , ) mockChart.setOption.mockClear() rerender( , ) const replaceMerge = ( mockChart.setOption.mock.calls[0]![1] as { replaceMerge?: readonly string[] } ).replaceMerge expect(replaceMerge).toBeUndefined() }) })