initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
252
web/src/lib/tokens.test.ts
Normal file
252
web/src/lib/tokens.test.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue