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 = { 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 = { 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) => { // Apply prop defaults from the component definition const mergedProps: Record = {} 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) || {} } 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('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('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('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('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('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('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('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('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('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('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('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('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('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('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('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('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('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: '
Mock Slot
' } const theme: ThemeLibrary = { components: { Button: { component: Button, slots: { icon: MockSlot, }, }, }, } const engine = new ThemeEngine(theme) //eslint-disable-next-line const themedButton: any = engine.getComponent('Button') expect(themedButton.setup).toBeDefined() expect(themedButton.setup({}, {})).toBeDefined() }) it('slot wrapper returns a render function', () => { const MockSlot = { name: 'MockSlot', template: '
Mock Slot
' } const theme: ThemeLibrary = { components: { Button: { component: Button, slots: { icon: MockSlot, }, }, }, } const engine = new ThemeEngine(theme) //eslint-disable-next-line const themedButton: any = engine.getComponent('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('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) { 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('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, { 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: `
{{ val }}
`, } 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('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('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('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('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: '
', } 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('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('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('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('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('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) { 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('Button') const renderFn = themedButton.setup({}, { slots: {} }) expect(typeof renderFn).toBe('function') const vnode = renderFn() expect(vnode).toBeDefined() }) })