go-bananas/web/src/lib/tokens.test.ts

252 lines
8.4 KiB
TypeScript
Raw Normal View History

2026-10-07 15:11:35 +02:00
/**
* 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')
})
})