import { describe, it, expect } from 'vitest' import { createTheme, type Theme } from '@mui/material' import { createPieOptionFactory, pieOptions } from './options' const theme = createTheme() as unknown as Theme describe('pieOptions (structural)', () => { it('returns no dataset and no axes (pies are radial)', () => { const out = pieOptions({ theme }) expect(out.dataset).toBeUndefined() expect(out.xAxis).toBeUndefined() expect(out.yAxis).toBeUndefined() }) it('emits a pie series template with v1 defaults (template only — no data yet)', () => { const out = pieOptions({ theme }) as { series: { type: string colorBy: string avoidLabelOverlap: boolean selectedOffset: number emphasis: { disabled: boolean } itemStyle: { borderColor: string; borderWidth: number } label: { show: boolean; position: string; rich: Record } }[] } const template = out.series[0]! expect(template.type).toBe('pie') expect(template.colorBy).toBe('data') expect(template.avoidLabelOverlap).toBe(true) expect(template.selectedOffset).toBe(0) expect(template.emphasis.disabled).toBe(true) expect(template.itemStyle.borderColor).toBe(theme.palette.background.paper) expect(template.itemStyle.borderWidth).toBe(1) expect(template.label.show).toBe(true) expect(template.label.position).toBe('center') // Rich tags `{c|…}` (value) and `{b|…}` (name) match v1. expect(template.label.rich.c).toBeDefined() expect(template.label.rich.b).toBeDefined() }) it('configures the tooltip with item-trigger, a positioner, and a formatter function', () => { const out = pieOptions({ theme }) const tooltip = out.tooltip as { trigger: string position?: unknown formatter?: unknown } expect(tooltip.trigger).toBe('item') expect(typeof tooltip.position).toBe('function') expect(typeof tooltip.formatter).toBe('function') }) it('matches bar/histogram tooltip styling (dark bg, white caption text, no border, padded)', () => { const out = pieOptions({ theme }) const tooltip = out.tooltip as { backgroundColor?: string borderWidth?: number padding?: unknown textStyle?: { color?: string; fontSize?: number; fontFamily?: string } } expect(tooltip.backgroundColor).toBe(theme.palette.grey[900]) expect(tooltip.borderWidth).toBe(0) expect(Array.isArray(tooltip.padding)).toBe(true) expect(tooltip.textStyle?.color).toBe(theme.palette.common.white) expect(tooltip.textStyle?.fontSize).toBe(11) expect(tooltip.textStyle?.fontFamily).toBe( theme.typography.caption.fontFamily, ) }) it('emits the v1 qualitative.bold color palette (no secondary prefix)', () => { // The Theme created by createTheme() here doesn't include a // `qualitative.bold`, so we fall through to `Object.values({})` = []. // Assert the shape — `color` is always an array (possibly empty). const out = pieOptions({ theme }) as { color?: unknown } expect(Array.isArray(out.color)).toBe(true) }) it('uses the legend builder so legend.show stays a boolean we can toggle', () => { const out = pieOptions({ theme }) as { legend?: { show?: boolean } } // `buildLegendConfig({ hasLegend: true })` sets show=true; the merger // re-emits it as { ...baseLegend, show: true } at fusion time. expect(out.legend?.show).toBe(true) }) }) describe('createPieOptionFactory — single-series (donut)', () => { it('builds one dataset, source identity preserved', () => { const merge = createPieOptionFactory({ theme }) const data = [ [ { name: 'A', value: 10 }, { name: 'B', value: 20 }, ], ] const out = merge({}, data) as { dataset: { source: object[] }[] } expect(out.dataset).toHaveLength(1) expect(out.dataset[0]?.source).toBe(data[0]) }) it('emits a single pie series with positional encoding by name / value', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, [[{ name: 'A', value: 10 }]]) as { series: { type: string datasetIndex: number encode: { itemName: string; value: string } }[] } expect(out.series[0]).toMatchObject({ type: 'pie', datasetIndex: 0, encode: { itemName: 'name', value: 'value' }, }) }) it('uses the v2 default donut radius `["58%", "74%"]` (shrunk vs. v1 to free vertical room for a wrappable bottom legend)', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, [[{ name: 'A', value: 1 }]]) as { series: { radius: string[] }[] } expect(out.series[0]?.radius).toEqual(['58%', '74%']) }) it('respects a custom radius', () => { const merge = createPieOptionFactory({ theme, radius: ['0%', '70%'] }) const out = merge({}, [[{ name: 'A', value: 1 }]]) as { series: { radius: string[] }[] } expect(out.series[0]?.radius).toEqual(['0%', '70%']) }) it('centers the donut at 50% horizontally', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, [[{ name: 'A', value: 1 }]]) as { series: { center: [string, string] }[] } expect(out.series[0]?.center[0]).toBe('50%') }) it('keeps legend.show = true (mirrors v1)', () => { const merge = createPieOptionFactory({ theme }) const single = merge({}, [[{ name: 'A', value: 1 }]]) as { legend?: { show?: boolean } } expect(single.legend?.show).toBe(true) }) it('uses series[0].name for the single series name when provided', () => { const merge = createPieOptionFactory({ theme, series: [{ name: '2024' }], }) const out = merge({}, [[{ name: 'A', value: 1 }]]) as { series: { name: string }[] } expect(out.series[0]?.name).toBe('2024') }) it('returns empty dataset/series for empty data', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, []) as { dataset: unknown[]; series: unknown[] } expect(out.dataset).toEqual([]) expect(out.series).toEqual([]) }) it('spreads the structural pie series template into the per-data series', () => { const merge = createPieOptionFactory({ theme }) const baseTemplate = { type: 'pie' as const, colorBy: 'data', emphasis: { disabled: true }, itemStyle: { borderColor: '#FFF', borderWidth: 1 }, label: { show: true, position: 'center' }, } const out = merge( // ECharts' `SeriesOption` is a tagged-union over every series type // and our local object literal trips its narrowing — the merger // treats `option.series` as `unknown[]` internally anyway. { series: [baseTemplate] as unknown as never }, [[{ name: 'A', value: 1 }]], ) as { series: { colorBy?: string emphasis?: { disabled?: boolean } itemStyle?: { borderColor?: string; borderWidth?: number } label?: { show?: boolean; position?: string } }[] } expect(out.series[0]?.colorBy).toBe('data') expect(out.series[0]?.emphasis?.disabled).toBe(true) expect(out.series[0]?.itemStyle?.borderColor).toBe('#FFF') expect(out.series[0]?.itemStyle?.borderWidth).toBe(1) expect(out.series[0]?.label?.position).toBe('center') expect(out.series[0]?.label?.show).toBe(true) }) it('emits a center-label formatter function (resolves rich text per slice)', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, [[{ name: 'A', value: 10 }]]) as { series: { label: { formatter: unknown } }[] } expect(typeof out.series[0]?.label.formatter).toBe('function') }) it('always emits an itemStyle.color callback, whether or not a selection is set', () => { const noSel = createPieOptionFactory({ theme })({}, [ [{ name: 'A', value: 1 }], ]) as { series: { itemStyle: { color?: unknown } }[] } const withSel = createPieOptionFactory({ theme, selection: ['A'] })({}, [ [{ name: 'A', value: 1 }], ]) as { series: { itemStyle: { color?: unknown } }[] } expect(typeof noSel.series[0]?.itemStyle.color).toBe('function') expect(typeof withSel.series[0]?.itemStyle.color).toBe('function') }) it('itemStyle.color resolves per-data palette entries (preserves colorBy:data even with callback)', () => { // Installing an `itemStyle.color` callback disables ECharts' automatic // per-data palette cycling — `params.color` collapses to the series // color. The callback re-implements palette resolution against // `option.color[params.dataIndex % palette.length]` so each slice // still gets its own swatch. const merge = createPieOptionFactory({ theme }) const out = merge({ color: ['#AAA', '#BBB', '#CCC'] }, [ [ { name: 'A', value: 1 }, { name: 'B', value: 2 }, { name: 'C', value: 3 }, ], ]) as { series: { itemStyle: { color: (p: { dataIndex: number color: string value: object name: string }) => string } }[] } const colorFn = out.series[0]!.itemStyle.color // The series-level fallback color (used when palette is missing) is // distinct from any palette entry so we can detect it. const seriesColor = '#DEAD00' expect( colorFn({ dataIndex: 0, color: seriesColor, value: { name: 'A', value: 1 }, name: 'A', }), ).toBe('#AAA') expect( colorFn({ dataIndex: 1, color: seriesColor, value: { name: 'B', value: 2 }, name: 'B', }), ).toBe('#BBB') expect( colorFn({ dataIndex: 2, color: seriesColor, value: { name: 'C', value: 3 }, name: 'C', }), ).toBe('#CCC') // Cycles when dataIndex >= palette.length. expect( colorFn({ dataIndex: 3, color: seriesColor, value: { name: 'D', value: 4 }, name: 'D', }), ).toBe('#AAA') }) it('itemStyle.color falls back to params.color when no palette is on the option', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, [[{ name: 'A', value: 1 }]]) as { series: { itemStyle: { color: (p: { dataIndex: number color: string value: object name: string }) => string } }[] } expect( out.series[0]!.itemStyle.color({ dataIndex: 0, color: '#FACADE', value: { name: 'A', value: 1 }, name: 'A', }), ).toBe('#FACADE') }) it('re-emits tooltip.formatter at fusion time so RelativeData (ctx.formatter) flows through', () => { const merge = createPieOptionFactory({ theme }) const out = merge( { tooltip: { trigger: 'item' } }, [[{ name: 'A', value: 1 }]], { formatter: (n: number) => `${n}%` }, ) as { tooltip?: { formatter?: unknown; trigger?: string } } expect(out.tooltip?.trigger).toBe('item') expect(typeof out.tooltip?.formatter).toBe('function') }) describe('donut tooltip formatter', () => { type ItemFormatter = ( params: unknown, ticket?: unknown, callback?: unknown, ) => string const SINGLE = [ [ { name: 'A', value: 12 }, { name: 'B', value: 3 }, ], ] const getFormatter = (ctx?: { formatter?: (n: number) => string labelFormatter?: (v: string | number) => string | number }): ItemFormatter => { const factory = createPieOptionFactory({ theme }) const out = factory({}, SINGLE, ctx) as { tooltip: { formatter: ItemFormatter } } return out.tooltip.formatter } const baseItem = { seriesName: 'Sales', name: 'A', marker: '', encode: { value: [1] }, value: { name: 'A', value: 12 }, } as const it('renders name + formatted value for a slice', () => { const fmt = getFormatter({ formatter: (n) => `${n}!` }) const html = fmt(baseItem) expect(html).toContain('A') expect(html).toContain('12!') }) it('falls back to the default encode index (1) when encode is missing', () => { const fmt = getFormatter() const html = fmt({ ...baseItem, encode: undefined }) expect(html).toContain('12') }) it('passes the slice name through labelFormatter', () => { const fmt = getFormatter({ labelFormatter: (v) => `((${String(v)}))`, }) expect(fmt(baseItem)).toContain('((A))') }) it('renders an empty value when item.value is array-shaped (defensive guard)', () => { const fmt = getFormatter() const html = fmt({ ...baseItem, value: [1, 2, 3] as unknown as { name: string; value: number }, }) // No value emitted (defensive `[]` fallback), but the name still appears. expect(html).toContain('A') }) }) }) describe('createPieOptionFactory — multi-series fallback (horizontal bar)', () => { const MULTI = [ [ { name: 'A', value: 47 }, { name: 'B', value: 3 }, ], [ { name: 'A', value: 20 }, { name: 'B', value: 10 }, ], ] it('emits bar series for every input series (no pie when multi-series)', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, MULTI) as { series: { type: string; datasetIndex: number }[] } expect(out.series).toHaveLength(2) expect(out.series[0]?.type).toBe('bar') expect(out.series[1]?.type).toBe('bar') expect(out.series[0]?.datasetIndex).toBe(0) expect(out.series[1]?.datasetIndex).toBe(1) }) it('uses a value xAxis with callable niceNum closures (matches v1)', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, MULTI) as { xAxis: { type: string; min?: unknown; max?: unknown } } expect(out.xAxis.type).toBe('value') expect(typeof out.xAxis.min).toBe('function') expect(typeof out.xAxis.max).toBe('function') }) it('uses a category yAxis', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, MULTI) as { yAxis: { type: string } } expect(out.yAxis.type).toBe('category') }) it('resolves nice bounds on the value (x) axis — 47 → niceNum(47) === 50', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, MULTI) as { xAxis: { min: () => number; max: () => number } } // The closures honor the closed-over fused bounds, so calling them // returns the actual niceMin/niceMax we resolve once per fusion. expect(out.xAxis.min()).toBe(0) expect(out.xAxis.max()).toBe(50) }) it('grid carries the v1 right margin (theme.spacing(4))', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, MULTI) as { grid: { right: number } } expect(out.grid.right).toBe(parseInt(theme.spacing(4))) }) it('tooltip is re-emitted with trigger="axis" and a function formatter', () => { const merge = createPieOptionFactory({ theme }) const out = merge( { tooltip: { trigger: 'item', backgroundColor: '#000' } }, MULTI, ) as { tooltip: { trigger: string backgroundColor?: string formatter?: unknown } } expect(out.tooltip.trigger).toBe('axis') // Dark styling from baseTooltip survives the spread. expect(out.tooltip.backgroundColor).toBe('#000') expect(typeof out.tooltip.formatter).toBe('function') }) it('each series carries barMaxWidth=100 and emphasis.focus="series" (v1 parity)', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, MULTI) as { series: { barMaxWidth: number; emphasis: { focus: string } }[] } expect(out.series[0]?.barMaxWidth).toBe(100) expect(out.series[0]?.emphasis.focus).toBe('series') expect(out.series[1]?.barMaxWidth).toBe(100) expect(out.series[1]?.emphasis.focus).toBe('series') }) it('itemStyle.color resolves per-SERIES palette (multi-series bar, not per-data)', () => { const merge = createPieOptionFactory({ theme }) const out = merge({ color: ['#AAA', '#BBB', '#CCC'] }, MULTI) as { series: { itemStyle: { color: (p: { seriesIndex: number dataIndex: number color: string value: object name: string }) => string } }[] } const series0Color = out.series[0]!.itemStyle.color const series1Color = out.series[1]!.itemStyle.color // Every data point in series 0 → palette[0]; every data point in // series 1 → palette[1] (one swatch per series, not per slice). expect( series0Color({ seriesIndex: 0, dataIndex: 0, color: '#000', value: { name: 'A', value: 47 }, name: 'A', }), ).toBe('#AAA') expect( series0Color({ seriesIndex: 0, dataIndex: 1, color: '#000', value: { name: 'B', value: 3 }, name: 'B', }), ).toBe('#AAA') expect( series1Color({ seriesIndex: 1, dataIndex: 0, color: '#000', value: { name: 'A', value: 20 }, name: 'A', }), ).toBe('#BBB') }) it('uses series[i].name for series.name when provided', () => { const merge = createPieOptionFactory({ theme, series: [{ name: '2024' }, { name: '2025' }], }) const out = merge({}, MULTI) 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 colour in the multi-series bar fusion', () => { const merge = createPieOptionFactory({ theme, series: [ { name: '2024', color: '#ff0000' }, { name: '2025' }, // no color ], }) const out = merge({}, MULTI) as { series: { color?: string }[] } expect(out.series[0]?.color).toBe('#ff0000') expect(out.series[1]?.color).toBeUndefined() }) it('keeps legend.show = true (the bar layout still surfaces series in the legend)', () => { const merge = createPieOptionFactory({ theme }) const out = merge({}, MULTI) as { legend?: { show?: boolean } } expect(out.legend?.show).toBe(true) }) describe('horizontal-bar tooltip formatter', () => { type AxisFormatter = ( params: unknown, ticket?: unknown, callback?: unknown, ) => string const baseItem = { seriesName: '2024', marker: '', name: 'A', encode: { x: [1] }, dimensionNames: ['name', 'value'], value: { name: 'A', value: 47 }, } as const const getFormatter = (ctx?: { formatter?: (n: number) => string labelFormatter?: (v: string | number) => string | number }): AxisFormatter => { const factory = createPieOptionFactory({ theme }) const out = factory({}, MULTI, ctx) as { tooltip: { formatter: AxisFormatter } } return out.tooltip.formatter } it('emits the value via the dimension index pulled from encode.x', () => { const fmt = getFormatter() const html = fmt([baseItem]) expect(html).toContain('A') expect(html).toContain('47') }) it('applies the ctx formatter to numeric values', () => { const fmt = getFormatter({ formatter: (n) => `$${n}` }) expect(fmt([baseItem])).toContain('$47') }) it('applies labelFormatter to the name', () => { const fmt = getFormatter({ labelFormatter: (v) => `[${String(v).toUpperCase()}]`, }) expect(fmt([baseItem])).toContain('[A]') }) it('renders a blank value when encode is missing', () => { const fmt = getFormatter() const html = fmt([{ ...baseItem, encode: undefined }]) // The name still renders even when the value is absent. expect(html).toContain('A') }) it('renders a blank seriesName prefix when seriesName is empty', () => { const fmt = getFormatter() const html = fmt([{ ...baseItem, seriesName: '' }]) expect(html).not.toContain('2024') expect(html).toContain('A') }) }) })