import { describe, it, expect } from 'vitest' import { createTheme, type Theme } from '@mui/material' import { createHistogramOptionFactory, histogramOptions } from './options' const theme = createTheme() as unknown as Theme describe('histogramOptions (structural)', () => { it('returns a category x-axis without baked data', () => { const out = histogramOptions({ 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 = histogramOptions({ 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 = histogramOptions({ 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('emits a dark themed tooltip with a positioner and a formatter (matches bar)', () => { const out = histogramOptions({ theme }) const tooltip = out.tooltip as { backgroundColor?: string borderWidth?: number position?: unknown formatter?: unknown textStyle?: { color?: string } } expect(tooltip.backgroundColor).toBe(theme.palette.grey[900]) expect(tooltip.borderWidth).toBe(0) expect(typeof tooltip.position).toBe('function') expect(typeof tooltip.formatter).toBe('function') expect(tooltip.textStyle?.color).toBe(theme.palette.common.white) }) it('emits the CARTO color palette starting with secondary.main', () => { const out = histogramOptions({ theme }) as { color?: string[] } expect(Array.isArray(out.color)).toBe(true) expect(out.color?.[0]).toBe(theme.palette.secondary.main) }) it('emits a themed axisPointer line', () => { const out = histogramOptions({ theme }) as { axisPointer?: { lineStyle?: { color?: string } } } expect(out.axisPointer?.lineStyle?.color).toBe(theme.palette.grey[400]) }) it('renders y-axis labels inside the plot, anchored to the bottom (matches bar)', () => { const out = histogramOptions({ theme }) const yAxis = out.yAxis as { axisLabel?: { inside?: boolean; verticalAlign?: string } } expect(yAxis.axisLabel?.inside).toBe(true) expect(yAxis.axisLabel?.verticalAlign).toBe('bottom') }) it('y-axis min/max are callback closures (niceNum extents), label formatter shows only the extents', () => { const fmt = (n: number): string => `${n}k` const out = histogramOptions({ theme, formatter: fmt }) const yAxis = out.yAxis as { min?: unknown max?: unknown axisLabel?: { formatter?: (v: number) => string } } expect(typeof yAxis.min).toBe('function') expect(typeof yAxis.max).toBe('function') // niceMin / niceMax default to 0 and 1 until the min/max callbacks run. expect(yAxis.axisLabel?.formatter?.(1)).toBe('1k') // 0 is suppressed (matches v1/bar) and intermediate values are blanked. expect(yAxis.axisLabel?.formatter?.(0)).toBe('') expect(yAxis.axisLabel?.formatter?.(0.5)).toBe('') }) }) describe('createHistogramOptionFactory (data → dataset merger)', () => { it('builds bin labels from consecutive tick pairs', () => { const merge = createHistogramOptionFactory({ theme, ticks: [0, 10, 20, 30], }) const out = merge({}, [[5, 10, 3]]) as { dataset: { source: [string, number][] }[] } const labels = out.dataset[0]!.source.map(([label]) => label) expect(labels).toEqual(['0–10', '10–20', '20–30']) }) it('produces one dataset per series with [binLabel, count] tuple rows', () => { const merge = createHistogramOptionFactory({ theme, ticks: [0, 10, 20, 30], }) const out = merge({}, [ [1, 2, 3], [10, 20, 30], ]) as { dataset: { source: [string, number][] }[]; series: unknown[] } expect(out.dataset).toHaveLength(2) expect(out.dataset[0]!.source).toEqual([ ['0–10', 1], ['10–20', 2], ['20–30', 3], ]) expect(out.dataset[1]!.source).toEqual([ ['0–10', 10], ['10–20', 20], ['20–30', 30], ]) expect(out.series).toHaveLength(2) }) it('encodes columns positionally (x: 0, y: 1) and references each series dataset by index', () => { const merge = createHistogramOptionFactory({ theme, ticks: [0, 10] }) const out = merge({}, [[1], [2]]) as { series: { type: string datasetIndex: number encode: { x: number; y: number } }[] } expect(out.series[0]).toMatchObject({ type: 'bar', datasetIndex: 0, encode: { x: 0, y: 1 }, }) expect(out.series[1]).toMatchObject({ type: 'bar', datasetIndex: 1, encode: { x: 0, y: 1 }, }) }) it('pads short series with 0 to match bin count', () => { const merge = createHistogramOptionFactory({ theme, ticks: [0, 10, 20, 30], }) const out = merge({}, [[5]]) as { dataset: { source: [string, number][] }[] } expect(out.dataset[0]!.source.map(([, count]) => count)).toEqual([5, 0, 0]) }) it('attaches an itemStyle.color callback to each series, whether or not a selection is set', () => { const merge = createHistogramOptionFactory({ theme, ticks: [0, 10, 20, 30], selection: [1], }) const out = merge({}, [[1, 2, 3]]) as { series: { itemStyle?: { color?: unknown } }[] } // The merger ALWAYS emits an `itemStyle.color` callback — when no // selection is set, the callback is a passthrough returning the // palette color. Always emitting is required so the next setOption // overrides the previous callback via normal merge; conditionally // dropping the key would let ECharts keep a stale dim callback alive // (items would stay dimmed forever after an external clear). const noSel = createHistogramOptionFactory({ theme, ticks: [0, 10] })({}, [ [1], ]) as { series: { itemStyle?: { color?: unknown } }[] } expect(typeof noSel.series[0]?.itemStyle?.color).toBe('function') expect(typeof out.series[0]?.itemStyle?.color).toBe('function') }) it('toggles legend.show based on series count (matches bar)', () => { const merge = createHistogramOptionFactory({ theme, ticks: [0, 10, 20] }) 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('emits a tooltip.formatter function in the merger output', () => { const merge = createHistogramOptionFactory({ theme, ticks: [0, 10] }) const out = merge({}, [[1]]) as { tooltip?: { formatter?: unknown } } expect(typeof out.tooltip?.formatter).toBe('function') }) it('resolves y-axis min/max via niceNum callbacks after fusion', () => { const merge = createHistogramOptionFactory({ theme, ticks: [0, 10, 20] }) const out = merge({}, [[3, 47]]) as { yAxis: { min: (extent: { min: number }) => number max: (extent: { min: number; max: number }) => number } } // Bounds are callbacks: ECharts supplies the (post-stack) extent and we // round it with niceNum, so stacked bins can't overflow the axis. expect(typeof out.yAxis.min).toBe('function') expect(typeof out.yAxis.max).toBe('function') expect(out.yAxis.min({ min: 0 })).toBe(0) // 47 → niceNum → 50 (Math.ceil(47 / 10) * 10) expect(out.yAxis.max({ min: 0, max: 47 })).toBe(50) }) it('uses series[i].name for series[i].name when provided', () => { const merge = createHistogramOptionFactory({ theme, ticks: [0, 10, 20], series: [{ name: '2024' }, { name: '2025' }], }) const out = merge({}, [ [1, 2], [3, 4], ]) 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 = createHistogramOptionFactory({ theme, ticks: [0, 10, 20], series: [{ name: '2024', color: '#ff0000' }, { name: '2025' }], }) 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('applies labelFormatter to bin labels', () => { const merge = createHistogramOptionFactory({ theme, ticks: [0, 10, 20], labelFormatter: (s) => `[${s})`, }) const out = merge({}, [[1, 2]]) as { dataset: { source: [string, number][] }[] } expect(out.dataset[0]!.source.map(([label]) => label)).toEqual([ '[0–10)', '[10–20)', ]) }) it('returns empty dataset/series when no data', () => { const merge = createHistogramOptionFactory({ theme, ticks: [0, 10, 20] }) const out = merge({ xAxis: { type: 'category' } }, []) as { dataset: unknown[] series: unknown[] } expect(out.dataset).toEqual([]) expect(out.series).toEqual([]) }) describe('tooltip formatter', () => { type ItemFormatter = ( params: unknown, ticket?: unknown, callback?: unknown, ) => string const getFormatter = (ctx?: { formatter?: (n: number) => string }): ItemFormatter => { const factory = createHistogramOptionFactory({ theme, ticks: [0, 10, 20], }) const out = factory({}, [[1, 2]], ctx) as { tooltip: { formatter: ItemFormatter } } return out.tooltip.formatter } const baseItem = { seriesName: '2024', name: '[0–10)', marker: '', value: ['[0–10)', 5] as [string, number], } as const it('reads the count from the value tuple and renders it', () => { const html = getFormatter()([baseItem]) expect(html).toContain('5') expect(html).toContain('2024') }) it('applies the ctx formatter to numeric values', () => { const html = getFormatter({ formatter: (n) => `${n}x` })([baseItem]) expect(html).toContain('5x') }) it('renders without a series prefix when seriesName is empty', () => { const html = getFormatter()([{ ...baseItem, seriesName: '' }]) expect(html).not.toContain('2024') }) it('renders an empty value when the tuple is missing', () => { const html = getFormatter()([ { ...baseItem, value: undefined as unknown as [string, number] }, ]) // Name still renders even when the value is empty. expect(html).toContain('[0–10)') }) }) })