252 lines
8.4 KiB
TypeScript
252 lines
8.4 KiB
TypeScript
|
|
/**
|
||
|
|
* 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')
|
||
|
|
})
|
||
|
|
})
|