import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { normalizeMapColor, withAlpha, useMapTokenColor, useMapTokenColors, useMapColorResolver, MAP_TOKEN_COLOR_FALLBACK, } from './use-map-token-color'; import type { ColorToken } from './map.types'; /** Drives what getComputedStyle reports for custom properties. */ let tokenValues: Record = {}; const realGetComputedStyle = window.getComputedStyle; beforeEach(() => { tokenValues = {}; vi.spyOn(window, 'getComputedStyle').mockImplementation(((element: Element) => { if (element === document.documentElement) { return { getPropertyValue: (property: string) => tokenValues[property] ?? '', } as CSSStyleDeclaration; } return realGetComputedStyle(element); }) as typeof window.getComputedStyle); }); afterEach(() => { vi.restoreAllMocks(); document.documentElement.className = ''; document.documentElement.removeAttribute('data-theme'); }); describe('normalizeMapColor', () => { it('expands three-digit hex', () => { expect(normalizeMapColor('#abc')).toBe('#aabbcc'); }); it('passes six-digit hex through', () => { expect(normalizeMapColor('#2C275B')).toBe('#2C275B'); }); it('drops the alpha channel from eight-digit hex', () => { expect(normalizeMapColor('#2c275b80')).toBe('#2c275b'); }); it('converts the rgba() form the tokens actually use', () => { expect(normalizeMapColor('rgba(99, 91, 255, 1)')).toBe('#635bff'); }); it('converts the modern space-and-slash form', () => { expect(normalizeMapColor('rgb(255 0 0 / 50%)')).toBe('#ff0000'); }); it('resolves percentage channels against 255', () => { expect(normalizeMapColor('rgb(100%, 0%, 0%)')).toBe('#ff0000'); }); it('leaves syntax it cannot parse untouched rather than guessing', () => { expect(normalizeMapColor('hsl(210 40% 50%)')).toBe('hsl(210 40% 50%)'); expect(normalizeMapColor('rebeccapurple')).toBe('rebeccapurple'); }); }); describe('withAlpha', () => { it('appends an alpha channel', () => { expect(withAlpha('#635bff', 1)).toBe('#635bffff'); expect(withAlpha('#635bff', 0)).toBe('#635bff00'); }); it('normalizes before applying alpha', () => { expect(withAlpha('rgba(99, 91, 255, 1)', 0.5)).toBe('#635bff80'); }); it('clamps out-of-range alpha', () => { expect(withAlpha('#635bff', 5)).toBe('#635bffff'); expect(withAlpha('#635bff', -1)).toBe('#635bff00'); }); it('returns unparseable colors unchanged', () => { expect(withAlpha('rebeccapurple', 0.5)).toBe('rebeccapurple'); }); }); describe('useMapTokenColor', () => { it('resolves a token to a literal color', () => { tokenValues['--primary'] = 'rgba(99, 91, 255, 1)'; const { result } = renderHook(() => useMapTokenColor('--primary')); expect(result.current).toBe('#635bff'); }); it('falls back when the token is undefined', () => { const { result } = renderHook(() => useMapTokenColor('--nope', '#123456')); expect(result.current).toBe('#123456'); }); it('uses the library fallback when none is given', () => { const { result } = renderHook(() => useMapTokenColor('--nope')); expect(result.current).toBe(MAP_TOKEN_COLOR_FALLBACK); }); it('re-resolves when the theme class changes', async () => { tokenValues['--primary'] = '#111111'; const { result } = renderHook(() => useMapTokenColor('--primary')); expect(result.current).toBe('#111111'); // This is the whole point of the hook: consumer copies of this resolver // read once at mount and never followed a light/dark switch. tokenValues['--primary'] = '#eeeeee'; await act(async () => { document.documentElement.classList.add('dark'); // MutationObserver callbacks are delivered as a microtask. await Promise.resolve(); }); expect(result.current).toBe('#eeeeee'); }); it('re-resolves when brand tokens are rewritten via inline style', async () => { tokenValues['--primary'] = '#111111'; const { result } = renderHook(() => useMapTokenColor('--primary')); tokenValues['--primary'] = '#222222'; await act(async () => { // BrandColorsContext applies themes with root.style.setProperty. document.documentElement.style.setProperty('--primary', '#222222'); await Promise.resolve(); }); expect(result.current).toBe('#222222'); }); }); describe('useMapTokenColors', () => { it('resolves a whole set in one pass', () => { tokenValues['--chart-1'] = '#aa0000'; tokenValues['--chart-2'] = '#00aa00'; const { result } = renderHook(() => useMapTokenColors(['--chart-1', '--chart-2'])); expect(result.current).toEqual({ '--chart-1': '#aa0000', '--chart-2': '#00aa00' }); }); it('keeps a stable record across renders with an equal-but-new array', () => { tokenValues['--chart-1'] = '#aa0000'; const { result, rerender } = renderHook(({ tokens }) => useMapTokenColors(tokens), { initialProps: { tokens: ['--chart-1'] as ColorToken[] }, }); const first = result.current; // A new array with the same contents must not rebuild the record. rerender({ tokens: ['--chart-1'] as ColorToken[] }); expect(result.current).toBe(first); }); }); describe('useMapColorResolver', () => { it('resolves tokens discovered during render', () => { tokenValues['--destructive'] = 'rgba(185, 28, 28, 1)'; const { result } = renderHook(() => useMapColorResolver()); expect(result.current('--destructive')).toBe('#b91c1c'); }); it('changes identity on a theme change so effects can re-run', async () => { const { result } = renderHook(() => useMapColorResolver()); const first = result.current; await act(async () => { document.documentElement.classList.add('dark'); await Promise.resolve(); }); expect(result.current).not.toBe(first); }); });