Files
hermes-agent/apps/shared/src/color.test.ts
Konstantin Khlopkov 1da0c50c98 fix(shared): normalize invalid ensureContrast step values before the ladder loop
ensureContrast's accumulating ladder loop trusted its `step` argument: step=0
never advanced (1,000,001 iterations, still unbounded), negative steps walked
away from the pole forever, NaN skipped the loop body entirely so the original
below-threshold color was returned silently, and Infinity/above-1 steps jumped
straight to a full blend. Normalize instead: a finite step in (0.001, 1] is used
as-is (clamped to 1 above that), everything else falls back to the default 0.2
ladder. The desktop 0.2 and TUI 0.05 float sequences are byte-identical.

Fixes #109955
Salvaged from #109975 by kokhlo
2026-09-27 02:48:48 -05:00

116 lines
4.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { contrastRatio, ensureContrast, mix, parseColor, readableOn, relativeLuminance, toHex } from './color'
describe('parseColor', () => {
it('parses hex6, hex3 and rgb() forms', () => {
expect(parseColor('#1a2b3c')).toEqual([0x1a, 0x2b, 0x3c])
expect(parseColor('1a2b3c')).toEqual([0x1a, 0x2b, 0x3c])
expect(parseColor('#abc')).toEqual([0xaa, 0xbb, 0xcc])
expect(parseColor('rgb(220,255,220)')).toEqual([220, 255, 220])
expect(parseColor('rgba(10, 20, 30, 0.5)')).toEqual([10, 20, 30])
})
it('rejects garbage', () => {
expect(parseColor('')).toBeNull()
expect(parseColor('ansi256(245)')).toBeNull()
expect(parseColor('#12345')).toBeNull()
})
})
describe('mix', () => {
it('lerps in sRGB and round-trips through toHex', () => {
expect(mix('#000000', '#ffffff', 0.5)).toBe('#808080')
expect(mix('#ff0000', '#0000ff', 0)).toBe('#ff0000')
expect(mix('#ff0000', '#0000ff', 1)).toBe('#0000ff')
expect(toHex(parseColor(mix('#123456', '#654321', 0.3))!)).toBe(mix('#123456', '#654321', 0.3))
})
it('passes unparseable inputs through unchanged', () => {
expect(mix('ansi256(245)', '#ffffff', 0.5)).toBe('ansi256(245)')
expect(mix('#ff0000', 'nope', 0.5)).toBe('#ff0000')
})
})
describe('contrast', () => {
it('measures WCAG ratios at the anchors and refuses to measure garbage', () => {
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 0)
expect(contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5)
expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 5)
expect(relativeLuminance('#000000')).toBeCloseTo(0, 5)
expect(relativeLuminance('ansi256(245)')).toBeNull()
expect(contrastRatio('#ffffff', 'ansi256(245)')).toBeNull()
})
// Mid-lightness accents are where a luminance threshold and a measurement
// disagree: GitHub green #4f9e5e sits just under L=0.5 (threshold → white,
// 3.29:1) while near-black measures 5.50:1. The contract is "the ink that
// measures better", so assert against the measurement, not a hardcoded hex.
it.each([
['#4f9e5e', ['#000000', '#ffffff']],
['#4f9e5e', ['#161616', '#ffffff']],
['#cba6f7', ['#161616', '#ffffff']],
['#ffffff', ['#000000', '#ffffff']],
['#101014', ['#000000', '#ffffff']]
] as const)('readableOn(%s) returns the ink with the higher measured contrast', (bg, inks) => {
const best = inks.reduce((a, b) => (contrastRatio(bg, b)! > contrastRatio(bg, a)! ? b : a))
expect(readableOn(bg, inks)).toBe(best)
})
it('readableOn defaults to the black/white poles', () => {
expect(readableOn('#ffffff')).toBe('#000000')
expect(readableOn('#101014')).toBe('#ffffff')
})
it.each([
['#FFF8DC', '#ffffff', 3.9],
['#4f9e5e', '#ffffff', 4.5],
['#3a3a5c', '#101014', 4.5],
['#0053fd', '#161616', 4.5],
['#cba6f7', '#ffffff', 7]
])('ensureContrast(%s on %s) clears %s', (color, bg, min) => {
const fixed = ensureContrast(color, bg, min)
expect(contrastRatio(fixed, bg)!).toBeGreaterThanOrEqual(min)
})
it('ensureContrast leaves passing and unparseable colors byte-identical', () => {
expect(ensureContrast('#3D2F13', '#ffffff', 3.9)).toBe('#3D2F13')
expect(ensureContrast('ansi256(245)', '#ffffff', 3.9)).toBe('ansi256(245)')
})
it.each([
['zero', 0],
['negative', -0.2],
['NaN', Number.NaN],
['Infinity', Number.POSITIVE_INFINITY],
['denormal', Number.MIN_VALUE]
])('ensureContrast normalizes a %s step to the default ladder and still clears the ratio', (_label, badStep) => {
const fixed = ensureContrast('#777777', '#ffffff', 4.5, badStep as number)
expect(fixed).toBe(ensureContrast('#777777', '#ffffff', 4.5))
expect(contrastRatio(fixed, '#ffffff')!).toBeGreaterThanOrEqual(4.5)
})
it('ensureContrast clamps an above-1 step to a single full blend toward the pole', () => {
expect(ensureContrast('#777777', '#ffffff', 4.5, 25)).toBe('#000000')
expect(contrastRatio(ensureContrast('#777777', '#ffffff', 4.5, 25), '#ffffff')!).toBeGreaterThanOrEqual(4.5)
})
it('ensureContrast accepts a sub-default rung down to 0.001 without renormalizing', () => {
const fixed = ensureContrast('#777777', '#ffffff', 4.5, 0.001)
expect(fixed).not.toBe(ensureContrast('#777777', '#ffffff', 4.5))
expect(contrastRatio(fixed, '#ffffff')!).toBeGreaterThanOrEqual(4.5)
})
it('ensureContrast keeps the documented ladders byte-identical', () => {
// Desktop default rungs and the TUI's 0.05 chain rely on the exact float
// sequence of the accumulating loop; normalization must not touch them.
expect(ensureContrast('#cba6f7', '#ffffff', 7)).toBe('#514263')
expect(ensureContrast('#cba6f7', '#ffffff', 7, 0.2)).toBe('#514263')
expect(ensureContrast('#3a3a5c', '#101014', 4.5, 0.05)).toBe('#7f7f95')
})
})