/** * Tests for the token-document helpers. * * These are the functions the theme editor leans on hardest, and the ones where a * silent mistake is most expensive: a lost token, an accidental shared reference * between two copies of the document, or a pruned group that turns a save into an * error. */ import { describe, expect, it } from 'vitest' import { appearanceOverrides, cloneTokens, colourMap, cssVariableName, deleteToken, effectiveValue, getToken, getTokenValue, isGroup, isToken, listGroups, listTokens, looksLikeColour, setAppearanceOverrides, setToken, toPickerValue, } from '../lib/tokens' import type { TokenDoc, TokenNode } from '../lib/types' /** A small but representative document. */ function fixture(): TokenDoc { return { $schema: 'https://urpi.be/schemas/go-bananas/tokens/v1', color: { palette: { brand: { '500': { $type: 'color', $value: '#4f46e5' }, '600': { $type: 'color', $value: '#4338ca' }, }, }, semantic: { background: { $type: 'color', $value: '#ffffff' }, primary: { $type: 'color', $value: '#4f46e5' }, }, }, space: { '4': { $type: 'dimension', $value: 1 }, }, $appearances: { dark: { color: { semantic: { background: { $type: 'color', $value: '#0b1220' }, }, }, }, }, } } describe('isToken / isGroup', () => { it('distinguishes leaves from groups', () => { expect(isToken({ $value: 1 })).toBe(true) expect(isToken({ nested: {} })).toBe(false) expect(isGroup({ nested: {} })).toBe(true) expect(isGroup({ $value: 1 })).toBe(false) expect(isGroup(null)).toBe(false) }) }) describe('getToken', () => { it('reads a leaf by dotted path', () => { const token = getToken(fixture(), 'color.semantic.primary') expect(token?.$value).toBe('#4f46e5') }) it('returns undefined for a missing path', () => { expect(getToken(fixture(), 'color.semantic.nope')).toBeUndefined() expect(getTokenValue(fixture(), 'nope.nope.nope')).toBeUndefined() }) it('does not mistake a group for a token', () => { expect(getToken(fixture(), 'color.semantic')).toBeUndefined() }) }) describe('setToken', () => { it('creates intermediate groups', () => { const next = setToken(fixture(), 'border.width.default', 1, 'dimension') expect(getTokenValue(next, 'border.width.default')).toBe(1) }) it('does not mutate the source document', () => { const source = fixture() const before = JSON.stringify(source) setToken(source, 'space.4', 99, 'dimension') expect(JSON.stringify(source)).toBe(before) }) it('rejects a reserved structural key', () => { expect(() => setToken(fixture(), '$schema.value', 1, 'dimension')).toThrow(/reserved/) }) it('allows an unrecognised group, leaving the server as the authority', () => { // The editor does not carry the engine's group list, so it writes optimistically // and lets validation reject an unknown group. Duplicating the schema client-side // would guarantee the two drifted apart. const next = setToken(fixture(), 'colour.typo', 1, 'dimension') expect(getTokenValue(next, 'colour.typo')).toBe(1) }) it('overwrites an existing leaf', () => { const next = setToken(fixture(), 'color.semantic.primary', '#ff0000', 'color') expect(getTokenValue(next, 'color.semantic.primary')).toBe('#ff0000') }) }) describe('deleteToken', () => { it('removes a leaf and prunes the group it emptied', () => { let doc = fixture() doc = setToken(doc, 'border.width.default', 1, 'dimension') doc = deleteToken(doc, 'border.width.default') expect(getToken(doc, 'border.width.default')).toBeUndefined() // The empty `border` and `width` groups must be gone, or validation fails. expect(doc.border).toBeUndefined() }) it('keeps siblings', () => { const next = deleteToken(fixture(), 'color.palette.brand.500') expect(getToken(next, 'color.palette.brand.600')).toBeDefined() expect(getToken(next, 'color.palette.brand.500')).toBeUndefined() }) it('refuses to delete a whole top-level group', () => { const source = fixture() expect(deleteToken(source, 'color')).toBe(source) }) it('is a no-op for a missing path', () => { const next = deleteToken(fixture(), 'nope.nope') expect(getToken(next, 'color.semantic.primary')).toBeDefined() }) }) describe('listTokens / listGroups', () => { it('lists leaves sorted, excluding structural keys', () => { const paths = listTokens(fixture()) expect(paths).toContain('color.semantic.primary') expect(paths).toContain('space.4') expect(paths.some((path) => path.startsWith('$'))).toBe(false) expect([...paths].sort()).toEqual(paths) }) it('lists groups sorted', () => { const groups = listGroups(fixture()) expect(groups).toContain('color') expect(groups).toContain('color.semantic') expect([...groups].sort()).toEqual(groups) }) }) describe('appearance overrides', () => { it('reads the overrides for an appearance', () => { const dark = appearanceOverrides(fixture(), 'dark') expect(getTokenValue(dark, 'color.semantic.background')).toBe('#0b1220') }) it('returns an empty document for an unknown appearance', () => { expect(Object.keys(appearanceOverrides(fixture(), 'sepia'))).toHaveLength(0) }) it('falls back to the base value when there is no override', () => { expect(effectiveValue(fixture(), 'color.semantic.primary', 'dark')).toBe('#4f46e5') }) it('prefers the override when one exists', () => { expect(effectiveValue(fixture(), 'color.semantic.background', 'dark')).toBe('#0b1220') }) it('writes overrides without disturbing the base', () => { const doc = setAppearanceOverrides(fixture(), 'dark', setToken({}, 'color.semantic.text', '#fff', 'color')) expect(getTokenValue(doc, 'color.semantic.background')).toBe('#ffffff') expect(getTokenValue(appearanceOverrides(doc, 'dark'), 'color.semantic.text')).toBe('#fff') }) }) describe('cssVariableName', () => { it('builds the published name', () => { expect(cssVariableName('color.semantic.background')).toBe('--bn-color-semantic-background') expect(cssVariableName('space.4')).toBe('--bn-space-4') }) it('kebab-cases the camelCase keys DTCG spells that way', () => { expect(cssVariableName('font.lineHeight.tight')).toBe('--bn-font-line-height-tight') expect(cssVariableName('font.letterSpacing.wide')).toBe('--bn-font-letter-spacing-wide') }) it('does not rewrite an ordinary camelCase token name', () => { // Only the known DTCG keys are aliased; a token genuinely called brandDark // must survive intact. expect(cssVariableName('color.brandDark')).toBe('--bn-color-brandDark') }) it('honours a custom prefix', () => { expect(cssVariableName('space.4', '--t')).toBe('--t-space-4') }) }) describe('colour helpers', () => { it('recognises the colour syntaxes', () => { for (const value of ['#fff', '#ffffff', '#ffffff80', 'rgb(0,0,0)', 'hsl(0 0% 0%)', 'oklch(0.5 0.1 20)']) { expect(looksLikeColour(value)).toBe(true) } expect(looksLikeColour('1px')).toBe(false) expect(looksLikeColour('Inter, sans-serif')).toBe(false) }) it('normalises to a form the native picker accepts', () => { expect(toPickerValue('#ABC')).toBe('#aabbcc') expect(toPickerValue('#4F46E5')).toBe('#4f46e5') expect(toPickerValue('rgb(79, 70, 229)')).toBe('#4f46e5') // Something unparseable must still yield a valid value, never an empty string // that would render as a broken swatch. expect(toPickerValue('var(--x)')).toMatch(/^#[0-9a-f]{6}$/) }) it('collects only the colour leaves', () => { const colours = colourMap(fixture(), 'light') expect(colours['color.semantic.primary']).toBe('#4f46e5') expect(colours['space.4']).toBeUndefined() }) }) describe('cloneTokens', () => { it('produces an independent copy', () => { const source = fixture() const copy = cloneTokens(source) // A structural clone means even a direct mutation of the copy leaves the // original alone, which is the property that lets the editor hold a working // copy and revert without a round trip. const color = copy.color as TokenNode const semantic = color.semantic as TokenNode semantic.primary = { $type: 'color', $value: '#000000' } expect(getTokenValue(source, 'color.semantic.primary')).toBe('#4f46e5') expect(getTokenValue(copy, 'color.semantic.primary')).toBe('#000000') }) })