UNPKG

poline

Version:

color palette generator mico-lib

722 lines (614 loc) 22.6 kB
import { describe, it, expect } from 'vitest'; import { Poline, ColorPoint, positionFunctions, pointToHSL, hslToPoint, randomHSLPair, randomHSLTriple, } from '../src/index'; describe('ColorPoint', () => { it('should initialize with xyz coordinates', () => { const point = new ColorPoint({ xyz: [0.5, 0.5, 1] }); expect(point.x).toBe(0.5); expect(point.y).toBe(0.5); expect(point.z).toBe(1); expect(point.color).toBeDefined(); expect(point.color.length).toBe(3); }); it('should initialize with HSL color', () => { const point = new ColorPoint({ color: [180, 0.5, 0.5] }); expect(point.color[0]).toBe(180); expect(point.color[1]).toBe(0.5); expect(point.color[2]).toBe(0.5); expect(point.x).toBeDefined(); expect(point.y).toBeDefined(); expect(point.z).toBeDefined(); }); it('should throw error when initialized with both xyz and color', () => { expect(() => { new ColorPoint({ xyz: [0.5, 0.5, 1], color: [180, 0.5, 0.5] }); }).toThrow('Point must be initialized with either x,y,z or hsl'); }); it('should throw error when initialized with neither xyz nor color', () => { expect(() => { new ColorPoint({}); }).toThrow('Point must be initialized with either x,y,z or hsl'); }); it('should update color when position is set', () => { const point = new ColorPoint({ xyz: [0.5, 0.5, 1] }); const initialColor = [...point.color]; point.position = [0.7, 0.7, 0.8]; expect(point.x).toBe(0.7); expect(point.y).toBe(0.7); expect(point.z).toBe(0.8); expect(point.color).not.toEqual(initialColor); }); it('should update position when HSL is set', () => { const point = new ColorPoint({ color: [180, 0.5, 0.5] }); const initialPosition = [point.x, point.y, point.z]; point.hsl = [90, 0.8, 0.3]; expect(point.color[0]).toBe(90); expect(point.color[1]).toBe(0.8); expect(point.color[2]).toBe(0.3); expect([point.x, point.y, point.z]).not.toEqual(initialPosition); }); it('should generate valid CSS color strings', () => { const point = new ColorPoint({ color: [180, 0.5, 0.5] }); expect(point.hslCSS).toMatch(/^hsl\(\d+\.?\d*, \d+\.?\d*%, \d+\.?\d*%\)$/); expect(point.oklchCSS).toMatch(/^oklch\(\d+\.?\d*% \d+\.?\d* \d+\.?\d*\)$/); expect(point.lchCSS).toMatch(/^lch\(\d+\.?\d*% \d+\.?\d* \d+\.?\d*\)$/); }); it('should handle invertedLightness correctly', () => { const point = new ColorPoint({ color: [0, 1, 0.2], invertedLightness: false, }); expect(point.color[2]).toBeCloseTo(0.2); point.invertedLightness = true; expect(point.color[2]).toBeCloseTo(0.8); point.invertedLightness = false; expect(point.color[2]).toBeCloseTo(0.2); }); it('should shift hue correctly', () => { const point = new ColorPoint({ color: [100, 0.5, 0.5] }); point.shiftHue(50); expect(point.color[0]).toBe(150); point.shiftHue(-200); expect(point.color[0]).toBe(310); // (150 - 200 + 360) % 360 }); it('should handle hue wrapping at 360 degrees', () => { const point = new ColorPoint({ color: [350, 0.5, 0.5] }); point.shiftHue(20); expect(point.color[0]).toBe(10); // (350 + 20) % 360 }); }); describe('pointToHSL and hslToPoint conversion', () => { it('should convert xyz to HSL', () => { const hsl = pointToHSL([0.5, 0.5, 1], false); expect(hsl).toBeDefined(); expect(hsl.length).toBe(3); expect(hsl[1]).toBe(1); // saturation from z expect(hsl[2]).toBeCloseTo(0); // lightness at center }); it('should convert HSL to xyz', () => { const xyz = hslToPoint([180, 0.8, 0.5], false); expect(xyz).toBeDefined(); expect(xyz.length).toBe(3); expect(xyz[2]).toBe(0.8); // z from saturation }); it('should be reversible (xyz -> HSL -> xyz)', () => { const originalXYZ: [number, number, number] = [0.7, 0.3, 0.9]; const hsl = pointToHSL(originalXYZ, false); const convertedXYZ = hslToPoint(hsl, false); expect(convertedXYZ[0]).toBeCloseTo(originalXYZ[0], 5); expect(convertedXYZ[1]).toBeCloseTo(originalXYZ[1], 5); expect(convertedXYZ[2]).toBeCloseTo(originalXYZ[2], 5); }); it('should be reversible (HSL -> xyz -> HSL)', () => { const originalHSL: [number, number, number] = [240, 0.6, 0.4]; const xyz = hslToPoint(originalHSL, false); const convertedHSL = pointToHSL(xyz, false); expect(convertedHSL[0]).toBeCloseTo(originalHSL[0], 5); expect(convertedHSL[1]).toBeCloseTo(originalHSL[1], 5); expect(convertedHSL[2]).toBeCloseTo(originalHSL[2], 5); }); it('should handle invertedLightness in conversions', () => { const hsl: [number, number, number] = [180, 0.5, 0.3]; const xyz = hslToPoint(hsl, false); const xyzInverted = hslToPoint(hsl, true); const hslBack = pointToHSL(xyz, false); const hslBackInverted = pointToHSL(xyzInverted, true); expect(hslBack[2]).toBeCloseTo(0.3, 5); expect(hslBackInverted[2]).toBeCloseTo(0.3, 5); }); }); describe('randomHSLPair and randomHSLTriple', () => { it('should generate random HSL pair with default values', () => { const pair = randomHSLPair(); expect(pair.length).toBe(2); expect(pair[0].length).toBe(3); expect(pair[1].length).toBe(3); // Check hue range expect(pair[0][0]).toBeGreaterThanOrEqual(0); expect(pair[0][0]).toBeLessThan(360); expect(pair[1][0]).toBeGreaterThanOrEqual(0); expect(pair[1][0]).toBeLessThan(360); // Check saturation range expect(pair[0][1]).toBeGreaterThanOrEqual(0); expect(pair[0][1]).toBeLessThanOrEqual(1); expect(pair[1][1]).toBeGreaterThanOrEqual(0); expect(pair[1][1]).toBeLessThanOrEqual(1); // Check lightness range expect(pair[0][2]).toBeGreaterThanOrEqual(0); expect(pair[0][2]).toBeLessThanOrEqual(1); expect(pair[1][2]).toBeGreaterThanOrEqual(0); expect(pair[1][2]).toBeLessThanOrEqual(1); }); it('should generate random HSL pair with custom values', () => { const pair = randomHSLPair(100, [0.5, 0.8], [0.2, 0.7]); expect(pair[0][0]).toBe(100); expect(pair[0][1]).toBe(0.5); expect(pair[0][2]).toBe(0.2); expect(pair[1][1]).toBe(0.8); expect(pair[1][2]).toBe(0.7); }); it('should generate random HSL triple with default values', () => { const triple = randomHSLTriple(); expect(triple.length).toBe(3); triple.forEach((color) => { expect(color.length).toBe(3); expect(color[0]).toBeGreaterThanOrEqual(0); expect(color[0]).toBeLessThan(360); expect(color[1]).toBeGreaterThanOrEqual(0); expect(color[1]).toBeLessThanOrEqual(1); expect(color[2]).toBeGreaterThanOrEqual(0); expect(color[2]).toBeLessThanOrEqual(1); }); }); it('should generate random HSL triple with custom values', () => { const triple = randomHSLTriple(200, [0.3, 0.6, 0.9], [0.1, 0.5, 0.8]); expect(triple[0][0]).toBe(200); expect(triple[0][1]).toBe(0.3); expect(triple[0][2]).toBe(0.1); expect(triple[1][1]).toBe(0.6); expect(triple[1][2]).toBe(0.5); expect(triple[2][1]).toBe(0.9); expect(triple[2][2]).toBe(0.8); }); }); describe('Poline', () => { it('should initialize with default values', () => { const poline = new Poline(); expect(poline.anchorPoints.length).toBe(2); // Default random pair expect(poline.numPoints).toBe(4); // Default numPoints expect(poline.invertedLightness).toBe(false); expect(poline.closedLoop).toBe(false); }); it('should initialize with custom options', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], numPoints: 10, invertedLightness: true, closedLoop: true, }); expect(poline.numPoints).toBe(10); expect(poline.invertedLightness).toBe(true); expect(poline.closedLoop).toBe(true); expect(poline.anchorPoints.length).toBe(2); }); it('should throw error when initialized with less than 2 anchor colors', () => { expect(() => { new Poline({ anchorColors: [[0, 1, 0.5]] }); }).toThrow('Must have at least two anchor colors'); }); it('should update anchor points when invertedLightness is toggled', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], // HSL invertedLightness: false, }); // Toggle invertedLightness poline.invertedLightness = true; // Check if the property updated expect(poline.invertedLightness).toBe(true); }); it('should correctly invert lightness for anchors', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.2], [0, 1, 0.2], ], // Dark color invertedLightness: false, }); // Initial state: Lightness 0.2 expect(poline.anchorPoints[0].color[2]).toBeCloseTo(0.2); // Invert poline.invertedLightness = true; // New state: Lightness should be 1 - 0.2 = 0.8 expect(poline.anchorPoints[0].color[2]).toBeCloseTo(0.8); // Invert back poline.invertedLightness = false; expect(poline.anchorPoints[0].color[2]).toBeCloseTo(0.2); }); it('should add and remove anchor points', () => { const poline = new Poline(); const initialCount = poline.anchorPoints.length; poline.addAnchorPoint({ color: [0, 1, 0.5] }); expect(poline.anchorPoints.length).toBe(initialCount + 1); poline.removeAnchorPoint({ index: initialCount }); expect(poline.anchorPoints.length).toBe(initialCount); }); it('should throw error when removing anchor point with less than 3 remaining', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], }); expect(() => { poline.removeAnchorPoint({ index: 0 }); }).toThrow('Must have at least two anchor points'); }); it('should get color at specific position', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [0, 1, 0.5], ], // Same color for easy check positionFunction: positionFunctions.linearPosition, }); const color = poline.getColorAt(0.5); expect(color.color[0]).toBeCloseTo(0); expect(color.color[1]).toBeCloseTo(1); expect(color.color[2]).toBeCloseTo(0.5); }); it('should throw error when getColorAt is called with invalid position', () => { const poline = new Poline(); expect(() => poline.getColorAt(-0.1)).toThrow( 'Position must be between 0 and 1' ); expect(() => poline.getColorAt(1.1)).toThrow( 'Position must be between 0 and 1' ); }); it('should handle getColorAt at edge positions', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.2], [180, 1, 0.8], ], positionFunction: positionFunctions.linearPosition, }); const colorStart = poline.getColorAt(0); const colorEnd = poline.getColorAt(1); // At start should be close to first anchor expect(colorStart.color[0]).toBeCloseTo(0, 0); expect(colorStart.color[2]).toBeCloseTo(0.2, 0); // At end should be close to second anchor expect(colorEnd.color[0]).toBeCloseTo(180, 0); expect(colorEnd.color[2]).toBeCloseTo(0.8, 0); }); it('should update numPoints correctly', () => { const poline = new Poline({ numPoints: 5 }); expect(poline.numPoints).toBe(5); poline.numPoints = 10; expect(poline.numPoints).toBe(10); }); it('should throw error when numPoints is less than 1', () => { const poline = new Poline(); expect(() => { poline.numPoints = 0; }).toThrow('Must have at least one point'); }); it('should update and retrieve position functions', () => { const poline = new Poline(); poline.positionFunction = positionFunctions.exponentialPosition; expect(poline.positionFunction).toBe( positionFunctions.exponentialPosition ); }); it('should handle array of position functions', () => { const poline = new Poline(); const funcs = [ positionFunctions.linearPosition, positionFunctions.exponentialPosition, positionFunctions.quadraticPosition, ]; poline.positionFunction = funcs; expect(poline.positionFunctionX).toBe(funcs[0]); expect(poline.positionFunctionY).toBe(funcs[1]); expect(poline.positionFunctionZ).toBe(funcs[2]); }); it('should throw error with invalid position function array', () => { const poline = new Poline(); expect(() => { poline.positionFunction = [positionFunctions.linearPosition]; }).toThrow('Position function array must have 3 elements'); }); it('should update individual position functions', () => { const poline = new Poline(); poline.positionFunctionX = positionFunctions.linearPosition; poline.positionFunctionY = positionFunctions.exponentialPosition; poline.positionFunctionZ = positionFunctions.quadraticPosition; expect(poline.positionFunctionX).toBe(positionFunctions.linearPosition); expect(poline.positionFunctionY).toBe( positionFunctions.exponentialPosition ); expect(poline.positionFunctionZ).toBe(positionFunctions.quadraticPosition); }); it('should add anchor point at specific index', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], }); poline.addAnchorPoint({ color: [90, 1, 0.5], insertAtIndex: 1 }); expect(poline.anchorPoints.length).toBe(3); expect(poline.anchorPoints[1].color[0]).toBe(90); }); it('should update anchor point by index', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], }); poline.updateAnchorPoint({ pointIndex: 0, color: [45, 0.8, 0.6] }); expect(poline.anchorPoints[0].color[0]).toBe(45); expect(poline.anchorPoints[0].color[1]).toBe(0.8); expect(poline.anchorPoints[0].color[2]).toBe(0.6); }); it('should update anchor point by reference', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], }); const anchor = poline.anchorPoints[0]; poline.updateAnchorPoint({ point: anchor, color: [90, 0.7, 0.4] }); expect(poline.anchorPoints[0].color[0]).toBe(90); }); it('should throw error when updating without point or index', () => { const poline = new Poline(); expect(() => { poline.updateAnchorPoint({ color: [0, 1, 0.5] }); }).toThrow('Must provide a point or pointIndex'); }); it('should throw error when updating without new values', () => { const poline = new Poline(); expect(() => { poline.updateAnchorPoint({ pointIndex: 0 }); }).toThrow('Must provide a new xyz position or color'); }); it('should find closest anchor point by xyz', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], }); const anchor0Pos = poline.anchorPoints[0].position; const closest = poline.getClosestAnchorPoint({ xyz: [anchor0Pos[0] + 0.01, anchor0Pos[1] + 0.01, anchor0Pos[2]], }); expect(closest).toBe(poline.anchorPoints[0]); }); it('should find closest anchor point by hsl', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], }); const closest = poline.getClosestAnchorPoint({ hsl: [5, 1, 0.5] }); expect(closest).toBe(poline.anchorPoints[0]); }); it('should return null when no anchor within maxDistance', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], }); const closest = poline.getClosestAnchorPoint({ xyz: [5, 5, 5], maxDistance: 0.01, }); expect(closest).toBeNull(); }); it('should throw error when getClosestAnchorPoint called without xyz or hsl', () => { const poline = new Poline(); expect(() => { poline.getClosestAnchorPoint({ maxDistance: 1 }); }).toThrow('Must provide a xyz or hsl'); }); it('should toggle closedLoop', () => { const poline = new Poline({ closedLoop: false }); expect(poline.closedLoop).toBe(false); poline.closedLoop = true; expect(poline.closedLoop).toBe(true); }); it('should shift hue for all anchor points', () => { const poline = new Poline({ anchorColors: [ [100, 1, 0.5], [200, 1, 0.5], ], }); poline.shiftHue(30); expect(poline.anchorPoints[0].color[0]).toBe(130); expect(poline.anchorPoints[1].color[0]).toBe(230); }); it('should generate flattenedPoints correctly', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], numPoints: 4, }); const flattened = poline.flattenedPoints; expect(flattened.length).toBeGreaterThan(0); expect(flattened[0]).toBeInstanceOf(ColorPoint); }); it('should generate colors array', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], numPoints: 4, }); const colors = poline.colors; expect(colors.length).toBeGreaterThan(0); colors.forEach((color) => { expect(color.length).toBe(3); }); }); it('should generate CSS color strings in different formats', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], numPoints: 2, }); const hslColors = poline.colorsCSS; const lchColors = poline.colorsCSSlch; const oklchColors = poline.colorsCSSoklch; expect(hslColors.length).toBeGreaterThan(0); expect(lchColors.length).toBeGreaterThan(0); expect(oklchColors.length).toBeGreaterThan(0); hslColors.forEach((color) => { expect(color).toMatch(/^hsl\(/); }); lchColors.forEach((color) => { expect(color).toMatch(/^lch\(/); }); oklchColors.forEach((color) => { expect(color).toMatch(/^oklch\(/); }); }); it('should handle closed loop with 2 anchors correctly', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [180, 1, 0.5], ], closedLoop: true, numPoints: 4, }); expect(poline.closedLoop).toBe(true); const colors = poline.colors; expect(colors.length).toBeGreaterThan(0); }); it('should handle multiple anchor points in closed loop', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.5], [120, 1, 0.5], [240, 1, 0.5], ], closedLoop: true, numPoints: 3, }); expect(poline.anchorPoints.length).toBe(3); expect(poline.closedLoop).toBe(true); }); it('should interpolate colors correctly across multiple segments', () => { const poline = new Poline({ anchorColors: [ [0, 1, 0.2], [120, 1, 0.5], [240, 1, 0.8], ], positionFunction: positionFunctions.linearPosition, numPoints: 2, }); // Test start const colorStart = poline.getColorAt(0); expect(colorStart.color[0]).toBeCloseTo(0, 0); // Test middle (should be in second segment) const colorMiddle = poline.getColorAt(0.5); expect(colorMiddle.color[0]).toBeGreaterThan(0); expect(colorMiddle.color[0]).toBeLessThan(240); // Test end const colorEnd = poline.getColorAt(1); expect(colorEnd.color[0]).toBeCloseTo(240, 0); }); }); describe('Position Functions', () => { it('should have all expected position functions', () => { expect(positionFunctions.linearPosition).toBeDefined(); expect(positionFunctions.exponentialPosition).toBeDefined(); expect(positionFunctions.quadraticPosition).toBeDefined(); expect(positionFunctions.cubicPosition).toBeDefined(); expect(positionFunctions.quarticPosition).toBeDefined(); expect(positionFunctions.sinusoidalPosition).toBeDefined(); expect(positionFunctions.asinusoidalPosition).toBeDefined(); expect(positionFunctions.arcPosition).toBeDefined(); expect(positionFunctions.smoothStepPosition).toBeDefined(); }); it('should return values between 0 and 1 for t between 0 and 1', () => { const functions = Object.values(positionFunctions); const testValues = [0, 0.25, 0.5, 0.75, 1]; functions.forEach((fn) => { testValues.forEach((t) => { const result = fn(t); expect(result).toBeGreaterThanOrEqual(0); expect(result).toBeLessThanOrEqual(1); }); }); }); it('should return 0 at t=0 for most functions', () => { expect(positionFunctions.linearPosition(0)).toBe(0); expect(positionFunctions.exponentialPosition(0)).toBe(0); expect(positionFunctions.quadraticPosition(0)).toBe(0); expect(positionFunctions.cubicPosition(0)).toBe(0); expect(positionFunctions.quarticPosition(0)).toBe(0); expect(positionFunctions.sinusoidalPosition(0)).toBeCloseTo(0); expect(positionFunctions.asinusoidalPosition(0)).toBeCloseTo(0); expect(positionFunctions.smoothStepPosition(0)).toBe(0); }); it('should return 1 at t=1 for all functions', () => { Object.values(positionFunctions).forEach((fn) => { expect(fn(1)).toBeCloseTo(1); }); }); it('should handle reverse parameter correctly', () => { const t = 0.3; const expNormal = positionFunctions.exponentialPosition(t, false); const expReverse = positionFunctions.exponentialPosition(t, true); expect(expReverse).toBeGreaterThan(expNormal); const sinNormal = positionFunctions.sinusoidalPosition(t, false); const sinReverse = positionFunctions.sinusoidalPosition(t, true); // sinReverse should be less than sinNormal for early t values // because reverse means 1 - sin((1-t) * PI/2) which is smaller for small t expect(sinReverse).toBeLessThan(sinNormal); }); it('linearPosition should return t', () => { expect(positionFunctions.linearPosition(0.3)).toBe(0.3); expect(positionFunctions.linearPosition(0.7)).toBe(0.7); }); it('smoothStepPosition should have smooth transition', () => { const p0 = positionFunctions.smoothStepPosition(0); const p25 = positionFunctions.smoothStepPosition(0.25); const p50 = positionFunctions.smoothStepPosition(0.5); const p75 = positionFunctions.smoothStepPosition(0.75); const p100 = positionFunctions.smoothStepPosition(1); expect(p0).toBe(0); expect(p50).toBe(0.5); expect(p100).toBe(1); expect(p25).toBeLessThan(0.5); expect(p75).toBeGreaterThan(0.5); }); });