Files
crewli-old/apps/app/src/components-v2/shared/__tests__/AppDialog.spec.ts

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')
})
})