Adds :key="component" to <component :is="Body"> so opening drawer B while A is open fully remounts (A's instance/state can't leak into B) — a real defect for a primary shell overlay. Same-name reopen still relies on the body reacting to prop changes (documented inline). Companion test asserts the cross-component switch swaps the body cleanly (A unmounts, B mounts). Addresses the Task 5 code-review Important finding. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
261 lines
9.2 KiB
TypeScript
261 lines
9.2 KiB
TypeScript
/**
|
|
* RightDrawer.spec.ts — unit tests for the global right-side drawer shell.
|
|
*
|
|
* Strategy: mount RightDrawer with @vue/test-utils, stub PrimeVue <Drawer>
|
|
* 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: `
|
|
<div class="drawer-stub" :data-visible="visible">
|
|
<slot />
|
|
</div>
|
|
`,
|
|
})
|
|
|
|
/**
|
|
* IconStub — prevents Iconify SVG lookups in jsdom.
|
|
*/
|
|
const IconStub = defineComponent({
|
|
name: 'Icon',
|
|
props: ['name', 'size'],
|
|
template: '<span class="icon-stub" :data-icon="name" />',
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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: '<div class="body-stub" :data-id="someId" />',
|
|
})
|
|
|
|
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: '<div class="body-a">A</div>' })
|
|
const BodyB = defineComponent({ template: '<div class="body-b">B</div>' })
|
|
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)
|
|
})
|
|
})
|