import { describe, it, expect, vi } from 'vitest' import type { ECharts, EChartsOption } from 'echarts' import { CENTERED, clampEdgeLabels, decideEdgeAlignment, resolveEdgeLabels, } from './edge-label-clamp' const FONT = '12px sans-serif' describe('decideEdgeAlignment', () => { // 'California' at 12px is ~50px wide → half ~25px. Tick offsets are chosen // well below/above that so the verdict is robust to exact font metrics. it("anchors 'left' when the first label overflows the left edge", () => { const out = decideEdgeAlignment({ firstLabel: 'California', lastLabel: 'X', font: FONT, firstTickX: 2, lastTickX: 690, width: 700, }) expect(out.alignMinLabel).toBe('left') }) it("anchors 'right' when the last label overflows the right edge", () => { const out = decideEdgeAlignment({ firstLabel: 'X', lastLabel: 'Wyoming', font: FONT, firstTickX: 350, lastTickX: 698, width: 700, }) expect(out.alignMaxLabel).toBe('right') }) it('leaves both centered (null) when the edge labels fit', () => { const out = decideEdgeAlignment({ firstLabel: 'California', lastLabel: 'Wyoming', font: FONT, firstTickX: 120, lastTickX: 580, width: 700, }) expect(out).toEqual(CENTERED) }) }) describe('resolveEdgeLabels', () => { const barOption = (): EChartsOption => ({ xAxis: { type: 'category', axisLabel: {} }, series: [ { type: 'bar', encode: { x: 'name', y: 'value' }, datasetIndex: 0 }, ], dataset: [ { source: [ { name: 'California', value: 3 }, { name: 'Texas', value: 2 }, { name: 'Wyoming', value: 1 }, ], }, ], }) as unknown as EChartsOption it('reads first/last category from the bar dataset via encode.x = name', () => { const out = resolveEdgeLabels(barOption()) expect(out?.firstLabel).toBe('California') expect(out?.lastLabel).toBe('Wyoming') expect(out?.count).toBe(3) expect(out?.font).toBe('12px sans-serif') }) it('reads positional encode.x = 0 for the histogram tuple dataset', () => { const out = resolveEdgeLabels({ xAxis: { type: 'category', axisLabel: {} }, series: [{ type: 'bar', encode: { x: 0, y: 1 }, datasetIndex: 0 }], dataset: [ { source: [ ['0–10', 5], ['90–100', 2], ], }, ], } as unknown as EChartsOption) expect(out?.firstLabel).toBe('0–10') expect(out?.lastLabel).toBe('90–100') }) it('applies the axisLabel formatter to the category value', () => { const opt = barOption() as unknown as { xAxis: { axisLabel: { formatter: (v: string | number) => string } } } opt.xAxis.axisLabel.formatter = (v) => `[${v}]` const out = resolveEdgeLabels(opt as unknown as EChartsOption) expect(out?.firstLabel).toBe('[California]') }) it('builds the font from axisLabel fontSize/fontFamily', () => { const out = resolveEdgeLabels({ xAxis: { type: 'category', axisLabel: { fontSize: 10, fontFamily: 'Inter' }, }, series: [{ encode: { x: 'name' }, datasetIndex: 0 }], dataset: [{ source: [{ name: 'A' }, { name: 'B' }] }], } as unknown as EChartsOption) expect(out?.font).toBe('10px Inter') }) it('returns null for a non-category x-axis', () => { expect( resolveEdgeLabels({ xAxis: { type: 'value' }, series: [{ encode: { x: 'name' }, datasetIndex: 0 }], dataset: [{ source: [{ name: 'A' }, { name: 'B' }] }], } as unknown as EChartsOption), ).toBeNull() }) it('returns null with fewer than two categories', () => { expect(resolveEdgeLabels(barOption2(['Solo']))).toBeNull() }) }) function barOption2(names: string[]): EChartsOption { return { xAxis: { type: 'category', axisLabel: {} }, series: [{ encode: { x: 'name' }, datasetIndex: 0 }], dataset: [{ source: names.map((name) => ({ name })) }], } as unknown as EChartsOption } describe('clampEdgeLabels', () => { const fakeChart = (firstTickX: number, lastTickX: number, width: number) => { const setOption = vi.fn() const chart = { convertToPixel: (_finder: unknown, index: number) => index === 0 ? firstTickX : lastTickX, getWidth: () => width, setOption, } as unknown as ECharts return { chart, setOption } } const overflowOption = barOption2(['California', 'Texas', 'Wyoming']) it('applies alignment via setOption when an edge overflows', () => { const { chart, setOption } = fakeChart(2, 698, 700) const result = clampEdgeLabels(chart, overflowOption, CENTERED) expect(result.alignMinLabel).toBe('left') expect(setOption).toHaveBeenCalledTimes(1) const applied = setOption.mock.calls[0]![0] as { xAxis: { axisLabel: { alignMinLabel: string | null } } } expect(applied.xAxis.axisLabel.alignMinLabel).toBe('left') }) it('does not call setOption when the verdict matches the previous state', () => { const { chart, setOption } = fakeChart(2, 698, 700) const prev = { alignMinLabel: 'left' as const, alignMaxLabel: 'right' as const, } clampEdgeLabels(chart, overflowOption, prev) expect(setOption).not.toHaveBeenCalled() }) it('resets to centered under dataZoom (zoom guard) without measuring', () => { const { chart, setOption } = fakeChart(2, 698, 700) const zoomed = { ...(overflowOption as object), dataZoom: [{ type: 'slider' }], } as unknown as EChartsOption const prev = { alignMinLabel: 'left' as const, alignMaxLabel: null } const result = clampEdgeLabels(chart, zoomed, prev) expect(result).toEqual(CENTERED) // Cleared the stale 'left' from before zoom. expect(setOption).toHaveBeenCalledTimes(1) }) it('leaves a non-category axis centered and untouched', () => { const { chart, setOption } = fakeChart(2, 698, 700) const valueAxis = { xAxis: { type: 'value' }, series: [{ encode: { x: 'name' }, datasetIndex: 0 }], dataset: [{ source: [{ name: 'A' }, { name: 'B' }] }], } as unknown as EChartsOption const result = clampEdgeLabels(chart, valueAxis, CENTERED) expect(result).toEqual(CENTERED) expect(setOption).not.toHaveBeenCalled() }) })