import { describe, it, expect } from 'vitest' import type { CallbackDataParams } from 'echarts/types/dist/shared' import type { Theme } from '@mui/material' import { niceNum, buildSeriesLabelConfig, buildHistogramSeriesLabelConfig, buildLegendConfig, buildGridConfig, buildAxisLabelStyle, createTooltipPositioner, createAxisLabelFormatter, applyXAxisFormatter, applyYAxisFormatter, createTooltipFormatter, } from './option-builders' const fakeTheme = { spacing: (n: number) => String(n * 8), } as unknown as Theme describe('niceNum', () => { it('should return 0 for 0', () => { expect(niceNum(0)).toBe(0) }) it('should return the same negative value for negative numbers', () => { expect(niceNum(-5)).toBe(-5) }) it('should round 547 to 600', () => { expect(niceNum(547)).toBe(600) }) it('should keep 200 as 200', () => { expect(niceNum(200)).toBe(200) }) it('should round 1200 to 2000', () => { expect(niceNum(1200)).toBe(2000) }) it('should round 18 to 20', () => { expect(niceNum(18)).toBe(20) }) it('should keep 5 as 5', () => { expect(niceNum(5)).toBe(5) }) it('should round 350 to 400', () => { expect(niceNum(350)).toBe(400) }) it('should keep 1 as 1', () => { expect(niceNum(1)).toBe(1) }) }) describe('buildSeriesLabelConfig', () => { it('should return empty object when no formatter provided', () => { expect(buildSeriesLabelConfig()).toEqual({}) expect(buildSeriesLabelConfig(undefined)).toEqual({}) }) it('should return label config with formatter when formatter provided', () => { const formatter = (v: number) => `$${v}` const result = buildSeriesLabelConfig(formatter) expect(result).toHaveProperty('label') expect((result as { label: { formatter: unknown } }).label).toHaveProperty( 'formatter', ) }) it('should format value from dataset params using y encode key', () => { const formatter = (v: number) => `$${v}` const result = buildSeriesLabelConfig(formatter, 'y') const label = ( result as { label: { formatter: (p: Partial) => string } } ).label const params = { encode: { y: [1] }, dimensionNames: ['category', 'amount'], value: { category: 'A', amount: 42 }, } expect(label.formatter(params as unknown as CallbackDataParams)).toBe('$42') }) it('should format value from dataset params using x encode key', () => { const formatter = (v: number) => `${v}%` const result = buildSeriesLabelConfig(formatter, 'x') const label = ( result as { label: { formatter: (p: Partial) => string } } ).label const params = { encode: { x: [1] }, dimensionNames: ['name', 'value'], value: { name: 'Foo', value: 75 }, } expect(label.formatter(params as unknown as CallbackDataParams)).toBe('75%') }) it('should return empty string when encode index is undefined', () => { const formatter = (v: number) => `$${v}` const result = buildSeriesLabelConfig(formatter, 'y') const label = ( result as { label: { formatter: (p: Partial) => string } } ).label const params = { encode: {}, dimensionNames: ['category', 'amount'], value: { category: 'A', amount: 42 }, } expect(label.formatter(params as unknown as CallbackDataParams)).toBe('') }) }) describe('buildHistogramSeriesLabelConfig', () => { it('should return empty object when no formatter provided', () => { expect(buildHistogramSeriesLabelConfig()).toEqual({}) expect(buildHistogramSeriesLabelConfig(undefined)).toEqual({}) }) it('should format raw numeric value', () => { const formatter = (v: number) => `${v} units` const result = buildHistogramSeriesLabelConfig(formatter) const label = ( result as { label: { formatter: (p: Partial) => string } } ).label const params = { value: 100 } expect(label.formatter(params as unknown as CallbackDataParams)).toBe( '100 units', ) }) it('should stringify non-numeric values', () => { const formatter = (v: number) => `${v} units` const result = buildHistogramSeriesLabelConfig(formatter) const label = ( result as { label: { formatter: (p: Partial) => string } } ).label const params = { value: 'text' } expect(label.formatter(params as unknown as CallbackDataParams)).toBe( 'text', ) }) }) describe('buildLegendConfig', () => { it('returns config with show=true when hasLegend=true', () => { const cfg = buildLegendConfig({ hasLegend: true }) expect(cfg.show).toBe(true) expect(cfg.icon).toBe('circle') }) it('returns config with show=false when hasLegend=false', () => { const cfg = buildLegendConfig({ hasLegend: false }) expect(cfg.show).toBe(false) }) it('does not set formatter when no labelFormatter provided', () => { const cfg = buildLegendConfig({ hasLegend: true }) expect(cfg.formatter).toBeUndefined() }) it('sets a wrapper formatter when labelFormatter is provided', () => { const cfg = buildLegendConfig({ hasLegend: true, labelFormatter: (v) => `[${v}]`, }) expect(typeof cfg.formatter).toBe('function') const fmt = cfg.formatter as (name: string) => string expect(fmt('hello')).toBe('[hello]') }) }) describe('buildGridConfig', () => { it('uses smaller bottom spacing when no legend', () => { const cfg = buildGridConfig(false, fakeTheme) expect(cfg.bottom).toBe(24) // 3 * 8 }) it('uses larger bottom spacing when legend present', () => { const cfg = buildGridConfig(true, fakeTheme) expect(cfg.bottom).toBe(56) // 7 * 8 }) }) describe('buildAxisLabelStyle', () => { const themeWithTokens = { typography: { overlineDelicate: { fontSize: '0.625rem', fontFamily: 'Inter, sans-serif', }, }, palette: { black: { 60: 'rgba(44,48,50,0.6)' }, text: { secondary: 'rgba(0,0,0,0.6)' }, }, } as unknown as Theme it('returns the overlineDelicate font + black[60] colour', () => { expect(buildAxisLabelStyle(themeWithTokens)).toEqual({ fontSize: '0.625rem', fontFamily: 'Inter, sans-serif', color: 'rgba(44,48,50,0.6)', }) }) it('falls back to text.secondary when black[60] is absent', () => { const themeNoBlack = { typography: { overlineDelicate: { fontSize: '0.625rem', fontFamily: 'Inter' }, }, palette: { text: { secondary: 'rgba(0,0,0,0.6)' } }, } as unknown as Theme expect(buildAxisLabelStyle(themeNoBlack).color).toBe('rgba(0,0,0,0.6)') }) }) describe('createTooltipPositioner', () => { it('returns a positioner that places tooltip on the left when there is room', () => { const positioner = createTooltipPositioner(fakeTheme) const position = positioner([100, 50], null, null, null, { contentSize: [50, 30], viewSize: [400, 200], }) expect(position.left).toBe(100) expect(position.right).toBeUndefined() expect(position.top).toBeDefined() }) it('returns a positioner that places tooltip on the right when there is no room', () => { const positioner = createTooltipPositioner(fakeTheme) const position = positioner([380, 50], null, null, null, { contentSize: [100, 30], viewSize: [400, 200], }) expect(position.right).toBe(20) // 400 - 380 expect(position.left).toBeUndefined() }) }) describe('createAxisLabelFormatter', () => { it('returns undefined when no formatter passed', () => { expect(createAxisLabelFormatter(undefined)).toBeUndefined() }) it('returns a wrapper function that calls the formatter', () => { const fn = createAxisLabelFormatter((v) => `$${v}`) expect(fn?.(5)).toBe('$5') }) }) describe('applyXAxisFormatter', () => { it('attaches a stringifying formatter when both xAxis is object and formatter provided', () => { const result = applyXAxisFormatter({ type: 'value' }, (v) => Number(v) + 1) const r = result as { axisLabel: { formatter: (v: unknown) => string } } expect(r.axisLabel.formatter(2)).toBe('3') }) it('returns undefined formatter when no formatter provided', () => { const result = applyXAxisFormatter({ type: 'value' }, undefined) const r = result as { axisLabel: { formatter: unknown } } expect(r.axisLabel.formatter).toBeUndefined() }) it('preserves existing axisLabel object keys', () => { const result = applyXAxisFormatter( { type: 'value', axisLabel: { color: 'red' } }, undefined, ) const r = result as { axisLabel: { color: string; formatter: unknown } } expect(r.axisLabel.color).toBe('red') }) it('handles xAxis being an array (skips object-only path)', () => { const result = applyXAxisFormatter([{ type: 'value' }], (v) => v) // formatter should not be attached when xAxis is an array const r = result as { axisLabel: { formatter: unknown } } expect(r.axisLabel.formatter).toBeUndefined() }) }) describe('applyYAxisFormatter', () => { it('applies formatter when yAxis is an object with type=value', () => { const result = applyYAxisFormatter({ type: 'value' }, (v) => `$${v}`) const r = result as { axisLabel: { formatter: (v: number) => string } } expect(r.axisLabel.formatter(5)).toBe('$5') }) it('does not apply formatter when yAxis is not type=value', () => { const result = applyYAxisFormatter({ type: 'category' }, (v) => `$${v}`) const r = result as { axisLabel: { formatter: unknown } } expect(r.axisLabel.formatter).toBeUndefined() }) it('handles yAxis being an array (skips object-only path)', () => { const result = applyYAxisFormatter([{ type: 'value' }], (v) => `$${v}`) const r = result as { axisLabel: { formatter: unknown } } expect(r.axisLabel.formatter).toBeUndefined() }) it('handles missing axisLabel gracefully', () => { const result = applyYAxisFormatter({ type: 'value' }, undefined) const r = result as { axisLabel: object } expect(r.axisLabel).toBeDefined() }) }) describe('createTooltipFormatter', () => { it('returns html for a single-item (object) trigger', () => { const fmt = createTooltipFormatter((item) => ({ name: 'A', seriesName: 'series', marker: '●', value: (item.value as number) ?? 0, })) const params = { value: 42 } as unknown as CallbackDataParams const html = fmt(params) expect(html).toContain('series') expect(html).toContain('42') }) it('handles multi-item (array) trigger', () => { const fmt = createTooltipFormatter((item) => ({ name: String(item.name ?? ''), seriesName: String(item.seriesName ?? ''), marker: '●', value: (item.value as number) ?? 0, })) const params = [ { name: 'X', seriesName: 's1', value: 1 }, { name: 'X', seriesName: 's2', value: 2 }, ] as unknown as CallbackDataParams[] const html = fmt(params) expect(html).toContain('s1') expect(html).toContain('s2') }) it('omits the header div when name is empty and there is only one item', () => { const fmt = createTooltipFormatter(() => ({ name: '', seriesName: '', marker: '●', value: 1, })) const params = { value: 1 } as unknown as CallbackDataParams const html = fmt(params) // no name → no top label div should be present expect(html).not.toContain( 'color:#FFFFFF;font-weight:400;line-height:1; margin-bottom: 10px', ) }) })