279 lines
9.5 KiB
TypeScript
279 lines
9.5 KiB
TypeScript
/**
|
|
* AppDialog.spec.ts — unit tests for the AppDialog reusable modal wrapper.
|
|
*
|
|
* Strategy: mount AppDialog with @vue/test-utils, stub PrimeVue <Dialog>
|
|
* so we avoid the full overlay/teleport/focus-trap machinery in jsdom.
|
|
* The DialogStub mirrors the v-model:visible contract and passes through
|
|
* all named slots so the inner header/tabs/body/footer DOM is fully mounted
|
|
* and inspectable.
|
|
*
|
|
* What is tested (real component logic, not stub theater):
|
|
* 1. open=true → Dialog visible; open=false → not visible.
|
|
* 2. title + sub render in the header when provided.
|
|
* 3. sub absent → no sub element in the DOM.
|
|
* 4. Dialog emits update:visible=false → AppDialog emits update:open=false.
|
|
* 5. Close button click → emits update:open=false.
|
|
* 6. #footer slot: provided → footer region renders; absent → no footer.
|
|
* 7. #tabs slot: provided → tabs region renders; absent → no tabs region.
|
|
* 8. Default slot content renders in the body.
|
|
*/
|
|
|
|
import { mount } from '@vue/test-utils'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { defineComponent, h } from 'vue'
|
|
import AppDialog from '@/components-v2/shared/AppDialog.vue'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stubs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* DialogStub mirrors the PrimeVue Dialog contract used by AppDialog:
|
|
* - `visible` prop (Boolean) driven by v-model:visible
|
|
* - emits `update:visible` to simulate overlay/Escape close
|
|
* - renders all named slots + default slot so the inner DOM is mounted
|
|
*
|
|
* We expose a `data-visible` attribute so tests can assert Dialog visibility
|
|
* without inspecting internal Vue internals.
|
|
*/
|
|
const DialogStub = defineComponent({
|
|
name: 'Dialog',
|
|
props: {
|
|
visible: { type: Boolean, default: false },
|
|
modal: { type: Boolean, default: false },
|
|
dismissableMask: { type: Boolean, default: false },
|
|
pt: { type: Object, default: () => ({}) },
|
|
style: { type: Object, default: () => ({}) },
|
|
},
|
|
emits: ['update:visible'],
|
|
template: `
|
|
<div class="dialog-stub" :data-visible="String(visible)">
|
|
<slot name="default" />
|
|
</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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface MountOptions {
|
|
open?: boolean
|
|
title?: string
|
|
sub?: string
|
|
width?: string
|
|
slots?: Record<string, () => ReturnType<typeof h>>
|
|
}
|
|
|
|
function mountDialog(options: MountOptions = {}) {
|
|
const { open = false, title, sub, width, slots = {} } = options
|
|
|
|
return mount(AppDialog, {
|
|
props: { open, title, sub, width },
|
|
slots,
|
|
global: {
|
|
stubs: {
|
|
Dialog: DialogStub,
|
|
Icon: IconStub,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AppDialog', () => {
|
|
// -------------------------------------------------------------------------
|
|
// 1. open prop drives Dialog visibility
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('passes visible=true to Dialog when open=true', () => {
|
|
const wrapper = mountDialog({ open: true })
|
|
const dialog = wrapper.find('.dialog-stub')
|
|
|
|
expect(dialog.attributes('data-visible')).toBe('true')
|
|
})
|
|
|
|
it('passes visible=false to Dialog when open=false', () => {
|
|
const wrapper = mountDialog({ open: false })
|
|
const dialog = wrapper.find('.dialog-stub')
|
|
|
|
expect(dialog.attributes('data-visible')).toBe('false')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 2. title + sub render in the header
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('renders the title when provided', () => {
|
|
const wrapper = mountDialog({ open: true, title: 'My Dialog Title' })
|
|
|
|
expect(wrapper.text()).toContain('My Dialog Title')
|
|
})
|
|
|
|
it('renders the sub text when provided', () => {
|
|
const wrapper = mountDialog({ open: true, title: 'Title', sub: 'Helpful subtitle' })
|
|
|
|
expect(wrapper.text()).toContain('Helpful subtitle')
|
|
|
|
// The sub element is the .mt-1 div under the header — assert it actually exists
|
|
expect(wrapper.find('.mt-1').exists()).toBe(true)
|
|
})
|
|
|
|
it('passes the width prop through to the Dialog style', () => {
|
|
const wrapper = mountDialog({ open: true, width: '400px' })
|
|
const dialog = wrapper.findComponent(DialogStub)
|
|
|
|
expect(dialog.props('style')).toEqual({ width: '400px' })
|
|
})
|
|
|
|
it('applies the default width when no width prop is given', () => {
|
|
const wrapper = mountDialog({ open: true })
|
|
const dialog = wrapper.findComponent(DialogStub)
|
|
|
|
expect(dialog.props('style')).toEqual({ width: 'min(680px, 100%)' })
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 3. sub absent → no sub element
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('does not render the sub element when sub is absent', () => {
|
|
const wrapper = mountDialog({ open: true, title: 'Title Only' })
|
|
|
|
// There should be no text for a subtitle
|
|
expect(wrapper.text()).not.toContain('Helpful subtitle')
|
|
|
|
// The v-if="props.sub" element should not render
|
|
// We look for a div with mt-1 class which is the sub element
|
|
const subEl = wrapper.find('.mt-1')
|
|
|
|
expect(subEl.exists()).toBe(false)
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 4. Dialog emits update:visible=false → AppDialog emits update:open=false
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('emits update:open=false AND close when Dialog emits update:visible=false', async () => {
|
|
const wrapper = mountDialog({ open: true })
|
|
|
|
await wrapper.findComponent(DialogStub).vm.$emit('update:visible', false)
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const openEvents = wrapper.emitted('update:open')
|
|
const closeEvents = wrapper.emitted('close')
|
|
|
|
expect(openEvents).toBeTruthy()
|
|
expect(openEvents![0]).toEqual([false])
|
|
expect(closeEvents).toBeTruthy()
|
|
expect(closeEvents![0]).toEqual([])
|
|
|
|
// Contract: update:open fires before close (JSDoc-documented order)
|
|
const order = wrapper.emitted()
|
|
const keys = Object.keys(order)
|
|
|
|
expect(keys.indexOf('update:open')).toBeLessThan(keys.indexOf('close'))
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 5. Close button click → emits update:open=false
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('emits update:open=false AND close when the close button is clicked', async () => {
|
|
const wrapper = mountDialog({ open: true })
|
|
|
|
const closeBtn = wrapper.find('button[aria-label="Close"]')
|
|
|
|
expect(closeBtn.exists()).toBe(true)
|
|
|
|
await closeBtn.trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const openEvents = wrapper.emitted('update:open')
|
|
const closeEvents = wrapper.emitted('close')
|
|
|
|
expect(openEvents).toBeTruthy()
|
|
expect(openEvents![0]).toEqual([false])
|
|
expect(closeEvents).toBeTruthy()
|
|
expect(closeEvents![0]).toEqual([])
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 6. #footer slot
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('renders the footer region when #footer slot is provided', () => {
|
|
const wrapper = mountDialog({
|
|
open: true,
|
|
slots: {
|
|
footer: () => h('span', { class: 'footer-content' }, 'Save'),
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.footer-content').exists()).toBe(true)
|
|
})
|
|
|
|
it('does not render a footer region when #footer slot is absent', () => {
|
|
const wrapper = mountDialog({ open: true })
|
|
|
|
// The footer div has `justify-end` + `gap-2` + `border-t` classes
|
|
// It should not exist when no footer slot is provided
|
|
const footerEl = wrapper.find('.justify-end.gap-2')
|
|
|
|
expect(footerEl.exists()).toBe(false)
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 7. #tabs slot
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('renders the tabs region when #tabs slot is provided', () => {
|
|
const wrapper = mountDialog({
|
|
open: true,
|
|
slots: {
|
|
tabs: () => h('div', { class: 'tabs-content' }, 'Tab 1'),
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.tabs-content').exists()).toBe(true)
|
|
})
|
|
|
|
it('does not render the tabs region when #tabs slot is absent', () => {
|
|
const wrapper = mountDialog({ open: true })
|
|
|
|
// The tabs wrapper div is the only flex-shrink-0 div that wraps the tabs slot
|
|
// It should not be in the DOM when no tabs slot is provided
|
|
expect(wrapper.find('.tabs-content').exists()).toBe(false)
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 8. Default slot content renders in the body
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('renders default slot content in the scrollable body', () => {
|
|
const wrapper = mountDialog({
|
|
open: true,
|
|
slots: {
|
|
default: () => h('p', { class: 'body-content' }, 'Body text here'),
|
|
},
|
|
})
|
|
|
|
const body = wrapper.find('.body-content')
|
|
|
|
expect(body.exists()).toBe(true)
|
|
expect(body.text()).toBe('Body text here')
|
|
})
|
|
})
|