import { describe, it, expect } from 'vitest' import { render, screen } from '@testing-library/react' import { formatNumber } from '@carto/ps-utils' import { LegendItemUI } from './legend-item-ui' import { LegendRampUI } from '../legend-ramp/legend-ramp-ui' import { LegendProportionUI } from '../legend-proportion/legend-proportion-ui' import type { LegendCategoryVariable, LegendIconVariable, LegendProportionVariable, LegendRampVariable, } from '../../stores' describe(' dispatch', () => { it('renders the category renderer', () => { const data: LegendCategoryVariable = { type: 'category', items: [{ label: 'A', color: '#000' }], } render() expect(screen.getByText('A')).toBeTruthy() }) it('renders the icon renderer', () => { const data: LegendIconVariable = { type: 'icon', items: [{ label: 'Store', icon: 'x.svg' }], } const { container } = render() expect(screen.getByText('Store')).toBeTruthy() expect(container.querySelector('img')).toBeTruthy() }) it('exposes the orientation on the list (default vertical, opt-in horizontal)', () => { const category: LegendCategoryVariable = { type: 'category', items: [ { label: 'A', color: '#111' }, { label: 'B', color: '#222' }, ], } const { container, rerender } = render() expect( container.querySelector('[data-orientation="vertical"]'), ).toBeTruthy() rerender() expect( container.querySelector('[data-orientation="horizontal"]'), ).toBeTruthy() // Both items still render in horizontal mode. expect(screen.getByText('A')).toBeTruthy() expect(screen.getByText('B')).toBeTruthy() // Icon legend honors the same flag. const icon: LegendIconVariable = { type: 'icon', orientation: 'horizontal', items: [{ label: 'Store', icon: 'x.svg' }], } rerender() expect( container.querySelector('[data-orientation="horizontal"]'), ).toBeTruthy() expect(screen.getByText('Store')).toBeTruthy() }) it('renders category and icon values verbatim — the consumer pre-formats', () => { // A plain pre-formatted string (e.g. a percent) renders as-is. const category: LegendCategoryVariable = { type: 'category', items: [{ label: 'A', color: '#000', value: '12%' }], } const { rerender } = render() expect(screen.getByText('12%')).toBeTruthy() // An arbitrary node is allowed too. rerender( 1.2K }], }} />, ) expect(screen.getByText('1.2K').tagName).toBe('STRONG') const icon: LegendIconVariable = { type: 'icon', items: [{ label: 'Store', icon: 'x.svg', value: '5.6M' }], } rerender() expect(screen.getByText('5.6M')).toBeTruthy() }) it('passes the variable shape + pattern through to the swatch', () => { const data: LegendCategoryVariable = { type: 'category', shape: 'square', fillPattern: 'dots', items: [ { label: 'A', color: '#111' }, { label: 'B', color: '#222' }, ], } const { container } = render() const swatches = container.querySelectorAll('svg') expect(swatches).toHaveLength(2) expect(swatches[0]!.getAttribute('data-shape')).toBe('square') expect(container.querySelector('pattern')).toBeTruthy() }) it('items override the variable-level swatch style (mixed list)', () => { const data: LegendCategoryVariable = { type: 'category', shape: 'square', fillPattern: 'solid', // variable default items: [ { label: 'Solid', color: '#111' }, { label: 'Dotted', color: '#222', fillPattern: 'dots' }, { label: 'Line', color: '#333', shape: 'line' }, { label: 'Outline', color: '#444', isStrokeColor: true }, ], } const { container } = render() const swatches = Array.from(container.querySelectorAll('svg')) expect(swatches.map((s) => s.getAttribute('data-shape'))).toEqual([ 'square', 'square', 'line', 'square', ]) // Only the dots-override item emits a fill . expect(container.querySelectorAll('pattern')).toHaveLength(1) expect(swatches[1]!.querySelector('pattern')).toBeTruthy() // The isStrokeColor-override item strokes instead of fills. const outlineRect = swatches[3]!.querySelector('rect')! expect(outlineRect.getAttribute('fill')).toBe('none') expect(outlineRect.getAttribute('stroke')).toBe('#444') }) }) describe(' ordering (regression)', () => { it('boundary contract: N+1 stops label the lines between N colors, in order', () => { const data: LegendRampVariable = { type: 'ramp', isContinuous: false, colors: ['#aaa', '#bbb', '#ccc'], stops: [ { label: 'a', value: 0 }, { label: 'b', value: 1 }, { label: 'c', value: 2 }, { label: 'd', value: 3 }, ], } render() // One step per color; each step's accessible name is its boundary range, // in low→high order — colors and labels never reversed. const steps = screen.getAllByRole('img') expect(steps.map((s) => s.getAttribute('aria-label'))).toEqual([ 'a – b', 'b – c', 'c – d', ]) // Every boundary label renders; the outer two clamp inside the bar // (no transform / right-aligned) while interior ones stay centered. for (const label of ['a', 'b', 'c', 'd']) { expect(screen.getByText(label)).toBeTruthy() } expect(screen.getByText('a').style.transform).toBe('') expect(screen.getByText('b').style.transform).toBe('translateX(-50%)') expect(screen.getByText('c').style.transform).toBe('translateX(-50%)') expect(screen.getByText('d').style.transform).toBe('translateX(-100%)') expect(screen.getByText('d').style.left).toBe('100%') }) it('invalid discrete (stops ≠ colors + 1) renders an error, not a bar', () => { const data: LegendRampVariable = { type: 'ramp', isContinuous: false, colors: ['#aaa', '#bbb', '#ccc'], // 3 colors → needs 4 stops stops: [ { label: 'low', value: 0 }, { label: 'mid', value: 1 }, { label: 'high', value: 2 }, ], attribute: 'income', } render() // The incoherent state shows an error message with the expected counts… expect(screen.getByText(/needs 4 stops .* but got 3/)).toBeTruthy() // …the attribute header stays for context… expect(screen.getByText('income')).toBeTruthy() // …and no bar/steps render. expect(screen.queryAllByRole('img')).toHaveLength(0) expect(screen.queryByText('low')).toBeNull() }) it('renders the avg marker at its linear position with a formatted tooltip', () => { const data: LegendRampVariable = { type: 'ramp', isContinuous: true, colors: ['#aaa', '#ccc'], stops: [ { label: '0', value: 0 }, { label: '100', value: 100 }, ], avg: 25, } render() // MUI Tooltip exposes its (string) title as the child's accessible name. const line = screen.getByLabelText('25') expect(line.style.left).toBe('25%') }) it('clamps an out-of-range avg to the bar edge; hides it without a scale', () => { const base: LegendRampVariable = { type: 'ramp', isContinuous: true, colors: ['#aaa', '#ccc'], stops: [ { label: '0', value: 0 }, { label: '100', value: 100 }, ], avg: 500, } const { rerender } = render() expect(screen.getByLabelText('500').style.left).toBe('100%') // Zero-width range → no scale → no marker. rerender( , ) expect(screen.queryByLabelText('500')).toBeNull() }) it('renders a continuous gradient bar instead of steps', () => { const data: LegendRampVariable = { type: 'ramp', isContinuous: true, colors: ['#aaa', '#ccc'], stops: [ { label: '0', value: 0 }, { label: '10', value: 10 }, ], } render() // The gradient bar is the only role=img element; no discrete steps. const imgs = screen.getAllByRole('img') expect(imgs).toHaveLength(1) expect(imgs[0]!.getAttribute('aria-label')).toBe('Color gradient') }) }) describe('', () => { // The MAX/MIN labels render the prefix and the formatted value as separate // text nodes, so match on the element's full textContent. const fullText = (text: string) => (_content: string, el: Element | null): boolean => el?.textContent === text const compact = (value: number): string => formatNumber(value, 'en-US') it('labels the breaks with MAX/MIN prefixes via the supplied formatValue', () => { const data: LegendProportionVariable = { type: 'proportion', min: 13_000, max: 36_000_000, stops: [36_000_000, 310_000, 110_000, 13_000], formatValue: compact, } const { container } = render() expect(screen.getByText(fullText('MAX: 36M'))).toBeTruthy() expect(screen.getByText(fullText('MIN: 13K'))).toBeTruthy() // Middle breaks are bare (single text node). expect(screen.getByText('310K')).toBeTruthy() expect(screen.getByText('110K')).toBeTruthy() // One decorative circle per break; no dashed tick connectors. expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(4) }) it('defaults to String(value) when no formatValue is given', () => { const data: LegendProportionVariable = { type: 'proportion', min: 0, max: 36_000_000, stops: [36_000_000, 13_000], } render() expect(screen.getByText(fullText('MAX: 36000000'))).toBeTruthy() expect(screen.getByText(fullText('MIN: 13000'))).toBeTruthy() }) it('clamps `steps` to 2–4 and regenerates the values dynamically', () => { const data: LegendProportionVariable = { type: 'proportion', min: 0, max: 100, steps: 6, // clamped to 4 } const { container, rerender } = render() expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(4) rerender() // clamped to 2 expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2) // Values follow the step count: only the endpoints remain. expect(screen.getByText(fullText('MAX: 100'))).toBeTruthy() expect(screen.getByText(fullText('MIN: 0'))).toBeTruthy() rerender() expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(3) expect(screen.getByText('50')).toBeTruthy() // dynamic middle break }) it('caps explicit stops at the 4 largest; circles match labels 1:1', () => { const data: LegendProportionVariable = { type: 'proportion', min: 0, max: 500, stops: [500, 400, 300, 200, 100], // 5 stops → 4 largest kept steps: 2, // ignored when stops are provided } const { container } = render() expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(4) expect(screen.getByText(fullText('MAX: 500'))).toBeTruthy() expect(screen.getByText(fullText('MIN: 200'))).toBeTruthy() // smallest shown expect(screen.queryByText('100')).toBeNull() // dropped }) it('falls back to evenly-spaced breaks from min/max when stops omitted', () => { const data: LegendProportionVariable = { type: 'proportion', min: 0, max: 1000, steps: 3, formatValue: compact, } render() expect(screen.getByText(fullText('MAX: 1K'))).toBeTruthy() expect(screen.getByText(fullText('MIN: 0'))).toBeTruthy() }) it('renders the "Radius range by" attribute header when set', () => { const data: LegendProportionVariable = { type: 'proportion', min: 1, max: 10, attribute: 'revenue', } render() expect(screen.getByText('Radius range by')).toBeTruthy() expect(screen.getByText('revenue')).toBeTruthy() }) })