temp tests
This commit is contained in:
parent
f92e7af18d
commit
2fac626e46
|
|
@ -3,7 +3,8 @@
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --hot server.tsx"
|
"dev": "bun run --hot server.tsx",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,19 @@
|
||||||
import type { JSX } from 'hono/jsx'
|
import type { JSX } from 'hono/jsx'
|
||||||
import { type TagDef, UnitlessProps } from './types'
|
import { type TagDef, UnitlessProps } from './types'
|
||||||
|
|
||||||
const styles: Record<string, Record<string, string>> = {}
|
export const styles: Record<string, Record<string, string>> = {}
|
||||||
export const Styles = () => <style dangerouslySetInnerHTML={{ __html: stylesToCSS(styles) }} />
|
export const Styles = () => <style dangerouslySetInnerHTML={{ __html: stylesToCSS(styles) }} />
|
||||||
|
|
||||||
// turns style object into string CSS definition
|
// turns style object into string CSS definition
|
||||||
function stylesToCSS(styles: Record<string, Record<string, string>>): string {
|
export function stylesToCSS(styles: Record<string, Record<string, string>>): string {
|
||||||
let out: string[] = []
|
let out: string[] = []
|
||||||
|
|
||||||
for (const [selector, style] of Object.entries(styles)) {
|
for (const [selector, style] of Object.entries(styles)) {
|
||||||
|
if (Object.keys(style).length === 0) continue
|
||||||
|
|
||||||
out.push(`.${selector} {`)
|
out.push(`.${selector} {`)
|
||||||
for (const [name, value] of Object.entries(style)) {
|
for (const [name, value] of Object.entries(style).sort(([a], [b]) => a.localeCompare(b)))
|
||||||
out.push(` ${name}: ${value};`)
|
out.push(` ${name}: ${value};`)
|
||||||
}
|
|
||||||
out.push(`}\n`)
|
out.push(`}\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +74,7 @@ function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<stri
|
||||||
}
|
}
|
||||||
if (!variantDef) continue
|
if (!variantDef) continue
|
||||||
|
|
||||||
classNames.push(`${variantName}-${variantKey}`)
|
classNames.push(variantKey === true ? variantName : `${variantName}-${variantKey}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ({ children, ...props }: { children: any, [key: string]: any }) =>
|
return ({ children, ...props }: { children: any, [key: string]: any }) =>
|
||||||
|
|
@ -85,19 +86,49 @@ function registerStyles(name: string, def: TagDef) {
|
||||||
const rootClassName = makeClassName(name)
|
const rootClassName = makeClassName(name)
|
||||||
styles[rootClassName] ??= makeStyle(def)
|
styles[rootClassName] ??= makeStyle(def)
|
||||||
|
|
||||||
|
for (const [state, style] of Object.entries(def.states ?? {}))
|
||||||
|
styles[`${rootClassName}${state}`] = makeStyle(style)
|
||||||
|
|
||||||
for (const [partName, partDef] of Object.entries(def.parts ?? {})) {
|
for (const [partName, partDef] of Object.entries(def.parts ?? {})) {
|
||||||
const partClassName = makeClassName(name, partName)
|
const partClassName = makeClassName(name, partName)
|
||||||
styles[partClassName] ??= makeStyle(partDef)
|
styles[partClassName] ??= makeStyle(partDef)
|
||||||
|
for (const [state, style] of Object.entries(partDef.states ?? {}))
|
||||||
|
styles[`${partClassName}${state}`] = makeStyle(style)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [variantName, variantConfig] of Object.entries(def.variants ?? {})) {
|
for (const [variantName, variantConfig] of Object.entries(def.variants ?? {})) {
|
||||||
for (const [variantKey, variantDef] of Object.entries(variantConfig as Record<string, TagDef>)) {
|
// Check if it's a boolean variant (has layout/look/parts directly) or a keyed variant
|
||||||
const className = makeClassName(name, undefined, variantName, variantKey)
|
if ('parts' in variantConfig || 'layout' in variantConfig || 'look' in variantConfig) {
|
||||||
|
// Boolean variant - treat variantConfig as TagDef
|
||||||
|
const variantDef = variantConfig as TagDef
|
||||||
|
const baseClassName = makeClassName(name)
|
||||||
|
const className = `${baseClassName}.${variantName}`
|
||||||
styles[className] ??= makeStyle({ layout: variantDef.layout, look: variantDef.look })
|
styles[className] ??= makeStyle({ layout: variantDef.layout, look: variantDef.look })
|
||||||
|
for (const [state, style] of Object.entries(variantDef.states ?? {}))
|
||||||
|
styles[`${className}${state}`] = makeStyle(style)
|
||||||
|
|
||||||
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
|
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
|
||||||
const partClassName = makeClassName(name, partName, variantName, variantKey)
|
const basePartClassName = makeClassName(name, partName)
|
||||||
|
const partClassName = `${basePartClassName}.${variantName}`
|
||||||
styles[partClassName] ??= makeStyle(partDef)
|
styles[partClassName] ??= makeStyle(partDef)
|
||||||
|
for (const [state, style] of Object.entries(partDef.states ?? {}))
|
||||||
|
styles[`${partClassName}${state}`] = makeStyle(style)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Keyed variant - iterate over the keys
|
||||||
|
for (const [variantKey, variantDef] of Object.entries(variantConfig as Record<string, TagDef>)) {
|
||||||
|
const className = makeClassName(name, undefined, variantName, variantKey)
|
||||||
|
styles[className] ??= makeStyle({ layout: variantDef.layout, look: variantDef.look })
|
||||||
|
for (const [state, style] of Object.entries(variantDef.states ?? {}))
|
||||||
|
styles[`${className}${state}`] = makeStyle(style)
|
||||||
|
|
||||||
|
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
|
||||||
|
const partClassName = makeClassName(name, partName, variantName, variantKey)
|
||||||
|
styles[partClassName] ??= makeStyle(partDef)
|
||||||
|
for (const [state, style] of Object.entries(partDef.states ?? {}))
|
||||||
|
styles[`${partClassName}${state}`] = makeStyle(style)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +150,7 @@ export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) {
|
||||||
parts[part] = makeComponent(name, def, props, part)
|
parts[part] = makeComponent(name, def, props, part)
|
||||||
|
|
||||||
parts.Root = makeComponent(name, def, props)
|
parts.Root = makeComponent(name, def, props)
|
||||||
return def.render ? def.render({ props, parts }) : <parts.Root {...props}>{props.children}</parts.Root>
|
return def.render?.({ props, parts }) ?? <parts.Root {...props}>{props.children}</parts.Root>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
748
src/tests/index.test.tsx
Normal file
748
src/tests/index.test.tsx
Normal file
|
|
@ -0,0 +1,748 @@
|
||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { define } from '../index'
|
||||||
|
import { renderToString, getStylesCSS, parseCSS } from './test_helpers'
|
||||||
|
|
||||||
|
describe('define - basic functionality', () => {
|
||||||
|
test('creates a component function', () => {
|
||||||
|
const Component = define({
|
||||||
|
layout: { display: 'flex' }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(typeof Component).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('component returns a JSX element', () => {
|
||||||
|
const Component = define({
|
||||||
|
layout: { display: 'flex' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = Component({})
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(typeof result).toBe('object')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applies className to rendered element', () => {
|
||||||
|
const Component = define('MyComponent', {
|
||||||
|
layout: { display: 'flex' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('class="MyComponent"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates unique anonymous component names', () => {
|
||||||
|
const Component1 = define({ layout: { display: 'flex' } })
|
||||||
|
const Component2 = define({ layout: { display: 'block' } })
|
||||||
|
|
||||||
|
const html1 = renderToString(Component1({}))
|
||||||
|
const html2 = renderToString(Component2({}))
|
||||||
|
|
||||||
|
// Should have different auto-generated names
|
||||||
|
expect(html1).toMatch(/class="Def\d+"/)
|
||||||
|
expect(html2).toMatch(/class="Def\d+"/)
|
||||||
|
expect(html1).not.toBe(html2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders default div element', () => {
|
||||||
|
const Component = define('DivTest', {
|
||||||
|
layout: { display: 'flex' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('<div')
|
||||||
|
expect(html).toContain('</div>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('respects custom base element', () => {
|
||||||
|
const Component = define('ButtonTest', {
|
||||||
|
base: 'button',
|
||||||
|
look: { color: 'blue' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('<button')
|
||||||
|
expect(html).toContain('</button>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes children through to component', () => {
|
||||||
|
const Component = define({})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ children: 'Hello World' }))
|
||||||
|
expect(html).toContain('Hello World')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes additional props through to component', () => {
|
||||||
|
const Component = define({})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ id: 'test-id', 'data-test': 'value' }))
|
||||||
|
expect(html).toContain('id="test-id"')
|
||||||
|
expect(html).toContain('data-test="value"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CSS generation - camelCase to kebab-case', () => {
|
||||||
|
test('converts camelCase properties to kebab-case', () => {
|
||||||
|
define('CamelTest', {
|
||||||
|
layout: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('flex-direction: column')
|
||||||
|
expect(css).toContain('align-items: center')
|
||||||
|
expect(css).toContain('justify-content: space-between')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles consecutive capital letters', () => {
|
||||||
|
define('ConsecutiveTest', {
|
||||||
|
look: {
|
||||||
|
backgroundColor: 'red',
|
||||||
|
borderRadius: 5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('background-color: red')
|
||||||
|
expect(css).toContain('border-radius: 5px')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CSS generation - numeric values and units', () => {
|
||||||
|
test('adds px unit to numeric layout values', () => {
|
||||||
|
define('NumericTest', {
|
||||||
|
layout: {
|
||||||
|
width: 100,
|
||||||
|
height: 200,
|
||||||
|
padding: 16,
|
||||||
|
margin: 8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['NumericTest']?.['width']).toBe('100px')
|
||||||
|
expect(styles['NumericTest']?.['height']).toBe('200px')
|
||||||
|
expect(styles['NumericTest']?.['padding']).toBe('16px')
|
||||||
|
expect(styles['NumericTest']?.['margin']).toBe('8px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves string values without adding px', () => {
|
||||||
|
define('StringTest', {
|
||||||
|
layout: {
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '1rem'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['StringTest']?.['width']).toBe('100%')
|
||||||
|
expect(styles['StringTest']?.['height']).toBe('auto')
|
||||||
|
expect(styles['StringTest']?.['margin']).toBe('0 auto')
|
||||||
|
expect(styles['StringTest']?.['padding']).toBe('1rem')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not add px to unitless properties', () => {
|
||||||
|
define('UnitlessTest', {
|
||||||
|
layout: {
|
||||||
|
flex: 1,
|
||||||
|
flexGrow: 2,
|
||||||
|
flexShrink: 1,
|
||||||
|
zIndex: 10,
|
||||||
|
order: 3
|
||||||
|
},
|
||||||
|
look: {
|
||||||
|
opacity: 0.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['UnitlessTest']?.['flex']).toBe('1')
|
||||||
|
expect(styles['UnitlessTest']?.['flex-grow']).toBe('2')
|
||||||
|
expect(styles['UnitlessTest']?.['flex-shrink']).toBe('1')
|
||||||
|
expect(styles['UnitlessTest']?.['z-index']).toBe('10')
|
||||||
|
expect(styles['UnitlessTest']?.['order']).toBe('3')
|
||||||
|
expect(styles['UnitlessTest']?.['opacity']).toBe('0.5')
|
||||||
|
expect(styles['UnitlessTest']?.['font-weight']).toBe('700')
|
||||||
|
expect(styles['UnitlessTest']?.['line-height']).toBe('1.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles numeric zero values correctly', () => {
|
||||||
|
define('ZeroTest', {
|
||||||
|
layout: {
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
zIndex: 0
|
||||||
|
},
|
||||||
|
look: {
|
||||||
|
opacity: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['ZeroTest']?.['margin']).toBe('0px')
|
||||||
|
expect(styles['ZeroTest']?.['padding']).toBe('0px')
|
||||||
|
expect(styles['ZeroTest']?.['z-index']).toBe('0')
|
||||||
|
expect(styles['ZeroTest']?.['opacity']).toBe('0')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CSS generation - layout and look', () => {
|
||||||
|
test('generates CSS for layout properties', () => {
|
||||||
|
define('LayoutTest', {
|
||||||
|
layout: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
padding: 20
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.LayoutTest')
|
||||||
|
expect(css).toContain('display: flex')
|
||||||
|
expect(css).toContain('flex-direction: column')
|
||||||
|
expect(css).toContain('gap: 16px')
|
||||||
|
expect(css).toContain('padding: 20px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates CSS for look properties', () => {
|
||||||
|
define('LookTest', {
|
||||||
|
look: {
|
||||||
|
color: 'blue',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.LookTest')
|
||||||
|
expect(css).toContain('color: blue')
|
||||||
|
expect(css).toContain('background-color: white')
|
||||||
|
expect(css).toContain('font-size: 16px')
|
||||||
|
expect(css).toContain('font-weight: 600')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('combines layout and look properties', () => {
|
||||||
|
define('CombinedTest', {
|
||||||
|
layout: {
|
||||||
|
display: 'flex',
|
||||||
|
padding: 16
|
||||||
|
},
|
||||||
|
look: {
|
||||||
|
color: 'blue',
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
const combined = styles['CombinedTest']!
|
||||||
|
expect(combined['display']).toBe('flex')
|
||||||
|
expect(combined['padding']).toBe('16px')
|
||||||
|
expect(combined['color']).toBe('blue')
|
||||||
|
expect(combined['background-color']).toBe('white')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CSS generation - parts', () => {
|
||||||
|
test('generates separate CSS for each part', () => {
|
||||||
|
define('PartTest', {
|
||||||
|
layout: { display: 'flex' },
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header', look: { color: 'red', fontSize: 20 } },
|
||||||
|
Body: { base: 'main', layout: { padding: 20 } },
|
||||||
|
Footer: { base: 'footer', look: { fontSize: 12 } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['PartTest_Header']?.['color']).toBe('red')
|
||||||
|
expect(styles['PartTest_Header']?.['font-size']).toBe('20px')
|
||||||
|
expect(styles['PartTest_Body']?.['padding']).toBe('20px')
|
||||||
|
expect(styles['PartTest_Footer']?.['font-size']).toBe('12px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('part className format is ComponentName_PartName', () => {
|
||||||
|
define('ComponentWithParts', {
|
||||||
|
parts: {
|
||||||
|
MyPart: { look: { color: 'green' } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.ComponentWithParts_MyPart')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('components with parts', () => {
|
||||||
|
test('creates part components accessible through render function', () => {
|
||||||
|
let capturedParts: any
|
||||||
|
|
||||||
|
const Component = define('PartRenderTest', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' },
|
||||||
|
Body: { base: 'main' }
|
||||||
|
},
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
capturedParts = parts
|
||||||
|
return <parts.Root {...props}>{props.children}</parts.Root>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Component({})
|
||||||
|
|
||||||
|
expect(typeof capturedParts.Header).toBe('function')
|
||||||
|
expect(typeof capturedParts.Body).toBe('function')
|
||||||
|
expect(typeof capturedParts.Root).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('part components render with correct className', () => {
|
||||||
|
const Component = define('PartClassTest', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' }
|
||||||
|
},
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return <parts.Header>Header Content</parts.Header>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('class="PartClassTest_Header"')
|
||||||
|
expect(html).toContain('<header')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parts render with correct base elements', () => {
|
||||||
|
const Component = define('PartBaseTest', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' },
|
||||||
|
Main: { base: 'main' },
|
||||||
|
Footer: { base: 'footer' }
|
||||||
|
},
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<parts.Header>Header</parts.Header>
|
||||||
|
<parts.Main>Main</parts.Main>
|
||||||
|
<parts.Footer>Footer</parts.Footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('<header')
|
||||||
|
expect(html).toContain('<main')
|
||||||
|
expect(html).toContain('<footer')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('variants - boolean variants', () => {
|
||||||
|
test('applies boolean variant class when true', () => {
|
||||||
|
const Component = define('BoolVariant', {
|
||||||
|
layout: { display: 'flex' },
|
||||||
|
variants: {
|
||||||
|
primary: {
|
||||||
|
look: { color: 'blue' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ primary: true }))
|
||||||
|
// Boolean variants add just the variant name as a class
|
||||||
|
expect(html).toContain('class="BoolVariant primary"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not apply boolean variant class when false or absent', () => {
|
||||||
|
const Component = define('BoolVariantFalse', {
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
look: { backgroundColor: 'green' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const htmlFalse = renderToString(Component({ active: false }))
|
||||||
|
const htmlAbsent = renderToString(Component({}))
|
||||||
|
|
||||||
|
// When false or absent, variant class should not be added (check the class attribute)
|
||||||
|
expect(htmlFalse).toContain('class="BoolVariantFalse"')
|
||||||
|
expect(htmlFalse).not.toContain('class="BoolVariantFalse active')
|
||||||
|
expect(htmlAbsent).toContain('class="BoolVariantFalse"')
|
||||||
|
expect(htmlAbsent).not.toContain(' active')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates CSS for component with boolean variant', () => {
|
||||||
|
define('BoolVariantCSS', {
|
||||||
|
layout: { display: 'block' },
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
look: { backgroundColor: 'green' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.BoolVariantCSS')
|
||||||
|
expect(css).toContain('display: block')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('variants - string/enum variants', () => {
|
||||||
|
test('applies string variant class', () => {
|
||||||
|
const Component = define('StringVariant', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { layout: { padding: 8 } },
|
||||||
|
medium: { layout: { padding: 16 } },
|
||||||
|
large: { layout: { padding: 24 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const htmlSmall = renderToString(Component({ size: 'small' }))
|
||||||
|
const htmlLarge = renderToString(Component({ size: 'large' }))
|
||||||
|
|
||||||
|
expect(htmlSmall).toContain('class="StringVariant size-small"')
|
||||||
|
expect(htmlLarge).toContain('class="StringVariant size-large"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates CSS for each variant option', () => {
|
||||||
|
define('ColorVariant', {
|
||||||
|
variants: {
|
||||||
|
color: {
|
||||||
|
red: { look: { color: 'red', backgroundColor: '#ffeeee' } },
|
||||||
|
blue: { look: { color: 'blue', backgroundColor: '#eeeeff' } },
|
||||||
|
green: { look: { color: 'green', backgroundColor: '#eeffee' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['ColorVariant.color-red']?.['color']).toBe('red')
|
||||||
|
expect(styles['ColorVariant.color-blue']?.['color']).toBe('blue')
|
||||||
|
expect(styles['ColorVariant.color-green']?.['color']).toBe('green')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applies multiple variant classes', () => {
|
||||||
|
const Component = define('MultiVariant', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { layout: { padding: 8 } },
|
||||||
|
large: { layout: { padding: 24 } }
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
red: { look: { color: 'red' } },
|
||||||
|
blue: { look: { color: 'blue' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ size: 'small', color: 'blue' }))
|
||||||
|
expect(html).toContain('size-small')
|
||||||
|
expect(html).toContain('color-blue')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores undefined variant values', () => {
|
||||||
|
const Component = define('UndefinedVariant', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { layout: { padding: 8 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ size: 'nonexistent' }))
|
||||||
|
expect(html).toContain('class="UndefinedVariant"')
|
||||||
|
expect(html).not.toContain('size-nonexistent')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores non-variant props', () => {
|
||||||
|
const Component = define('NonVariantProps', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { layout: { padding: 8 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ size: 'small', randomProp: 'value', id: 'test' }))
|
||||||
|
expect(html).toContain('size-small')
|
||||||
|
expect(html).toContain('id="test"')
|
||||||
|
expect(html).toContain('randomProp="value"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('variants with parts', () => {
|
||||||
|
test('generates CSS for variant parts', () => {
|
||||||
|
define('VariantParts', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' }
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
theme: {
|
||||||
|
dark: {
|
||||||
|
parts: {
|
||||||
|
Header: { look: { color: 'white', backgroundColor: 'black' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
parts: {
|
||||||
|
Header: { look: { color: 'black', backgroundColor: 'white' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['VariantParts_Header.theme-dark']?.['color']).toBe('white')
|
||||||
|
expect(styles['VariantParts_Header.theme-dark']?.['background-color']).toBe('black')
|
||||||
|
expect(styles['VariantParts_Header.theme-light']?.['color']).toBe('black')
|
||||||
|
expect(styles['VariantParts_Header.theme-light']?.['background-color']).toBe('white')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('part elements include variant classes', () => {
|
||||||
|
const Component = define('VariantPartClass', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' }
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
large: { layout: { padding: 20 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return <parts.Header>Header Content</parts.Header>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ size: 'large' }))
|
||||||
|
expect(html).toContain('VariantPartClass_Header')
|
||||||
|
expect(html).toContain('size-large')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('custom render function', () => {
|
||||||
|
test('uses custom render when provided', () => {
|
||||||
|
const Component = define('CustomRender', {
|
||||||
|
layout: { display: 'flex' },
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return <div class="custom-wrapper">{props.children}</div>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ children: 'Content' }))
|
||||||
|
expect(html).toContain('class="custom-wrapper"')
|
||||||
|
expect(html).toContain('Content')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('custom render receives props', () => {
|
||||||
|
let receivedProps: any
|
||||||
|
|
||||||
|
const Component = define({
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
receivedProps = props
|
||||||
|
return <parts.Root {...props}>{props.children}</parts.Root>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Component({ testProp: 'value', children: 'Test' })
|
||||||
|
|
||||||
|
expect(receivedProps.testProp).toBe('value')
|
||||||
|
expect(receivedProps.children).toBe('Test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('custom render can compose parts', () => {
|
||||||
|
const Component = define('ComposeParts', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' },
|
||||||
|
Body: { base: 'main' },
|
||||||
|
Footer: { base: 'footer' }
|
||||||
|
},
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return (
|
||||||
|
<parts.Root>
|
||||||
|
<parts.Header>Header</parts.Header>
|
||||||
|
<parts.Body>{props.children}</parts.Body>
|
||||||
|
<parts.Footer>Footer</parts.Footer>
|
||||||
|
</parts.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ children: 'Main Content' }))
|
||||||
|
expect(html).toContain('<header')
|
||||||
|
expect(html).toContain('<main')
|
||||||
|
expect(html).toContain('<footer')
|
||||||
|
expect(html).toContain('Main Content')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Styles component', () => {
|
||||||
|
test('Styles is accessible via define.Styles', () => {
|
||||||
|
expect(typeof define.Styles).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Styles returns a style element', () => {
|
||||||
|
const result = define.Styles() as any
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result.props.dangerouslySetInnerHTML).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Styles renders to HTML with CSS', () => {
|
||||||
|
define('StylesComp1', { layout: { width: 100 } })
|
||||||
|
|
||||||
|
const html = renderToString(define.Styles())
|
||||||
|
expect(html).toContain('<style>')
|
||||||
|
expect(html).toContain('.StylesComp1')
|
||||||
|
expect(html).toContain('width: 100px')
|
||||||
|
expect(html).toContain('</style>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Styles includes CSS for all registered components', () => {
|
||||||
|
define('StylesComp2', { layout: { width: 100 } })
|
||||||
|
define('StylesComp3', { layout: { height: 200 } })
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.StylesComp2')
|
||||||
|
expect(css).toContain('width: 100px')
|
||||||
|
expect(css).toContain('.StylesComp3')
|
||||||
|
expect(css).toContain('height: 200px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Styles includes variant CSS', () => {
|
||||||
|
define('StylesVariant', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { layout: { padding: 8 } },
|
||||||
|
large: { layout: { padding: 24 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.StylesVariant.size-small')
|
||||||
|
expect(css).toContain('padding: 8px')
|
||||||
|
expect(css).toContain('.StylesVariant.size-large')
|
||||||
|
expect(css).toContain('padding: 24px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Styles includes part CSS', () => {
|
||||||
|
define('StylesPart', {
|
||||||
|
parts: {
|
||||||
|
Header: { look: { color: 'red' } },
|
||||||
|
Body: { look: { color: 'blue' } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.StylesPart_Header')
|
||||||
|
expect(css).toContain('color: red')
|
||||||
|
expect(css).toContain('.StylesPart_Body')
|
||||||
|
expect(css).toContain('color: blue')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
test('handles empty definition', () => {
|
||||||
|
const Component = define({})
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
|
||||||
|
expect(html).toBeDefined()
|
||||||
|
expect(html).toMatch(/class="Def\d+"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles definition with only parts', () => {
|
||||||
|
const Component = define({
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = Component({})
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles definition with only variants', () => {
|
||||||
|
const Component = define({
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { layout: { padding: 8 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ size: 'small' }))
|
||||||
|
expect(html).toContain('size-small')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not duplicate styles when registered multiple times', () => {
|
||||||
|
define('NoDuplicate', { layout: { width: 100 } })
|
||||||
|
define('NoDuplicate', { layout: { width: 200 } })
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
// Should keep first value (??= operator)
|
||||||
|
expect(styles['NoDuplicate']?.['width']).toBe('100px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles complex nested structures', () => {
|
||||||
|
define('ComplexNested', {
|
||||||
|
layout: { display: 'grid' },
|
||||||
|
parts: {
|
||||||
|
Container: { layout: { padding: 16 } },
|
||||||
|
Item: { look: { fontSize: 14 } }
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
theme: {
|
||||||
|
dark: {
|
||||||
|
look: { backgroundColor: 'black' },
|
||||||
|
parts: {
|
||||||
|
Container: { look: { backgroundColor: '#222' } },
|
||||||
|
Item: { look: { color: 'white' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['ComplexNested']?.['display']).toBe('grid')
|
||||||
|
expect(styles['ComplexNested_Container']?.['padding']).toBe('16px')
|
||||||
|
expect(styles['ComplexNested_Item']?.['font-size']).toBe('14px')
|
||||||
|
expect(styles['ComplexNested.theme-dark']?.['background-color']).toBe('black')
|
||||||
|
expect(styles['ComplexNested_Container.theme-dark']?.['background-color']).toBe('#222')
|
||||||
|
expect(styles['ComplexNested_Item.theme-dark']?.['color']).toBe('white')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles JSX children correctly', () => {
|
||||||
|
const Component = define('ChildrenTest', {})
|
||||||
|
|
||||||
|
const html = renderToString(Component({
|
||||||
|
children: <span>Nested Element</span>
|
||||||
|
}))
|
||||||
|
expect(html).toContain('<span>Nested Element</span>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles multiple children', () => {
|
||||||
|
const Component = define('MultiChildren', {
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return <parts.Root>{props.children}</parts.Root>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({
|
||||||
|
children: [
|
||||||
|
<div>First</div>,
|
||||||
|
<div>Second</div>
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
expect(html).toContain('First')
|
||||||
|
expect(html).toContain('Second')
|
||||||
|
})
|
||||||
|
})
|
||||||
32
src/tests/test_helpers.ts
Normal file
32
src/tests/test_helpers.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { define } from '../index'
|
||||||
|
|
||||||
|
export function renderToString(jsx: any): string {
|
||||||
|
return jsx.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStylesCSS(): string {
|
||||||
|
const StylesComponent = define.Styles
|
||||||
|
const result = StylesComponent() as any
|
||||||
|
return result.props.dangerouslySetInnerHTML.__html as string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCSS(css: string): Record<string, Record<string, string>> {
|
||||||
|
const styles: Record<string, Record<string, string>> = {}
|
||||||
|
const classMatches = Array.from(css.matchAll(/\.([^\s{]+)\s*\{([^}]+)\}/g))
|
||||||
|
|
||||||
|
for (const match of classMatches) {
|
||||||
|
if (!match[1] || !match[2]) continue
|
||||||
|
|
||||||
|
const className = match[1]
|
||||||
|
const cssText = match[2]
|
||||||
|
styles[className] = {}
|
||||||
|
|
||||||
|
const propMatches = Array.from(cssText.matchAll(/\s*([^:]+):\s*([^;]+);/g))
|
||||||
|
for (const propMatch of propMatches) {
|
||||||
|
if (!propMatch[1] || !propMatch[2]) continue
|
||||||
|
styles[className]![propMatch[1].trim()] = propMatch[2].trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user