import { describe, it, expect } from 'vitest' import { createPercentFormatter, toRelativeData } from './transforms' describe('toRelativeData (default — named-value shape)', () => { it('rewrites each datum value as its share of the series total in 0-100', () => { const input = [ [ { name: 'a', value: 25 }, { name: 'b', value: 75 }, ], ] expect(toRelativeData(input)).toEqual([ [ { name: 'a', value: 25 }, { name: 'b', value: 75 }, ], ]) }) it('handles non-100 totals (50/200 = 25%)', () => { const input = [ [ { name: 'a', value: 50 }, { name: 'b', value: 150 }, ], ] expect(toRelativeData(input)).toEqual([ [ { name: 'a', value: 25 }, { name: 'b', value: 75 }, ], ]) }) it('handles multiple series independently', () => { const input = [ [{ name: 'x', value: 10 }], [ { name: 'x', value: 50 }, { name: 'y', value: 50 }, ], ] const out = toRelativeData(input) as { value: number }[][] expect(out[0]?.[0]?.value).toBe(100) expect(out[1]?.[0]?.value).toBe(50) expect(out[1]?.[1]?.value).toBe(50) }) it('preserves extra fields on each datum', () => { const input = [[{ name: 'a', value: 10, color: '#fff' }]] expect(toRelativeData(input)).toEqual([ [{ name: 'a', value: 100, color: '#fff' }], ]) }) it('returns the series unchanged when total is zero', () => { const input = [ [ { name: 'a', value: 0 }, { name: 'b', value: 0 }, ], ] expect(toRelativeData(input)).toEqual(input) }) it('produces signed share-of-magnitude percentages for mixed-sign series', () => { const input = [ [ { name: 'a', value: 1000 }, { name: 'b', value: -990 }, ], ] const out = toRelativeData(input) as { value: number }[][] // Denominator is 1000 + 990 = 1990, so 1000 / 1990 ≈ 50.25% expect(out[0]?.[0]?.value).toBeCloseTo(50.25126, 4) expect(out[0]?.[1]?.value).toBeCloseTo(-49.74874, 4) }) it('works for all-negative series (signed shares sum to -100)', () => { const input = [ [ { name: 'a', value: -100 }, { name: 'b', value: -200 }, ], ] const out = toRelativeData(input) as { value: number }[][] expect(out[0]?.[0]?.value).toBeCloseTo(-33.333, 2) expect(out[0]?.[1]?.value).toBeCloseTo(-66.667, 2) }) it('returns the input unchanged when shape does not match named-value', () => { expect(toRelativeData(null)).toBe(null) expect(toRelativeData('not an array')).toBe('not an array') // A top-level array whose entries are neither arrays-of-named-value // nor matching the predicate falls through. Each series that isn't // a named-value array is returned as-is — that's how widgets with // a different shape (and their own transform) avoid double-running // through this one. expect(toRelativeData([42])).toEqual([42]) expect(toRelativeData([[1, 2, 3]])).toEqual([[1, 2, 3]]) expect( toRelativeData([ [ [1, 10], [2, 20], ], ]), ).toEqual([ [ [1, 10], [2, 20], ], ]) }) }) describe('createPercentFormatter', () => { it('formats 0-100 input as percentages via Intl.NumberFormat', () => { const fmt = createPercentFormatter('en-US') // minimumFractionDigits: 0, maximumFractionDigits: 2 — integers render // without decimals, fractions get up to two decimal places. expect(fmt(50)).toBe('50%') expect(fmt(2.5)).toBe('2.5%') expect(fmt(100)).toBe('100%') expect(fmt(12.345)).toBe('12.35%') }) it('respects the provided locale (en-US uses dot, es-ES uses comma)', () => { const en = createPercentFormatter('en-US') const es = createPercentFormatter('es-ES') expect(en(12.3)).toContain('12.3') // Spanish locale uses comma as decimal separator. Format strings vary by // ICU version (some emit narrow no-break space before "%"); assert on the // decimal separator and the digits rather than the entire output. const esOut = es(12.3) expect(esOut).toContain('12,3') expect(esOut).toContain('%') }) it('passes through non-finite values as their string form', () => { const fmt = createPercentFormatter('en-US') expect(fmt(NaN)).toBe('NaN') expect(fmt(Infinity)).toBe('Infinity') }) it('falls back to runtime default locale when none is provided', () => { const fmt = createPercentFormatter() // Whatever locale the runtime uses, 50 should produce a string ending in '%' expect(fmt(50)).toMatch(/%$/) }) })