import { describe, it, expect } from 'vitest' import { createTheme, type Theme } from '@mui/material' import { createScatterplotOptionFactory, scatterplotOptions } from './options' const theme = createTheme() as unknown as Theme describe('scatterplotOptions (structural)', () => { it('uses value axes on both x and y (not category)', () => { const out = scatterplotOptions({ theme }) expect((out.xAxis as { type: string }).type).toBe('value') expect((out.yAxis as { type: string }).type).toBe('value') }) it('returns no series and no dataset (data-agnostic structural option)', () => { const out = scatterplotOptions({ theme }) expect(out.series).toBeUndefined() expect(out.dataset).toBeUndefined() }) it("uses tooltip trigger 'item' (per-point), not 'axis'", () => { const out = scatterplotOptions({ theme }) expect((out.tooltip as { trigger: string }).trigger).toBe('item') }) it('wires a dark themed tooltip (grey[900] background, function position + formatter)', () => { const out = scatterplotOptions({ 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('passes xFormatter to x-axis label and yFormatter to y-axis label', () => { const xFmt = (n: number): string => `x:${n}` const yFmt = (n: number): string => `y:${n}` const out = scatterplotOptions({ theme, xFormatter: xFmt, yFormatter: yFmt, }) const xAxis = out.xAxis as { axisLabel?: { formatter?: unknown } } const yAxis = out.yAxis as { axisLabel?: { formatter?: unknown } } expect(xAxis.axisLabel?.formatter).toBe(xFmt) expect(yAxis.axisLabel?.formatter).toBe(yFmt) }) it('polishes both axes the same way (no axisLine, no axisTick, splitLine color set)', () => { const out = scatterplotOptions({ theme }) const xAxis = out.xAxis as { axisLine?: { show?: boolean } axisTick?: { show?: boolean } splitLine?: { lineStyle?: { color?: string } } } const yAxis = out.yAxis as { axisLine?: { show?: boolean } axisTick?: { show?: boolean } splitLine?: { lineStyle?: { color?: string } } } expect(xAxis.axisLine?.show).toBe(false) expect(xAxis.axisTick?.show).toBe(false) expect(xAxis.splitLine?.lineStyle?.color).toBeDefined() expect(yAxis.axisLine?.show).toBe(false) expect(yAxis.axisTick?.show).toBe(false) expect(yAxis.splitLine?.lineStyle?.color).toBeDefined() }) it('wires a structural legend (show:false), toggled on by the merger for multi-series', () => { const out = scatterplotOptions({ theme }) const legend = out.legend as { show?: boolean } | undefined expect(legend).toBeDefined() expect(legend?.show).toBe(false) }) it('uses the qualitative.bold + secondary palette like bar/histogram/pie', () => { const out = scatterplotOptions({ theme }) const colors = out.color as readonly string[] expect(Array.isArray(colors)).toBe(true) expect(colors[0]).toBe(theme.palette.secondary.main) }) it('sets axisPointer.lineStyle.color from grey[400]', () => { const out = scatterplotOptions({ theme }) const ap = out.axisPointer as { lineStyle?: { color?: string } } | undefined expect(ap?.lineStyle?.color).toBe(theme.palette.grey[400]) }) }) describe('createScatterplotOptionFactory', () => { it('builds one dataset per series with the data array as source', () => { const merge = createScatterplotOptionFactory({ theme }) const data = [ [ [1, 2], [3, 4], ], ] as const const out = merge({}, data) as { dataset: { source: unknown[] }[] } expect(out.dataset).toHaveLength(1) expect(out.dataset[0]?.source).toBe(data[0]) }) it('uses positional encoding (x: 0, y: 1) and type=scatter, datasetIndex per series', () => { const merge = createScatterplotOptionFactory({ theme }) const out = merge({}, [[[1, 2]], [[3, 4]]]) as { series: { type: string datasetIndex: number encode: { x: number; y: number } symbolSize: number }[] } expect(out.series[0]).toMatchObject({ type: 'scatter', datasetIndex: 0, encode: { x: 0, y: 1 }, }) expect(out.series[1]).toMatchObject({ type: 'scatter', datasetIndex: 1, encode: { x: 0, y: 1 }, }) }) it('uses default symbolSize=8', () => { const merge = createScatterplotOptionFactory({ theme }) const out = merge({}, [[[1, 2]]]) as { series: { symbolSize: number }[] } expect(out.series[0]?.symbolSize).toBe(8) }) it('respects a custom symbolSize', () => { const merge = createScatterplotOptionFactory({ theme, symbolSize: 14 }) const out = merge({}, [[[1, 2]]]) as { series: { symbolSize: number }[] } expect(out.series[0]?.symbolSize).toBe(14) }) it('toggles legend.show off for single-series, on for multi-series', () => { const merge = createScatterplotOptionFactory({ theme }) const single = merge({}, [[[1, 2]]]) as { legend: { show: boolean } } const multi = merge({}, [[[1, 2]], [[3, 4]]]) as { legend: { show: boolean } } expect(single.legend.show).toBe(false) expect(multi.legend.show).toBe(true) }) it('uses series[i].name for series.name when provided', () => { const merge = createScatterplotOptionFactory({ theme, series: [{ name: 'Group A' }, { name: 'Group B' }], }) const out = merge({}, [[[1, 2]], [[3, 4]]]) as { series: { name: string }[] } expect(out.series[0]?.name).toBe('Group A') expect(out.series[1]?.name).toBe('Group B') }) it('applies series[i].color as a per-series colour override', () => { const merge = createScatterplotOptionFactory({ theme, series: [{ name: 'Group A', color: '#ff0000' }, { name: 'Group B' }], }) const out = merge({}, [[[1, 2]], [[3, 4]]]) as { series: { color?: string }[] } expect(out.series[0]?.color).toBe('#ff0000') expect(out.series[1]?.color).toBeUndefined() }) it('falls back to `Series N` naming when series is missing', () => { const merge = createScatterplotOptionFactory({ theme }) const out = merge({}, [[[1, 2]], [[3, 4]]]) as { series: { name: string }[] } expect(out.series[0]?.name).toBe('Series 1') expect(out.series[1]?.name).toBe('Series 2') }) it('emits emphasis.focus="series" on every series', () => { const merge = createScatterplotOptionFactory({ theme }) const out = merge({}, [[[1, 2]], [[3, 4]]]) as { series: { emphasis: { focus: string } }[] } expect(out.series[0]?.emphasis?.focus).toBe('series') expect(out.series[1]?.emphasis?.focus).toBe('series') }) it('frames the chart with niceNum-rounded x/y bounds (data: [[1,47],[2,3]] → max y=50)', () => { const merge = createScatterplotOptionFactory({ theme }) const out = merge({}, [ [ [1, 47], [2, 3], ], ]) as { xAxis: { min: number; max: number } yAxis: { min: number; max: number } } // niceNum(47) === 50, niceNum(2) === 2 (or rounded up to small nice // number — both x.min and y.min clamp to 0 since data is non-negative). expect(out.yAxis.max).toBe(50) expect(out.xAxis.max).toBeGreaterThanOrEqual(2) expect(out.xAxis.min).toBe(0) expect(out.yAxis.min).toBe(0) }) it('always emits itemStyle.color as a callback (passthrough when no selection)', () => { const merge = createScatterplotOptionFactory({ theme }) const out = merge({}, [[[1, 2]]]) as { series: { itemStyle: { color: unknown } }[] } expect(typeof out.series[0]?.itemStyle?.color).toBe('function') }) it('keeps itemStyle.color callable when a selection is active (dim path)', () => { const merge = createScatterplotOptionFactory({ theme, selection: ['0:0'] }) const out = merge({}, [ [ [1, 2], [3, 4], ], ]) as { series: { itemStyle: { color: unknown } }[] } expect(typeof out.series[0]?.itemStyle?.color).toBe('function') }) it('returns empty dataset/series for empty data', () => { const merge = createScatterplotOptionFactory({ theme }) const out = merge({}, []) as { dataset: unknown[]; series: unknown[] } expect(out.dataset).toEqual([]) expect(out.series).toEqual([]) }) it('applies ctx.formatter to the y-axis label (RelativeData path)', () => { const merge = createScatterplotOptionFactory({ theme }) const pct = (v: number) => `${v}%` const out = merge({}, [[[1, 25]]], { formatter: pct }) as { yAxis: { axisLabel: { formatter?: unknown } } } expect(out.yAxis.axisLabel.formatter).toBe(pct) }) it('rebuilds the tooltip formatter when ctx.formatter is provided', () => { const merge = createScatterplotOptionFactory({ theme }) const pct = (v: number) => `${v}%` const out = merge( { tooltip: { formatter: () => 'structural' } }, [[[1, 25]]], { formatter: pct }, ) as { tooltip: { formatter: unknown } } expect(typeof out.tooltip.formatter).toBe('function') // Different identity from the structural formatter — the merger // built a new closure that uses pct for y. expect(out.tooltip.formatter).not.toBe( ((arg: { tooltip: { formatter: unknown } }) => arg.tooltip.formatter)({ tooltip: { formatter: () => 'structural' }, }), ) }) it('keeps the structural tooltip formatter when ctx.formatter is absent', () => { const structural = () => 'structural' const merge = createScatterplotOptionFactory({ theme }) const out = merge({ tooltip: { formatter: structural } }, [[[1, 25]]]) as { tooltip: { formatter: unknown } } expect(out.tooltip.formatter).toBe(structural) }) describe('tooltip formatters', () => { type ItemFormatter = ( params: unknown, ticket?: unknown, callback?: unknown, ) => string const baseItem = { seriesName: '2024', marker: '', name: 'p1', value: [3, 7] as [number, number], } as const it('structural formatter renders (x, y) using the structural formatters', () => { const out = scatterplotOptions({ theme, xFormatter: (n) => `x${n}`, yFormatter: (n) => `y${n}`, }) const fmt = (out.tooltip as { formatter: ItemFormatter }).formatter const html = fmt(baseItem) expect(html).toContain('x3') expect(html).toContain('y7') }) it('structural formatter falls back to String() when no x/y formatter is supplied', () => { const out = scatterplotOptions({ theme }) const fmt = (out.tooltip as { formatter: ItemFormatter }).formatter const html = fmt(baseItem) expect(html).toContain('3') expect(html).toContain('7') }) it('reactive formatter applies ctx.formatter for y but keeps the structural xFormatter for x', () => { const merge = createScatterplotOptionFactory({ theme, xFormatter: (n) => `x${n}`, }) const out = merge({}, [[[3, 7]]], { formatter: (n) => `${n}%`, }) as { tooltip: { formatter: ItemFormatter } } const html = out.tooltip.formatter(baseItem) expect(html).toContain('x3') expect(html).toContain('7%') }) it('reactive formatter renders empty x/y when the value tuple is missing', () => { const merge = createScatterplotOptionFactory({ theme }) const out = merge({}, [[[3, 7]]], { formatter: (n) => `${n}!`, }) as { tooltip: { formatter: ItemFormatter } } const html = out.tooltip.formatter({ ...baseItem, value: undefined as unknown as [number, number], }) // No numeric values to format, but the name template still emits the parens. expect(html).toContain('(') }) }) it('reserves grid.bottom for a dataZoom slider (ZoomToggle layout)', () => { const merge = createScatterplotOptionFactory({ theme }) const baseline = merge({}, [[[1, 2]]]) as { grid: { bottom: number } } const withZoom = merge( { dataZoom: [{ type: 'inside' }, { type: 'slider' }] }, [[[1, 2]]], ) as { grid: { bottom: number }; dataZoom: unknown[] } expect(withZoom.dataZoom).toBeDefined() expect(withZoom.grid.bottom).toBeGreaterThan(baseline.grid.bottom) }) it('lifts the slider above the legend row when both are present', () => { const merge = createScatterplotOptionFactory({ theme }) const out = merge({ dataZoom: [{ type: 'inside' }, { type: 'slider' }] }, [ [[1, 2]], [[3, 4]], ]) as { dataZoom: ({ type: string; bottom?: number } | undefined)[] } const slider = out.dataZoom.find((dz) => dz?.type === 'slider') expect(slider?.bottom).toBeDefined() expect(typeof slider?.bottom).toBe('number') }) it('reserves grid.right for a y-axis slider (2D zoom layout)', () => { const merge = createScatterplotOptionFactory({ theme }) const baseline = merge({}, [[[1, 2]]]) as { grid: { right: number } } const withYZoom = merge( { dataZoom: [ { type: 'inside', yAxisIndex: [0] }, { type: 'slider', yAxisIndex: [0] }, ], }, [[[1, 2]]], ) as { grid: { right: number; bottom: number }; dataZoom: unknown[] } expect(withYZoom.grid.right).toBeGreaterThan(baseline.grid.right) }) it('reserves grid.bottom AND grid.right when both x- and y-sliders are present', () => { const merge = createScatterplotOptionFactory({ theme }) const out = merge( { dataZoom: [ { type: 'inside', xAxisIndex: [0] }, { type: 'inside', yAxisIndex: [0] }, { type: 'slider', xAxisIndex: [0] }, { type: 'slider', yAxisIndex: [0] }, ], }, [[[1, 2]]], ) as { grid: { right: number; bottom: number } } expect(out.grid.bottom).toBeGreaterThan(24) expect(out.grid.right).toBeGreaterThan(8) }) it('does not reserve grid space for inside-only dataZoom (no sliders)', () => { const merge = createScatterplotOptionFactory({ theme }) const baseline = merge({}, [[[1, 2]]]) as { grid: { right: number; bottom: number } } const insideOnly = merge( { dataZoom: [{ type: 'inside', xAxisIndex: [0] }] }, [[[1, 2]]], ) as { grid: { right: number; bottom: number } } expect(insideOnly.grid.bottom).toBe(baseline.grid.bottom) expect(insideOnly.grid.right).toBe(baseline.grid.right) }) })