Nychthemeron/packages/library/tests/engine.spec.ts

894 lines
23 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import { h, ref } from 'vue'
import { ThemeEngine, ThemeLibrary, type Themeable } from '../src/engine'
const getTheme = () => {
const button: Themeable<typeof Button> = {
classes: ['test-class'],
styles: ['width: 50px'],
propMutator: (props) => {
if (props.variant) {
const pt = props.pt as { root: { class?: string } }
const classes = pt.root.class?.split(' ') ?? []
classes.push(`test-${props.variant}`)
pt.root.class = classes.join(' ')
props.pt = pt
}
return props
},
injectionKeys: {
class: 'pt',
},
component: Button,
}
const buttonTheme: ThemeLibrary = {
components: {
Button: button,
},
}
return buttonTheme
}
const getSimpleTheme = () => {
const button: Themeable<typeof Button> = {
classes: ['btn-default'],
styles: ['padding: 8px'],
injectionKeys: {
class: 'pt',
},
component: Button,
}
return {
components: {
Button: button,
},
}
}
//eslint-disable-next-line
const callSetup = (component: any, props: Record<string, unknown>) => {
// Apply prop defaults from the component definition
const mergedProps: Record<string, unknown> = {}
if (component.props) {
for (const [key, propDef] of Object.entries(component.props)) {
if (typeof propDef === 'object' && propDef !== null && 'default' in propDef) {
mergedProps[key] = propDef.default
}
}
}
// Merge in user-provided props
Object.assign(mergedProps, props)
if (component.setup) {
const result = component.setup(mergedProps, { slots: {}, attrs: {} })
// Engine always returns a render function; call it and return the vnode's props
// so tests can inspect the final merged props passed to the original component.
if (typeof result === 'function') {
const vnode = result()
return (vnode?.props as Record<string, unknown>) || {}
}
return result
}
return mergedProps
}
describe('ThemeEngine', () => {
it('instantiates properly', () => {
const theme = getTheme()
const engine = new ThemeEngine(theme)
expect(engine).not.toBeNull()
expect(engine.lib).toBe(theme)
})
it('returns a wrapped component', () => {
const theme = getTheme()
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
expect(themedButton).not.toBeNull()
expect(themedButton.props).toBeDefined()
expect(themedButton.setup).toBeDefined()
})
it('injects classes from theme definition', () => {
const theme = getSimpleTheme()
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt?.root?.class).toContain('btn-default')
})
it('injects styles from theme definition', () => {
const theme = getSimpleTheme()
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt?.root?.style).toContain('padding: 8px')
})
it('applies propMutator to modify component props', () => {
const theme = getTheme()
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
variant: 'primary',
})
expect(result.pt?.root?.class).toContain('test-primary')
})
it('handles PT injection key for PrimeVue components', () => {
const theme = getTheme()
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt).toBeDefined()
expect(result.pt.root).toBeDefined()
expect(result.pt.root.class).toBeDefined()
expect(result.pt.root.style).toBeDefined()
})
it('applies class prefix when stylePlug is provided', () => {
const simpleTheme = getSimpleTheme()
const engine = new ThemeEngine(simpleTheme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
stylePlug: 'custom',
pt: {},
})
expect(result.pt?.root?.class).toContain('custom-btn-default')
})
it('merges user-provided PT with theme-injected classes', () => {
const simpleTheme = getSimpleTheme()
const engine = new ThemeEngine(simpleTheme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {
label: {
class: 'user-label-class',
},
},
})
// Theme classes should be injected to root
expect(result.pt?.root?.class).toContain('btn-default')
// User-provided PT structure should be preserved
expect(result.pt?.label?.class).toBe('user-label-class')
})
it('preserves existing PT structure when merging', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {
root: { class: 'existing' },
label: { class: 'label-class' },
},
})
expect(result.pt?.root).toBeDefined()
expect(result.pt?.label?.class).toBe('label-class')
})
it('preserves theme classes when user provides pt.root', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['theme-class'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: { root: { class: 'user-root' } },
})
expect(result.pt?.root?.class).toContain('theme-class')
expect(result.pt?.root?.class).toContain('user-root')
})
it('handles missing injectionKeys by defaulting to class and style', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['default-class'],
styles: ['color: red'],
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {})
expect(result.class).toBe('default-class')
expect(result.style).toBe('color: red')
})
it('supports unstyled prop passthrough', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
unstyled: true,
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
expect(themedButton.props.unstyled.default).toBe(true)
})
it('combines multiple classes in correct order', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['class-a', 'class-b', 'class-c'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt?.root?.class).toBe('class-a class-b class-c')
})
it('combines multiple styles correctly', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
styles: ['padding: 8px', 'margin: 4px', 'border: 1px solid'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt?.root?.style).toBe('padding: 8px; margin: 4px; border: 1px solid')
})
it('properly injects injectedClasses and injectedStyles props', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['injected-class'],
styles: ['injected-style'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
expect(themedButton.props.injectedClasses).toBeDefined()
expect(themedButton.props.injectedStyles).toBeDefined()
expect(themedButton.props.injectedClasses.default).toEqual(['injected-class'])
expect(themedButton.props.injectedStyles.default).toEqual(['injected-style'])
})
it('applies non-PT class injection correctly', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['my-class'],
styles: ['my-style'],
injectionKeys: {
class: 'class',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {})
expect(result.class).toBe('my-class')
expect(result.style).toBe('my-style')
})
it('handles object-form class values', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['theme-class'],
injectionKeys: {
class: 'class',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
class: { 'active-class': true, 'inactive-class': false },
})
expect(result.class).toContain('active-class')
expect(result.class).not.toContain('inactive-class')
expect(result.class).toContain('theme-class')
})
it('merges array and string classes correctly', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['theme-class'],
injectionKeys: {
class: 'class',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result1 = callSetup(themedButton, {
class: ['user-class-1', 'user-class-2'],
})
expect(result1.class).toContain('user-class-1')
expect(result1.class).toContain('user-class-2')
expect(result1.class).toContain('theme-class')
const result2 = callSetup(themedButton, {
class: 'user-string-class',
})
expect(result2.class).toContain('user-string-class')
expect(result2.class).toContain('theme-class')
})
it('wraps components with custom slots', () => {
const MockSlot = { name: 'MockSlot', template: '<div>Mock Slot</div>' }
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
slots: {
icon: MockSlot,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
expect(themedButton.setup).toBeDefined()
expect(themedButton.setup({}, {})).toBeDefined()
})
it('slot wrapper returns a render function', () => {
const MockSlot = { name: 'MockSlot', template: '<div>Mock Slot</div>' }
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
slots: {
icon: MockSlot,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
const setupResult = themedButton.setup({}, {})
expect(typeof setupResult).toBe('function')
})
it('combines slots with classes and styles', () => {
const MockSlot = { name: 'MockSlot' }
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['btn-class'],
styles: ['color: blue'],
slots: {
icon: MockSlot,
},
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
// With slots, setup returns a render function, not props
const setupResult = themedButton.setup(
{
injectedClasses: ['btn-class'],
injectedStyles: ['color: blue'],
pt: {},
},
{},
)
expect(typeof setupResult).toBe('function')
})
it('renders original component with modified props via h()', () => {
const ComponentWithSetup = {
props: { class: String },
//eslint-disable-next-line
setup(props: Record<string, unknown>) {
return () => h('div', { class: props.class })
},
}
const theme: ThemeLibrary = {
components: {
Custom: {
component: ComponentWithSetup,
classes: ['custom-class'],
},
},
}
const engine = new ThemeEngine(theme)
const themedComponent = engine.getComponent('Custom')
const result = callSetup(themedComponent, {})
// Engine wraps via h() — vnode props contain the theme-injected class
expect(result.class).toBe('custom-class')
})
it('slot functions are created for each slot component', () => {
const MockSlot = {
name: 'MockSlot',
//eslint-disable-next-line
setup() {
return () => h('span', 'slot content')
},
}
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
slots: {
icon: MockSlot,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
const renderFn = themedButton.setup({}, { slots: {} })
expect(typeof renderFn).toBe('function')
const vnode = renderFn()
expect(vnode).toBeDefined()
})
it('forwards update:modelValue so v-model works on wrapped components', async () => {
// Regression: a cloned wrapper inherited the component's emits/extends, so
// Vue withheld onUpdate:modelValue from ctx.attrs and the engine never
// forwarded it — breaking two-way v-model on every wrapped component.
const Emitter = {
name: 'Emitter',
props: { modelValue: { type: String, default: '' } },
emits: ['update:modelValue'],
//eslint-disable-next-line
setup(_props: Record<string, unknown>, { emit }: { emit: (e: string, v: string) => void }) {
return () => h('button', { onClick: () => emit('update:modelValue', 'changed') }, 'x')
},
}
const theme: ThemeLibrary = { components: { Emitter: { component: Emitter } } }
const engine = new ThemeEngine(theme)
const Wrapped = engine.getComponent('Emitter')
const Parent = {
components: { Wrapped },
setup() {
const val = ref('initial')
return { val }
},
template: `<div><Wrapped v-model="val" /><span class="out">{{ val }}</span></div>`,
}
const wrapper = mount(Parent)
expect(wrapper.find('.out').text()).toBe('initial')
await wrapper.find('button').trigger('click')
expect(wrapper.find('.out').text()).toBe('changed')
})
it('merges theme slots with original component slots', () => {
const ThemeSlot = {
name: 'ThemeSlot',
//eslint-disable-next-line
setup() {
return () => h('span', 'theme slot')
},
}
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
slots: {
icon: ThemeSlot,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
const originalSlots = {
label: () => h('span', 'label slot'),
}
const renderFn = themedButton.setup({}, { slots: originalSlots })
expect(typeof renderFn).toBe('function')
const vnode = renderFn()
expect(vnode).toBeDefined()
})
it('handles non-string style values by defaulting to empty', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
styles: ['theme-style'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
style: { someObject: 'value' },
pt: {},
})
// When style is not a string, it defaults to empty array, so only theme styles are applied
expect(result.pt?.root?.style).toBe('theme-style')
})
it('preserves already-prefixed classes', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['my-class', 'custom-already-prefixed'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
stylePlug: 'custom',
pt: {},
})
expect(result.pt?.root?.class).toContain('custom-my-class')
expect(result.pt?.root?.class).toContain('custom-already-prefixed')
})
it('handles styleKey as pt injection key', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
styles: ['style-value'],
injectionKeys: {
class: 'pt',
style: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt?.root?.style).toContain('style-value')
})
it('slot wrapper works with non-function modifiedSetup', () => {
const MockSlot = {
name: 'MockSlot',
//eslint-disable-next-line
setup() {
return () => h('span', 'slot')
},
}
const ComponentWithoutSetup = {
name: 'ComponentWithoutSetup',
template: '<div></div>',
}
const theme: ThemeLibrary = {
components: {
Custom: {
component: ComponentWithoutSetup,
slots: {
icon: MockSlot,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedComponent: any = engine.getComponent('Custom')
const renderFn = themedComponent.setup({}, { slots: {} })
expect(typeof renderFn).toBe('function')
const vnode = renderFn()
expect(vnode).toBeDefined()
})
it('does not pass internal engine props to the wrapped component', () => {
const theme = getSimpleTheme()
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, { pt: {}, stylePlug: 'custom' })
expect(result).not.toHaveProperty('injectedClasses')
expect(result).not.toHaveProperty('injectedStyles')
expect(result).not.toHaveProperty('stylePlug')
})
it('handles classKey that is not pt', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['my-class'],
styles: ['my-style'],
injectionKeys: {
class: 'customClass',
style: 'customStyle',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {})
expect(result.customClass).toBe('my-class')
expect(result.customStyle).toBe('my-style')
})
it('spreads existing pt object when merging theme props', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['theme-class'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {
label: { class: 'label-class', style: 'color: red' },
icon: { class: 'icon-class' },
tooltip: { content: 'help text' },
},
})
expect(result.pt?.root?.class).toContain('theme-class')
expect(result.pt?.label?.class).toBe('label-class')
expect(result.pt?.label?.style).toBe('color: red')
expect(result.pt?.icon?.class).toBe('icon-class')
expect(result.pt?.tooltip?.content).toBe('help text')
})
it('handles style as string and splits it correctly', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
styles: ['margin: 10px'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
style: 'padding: 5px; color: blue',
pt: {},
})
expect(result.pt?.root?.style).toContain('padding: 5px')
expect(result.pt?.root?.style).toContain('color: blue')
expect(result.pt?.root?.style).toContain('margin: 10px')
})
it('creates pt structure when pt is not provided', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['btn-class'],
styles: ['btn-style'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {})
expect(result.pt).toBeDefined()
expect(result.pt?.root?.class).toBe('btn-class')
expect(result.pt?.root?.style).toBe('btn-style')
})
it('slot functions created with proper scope handling', () => {
const mockSlotComponent = {
name: 'IconSlot',
//eslint-disable-next-line
setup(props: Record<string, unknown>) {
return () => h('i', `Icon: ${props.name}`)
},
}
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
slots: {
icon: mockSlotComponent,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
const renderFn = themedButton.setup({}, { slots: {} })
expect(typeof renderFn).toBe('function')
const vnode = renderFn()
expect(vnode).toBeDefined()
})
})