import { describe, it, expect } from 'vitest' import { createTheme, type Theme } from '@mui/material' import { createTimeseriesOptionFactory, timeseriesOptions } from './options' const theme = createTheme() as unknown as Theme describe('timeseriesOptions (structural)', () => { it('uses a time x-axis (not category)', () => { const out = timeseriesOptions({ theme }) const xAxis = out.xAxis as { type: string; data?: unknown } expect(xAxis.type).toBe('time') expect(xAxis.data).toBeUndefined() }) it('returns no series and no dataset', () => { const out = timeseriesOptions({ 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 = timeseriesOptions({ 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('uses the dark themed tooltip (grey[900] + positioner)', () => { const out = timeseriesOptions({ theme }) const tooltip = out.tooltip as { backgroundColor?: string borderWidth?: number textStyle?: { color?: string } position?: unknown formatter?: unknown } expect(tooltip.backgroundColor).toBe(theme.palette.grey[900]) expect(tooltip.borderWidth).toBe(0) expect(tooltip.textStyle?.color).toBe(theme.palette.common.white) expect(typeof tooltip.position).toBe('function') expect(typeof tooltip.formatter).toBe('function') }) it('renders a structural legend (show is toggled by the merger)', () => { const out = timeseriesOptions({ theme }) expect(out.legend).toBeDefined() expect(typeof out.legend).toBe('object') }) it('color palette starts with secondary.main', () => { const out = timeseriesOptions({ theme }) const colors = (out as { color?: string[] }).color expect(colors?.[0]).toBe(theme.palette.secondary.main) }) it('y-axis label formatter only renders min/max via the user formatter', () => { const fmt = (n: number): string => `$${n}` const out = timeseriesOptions({ 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 bar/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 = timeseriesOptions({ theme }) const yAxis = out.yAxis as { axisLabel?: { inside?: boolean; verticalAlign?: string } } expect(yAxis.axisLabel?.inside).toBe(true) expect(yAxis.axisLabel?.verticalAlign).toBe('bottom') }) it('wraps labelFormatter into a Date-aware x-axis formatter', () => { const labelFmt = (d: Date): string => d.toISOString().slice(0, 10) const out = timeseriesOptions({ theme, labelFormatter: labelFmt }) const xAxis = out.xAxis as { axisLabel?: { formatter?: (v: number) => string } } // ECharts time-axis passes a numeric timestamp; the wrapper converts to Date. const ts = Date.UTC(2025, 0, 15) expect(xAxis.axisLabel?.formatter?.(ts)).toBe('2025-01-15') }) }) describe('createTimeseriesOptionFactory', () => { it('builds one dataset per series with the data array as source', () => { const merge = createTimeseriesOptionFactory({ theme }) const data = [ [ { name: '2025-01-01', value: 1 }, { name: '2025-01-02', value: 2 }, ], ] const out = merge({}, data) as { dataset: { source: object[] }[] } expect(out.dataset).toHaveLength(1) expect(out.dataset[0]?.source).toBe(data[0]) }) it('encodes name → x, value → y, datasetIndex per series, type=line', () => { const merge = createTimeseriesOptionFactory({ theme }) const out = merge({}, [ [{ name: '2025-01-01', value: 1 }], [{ name: '2025-01-01', value: 2 }], ]) as { series: { type: string datasetIndex: number encode: { x: string; y: string } smooth: boolean showSymbol: boolean }[] } expect(out.series[0]).toMatchObject({ type: 'line', datasetIndex: 0, encode: { x: 'name', y: 'value' }, smooth: true, showSymbol: false, }) expect(out.series[1]).toMatchObject({ type: 'line', datasetIndex: 1, encode: { x: 'name', y: 'value' }, }) }) it('respects smooth=false when overridden', () => { const merge = createTimeseriesOptionFactory({ theme, smooth: false }) const out = merge({}, [[{ name: 'a', value: 1 }]]) as { series: { smooth: boolean }[] } expect(out.series[0]?.smooth).toBe(false) }) it('adds areaStyle when area=true', () => { const merge = createTimeseriesOptionFactory({ theme, area: true }) const out = merge({}, [[{ name: 'a', value: 1 }]]) as { series: { areaStyle?: object }[] } expect(out.series[0]?.areaStyle).toBeDefined() }) it('omits areaStyle by default', () => { const merge = createTimeseriesOptionFactory({ theme }) const out = merge({}, [[{ name: 'a', value: 1 }]]) as { series: { areaStyle?: object }[] } expect(out.series[0]?.areaStyle).toBeUndefined() }) it('toggles legend visibility based on series count, preserving styling', () => { const merge = createTimeseriesOptionFactory({ 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 = createTimeseriesOptionFactory({ 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('uses series[i].name for series.name when provided', () => { const merge = createTimeseriesOptionFactory({ 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 both root color and lineStyle.color', () => { const merge = createTimeseriesOptionFactory({ theme, series: [{ name: '2024', color: '#ff0000' }, { name: '2025' }], }) const out = merge({}, [ [{ name: 'a', value: 1 }], [{ name: 'a', value: 2 }], ]) as { series: { color?: string; lineStyle?: { color?: string } }[] } expect(out.series[0]?.color).toBe('#ff0000') expect(out.series[0]?.lineStyle?.color).toBe('#ff0000') expect(out.series[1]?.color).toBeUndefined() expect(out.series[1]?.lineStyle?.color).toBeUndefined() }) it('returns empty dataset/series for empty data', () => { const merge = createTimeseriesOptionFactory({ theme }) const out = merge({}, []) as { dataset: unknown[]; series: unknown[] } expect(out.dataset).toEqual([]) expect(out.series).toEqual([]) }) it('merges incoming series template into every series (Stack survives)', () => { const merge = createTimeseriesOptionFactory({ 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('line') 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 = createTimeseriesOptionFactory({ 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%') expect(out.yAxis.axisLabel.formatter(50)).toBe('') expect(out.yAxis.axisLabel.formatter(0)).toBe('') }) it('uses ctx.formatter for the tooltip value and the merger labelFormatter for the name (RelativeData support)', () => { const lf = (d: Date) => `[${d.toISOString().slice(0, 10)}]` const merge = createTimeseriesOptionFactory({ theme, labelFormatter: lf }) const fmt = (n: number) => `${n}%` const ts = Date.UTC(2025, 0, 15) const out = merge({}, [[{ name: new Date(ts), value: 50 }]], { formatter: fmt, }) as { tooltip: { formatter: (params: unknown) => string } } const html = out.tooltip.formatter([ { value: { name: new Date(ts), value: 50 }, name: ts, seriesName: '', marker: '', }, ]) expect(html).toContain('50%') expect(html).toContain('[2025-01-15]') }) it('reserves grid bottom space for the zoom slider when dataZoom is present', () => { const merge = createTimeseriesOptionFactory({ theme }) 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) }) describe('itemStyle.color (dim non-selected)', () => { it('passes through the params color when no selection is set', () => { const merge = createTimeseriesOptionFactory({ 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', seriesIndex: 0, dataIndex: 0, name: 'a', }), ).toBe('#abc') }) it('keeps the base colour for points that match the selection', () => { const ts = Date.UTC(2024, 0, 1) const merge = createTimeseriesOptionFactory({ theme, selection: [ts], }) const out = merge({}, [[{ name: new Date(ts), value: 1 }]]) as { series: { itemStyle: { color: (p: unknown) => string } }[] } const colorFn = out.series[0]!.itemStyle.color expect( colorFn({ value: { name: new Date(ts) }, color: '#abc', }), ).toBe('#abc') }) it('dims points outside the selection via 15% alpha', () => { const merge = createTimeseriesOptionFactory({ theme, selection: ['locked'], }) 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', }) expect(dimmed).not.toBe('#FF0000') }) it('passes through the base colour when the row has no name', () => { const merge = createTimeseriesOptionFactory({ theme, selection: ['x'], }) 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: undefined }, color: '#abc', }), ).toBe('#abc') }) }) it('lifts the zoom slider above the legend when both are present', () => { const merge = createTimeseriesOptionFactory({ 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) }) })