import { describe, it, expect } from 'vitest' import { createTheme, type Theme } from '@mui/material' import { barOptions, createBarOptionFactory } from './options' const theme = createTheme() as unknown as Theme describe('barOptions (structural)', () => { it('returns a category x-axis without baked data', () => { const out = barOptions({ theme }) const xAxis = out.xAxis as { type: string; data?: unknown } expect(xAxis.type).toBe('category') expect(xAxis.data).toBeUndefined() }) it('returns no series and no dataset (data is fused at render time)', () => { const out = barOptions({ theme }) expect(out.series).toBeUndefined() expect(out.dataset).toBeUndefined() }) it('hides x and y axis lines and ticks for the v1 minimal look', () => { const out = barOptions({ theme }) const xAxis = out.xAxis as { axisLine?: { show?: boolean } axisTick?: { show?: boolean } } const yAxis = out.yAxis as { axisLine?: { show?: boolean } axisTick?: { show?: boolean } splitLine?: { show?: boolean } } expect(xAxis.axisLine?.show).toBe(false) expect(xAxis.axisTick?.show).toBe(false) expect(yAxis.axisLine?.show).toBe(false) expect(yAxis.axisTick?.show).toBe(false) expect(yAxis.splitLine?.show).toBe(true) }) it('y-axis label formatter only renders min/max via the user formatter', () => { const fmt = (n: number): string => `$${n}` const out = barOptions({ theme, formatter: fmt }) const yAxis = out.yAxis as { axisLabel?: { formatter?: (v: number) => string } } // niceMin / niceMax default to 0 and 1 until min/max callbacks run. expect(yAxis.axisLabel?.formatter?.(1)).toBe('$1') // 0 is suppressed (matches v1) and intermediate values are blanked. expect(yAxis.axisLabel?.formatter?.(0)).toBe('') expect(yAxis.axisLabel?.formatter?.(0.5)).toBe('') }) it('renders y-axis labels inside the plot, anchored to the bottom', () => { const out = barOptions({ theme }) const yAxis = out.yAxis as { axisLabel?: { inside?: boolean; verticalAlign?: string } } expect(yAxis.axisLabel?.inside).toBe(true) expect(yAxis.axisLabel?.verticalAlign).toBe('bottom') }) it('wires labelFormatter into the x-axis label', () => { const lf = (v: string | number) => `[${v}]` const out = barOptions({ theme, labelFormatter: lf }) const xAxis = out.xAxis as { axisLabel?: { formatter?: (v: string | number) => string } } expect(xAxis.axisLabel?.formatter?.('A')).toBe('[A]') }) }) describe('createBarOptionFactory', () => { it('builds one dataset per series with the data array as source', () => { const merge = createBarOptionFactory({ theme }) const data = [ [ { name: 'A', value: 1 }, { name: 'B', value: 2 }, ], [ { name: 'A', value: 3 }, { name: 'B', value: 4 }, ], ] const out = merge({}, data) as { dataset: { source: object[] }[] } expect(out.dataset).toHaveLength(2) expect(out.dataset[0]?.source).toBe(data[0]) expect(out.dataset[1]?.source).toBe(data[1]) }) it('encodes name → x and value → y, with each series referencing its dataset by index', () => { const merge = createBarOptionFactory({ theme }) const out = merge({}, [ [{ name: 'A', value: 1 }], [{ name: 'A', value: 2 }], ]) as { series: { type: string datasetIndex: number encode: { x: string; y: string } barMaxWidth: number emphasis: { focus: string } }[] } expect(out.series[0]).toMatchObject({ type: 'bar', datasetIndex: 0, encode: { x: 'name', y: 'value' }, barMaxWidth: 100, emphasis: { focus: 'series' }, }) expect(out.series[1]).toMatchObject({ type: 'bar', datasetIndex: 1, encode: { x: 'name', y: 'value' }, barMaxWidth: 100, emphasis: { focus: 'series' }, }) }) it('uses series[i].name for legend names when provided', () => { const merge = createBarOptionFactory({ theme, series: [{ name: '2024' }, { name: '2025' }], }) const out = merge({}, [ [{ name: 'A', value: 1 }], [{ name: 'A', value: 2 }], ]) as { series: { name: string }[] } expect(out.series[0]?.name).toBe('2024') expect(out.series[1]?.name).toBe('2025') }) it('applies series[i].color as a per-series itemStyle override', () => { const merge = createBarOptionFactory({ theme, series: [ { name: '2024', color: '#ff0000' }, { name: '2025' }, // no color → falls back to palette ], }) const out = merge({}, [ [{ name: 'A', value: 1 }], [{ name: 'A', value: 2 }], ]) as { series: { color?: string }[] } expect(out.series[0]?.color).toBe('#ff0000') // Second series has no explicit color → echart palette picks one. expect(out.series[1]?.color).toBeUndefined() }) it('toggles legend visibility based on series count, preserving styling', () => { const merge = createBarOptionFactory({ theme }) const baseLegend = { icon: 'circle', type: 'scroll' } const single = merge({ legend: baseLegend }, [ [{ name: 'A', value: 1 }], ]) as { legend: { show: boolean; icon?: string } } const multi = merge({ legend: baseLegend }, [ [{ name: 'A', value: 1 }], [{ name: 'A', value: 2 }], ]) as { legend: { show: boolean; icon?: string } } expect(single.legend.show).toBe(false) expect(single.legend.icon).toBe('circle') expect(multi.legend.show).toBe(true) expect(multi.legend.icon).toBe('circle') }) it('reserves extra grid bottom space when a legend is shown', () => { const merge = createBarOptionFactory({ theme }) const baseGrid = { bottom: 24 } const single = merge({ grid: baseGrid }, [[{ name: 'A', value: 1 }]]) as { grid: { bottom: number } } const multi = merge({ grid: baseGrid }, [ [{ name: 'A', value: 1 }], [{ name: 'A', value: 2 }], ]) as { grid: { bottom: number } } expect(single.grid.bottom).toBe(24) expect(multi.grid.bottom).toBe(56) }) it('returns empty dataset/series for empty data', () => { const merge = createBarOptionFactory({ theme }) const out = merge({ xAxis: { type: 'category' } }, []) as { dataset: unknown[] series: unknown[] } expect(out.dataset).toEqual([]) expect(out.series).toEqual([]) }) it('merges incoming series template into every series (Stack survives)', () => { // Simulates `addStack` having run as a config transform: the option // arrives with a single broadcast template carrying { stack: 'total' }. const merge = createBarOptionFactory({ theme }) const out = merge({ series: [{ stack: 'total' }] }, [ [{ name: 'A', value: 1 }], [{ name: 'A', value: 2 }], ]) as { series: { stack?: string; type: string; datasetIndex: number }[] } expect(out.series).toHaveLength(2) expect(out.series[0]?.stack).toBe('total') expect(out.series[1]?.stack).toBe('total') // Authoritative properties still win over the template. expect(out.series[0]?.type).toBe('bar') expect(out.series[0]?.datasetIndex).toBe(0) expect(out.series[1]?.datasetIndex).toBe(1) }) it('uses ctx.formatter for the y-axis min/max label (RelativeData support)', () => { const merge = createBarOptionFactory({ theme }) const fmt = (n: number) => `${n}%` const out = merge( { yAxis: { type: 'value' } }, [ [ { name: 'A', value: 0 }, { name: 'B', value: 100 }, ], ], { formatter: fmt }, ) as { yAxis: { min: (extent: { min: number }) => number max: (extent: { min: number; max: number }) => number axisLabel: { formatter: (v: number) => string } } } // Bounds are callbacks now: ECharts supplies the (post-stack) extent and // we round it with niceNum. Invoke them as ECharts would before reading // the label formatter (which keys off the closure-cached extents). expect(typeof out.yAxis.min).toBe('function') expect(typeof out.yAxis.max).toBe('function') expect(out.yAxis.min({ min: 0 })).toBe(0) expect(out.yAxis.max({ min: 0, max: 100 })).toBe(100) expect(out.yAxis.axisLabel.formatter(100)).toBe('100%') // Intermediate values are still suppressed. expect(out.yAxis.axisLabel.formatter(50)).toBe('') expect(out.yAxis.axisLabel.formatter(0)).toBe('') }) it('rounds the ECharts-supplied (stacked) extent so stacked bars cannot overflow', () => { // Regression: previously the merge phase precomputed the y-axis max from // the largest *single* series value, so stacking (StackToggle → // { stack: 'total' }) made the summed bars overflow the axis. The bound // is now a callback: ECharts computes the extent AFTER stacking and we // round that. Two series of 600 stack to 1200; ECharts hands us that // extent and niceNum(1200) → 2000 contains it (vs. the old 600). const merge = createBarOptionFactory({ theme }) const out = merge({ series: [{ stack: 'total' }] }, [ [{ name: 'A', value: 600 }], [{ name: 'A', value: 600 }], ]) as { yAxis: { min: (extent: { min: number }) => number max: (extent: { min: number; max: number }) => number } } expect(typeof out.yAxis.max).toBe('function') expect(out.yAxis.max({ min: 0, max: 1200 })).toBe(2000) expect(out.yAxis.min({ min: 0 })).toBe(0) }) it('reserves grid bottom space for the zoom slider when dataZoom is present', () => { const merge = createBarOptionFactory({ theme }) // Single series, no legend. const single = merge( { grid: { bottom: 24 }, dataZoom: [{ type: 'inside' }, { type: 'slider', bottom: 0 }], }, [[{ name: 'A', value: 1 }]], ) as { grid: { bottom: number } } // 24 (base) + 32 (slider height) + 8 (gap) = 64. expect(single.grid.bottom).toBe(64) }) it('lifts the zoom slider above the legend when both are present', () => { const merge = createBarOptionFactory({ theme }) const out = merge( { dataZoom: [{ type: 'inside' }, { type: 'slider', bottom: 0 }], }, [[{ name: 'A', value: 1 }], [{ name: 'A', value: 2 }]], ) as { dataZoom: { type: string; bottom?: number }[] grid: { bottom: number } } const slider = out.dataZoom.find((d) => d.type === 'slider') expect(slider?.bottom).toBe(28) // Legend space (56) + slider height (32) + gap (8) = 96. expect(out.grid.bottom).toBe(96) }) describe('itemStyle.color (dim non-selected)', () => { it('passes through the params color when no selection is set', () => { const merge = createBarOptionFactory({ theme }) const out = merge({}, [[{ name: 'A', value: 1 }]]) as { series: { itemStyle: { color: (p: unknown) => string } }[] } const colorFn = out.series[0]!.itemStyle.color expect(colorFn({ value: { name: 'A' }, color: '#abc', name: 'A' })).toBe( '#abc', ) }) it('keeps the base colour for bars matching the selection', () => { const merge = createBarOptionFactory({ theme, selection: ['A'] }) const out = merge({}, [[{ name: 'A', value: 1 }]]) as { series: { itemStyle: { color: (p: unknown) => string } }[] } const colorFn = out.series[0]!.itemStyle.color expect(colorFn({ value: { name: 'A' }, color: '#abc', name: 'A' })).toBe( '#abc', ) }) it('dims bars outside the selection via 15% alpha', () => { const merge = createBarOptionFactory({ theme, selection: ['B'] }) const out = merge({}, [[{ name: 'A', value: 1 }]]) as { series: { itemStyle: { color: (p: unknown) => string } }[] } const colorFn = out.series[0]!.itemStyle.color const dimmed = colorFn({ value: { name: 'A' }, color: '#FF0000', name: 'A', }) expect(dimmed).not.toBe('#FF0000') }) it('falls back to params.name when datum has no name', () => { const merge = createBarOptionFactory({ theme, selection: ['A'] }) const out = merge({}, [[{ name: 'A', value: 1 }]]) as { series: { itemStyle: { color: (p: unknown) => string } }[] } const colorFn = out.series[0]!.itemStyle.color expect(colorFn({ value: undefined, color: '#abc', name: 'A' })).toBe( '#abc', ) }) }) it('uses ctx formatters for the tooltip (RelativeData support)', () => { const merge = createBarOptionFactory({ theme }) const fmt = (n: number) => `${n}%` const lf = (v: string | number) => `[ITEM:${v}]` const out = merge({}, [[{ name: 'A', value: 50 }]], { formatter: fmt, labelFormatter: lf, }) as { tooltip: { formatter: (params: unknown) => string } } const html = out.tooltip.formatter([ { value: { name: 'A', value: 50 }, name: 'A', seriesName: '', marker: '', }, ]) // The percent formatter and label formatter both ran. expect(html).toContain('50%') expect(html).toContain('[ITEM:A]') }) })