import { describe, it, expect } from 'vitest'; import { classifyValues, classIndexOf, interpolateRamp, formatClassLabel } from './map-classify'; describe('classifyValues', () => { it('returns nothing for empty input', () => { expect(classifyValues([], 5)).toEqual([]); }); it('collapses a single distinct value into one class', () => { // Five identical values cannot support five ranges. expect(classifyValues([7, 7, 7], 5)).toEqual([{ min: 7, max: 7, index: 0 }]); }); it('ignores non-finite values', () => { const classes = classifyValues([1, NaN, 10, Infinity], 2, 'equal-interval'); expect(classes[0].min).toBe(1); expect(classes[classes.length - 1].max).toBe(10); }); describe('equal-interval', () => { it('splits the range into even widths', () => { const classes = classifyValues([0, 50, 100], 2, 'equal-interval'); expect(classes).toHaveLength(2); expect(classes[0]).toMatchObject({ min: 0, max: 50 }); expect(classes[1]).toMatchObject({ min: 50, max: 100 }); }); it('ignores how the values are distributed', () => { // Heavily skewed data still gets even ranges — that is the point. const classes = classifyValues([1, 1, 1, 1, 100], 2, 'equal-interval'); expect(classes[0].max).toBeCloseTo(50.5, 5); }); }); describe('quantile', () => { it('places breaks by rank, not by value', () => { const classes = classifyValues([1, 2, 3, 100], 2, 'quantile'); // The median sits between 2 and 3, not near 50. expect(classes[0].max).toBeCloseTo(2.5, 5); }); it('is the default method', () => { expect(classifyValues([1, 2, 3, 100], 2)).toEqual( classifyValues([1, 2, 3, 100], 2, 'quantile') ); }); it('drops degenerate classes when values are heavily tied', () => { const classes = classifyValues([0, 0, 0, 0, 0, 0, 0, 10], 4, 'quantile'); // Empty ranges no feature could fall into are not emitted. expect(classes.every(c => c.min !== c.max || classes.length === 1)).toBe(true); expect(classes.length).toBeLessThanOrEqual(4); }); }); it('numbers classes consecutively from zero', () => { const classes = classifyValues([0, 25, 50, 75, 100], 5, 'equal-interval'); expect(classes.map(c => c.index)).toEqual([0, 1, 2, 3, 4]); }); }); describe('classIndexOf', () => { const classes = classifyValues([0, 25, 50, 75, 100], 4, 'equal-interval'); it('finds the class containing a value', () => { expect(classIndexOf(0, classes)).toBe(0); expect(classIndexOf(30, classes)).toBe(1); }); it('includes the maximum in the last class', () => { // Upper bounds are exclusive except at the top, or the max would fall out. expect(classIndexOf(100, classes)).toBe(classes.length - 1); }); it('returns -1 for values outside every class', () => { expect(classIndexOf(-1, classes)).toBe(-1); expect(classIndexOf(101, classes)).toBe(-1); expect(classIndexOf(NaN, classes)).toBe(-1); }); it('returns -1 when there are no classes', () => { expect(classIndexOf(5, [])).toBe(-1); }); }); describe('interpolateRamp', () => { it('mixes intermediate shades between two stops', () => { expect(interpolateRamp(['#000000', '#ffffff'], 3)).toEqual(['#000000', '#808080', '#ffffff']); }); it('passes through a three-stop ramp at its midpoint', () => { const ramp = interpolateRamp(['#000000', '#ff0000', '#ffffff'], 3); expect(ramp[1]).toBe('#ff0000'); }); it('repeats a single stop', () => { expect(interpolateRamp(['#123456'], 3)).toEqual(['#123456', '#123456', '#123456']); }); it('returns the first stop when only one class is needed', () => { expect(interpolateRamp(['#000000', '#ffffff'], 1)).toEqual(['#000000']); }); it('does not invent colors it cannot mix', () => { // An unresolved token would otherwise silently produce garbage shades. expect(interpolateRamp(['rebeccapurple', '#ffffff'], 3)).toEqual([ 'rebeccapurple', '#ffffff', '#ffffff', ]); }); it('handles empty input', () => { expect(interpolateRamp([], 3)).toEqual([]); }); }); describe('formatClassLabel', () => { it('formats a range', () => { expect(formatClassLabel({ min: 12.4, max: 48.6, index: 0 })).toBe('12 – 49'); }); it('honours a digit count', () => { expect(formatClassLabel({ min: 12.44, max: 48.66, index: 0 }, 1)).toBe('12.4 – 48.7'); }); });