/** * RightDrawer.spec.ts — unit tests for the global right-side drawer shell. * * Strategy: mount RightDrawer with @vue/test-utils, stub PrimeVue * so we avoid the full overlay/teleport machinery in jsdom. The stub exposes * the same `visible` prop and emits `update:visible` so we can test the * writable-computed v-model:visible wiring against the real Pinia store. * * What is tested (real store/composable, not stub theater): * 1. Drawer visible when store drawer is open, title renders. * 2. Emitting update:visible=false closes the store drawer. * 3. flush:true in drawerProps → body has p-0 class (no padding). * 4. flush:false/absent → body has p-4 class (padded). * 5. Unknown component name (nothing registered) → graceful empty state, no throw. * 6. Registered stub component renders in body; non-chrome props forwarded. * * PrimeVue Drawer stub: mirrors only the contract surface used by RightDrawer — * `visible` prop + `update:visible` emit + default slot passthrough. * * Icon is stubbed to a simple span so jsdom doesn't choke on Iconify SVG fetch. */ import { createPinia, setActivePinia } from 'pinia' import { mount } from '@vue/test-utils' import { beforeEach, describe, expect, it } from 'vitest' import { defineComponent } from 'vue' import { registerDrawerComponent } from '@/composables/drawerRegistry' import { useShellUiStore } from '@/stores/useShellUiStore' import RightDrawer from '@/components-v2/layout/RightDrawer.vue' // --------------------------------------------------------------------------- // Unique prefix per run — drawerRegistry is a module singleton; avoid leakage. // --------------------------------------------------------------------------- const PREFIX = `rd-spec-${Date.now()}-` // --------------------------------------------------------------------------- // Stubs // --------------------------------------------------------------------------- /** * DrawerStub mirrors the PrimeVue Drawer contract used by RightDrawer: * - `visible` prop (Boolean) * - emits `update:visible` on close (v-model:visible contract) * - renders its default slot so header/body children are mounted */ const DrawerStub = defineComponent({ name: 'Drawer', props: { visible: { type: Boolean, default: false }, position: { type: String, default: 'right' }, pt: { type: Object, default: () => ({}) }, }, emits: ['update:visible'], template: `
`, }) /** * IconStub — prevents Iconify SVG lookups in jsdom. */ const IconStub = defineComponent({ name: 'Icon', props: ['name', 'size'], template: '', }) // --------------------------------------------------------------------------- // Mount helper // --------------------------------------------------------------------------- function mountDrawer() { return mount(RightDrawer, { global: { plugins: [createPinia()], stubs: { Drawer: DrawerStub, Icon: IconStub, }, }, }) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('RightDrawer', () => { beforeEach(() => { setActivePinia(createPinia()) }) // ------------------------------------------------------------------------- // 1. Store open → Drawer visible, title renders // ------------------------------------------------------------------------- it('renders Drawer as visible when store drawer is open', async () => { const wrapper = mountDrawer() const shell = useShellUiStore() shell.openDrawer('SomePanel', { title: 'Hello' }) await wrapper.vm.$nextTick() const drawer = wrapper.find('.drawer-stub') expect(drawer.attributes('data-visible')).toBe('true') }) it('renders the title from drawerProps', async () => { const wrapper = mountDrawer() const shell = useShellUiStore() shell.openDrawer('SomePanel', { title: 'Hello' }) await wrapper.vm.$nextTick() expect(wrapper.text()).toContain('Hello') }) it('Drawer is not visible when store drawer is closed', async () => { const wrapper = mountDrawer() // store starts closed by default await wrapper.vm.$nextTick() const drawer = wrapper.find('.drawer-stub') expect(drawer.attributes('data-visible')).toBe('false') }) // ------------------------------------------------------------------------- // 2. Emitting update:visible=false → store drawer closed // ------------------------------------------------------------------------- it('emitting update:visible=false closes the store drawer', async () => { const wrapper = mountDrawer() const shell = useShellUiStore() shell.openDrawer('SomePanel', { title: 'Test' }) await wrapper.vm.$nextTick() // Simulate PrimeVue Drawer's v-model:visible setter called with false // (e.g. user clicks overlay or presses Escape) await wrapper.findComponent(DrawerStub).vm.$emit('update:visible', false) await wrapper.vm.$nextTick() expect(shell.drawer.isOpen).toBe(false) }) // ------------------------------------------------------------------------- // 3 & 4. flush prop → padding class // ------------------------------------------------------------------------- it('body has p-0 class when flush is true', async () => { const wrapper = mountDrawer() const shell = useShellUiStore() shell.openDrawer('SomePanel', { title: 'Flush', flush: true }) await wrapper.vm.$nextTick() // The body div is the scrollable container (class includes flex-1 overflow-y-auto) const bodyDiv = wrapper.find('.flex-1.overflow-y-auto') expect(bodyDiv.classes()).toContain('p-0') expect(bodyDiv.classes()).not.toContain('p-4') }) it('body has p-4 class when flush is false or absent', async () => { const wrapper = mountDrawer() const shell = useShellUiStore() shell.openDrawer('SomePanel', { title: 'Padded' }) await wrapper.vm.$nextTick() const bodyDiv = wrapper.find('.flex-1.overflow-y-auto') expect(bodyDiv.classes()).toContain('p-4') expect(bodyDiv.classes()).not.toContain('p-0') }) // ------------------------------------------------------------------------- // 5. Unknown component name → graceful empty state, no throw // ------------------------------------------------------------------------- it('renders graceful empty state for an unregistered component name', async () => { const wrapper = mountDrawer() const shell = useShellUiStore() shell.openDrawer(`${PREFIX}NotRegistered`, { title: 'Missing' }) await wrapper.vm.$nextTick() // Should show the empty state text, not throw expect(wrapper.text()).toContain('Geen inhoud') }) // ------------------------------------------------------------------------- // 6. Registered component renders; non-chrome props forwarded // ------------------------------------------------------------------------- it('renders a registered body component and forwards non-chrome props', async () => { const BodyStub = defineComponent({ props: { someId: { type: String, default: '' } }, template: '
', }) const name = `${PREFIX}BodyStub` registerDrawerComponent(name, BodyStub) const wrapper = mountDrawer() const shell = useShellUiStore() // `title` and `flush` are chrome keys — they must NOT reach BodyStub. // `someId` is a body prop — it MUST be forwarded. shell.openDrawer(name, { title: 'With Body', flush: false, someId: 'abc-123' }) await wrapper.vm.$nextTick() const body = wrapper.find('.body-stub') expect(body.exists()).toBe(true) expect(body.attributes('data-id')).toBe('abc-123') // Graceful-empty-state text must NOT appear when a component is rendered expect(wrapper.text()).not.toContain('Geen inhoud') }) // ------------------------------------------------------------------------- // 7. Switching the open drawer component swaps the body cleanly // (the :key="component" remount guard — opening B while A is open // unmounts A and mounts B; A's DOM/state must not linger). // ------------------------------------------------------------------------- it('remounts the body when the open drawer switches to another component', async () => { const BodyA = defineComponent({ template: '
A
' }) const BodyB = defineComponent({ template: '
B
' }) const nameA = `${PREFIX}BodyA` const nameB = `${PREFIX}BodyB` registerDrawerComponent(nameA, BodyA) registerDrawerComponent(nameB, BodyB) const wrapper = mountDrawer() const shell = useShellUiStore() shell.openDrawer(nameA, { title: 'A' }) await wrapper.vm.$nextTick() expect(wrapper.find('.body-a').exists()).toBe(true) expect(wrapper.find('.body-b').exists()).toBe(false) // Switch to B while the drawer is still open — :key change forces a // full remount so A is gone and B is mounted (no stale A instance). shell.openDrawer(nameB, { title: 'B' }) await wrapper.vm.$nextTick() expect(wrapper.find('.body-b').exists()).toBe(true) expect(wrapper.find('.body-a').exists()).toBe(false) }) })