Skip to content

Commit 71dd913

Browse files
committed
fix: add unit tests fir branding and utils
1 parent 3c81715 commit 71dd913

File tree

2 files changed

+575
-0
lines changed

2 files changed

+575
-0
lines changed

tests/unit/branding.test.js

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
// ─── Functions under test ────────────────────────────────────────────────────
4+
// Copied from server/server.js. Pure color math functions with no Express or
5+
// DB dependencies. When server.js is refactored to export these, replace with
6+
// an import.
7+
8+
function hexToHsl(hex) {
9+
let r = parseInt(hex.slice(1, 3), 16) / 255;
10+
let g = parseInt(hex.slice(3, 5), 16) / 255;
11+
let b = parseInt(hex.slice(5, 7), 16) / 255;
12+
13+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
14+
let h, s, l = (max + min) / 2;
15+
16+
if (max === min) {
17+
h = s = 0;
18+
} else {
19+
const d = max - min;
20+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
21+
switch (max) {
22+
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
23+
case g: h = ((b - r) / d + 2) / 6; break;
24+
case b: h = ((r - g) / d + 4) / 6; break;
25+
}
26+
}
27+
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
28+
}
29+
30+
function hslToHex(h, s, l) {
31+
s /= 100; l /= 100;
32+
const k = n => (n + h / 30) % 12;
33+
const a = s * Math.min(l, 1 - l);
34+
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
35+
return "#" + [f(0), f(8), f(4)]
36+
.map(x => Math.round(x * 255).toString(16).padStart(2, "0"))
37+
.join("");
38+
}
39+
40+
function getLuminance(hex) {
41+
const r = parseInt(hex.slice(1, 3), 16) / 255;
42+
const g = parseInt(hex.slice(3, 5), 16) / 255;
43+
const b = parseInt(hex.slice(5, 7), 16) / 255;
44+
const toLinear = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
45+
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
46+
}
47+
48+
function buildPalette(hex) {
49+
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) hex = "#111827";
50+
51+
const [h, s, l] = hexToHsl(hex);
52+
const secondaryHex = hslToHex((h + 180) % 360, Math.min(s, 60), Math.max(l, 35));
53+
const onPrimary = getLuminance(hex) > 0.179 ? "#111827" : "#ffffff";
54+
const onSecondary = getLuminance(secondaryHex) > 0.179 ? "#111827" : "#ffffff";
55+
56+
return {
57+
primary: hex,
58+
primaryHover: hslToHex(h, s, Math.max(l - 10, 10)),
59+
primaryLight: hslToHex(h, Math.min(s, 80), Math.min(l + 40, 96)),
60+
primaryDark: hslToHex(h, s, Math.max(l - 20, 5)),
61+
onPrimary,
62+
secondary: secondaryHex,
63+
secondaryHover: hslToHex((h + 180) % 360, Math.min(s, 60), Math.max(l - 10, 10)),
64+
secondaryLight: hslToHex((h + 180) % 360, Math.min(s, 60), Math.min(l + 40, 96)),
65+
onSecondary,
66+
};
67+
}
68+
69+
// ─── hexToHsl ─────────────────────────────────────────────────────────────────
70+
71+
describe('hexToHsl', () => {
72+
it('converts pure red correctly', () => {
73+
const [h, s, l] = hexToHsl("#ff0000")
74+
expect(h).toBe(0)
75+
expect(s).toBe(100)
76+
expect(l).toBe(50)
77+
})
78+
79+
it('converts pure green correctly', () => {
80+
const [h, s, l] = hexToHsl("#00ff00")
81+
expect(h).toBe(120)
82+
expect(s).toBe(100)
83+
expect(l).toBe(50)
84+
})
85+
86+
it('converts pure blue correctly', () => {
87+
const [h, s, l] = hexToHsl("#0000ff")
88+
expect(h).toBe(240)
89+
expect(s).toBe(100)
90+
expect(l).toBe(50)
91+
})
92+
93+
it('converts white correctly', () => {
94+
const [h, s, l] = hexToHsl("#ffffff")
95+
expect(s).toBe(0)
96+
expect(l).toBe(100)
97+
})
98+
99+
it('converts black correctly', () => {
100+
const [h, s, l] = hexToHsl("#000000")
101+
expect(s).toBe(0)
102+
expect(l).toBe(0)
103+
})
104+
105+
it('returns array of three numbers', () => {
106+
const result = hexToHsl("#7c3aed")
107+
expect(result).toHaveLength(3)
108+
result.forEach(v => expect(typeof v).toBe('number'))
109+
})
110+
111+
it('returns values in valid ranges', () => {
112+
const [h, s, l] = hexToHsl("#7c3aed")
113+
expect(h).toBeGreaterThanOrEqual(0)
114+
expect(h).toBeLessThanOrEqual(360)
115+
expect(s).toBeGreaterThanOrEqual(0)
116+
expect(s).toBeLessThanOrEqual(100)
117+
expect(l).toBeGreaterThanOrEqual(0)
118+
expect(l).toBeLessThanOrEqual(100)
119+
})
120+
})
121+
122+
// ─── hslToHex ─────────────────────────────────────────────────────────────────
123+
124+
describe('hslToHex', () => {
125+
it('converts pure red correctly', () => {
126+
expect(hslToHex(0, 100, 50)).toBe("#ff0000")
127+
})
128+
129+
it('converts pure green correctly', () => {
130+
expect(hslToHex(120, 100, 50)).toBe("#00ff00")
131+
})
132+
133+
it('converts pure blue correctly', () => {
134+
expect(hslToHex(240, 100, 50)).toBe("#0000ff")
135+
})
136+
137+
it('converts white correctly', () => {
138+
expect(hslToHex(0, 0, 100)).toBe("#ffffff")
139+
})
140+
141+
it('converts black correctly', () => {
142+
expect(hslToHex(0, 0, 0)).toBe("#000000")
143+
})
144+
145+
it('returns a valid hex string', () => {
146+
const result = hslToHex(270, 70, 50)
147+
expect(result).toMatch(/^#[0-9a-f]{6}$/)
148+
})
149+
150+
it('round-trips with hexToHsl within rounding tolerance', () => {
151+
// hex → hsl → hex will not be bit-perfect because hexToHsl uses
152+
// Math.round(), which loses a small amount of precision. We verify
153+
// the result is visually close (each channel within +-2/255).
154+
const original = "#7c3aed"
155+
const [h, s, l] = hexToHsl(original)
156+
const result = hslToHex(h, s, l)
157+
158+
const toChannels = hex => [
159+
parseInt(hex.slice(1, 3), 16),
160+
parseInt(hex.slice(3, 5), 16),
161+
parseInt(hex.slice(5, 7), 16)
162+
]
163+
const [r1, g1, b1] = toChannels(original)
164+
const [r2, g2, b2] = toChannels(result)
165+
166+
expect(Math.abs(r1 - r2)).toBeLessThanOrEqual(2)
167+
expect(Math.abs(g1 - g2)).toBeLessThanOrEqual(2)
168+
expect(Math.abs(b1 - b2)).toBeLessThanOrEqual(2)
169+
})
170+
})
171+
172+
// ─── getLuminance ─────────────────────────────────────────────────────────────
173+
174+
describe('getLuminance', () => {
175+
it('returns 1 for white', () => {
176+
expect(getLuminance("#ffffff")).toBeCloseTo(1, 2)
177+
})
178+
179+
it('returns 0 for black', () => {
180+
expect(getLuminance("#000000")).toBeCloseTo(0, 2)
181+
})
182+
183+
it('returns value between 0 and 1', () => {
184+
const l = getLuminance("#7c3aed")
185+
expect(l).toBeGreaterThan(0)
186+
expect(l).toBeLessThan(1)
187+
})
188+
189+
it('dark colors have lower luminance than light colors', () => {
190+
const dark = getLuminance("#111827")
191+
const light = getLuminance("#f3f4f6")
192+
expect(dark).toBeLessThan(light)
193+
})
194+
195+
it('white has higher luminance than any color', () => {
196+
expect(getLuminance("#ffffff")).toBeGreaterThan(getLuminance("#ff0000"))
197+
expect(getLuminance("#ffffff")).toBeGreaterThan(getLuminance("#7c3aed"))
198+
})
199+
})
200+
201+
// ─── buildPalette ─────────────────────────────────────────────────────────────
202+
203+
describe('buildPalette', () => {
204+
it('returns all required keys', () => {
205+
const palette = buildPalette("#7c3aed")
206+
const requiredKeys = [
207+
'primary', 'primaryHover', 'primaryLight', 'primaryDark',
208+
'onPrimary', 'secondary', 'secondaryHover', 'secondaryLight',
209+
'onSecondary'
210+
]
211+
requiredKeys.forEach(key => {
212+
expect(palette).toHaveProperty(key)
213+
})
214+
})
215+
216+
it('all color values are valid hex strings', () => {
217+
const palette = buildPalette("#7c3aed")
218+
const hexPattern = /^#[0-9a-f]{6}$/i
219+
Object.values(palette).forEach(val => {
220+
expect(val).toMatch(hexPattern)
221+
})
222+
})
223+
224+
it('primary matches the input color', () => {
225+
expect(buildPalette("#7c3aed").primary).toBe("#7c3aed")
226+
expect(buildPalette("#111827").primary).toBe("#111827")
227+
})
228+
229+
it('falls back to default on invalid hex', () => {
230+
expect(buildPalette("notacolor").primary).toBe("#111827")
231+
expect(buildPalette("").primary).toBe("#111827")
232+
expect(buildPalette(null).primary).toBe("#111827")
233+
})
234+
235+
it('onPrimary is white for dark primary colors', () => {
236+
// #111827 is very dark — text on it should be white
237+
expect(buildPalette("#111827").onPrimary).toBe("#ffffff")
238+
})
239+
240+
it('onPrimary is dark for light primary colors', () => {
241+
// #f3f4f6 is very light — text on it should be dark
242+
expect(buildPalette("#f3f4f6").onPrimary).toBe("#111827")
243+
})
244+
245+
it('works with uppercase hex', () => {
246+
const palette = buildPalette("#7C3AED")
247+
expect(palette.primary).toBe("#7C3AED")
248+
expect(palette).toHaveProperty('secondary')
249+
})
250+
})

0 commit comments

Comments
 (0)