diff --git a/packages/components/dialog/__tests__/dialog-card.test.tsx b/packages/components/dialog/__tests__/dialog-card.test.tsx new file mode 100644 index 0000000000..ff4dc5a71e --- /dev/null +++ b/packages/components/dialog/__tests__/dialog-card.test.tsx @@ -0,0 +1,278 @@ +// @ts-nocheck +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { nextTick } from 'vue'; +import DialogCard from '../dialog-card'; + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('props', () => { + it('header[string]', async () => { + const wrapper = mount(() => ); + + await nextTick(); + expect(wrapper.find('.t-dialog__header').exists()).toBe(true); + expect(wrapper.find('.t-dialog__header').text()).toBe('Test Title'); + }); + + it('header[function]', async () => { + const renderHeader = () => Custom Header; + const wrapper = mount(() => ); + + await nextTick(); + expect(wrapper.find('.custom-header').exists()).toBe(true); + expect(wrapper.find('.custom-header').text()).toBe('Custom Header'); + }); + + it('header[slot]', async () => { + const wrapper = mount(() => ( +
Slot Header
}}>
+ )); + + await nextTick(); + expect(wrapper.find('.slot-header').exists()).toBe(true); + expect(wrapper.find('.slot-header').text()).toBe('Slot Header'); + }); + + it('body[string]', async () => { + const wrapper = mount(() => ); + + await nextTick(); + const body = wrapper.find('.t-dialog__body'); + expect(body.exists()).toBe(true); + expect(body.text()).toBe('String Body'); + }); + + it('body[function]', async () => { + const renderBody = () =>
Custom Body
; + const wrapper = mount(() => ); + + await nextTick(); + expect(wrapper.find('.custom-body').exists()).toBe(true); + expect(wrapper.find('.custom-body').text()).toBe('Custom Body'); + }); + + it('body[slot]', async () => { + const wrapper = mount(() => ( +
Slot Body
}}>
+ )); + + await nextTick(); + expect(wrapper.find('.slot-body').exists()).toBe(true); + expect(wrapper.find('.slot-body').text()).toBe('Slot Body'); + }); + + it('footer[boolean]', async () => { + // footer=true 应该显示默认按钮 + const wrapperTrue = mount(() => ); + await nextTick(); + expect(wrapperTrue.find('.t-dialog__footer').exists()).toBe(true); + + // footer=false 应该隐藏footer + const wrapperFalse = mount(() => ); + await nextTick(); + expect(wrapperFalse.find('.t-dialog__footer').exists()).toBe(false); + }); + + it('footer[function]', async () => { + const renderFooter = () => ; + const wrapper = mount(() => ); + + await nextTick(); + expect(wrapper.find('.custom-footer').exists()).toBe(true); + expect(wrapper.find('.custom-footer').text()).toBe('Custom Footer'); + }); + + it('footer[slot]', async () => { + const wrapper = mount(() => ( + }} + > + )); + + await nextTick(); + expect(wrapper.find('.slot-footer').exists()).toBe(true); + expect(wrapper.find('.slot-footer').text()).toBe('Slot Footer'); + }); + + it('theme[string]', async () => { + const themes = ['default', 'info', 'warning', 'danger', 'success', ''] as const; + + themes.forEach(async (theme) => { + const wrapper = mount(() => ); + await nextTick(); + const dialog = wrapper.find('.t-dialog'); + expect(dialog.classes()).toContain(`t-dialog__modal-${theme}`); + }); + }); + + it('closeBtn[boolean]', async () => { + // 默认应该显示关闭按钮 + const wrapperDefault = mount(() => ); + await nextTick(); + expect(wrapperDefault.find('.t-dialog__close').exists()).toBe(true); + + // closeBtn=false 应该隐藏关闭按钮 + const wrapperFalse = mount(() => ); + await nextTick(); + expect(wrapperFalse.find('.t-dialog__close').exists()).toBe(false); + }); + + it('closeBtn[function]', async () => { + const renderCloseBtn = () => ×; + const wrapper = mount(() => ); + + await nextTick(); + expect(wrapper.find('.custom-close').exists()).toBe(true); + expect(wrapper.find('.custom-close').text()).toBe('×'); + }); + + it('placement[string]', async () => { + const placements = ['top', 'center'] as const; + + placements.forEach(async (placement) => { + const wrapper = mount(() => ); + await nextTick(); + const dialog = wrapper.find('.t-dialog'); + expect(dialog.classes()).toContain(`t-dialog--${placement}`); + }); + }); + + it('width[number]', async () => { + const wrapper = mount(() => ); + + await nextTick(); + const dialog = wrapper.find('.t-dialog'); + expect(dialog.element.style.width).toBe('500px'); + }); + + it('width[string]', async () => { + const wrapper = mount(() => ); + + await nextTick(); + const dialog = wrapper.find('.t-dialog'); + expect(dialog.element.style.width).toBe('80%'); + }); + + it('draggable[boolean]', async () => { + const wrapper = mount(() => ); + + await nextTick(); + const dialog = wrapper.find('.t-dialog'); + expect(dialog.classes()).toContain('t-dialog--draggable'); + }); + + it('mode[string]', async () => { + const modes = ['modal', 'modeless'] as const; + + modes.forEach(async (mode) => { + const wrapper = mount(() => ); + await nextTick(); + // 这里需要根据实际的class名称进行调整 + const dialog = wrapper.find('.t-dialog'); + expect(dialog.exists()).toBe(true); + }); + }); + + it('zIndex[number]', async () => { + const wrapper = mount(() => ); + + await nextTick(); + const dialog = wrapper.find('.t-dialog'); + expect(dialog.exists()).toBe(true); + // 这里需要根据实际的样式应用方式进行调整 + }); + + it('preventScrollThrough[boolean]', async () => { + const wrapper = mount(() => ); + + await nextTick(); + const dialog = wrapper.find('.t-dialog'); + expect(dialog.exists()).toBe(true); + // 这里需要根据实际的行为进行测试调整 + }); + + it('should switch body className correctly in full-screen mode', async () => { + // footer 存在时 + const wrapperWithFooter = mount(() => ( + + )); + await nextTick(); + const bodyWithFooter = wrapperWithFooter.find('.t-dialog__body'); + expect(bodyWithFooter.exists()).toBe(true); + expect(bodyWithFooter.classes()).toContain('t-dialog__body--fullscreen'); + + // footer 不存在时 + const wrapperWithoutFooter = mount(() => ( + + )); + await nextTick(); + const bodyWithoutFooter = wrapperWithoutFooter.find('.t-dialog__body'); + expect(bodyWithoutFooter.exists()).toBe(true); + expect(bodyWithoutFooter.classes()).toContain('t-dialog__body--fullscreen--without-footer'); + }); +}); + +describe('events', () => { + it('onCloseBtnClick', async () => { + const onCloseBtnClick = vi.fn(); + const wrapper = mount(() => ); + + await nextTick(); + const closeBtn = wrapper.find('.t-dialog__close'); + await closeBtn.trigger('click'); + expect(onCloseBtnClick).toHaveBeenCalled(); + }); + + it('onStopDown: should call stopPropagation when mode is modeless and draggable is true', async () => { + const stopPropagation = vi.fn(); + const wrapper = mount(() => ); + await nextTick(); + // 触发 body 区域的 mousedown + const body = wrapper.find('.t-dialog__body'); + await body.trigger('mousedown', { stopPropagation }); + expect(stopPropagation).toHaveBeenCalled(); + }); +}); + +describe('slots', () => { + it('default slot', async () => { + const wrapper = mount(() => ( + +
Default Slot Content
+
+ )); + + await nextTick(); + expect(wrapper.find('.default-slot').exists()).toBe(true); + expect(wrapper.find('.default-slot').text()).toBe('Default Slot Content'); + }); +}); + +describe('edge cases', () => { + it('handles empty props', async () => { + const wrapper = mount(() => ); + + await nextTick(); + // 空字符串仍应渲染对应结构 + expect(wrapper.find('.t-dialog__header').exists()).toBe(true); + expect(wrapper.find('.t-dialog__body').exists()).toBe(true); + }); + + it('handles undefined props', async () => { + const wrapper = mount(() => ); + + await nextTick(); + // 未提供props时的默认行为 + expect(wrapper.find('.t-dialog').exists()).toBe(true); + }); +}); diff --git a/packages/components/dialog/__tests__/dialog.hooks.test.tsx b/packages/components/dialog/__tests__/dialog.hooks.test.tsx new file mode 100644 index 0000000000..3105e01c2a --- /dev/null +++ b/packages/components/dialog/__tests__/dialog.hooks.test.tsx @@ -0,0 +1,490 @@ +// @ts-nocheck +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { ref, defineComponent } from 'vue'; +import { useAction, useSameTarget } from '../hooks'; + +const UnifiedTestComponent = defineComponent({ + props: { + // useAction 相关 props + confirmBtn: [String, Object, Function], + cancelBtn: [String, Object, Function], + confirmLoading: Boolean, + theme: String, + size: String, + className: String, + globalConfirm: [String, Object], + globalCancel: [String, Object], + globalConfirmBtnTheme: Object, + + // useSameTarget 相关 props + onClick: Function, + disabled: Boolean, + + // 测试模式控制 + testMode: { + type: String, + default: 'action', // 'action' | 'sameTarget' | 'extended' + }, + }, + setup(props, { expose }) { + // useAction 测试逻辑 + if (props.testMode === 'action') { + const mockAction = { + confirmBtnAction: vi.fn(), + cancelBtnAction: vi.fn(), + }; + + const { getConfirmBtn, getCancelBtn } = useAction(mockAction); + + const confirmOptions = { + confirmBtn: props.confirmBtn, + globalConfirm: props.globalConfirm || '确认', + className: props.className || 'test-confirm', + size: props.size || 'medium', + confirmLoading: props.confirmLoading, + theme: props.theme, + globalConfirmBtnTheme: props.globalConfirmBtnTheme || { + default: 'primary', + info: 'info', + warning: 'warning', + danger: 'danger', + success: 'success', + }, + }; + + const cancelOptions = { + cancelBtn: props.cancelBtn, + globalCancel: props.globalCancel || '取消', + className: props.className || 'test-cancel', + size: props.size || 'medium', + }; + + const confirmBtn = getConfirmBtn(confirmOptions); + const cancelBtn = getCancelBtn(cancelOptions); + + expose({ confirmBtn, cancelBtn, mockAction }); + + return () => ( +
+ {confirmBtn} + {cancelBtn} +
+ ); + } + + // useSameTarget 测试逻辑 + if (props.testMode === 'sameTarget') { + const { onClick, onMousedown, onMouseup } = useSameTarget(props.onClick); + + expose({ onClick, onMousedown, onMouseup }); + + return () => ( +
+ 测试目标 +
+ ); + } + + // 扩展测试逻辑 + if (props.testMode === 'extended') { + const handleClick = vi.fn(); + const extendDisabled = ref(props.disabled); + const { onClick, onMousedown, onMouseup } = useSameTarget(extendDisabled.value ? undefined : handleClick); + + expose({ onClick, onMousedown, onMouseup, handleClick, extendDisabled }); + + return () => ( +
+ 扩展测试 +
+ ); + } + + return () =>
默认测试组件
; + }, +}); + +describe('useAction', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getConfirmBtn', () => { + it('should return null when confirmBtn is null', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: null, + }, + }); + expect(wrapper.vm.confirmBtn).toBe(null); + }); + + it('should return default button when confirmBtn is undefined', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: undefined, + }, + }); + const button = wrapper.find('button'); + expect(button.exists()).toBeTruthy(); + expect(button.text()).toBe('确认'); + expect(button.classes()).toContain('t-button--theme-primary'); + }); + + it('should display custom content when confirmBtn is string', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: '自定义确认', + }, + }); + const button = wrapper.find('button'); + expect(button.text()).toBe('自定义确认'); + }); + + it('should apply object properties when confirmBtn is object', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: { content: '保存', theme: 'success' }, + }, + }); + const button = wrapper.find('button'); + expect(button.text()).toBe('保存'); + expect(button.classes()).toContain('t-button--theme-success'); + }); + + it('should show loading state', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: '确认', + confirmLoading: true, + }, + }); + const button = wrapper.find('button'); + expect(button.classes()).toContain('t-is-loading'); + }); + + it('should trigger confirmBtnAction on click', async () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: '确认', + }, + }); + const button = wrapper.find('button'); + await button.trigger('click'); + expect(wrapper.vm.mockAction.confirmBtnAction).toHaveBeenCalled(); + }); + + it('should handle different theme types', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: '确认', + theme: 'warning', + }, + }); + const button = wrapper.find('button'); + expect(button.classes()).toContain('t-button--theme-warning'); + }); + + it('should handle global config as object', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: undefined, + globalConfirm: { content: '全局确认', theme: 'info' }, + }, + }); + const button = wrapper.find('button'); + expect(button.text()).toBe('全局确认'); + expect(button.classes()).toContain('t-button--theme-info'); + }); + + it('When both confirmBtn props and slot exist, props should be preferred and a warning should be output', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: 'props优先', + }, + slots: { + confirmBtn: () => 'slot内容', + }, + }); + // 断言 warning 被调用 + expect(warnSpy).toHaveBeenCalledWith( + 'Both $props.confirmBtn and $scopedSlots.confirmBtn exist, $props.confirmBtn is preferred.', + ); + // 断言按钮内容为 props + const button = wrapper.find('button'); + expect(button.text()).toBe('props优先'); + warnSpy.mockRestore(); + }); + + it('should render content returned by confirmBtn function', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: () => '函数按钮', + }, + }); + expect(wrapper.html()).toContain('函数按钮'); + }); + + it('should render slot content when only confirmBtn slot is provided', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: undefined, + }, + slots: { + confirmBtn: () => '插槽按钮', + }, + }); + expect(wrapper.html()).toContain('插槽按钮'); + }); + }); + + describe('getCancelBtn', () => { + it('When both cancelBtn props and slot exist, props should be preferred and a warning should be output', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + cancelBtn: 'props优先', + }, + slots: { + cancelBtn: () => 'slot内容', + }, + }); + // 断言 warning 被调用 + expect(warnSpy).toHaveBeenCalledWith( + 'Both $props.cancelBtn and $scopedSlots.cancelBtn exist, $props.cancelBtn is preferred.', + ); + // 断言按钮内容为 props + const button = wrapper.findAll('button'); + expect(button[1].text()).toBe('props优先'); + warnSpy.mockRestore(); + }); + + it('should return null when cancelBtn is null', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + cancelBtn: null, + }, + }); + expect(wrapper.vm.cancelBtn).toBe(null); + }); + + it('should return default button when cancelBtn is undefined', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: null, + cancelBtn: undefined, + }, + }); + const buttons = wrapper.findAll('button'); + const cancelButton = buttons[0]; + expect(cancelButton.exists()).toBeTruthy(); + expect(cancelButton.text()).toBe('取消'); + expect(cancelButton.classes()).toContain('t-button--theme-default'); + }); + + it('should display custom content when cancelBtn is string', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: null, + cancelBtn: '自定义取消', + }, + }); + const button = wrapper.find('button'); + expect(button.text()).toBe('自定义取消'); + }); + + it('should apply object properties when cancelBtn is object', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: null, + cancelBtn: { content: '关闭', theme: 'danger' }, + }, + }); + const button = wrapper.find('button'); + expect(button.text()).toBe('关闭'); + expect(button.classes()).toContain('t-button--theme-danger'); + }); + + it('should trigger cancelBtnAction on click', async () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + confirmBtn: null, + cancelBtn: '取消', + }, + }); + const button = wrapper.find('button'); + await button.trigger('click'); + expect(wrapper.vm.mockAction.cancelBtnAction).toHaveBeenCalled(); + }); + + it('should use globalCancel string as button content', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + cancelBtn: undefined, + globalCancel: '全局取消', + }, + }); + const buttons = wrapper.findAll('button'); + const cancelButton = buttons[1]; + expect(cancelButton.text()).toBe('全局取消'); + }); + + it('globalCancel object should set button content and theme', () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'action', + cancelBtn: undefined, + globalCancel: { content: '全局取消对象', theme: 'danger' }, + }, + }); + const buttons = wrapper.findAll('button'); + const cancelButton = buttons[1]; + expect(cancelButton.text()).toBe('全局取消对象'); + expect(cancelButton.classes()).toContain('t-button--theme-danger'); + }); + }); +}); + +describe('useSameTarget', () => { + it('should return event handler functions', () => { + const handleClick = vi.fn(); + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'sameTarget', + onClick: handleClick, + }, + }); + expect(typeof wrapper.vm.onClick).toBe('function'); + expect(typeof wrapper.vm.onMousedown).toBe('function'); + expect(typeof wrapper.vm.onMouseup).toBe('function'); + }); + + it('should call handleClick when mousedown and mouseup targets are same', async () => { + const handleClick = vi.fn(); + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'sameTarget', + onClick: handleClick, + }, + }); + const mockTarget = document.createElement('div'); + await wrapper.vm.onMousedown({ target: mockTarget, currentTarget: mockTarget } as MouseEvent); + await wrapper.vm.onMouseup({ target: mockTarget, currentTarget: mockTarget } as MouseEvent); + await wrapper.vm.onClick({} as MouseEvent); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('should not call handleClick when mousedown targets are different', async () => { + const handleClick = vi.fn(); + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'sameTarget', + onClick: handleClick, + }, + }); + const mockTarget1 = document.createElement('div'); + const mockTarget2 = document.createElement('div'); + await wrapper.vm.onMousedown({ target: mockTarget1, currentTarget: mockTarget2 } as MouseEvent); + await wrapper.vm.onMouseup({ target: mockTarget1, currentTarget: mockTarget1 } as MouseEvent); + await wrapper.vm.onClick({} as MouseEvent); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('should not call handleClick when mouseup targets are different', async () => { + const handleClick = vi.fn(); + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'sameTarget', + onClick: handleClick, + }, + }); + const mockTarget1 = document.createElement('div'); + const mockTarget2 = document.createElement('div'); + await wrapper.vm.onMousedown({ target: mockTarget1, currentTarget: mockTarget1 } as MouseEvent); + await wrapper.vm.onMouseup({ target: mockTarget1, currentTarget: mockTarget2 } as MouseEvent); + await wrapper.vm.onClick({} as MouseEvent); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('should reset state after click', async () => { + const handleClick = vi.fn(); + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'sameTarget', + onClick: handleClick, + }, + }); + const mockTarget = document.createElement('div'); + await wrapper.vm.onMousedown({ target: mockTarget, currentTarget: mockTarget } as MouseEvent); + await wrapper.vm.onMouseup({ target: mockTarget, currentTarget: mockTarget } as MouseEvent); + await wrapper.vm.onClick({} as MouseEvent); + expect(handleClick).toHaveBeenCalledTimes(1); + await wrapper.vm.onClick({} as MouseEvent); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('should not throw error when no handleClick function provided', async () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'sameTarget', + onClick: undefined, + }, + }); + const mockTarget = document.createElement('div'); + expect(() => { + wrapper.vm.onMousedown({ target: mockTarget, currentTarget: mockTarget } as MouseEvent); + wrapper.vm.onMouseup({ target: mockTarget, currentTarget: mockTarget } as MouseEvent); + wrapper.vm.onClick({} as MouseEvent); + }).not.toThrow(); + }); + + it('should handle extended parameters correctly', async () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'extended', + disabled: false, + }, + }); + const mockTarget = document.createElement('div'); + await wrapper.vm.onMousedown({ target: mockTarget, currentTarget: mockTarget } as MouseEvent); + await wrapper.vm.onMouseup({ target: mockTarget, currentTarget: mockTarget } as MouseEvent); + await wrapper.vm.onClick({} as MouseEvent); + expect(wrapper.vm.handleClick).toHaveBeenCalled(); + }); + + it('should return correct state when all conditions are not met', async () => { + const wrapper = mount(UnifiedTestComponent, { + props: { + testMode: 'sameTarget', + onClick: undefined, + }, + }); + const mockTarget1 = document.createElement('div'); + const mockTarget2 = document.createElement('div'); + await wrapper.vm.onMousedown({ target: mockTarget1, currentTarget: mockTarget2 } as MouseEvent); + await wrapper.vm.onMouseup({ target: mockTarget1, currentTarget: mockTarget2 } as MouseEvent); + expect(() => { + wrapper.vm.onClick({} as MouseEvent); + }).not.toThrow(); + }); +}); diff --git a/packages/components/dialog/__tests__/dialog.plugin.test.ts b/packages/components/dialog/__tests__/dialog.plugin.test.ts new file mode 100644 index 0000000000..fb3d321ae7 --- /dev/null +++ b/packages/components/dialog/__tests__/dialog.plugin.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createApp, nextTick } from 'vue'; +import DialogPlugin from '../../dialog/plugin'; + +describe('DialogPlugin', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const app: ReturnType = createApp({ render: null }); + app.use(DialogPlugin); + + it('should install plugin and add $dialog to globalProperties', () => { + const dialog = app.config.globalProperties.$dialog; + expect(dialog).toBeDefined(); + expect(typeof dialog).toBe('function'); + expect(typeof dialog.confirm).toBe('function'); + expect(typeof dialog.alert).toBe('function'); + }); + + it('should open and close dialog via $dialog', async () => { + const dialog = app.config.globalProperties.$dialog({ + body: 'test content', + }); + await nextTick(); + dialog.show(); + await nextTick(); + dialog.hide(); + await nextTick(); + expect(dialog).toBeDefined(); + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should open confirm dialog', async () => { + const dialog = app.config.globalProperties.$dialog.confirm({ + body: 'confirm content', + }); + expect(dialog).toBeDefined(); + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should open alert dialog (no cancel button)', async () => { + const dialog = app.config.globalProperties.$dialog.alert({ + body: 'alert content', + }); + expect(dialog).toBeDefined(); + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should set confirm loading', async () => { + const dialog = app.config.globalProperties.$dialog({ + body: 'loading content', + }); + dialog.setConfirmLoading(true); + dialog.setConfirmLoading(false); + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should update dialog options', async () => { + const dialog = app.config.globalProperties.$dialog({ + body: 'update content', + }); + dialog.update({ body: 'updated', className: 'test-class2' }); + await nextTick(); + // 检查内容是否更新 + const dialogBody = document.body.querySelector('.t-dialog__body'); + expect(dialogBody).toBeTruthy(); + expect(dialogBody && dialogBody.textContent).toBe('updated'); + // 检查class是否更新 + const dialogCtx = document.body.querySelector('.t-dialog__ctx'); + expect(dialogCtx).toBeTruthy(); + expect(dialogCtx && dialogCtx.classList.contains('test-class2')).toBeTruthy(); + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should apply style to dialog wrapper', async () => { + const style = 'color: red;'; + const dialog = app.config.globalProperties.$dialog({ + body: 'styled content', + style, + }); + const wrapper = document.body.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeTruthy(); + expect((wrapper as HTMLElement).style.cssText.replace(/\s/g, style)).toContain(style); + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should use default onClose when no custom onClose is provided', async () => { + const dialog = app.config.globalProperties.$dialog({ + body: 'no custom onClose', + onClose: null, + }); + // 关闭弹窗 + dialog.hide(); + await nextTick(); + // 检查 DOM 是否被隐藏 + const wrapper = document.body.querySelector('.t-dialog__ctx'); + // 这里可以根据实际实现,判断 wrapper 是否 display: none 或者直接被移除 + expect(wrapper === null || (wrapper instanceof HTMLElement && wrapper.style.display === 'none')).toBeTruthy(); + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should use custom onClose when provided', async () => { + const onClose = vi.fn(); + const dialog = app.config.globalProperties.$dialog({ + body: '自定义 onClose', + onClose, + }); + await nextTick(); + // 模拟关闭按钮点击 + // 这里直接调用 onClose 事件 + dialog.hide(); // 先显示再隐藏,确保 visible 变化 + await nextTick(); + // 手动触发 onClose + onClose(); + expect(onClose).toHaveBeenCalled(); + // 由于自定义 onClose 不会自动隐藏,visible 仍为 true + // 但我们无法直接访问 visible,只能保证 onClose 被调用 + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should attach dialog to custom container when attach is provided', async () => { + // 创建自定义容器并加入 body + const customContainer = document.createElement('div'); + customContainer.id = 'custom-dialog-container'; + document.body.appendChild(customContainer); + + // 打开 dialog,attach 指定到自定义容器 + const dialog = app.config.globalProperties.$dialog({ + body: 'attach content', + attach: '#custom-dialog-container', + }); + await nextTick(); + + // 检查 dialog 是否被挂载到自定义容器 + const wrapper = customContainer.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeTruthy(); + + dialog.destroy(); + vi.runAllTimers(); + document.body.removeChild(customContainer); + }); + + it('should log error when attach target does not exist', async () => { + // mock console.error + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const dialog = app.config.globalProperties.$dialog({ + body: 'attach error', + attach: '#not-exist-container', + }); + await nextTick(); + + expect(errorSpy).toHaveBeenCalledWith('attach is not exist'); + dialog.destroy(); + vi.runAllTimers(); + errorSpy.mockRestore(); + }); + + it('should delay unmount for 0.3s after destroy', async () => { + const dialog = app.config.globalProperties.$dialog({ + body: '延迟销毁测试', + }); + await nextTick(); + + const wrapper = document.body.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeTruthy(); + + dialog.destroy(); + + // 立即检查,DOM 还在 + expect(document.body.querySelector('.t-dialog__ctx')).toBeTruthy(); + + // 300ms 后检查,DOM 应该被移除 + vi.advanceTimersByTime(310); + expect(document.body.querySelector('.t-dialog__ctx')).toBeFalsy(); + }); + + it('should not update className if it does not change', async () => { + const dialog = app.config.globalProperties.$dialog({ + body: 'className unchanged', + className: 'test-class', + }); + await nextTick(); + // 记录当前 classList + const wrapper = document.body.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeTruthy(); + const oldClassList = [...(wrapper as HTMLElement).classList]; + + // update 时 className 不变 + dialog.update({ className: 'test-class' }); + await nextTick(); + + // 断言 classList 没有变化 + const newClassList = [...(wrapper as HTMLElement).classList]; + expect(newClassList).toEqual(oldClassList); + + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should remove old className when className changes', async () => { + const dialog = app.config.globalProperties.$dialog({ + body: 'className remove', + className: 'class-a class-b', + }); + await nextTick(); + const wrapper = document.body.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeTruthy(); + expect(wrapper && wrapper.classList.contains('class-a')).toBeTruthy(); + expect(wrapper && wrapper.classList.contains('class-b')).toBeTruthy(); + + // 更新 className,移除旧的 class-a class-b,添加 class-c + dialog.update({ className: 'class-c' }); + await nextTick(); + expect(wrapper && wrapper.classList.contains('class-a')).toBeFalsy(); + expect(wrapper && wrapper.classList.contains('class-b')).toBeFalsy(); + expect(wrapper && wrapper.classList.contains('class-c')).toBeTruthy(); + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should append style when style changes', async () => { + const dialog = app.config.globalProperties.$dialog({ + body: 'style append', + style: 'color: blue;', + }); + await nextTick(); + const wrapper = document.body.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeTruthy(); + expect((wrapper as HTMLElement).style.cssText.replace(/\s/g, '')).toContain('color:blue;'); + + // 追加 style + dialog.update({ style: 'background: yellow;' }); + await nextTick(); + // cssText 追加了新样式 + expect((wrapper as HTMLElement).style.cssText.replace(/\s/g, '')).toContain('color:blue;'); + expect((wrapper as HTMLElement).style.cssText.replace(/\s/g, '')).toContain('background:yellow;'); + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should use default onClose logic when no custom onClose provided', async () => { + const dialog = app.config.globalProperties.$dialog({ + body: 'default onClose test', + destroyOnClose: false, + }); + await nextTick(); + + // 获取对话框元素 + const wrapper = document.body.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeTruthy(); + + // 模拟点击关闭按钮触发默认的 onClose 逻辑 + const closeBtn = wrapper.querySelector('.t-dialog__close'); + expect(closeBtn).toBeTruthy(); + (closeBtn as HTMLElement).click(); + await nextTick(); + + // 验证对话框被隐藏但未销毁(destroyOnClose: false) + // 检查对话框是否有隐藏的样式类或属性 + const dialogElement = wrapper.querySelector('.t-dialog'); + expect(dialogElement).toBeTruthy(); + + dialog.destroy(); + vi.runAllTimers(); + }); + + it('should destroy dialog after 300ms when destroyOnClose is true', async () => { + app.config.globalProperties.$dialog({ + body: 'destroyOnClose test', + destroyOnClose: true, + }); + await nextTick(); + + // 获取对话框元素 + let wrapper = document.body.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeTruthy(); + + // 模拟点击关闭按钮触发 onClose 事件 + const closeBtn = wrapper.querySelector('.t-dialog__close'); + expect(closeBtn).toBeTruthy(); + (closeBtn as HTMLElement).click(); + await nextTick(); + + // 立即检查,DOM 还在 + wrapper = document.body.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeTruthy(); + + // 300ms 后检查,DOM 应该被移除 + vi.advanceTimersByTime(300); + wrapper = document.body.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeFalsy(); + }); + + it('should not destroy dialog when destroyOnClose is false', async () => { + const dialog = app.config.globalProperties.$dialog({ + body: 'no destroy test', + destroyOnClose: false, + }); + await nextTick(); + + // 获取对话框元素 + let wrapper = document.body.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeTruthy(); + + // 模拟点击关闭按钮触发默认的 onClose 逻辑 + const closeBtn = wrapper.querySelector('.t-dialog__close'); + expect(closeBtn).toBeTruthy(); + (closeBtn as HTMLElement).click(); + await nextTick(); + + // 300ms 后检查,DOM 仍然存在(因为 destroyOnClose: false) + vi.advanceTimersByTime(300); + wrapper = document.body.querySelector('.t-dialog__ctx'); + expect(wrapper).toBeTruthy(); + + // 手动销毁 + dialog.destroy(); + vi.runAllTimers(); + }); +}); diff --git a/packages/components/dialog/__tests__/dialog.test.tsx b/packages/components/dialog/__tests__/dialog.test.tsx index 9e5d4dad26..33a88c83b0 100644 --- a/packages/components/dialog/__tests__/dialog.test.tsx +++ b/packages/components/dialog/__tests__/dialog.test.tsx @@ -1,346 +1,945 @@ // @ts-nocheck +import { nextTick, ref } from 'vue'; +import type { Ref } from 'vue'; import { mount } from '@vue/test-utils'; -import { describe, expect, it, vi } from 'vitest'; -import { ref, nextTick } from 'vue'; +import type { VueWrapper } from '@vue/test-utils'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import { CloseIcon } from 'tdesign-icons-vue-next'; import Dialog from '@tdesign/components/dialog'; -describe('Dialog', () => { - describe(':props', () => { - it('', async () => { - const visible = ref(true); - const wrapper = mount(() => this is content); - const body = wrapper.find('.t-dialog .t-dialog__body'); - await nextTick(); - expect(body.exists()).toBeTruthy(); - expect(body.text()).toBe('this is content'); - }); +describe('props', () => { + let wrapper: VueWrapper> | null = null; + let visible: Ref; - it('default', async () => { - const visible = ref(true); - const wrapper = mount(() => this is content); - const body = wrapper.find('.t-dialog .t-dialog__body'); - await nextTick(); - expect(body.exists()).toBeTruthy(); - expect(body.text()).toBe('this is content'); - }); + beforeEach(() => { + visible = ref(true); + wrapper = mount(() => this is content) as VueWrapper< + InstanceType + >; + }); - it('default', async () => { - const visible = ref(true); - const wrapper = mount(() => ); - const body = wrapper.find('.t-dialog .t-dialog__body'); - await nextTick(); - expect(body.exists()).toBeTruthy(); - expect(body.text()).toBe('this is content'); - }); + it('renders dialog content through default slot', async () => { + const body = wrapper.find('.t-dialog .t-dialog__body'); + await nextTick(); + expect(body.exists()).toBeTruthy(); + expect(body.text()).toBe('this is content'); + }); - it(':cancelBtn', async () => { - const visible = ref(true); - const wrapper = mount(() => ); - const btn = wrapper.find('.t-dialog__footer .t-dialog__cancel'); - await nextTick(); - expect(btn.exists()).toBeTruthy(); - expect(btn.text()).toBe('取消'); - }); + it(':body - renders content through body prop as string', async () => { + const wrapper = mount(() => ); + const body = wrapper.find('.t-dialog .t-dialog__body'); + await nextTick(); + expect(body.exists()).toBeTruthy(); + expect(body.text()).toBe('this is content'); + }); - it(':confirmBtn', async () => { - const visible = ref(true); - const wrapper = mount(() => ); - const btn = wrapper.find('.t-dialog__footer .t-dialog__confirm'); - await nextTick(); - expect(btn.exists()).toBeTruthy(); - expect(btn.text()).toBe('确认'); - }); + it(':body - renders content through body prop as function', async () => { + const bodyFn = () => 'function content'; + const wrapper = mount(() => ); + const body = wrapper.find('.t-dialog .t-dialog__body'); + await nextTick(); + expect(body.exists()).toBeTruthy(); + expect(body.text()).toBe('function content'); + }); - it(':closeBtn', async () => { - const visible = ref(true); - const wrapper = mount(() => ); - const close = wrapper.find('.t-dialog__close'); - await nextTick(); - expect(close.exists()).toBeTruthy(); - expect(close.findComponent(CloseIcon).exists()).toBeTruthy(); - }); + it(':default prop works same as body', async () => { + const wrapper = mount(() => ); + const body = wrapper.find('.t-dialog .t-dialog__body'); + await nextTick(); + expect(body.exists()).toBeTruthy(); + expect(body.text()).toBe('default content'); + }); - it(':footer', async () => { - const visible = ref(true); - const wrapper = mount(() => ); - const footer = wrapper.find('.t-dialog__footer'); - await nextTick(); - expect(footer.exists()).toBeTruthy(); - expect(footer.findAll('button').length).toBe(2); - }); + it(':attach - attaches dialog to specified element', async () => { + const attachElement = document.createElement('div'); + attachElement.id = 'attach-container'; + document.body.appendChild(attachElement); - it(':header', async () => { - const visible = ref(true); - const wrapper = mount(() => ( - - )); - const header = wrapper.find('.t-dialog__header'); - await nextTick(); - expect(header.exists()).toBeTruthy(); - expect(header.text()).toBe('this is header'); - }); + mount(() => ); + await nextTick(); - it(':header:false', async () => { - const visible = ref(true); - const wrapper = mount(() => ( - - )); - const header = wrapper.find('.t-dialog__header'); - await nextTick(); - expect(header.exists()).toBeFalsy(); - }); + expect(attachElement.children.length).toBeGreaterThan(0); + document.body.removeChild(attachElement); + }); - it(':placement', async () => { - const placementList = ['top', 'center']; - const visible = ref(true); - placementList.forEach(async (placement) => { - const wrapper = mount(() => ( - - )); - const dialog = wrapper.find('.t-dialog'); - await nextTick(); - expect(dialog.exists()).toBeTruthy(); - expect(dialog.classes()).toContain(`t-dialog--${placement}`); - }); - }); + it(':attach - supports function type', async () => { + const attachElement = document.createElement('div'); + document.body.appendChild(attachElement); - it(':mode:modeless', async () => { - const visible = ref(true); - const wrapper = mount(() => ( - - )); - const ctx = wrapper.find('.t-dialog__ctx'); - await nextTick(); - expect(ctx.exists()).toBeTruthy(); - expect(ctx.classes()).toContain('t-dialog__ctx--modeless'); - }); + mount(() => attachElement} body="this is content">); + await nextTick(); - it(':mode:normal', async () => { - const visible = ref(true); - const wrapper = mount(() => ( - - )); - const ctx = wrapper.find('.t-dialog__ctx'); - await nextTick(); - expect(ctx.find('.t-dialog__position').exists()).toBeFalsy(); - }); + expect(attachElement.children.length).toBeGreaterThan(0); + document.body.removeChild(attachElement); + }); - it(':mode:full-screen', async () => { - const visible = ref(true); - const wrapper = mount(() => ( - - )); - const ctx = wrapper.find('.t-dialog__ctx'); - await nextTick(); - expect(ctx.find('.t-dialog__position_fullscreen').exists()).toBeTruthy(); - }); + it(':cancelBtn - renders default cancel button', async () => { + const btn = wrapper.find('.t-dialog__footer .t-dialog__cancel'); + await nextTick(); + expect(btn.exists()).toBeTruthy(); + expect(btn.text()).toBe('取消'); + }); - it(':showOverlay', async () => { - const visible = ref(true); - const wrapper = mount(() => ); - const ctx = wrapper.find('.t-dialog__ctx'); - await nextTick(); - expect(ctx.find('.t-dialog__mask').exists()).toBeTruthy(); - }); + it(':cancelBtn - renders custom cancel button with string', async () => { + const wrapper = mount(() => ( + + )); + const btn = wrapper.find('.t-dialog__footer .t-dialog__cancel'); + await nextTick(); + expect(btn.exists()).toBeTruthy(); + expect(btn.text()).toBe('自定义取消'); + }); - it(':theme', async () => { - const themeList = ['default', 'success', 'info', 'warning', 'danger']; - const visible = ref(true); - themeList.forEach(async (theme) => { - const wrapper = mount(() => ( - - )); - const dialog = wrapper.find('.t-dialog'); - await nextTick(); - expect(dialog.classes()).toContain(`t-dialog__modal-${theme}`); - }); - }); + it(':cancelBtn - renders custom cancel button with object', async () => { + const wrapper = mount(() => ( + + )); + const btn = wrapper.find('.t-dialog__footer .t-dialog__cancel'); + await nextTick(); + expect(btn.exists()).toBeTruthy(); + expect(btn.text()).toBe('自定义取消'); + expect(btn.classes()).toContain('t-button--theme-danger'); + }); - it(':top', async () => { - const visible = ref(true); - const wrapper = mount(() => ); - const top = wrapper.find('.t-dialog--top'); - await nextTick(); - expect(getComputedStyle(top.element, null).paddingTop).toBe('200px'); - }); + it(':cancelBtn - hides cancel button when null', async () => { + const wrapper = mount(() => ( + + )); + const btn = wrapper.find('.t-dialog__footer .t-dialog__cancel'); + await nextTick(); + expect(btn.exists()).toBeFalsy(); + }); - it(':width', async () => { - const visible = ref(true); - const wrapper = mount(() => ); - const dialog = wrapper.find('.t-dialog'); - await nextTick(); - expect(getComputedStyle(dialog.element, null).width).toBe('80%'); - }); + it(':cancelBtn - renders custom cancel button with function', async () => { + const cancelBtnFn = () => ; + const wrapper = mount(() => ( + + )); + await nextTick(); + expect(wrapper.find('.custom-cancel').exists()).toBeTruthy(); + }); - it(':zIndex', async () => { - const visible = ref(true); - const wrapper = mount(() => ( - - )); - const ctx = wrapper.find('.t-dialog__ctx'); - await nextTick(); - expect(getComputedStyle(ctx.element, null).zIndex).toBe('2022'); - }); + it(':confirmBtn - renders default confirm button', async () => { + const btn = wrapper.find('.t-dialog__footer .t-dialog__confirm'); + await nextTick(); + expect(btn.exists()).toBeTruthy(); + expect(btn.text()).toBe('确认'); + }); - it(':draggable', async () => { - const visible = ref(true); - const wrapper = mount(() => ( - - )); - const dialog = wrapper.find('.t-dialog'); - await nextTick(); - expect(dialog.classes()).toContain('t-dialog--draggable'); - }); + it(':confirmBtn - renders custom confirm button with string', async () => { + const wrapper = mount(() => ( + + )); + const btn = wrapper.find('.t-dialog__footer .t-dialog__confirm'); + await nextTick(); + expect(btn.exists()).toBeTruthy(); + expect(btn.text()).toBe('自定义确认'); + }); - it(':draggable', async () => { - const visible = ref(true); - const wrapper = mount(() => ( - - )); - const dialog = wrapper.find('.t-dialog'); - await nextTick(); - expect(dialog.classes()).toContain('t-dialog--draggable'); - }); + it(':confirmBtn - renders custom confirm button with object', async () => { + const wrapper = mount(() => ( + + )); + const btn = wrapper.find('.t-dialog__footer .t-dialog__confirm'); + await nextTick(); + expect(btn.exists()).toBeTruthy(); + expect(btn.text()).toBe('自定义确认'); + expect(btn.classes()).toContain('t-button--theme-success'); + }); - it(':dialogClassName', async () => { - const visible = ref(true); - const wrapper = mount(() => ( - - )); - const dialog = wrapper.find('.t-dialog'); - await nextTick(); - expect(dialog.classes()).toContain('custom-class'); - }); + it(':confirmBtn - hides confirm button when null', async () => { + const wrapper = mount(() => ( + + )); + const btn = wrapper.find('.t-dialog__footer .t-dialog__confirm'); + await nextTick(); + expect(btn.exists()).toBeFalsy(); + }); - it(':dialogStyle', async () => { - const visible = ref(true); - const wrapper = mount(() => ( - - )); - const dialog = wrapper.find('.t-dialog'); - await nextTick(); - expect(getComputedStyle(dialog.element, null).padding).toBe('99px'); - }); + it(':confirmBtn - renders custom confirm button with function', async () => { + const confirmBtnFn = () => ; + const wrapper = mount(() => ( + + )); + await nextTick(); + expect(wrapper.find('.custom-confirm').exists()).toBeTruthy(); + }); + + it(':closeBtn - renders default close button', async () => { + const close = wrapper.find('.t-dialog__close'); + await nextTick(); + expect(close.exists()).toBeTruthy(); + expect(close.findComponent(CloseIcon).exists()).toBeTruthy(); + }); + + it(':closeBtn - hides close button when false', async () => { + const wrapper = mount(() => ( + + )); + const close = wrapper.find('.t-dialog__close'); + await nextTick(); + expect(close.exists()).toBeFalsy(); + }); + + it(':closeBtn - renders custom close button with string', async () => { + const wrapper = mount(() => ( + + )); + const close = wrapper.find('.t-dialog__close'); + await nextTick(); + expect(close.exists()).toBeTruthy(); + expect(close.text()).toBe('关闭'); + }); + + it(':closeBtn - renders custom close button with function', async () => { + const closeBtnFn = () => ×; + const wrapper = mount(() => ( + + )); + await nextTick(); + expect(wrapper.find('.custom-close').exists()).toBeTruthy(); + }); + + it(':closeOnEscKeydown - true by default, closes dialog on ESC', async () => { + const onClose = vi.fn(); + mount(() => ); + await nextTick(); + + // 模拟按下ESC键 + const escEvent = new KeyboardEvent('keydown', { code: 'Escape' }); + document.dispatchEvent(escEvent); + + expect(onClose).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'esc' })); + }); + + it(':closeOnEscKeydown - false prevents closing on ESC', async () => { + const onClose = vi.fn(); + mount(() => ( + + )); + await nextTick(); + + const escEvent = new KeyboardEvent('keydown', { code: 'Escape' }); + document.dispatchEvent(escEvent); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it(':closeOnOverlayClick - true by default, closes dialog on overlay click', async () => { + const onClose = vi.fn(); + const wrapper = mount(() => ( + + )); + await nextTick(); + + const wrap = wrapper.find('.t-dialog__position'); + await wrap.trigger('mousedown'); + await wrap.trigger('mouseup'); + await wrap.trigger('click'); + await nextTick(); + + expect(visible.value).toBe(false); // 检查状态变化 + expect(onClose).toHaveBeenCalledTimes(1); // 确保回调触发 + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ trigger: 'overlay' }), // 验证回调参数 + ); + }); + + it(':closeOnOverlayClick - false prevents closing on overlay click', async () => { + const onClose = vi.fn(); + const wrapper = mount(() => ( + + )); + await nextTick(); - it('update dialog confirmBtnLoading', async () => { - const visible = ref(true); - const loading = ref(false); + const wrap = wrapper.find('.t-dialog__position'); + await wrap.trigger('mousedown'); + await wrap.trigger('mouseup'); + await wrap.trigger('click'); + await nextTick(); + + expect(visible.value).toBe(true); // 检查状态变化 + expect(onClose).not.toHaveBeenCalled(); + }); + + it(':confirmLoading - shows loading state on confirm button', async () => { + const confirmLoading = ref(true); + const wrapper = mount(() => ( + + )); + await nextTick(); + + const confirmBtn = wrapper.find('.t-dialog__confirm'); + expect(confirmBtn.classes()).toContain('t-is-loading'); + }); + + it(':confirmOnEnter - triggers confirm on Enter key', async () => { + visible = ref(true); + const onConfirm = vi.fn(); + mount(() => ( + + )); + await nextTick(); + + // 触发 Enter 键,确保 event.target 存在,避免 undefined 报错 + const enterEvent = new KeyboardEvent('keydown', { code: 'Enter' }); + Object.defineProperty(enterEvent, 'target', { value: document.body }); + document.dispatchEvent(enterEvent); + + await nextTick(); + + expect(onConfirm).toHaveBeenCalled(); + }); + + it(':confirmOnEnter - triggers confirm on NumpadEnter key', async () => { + visible = ref(true); + const onConfirm = vi.fn(); + mount(() => ( + + )); + await nextTick(); + + // 触发 NumpadEnter 键,确保 event.target 存在,避免 undefined 报错 + const enterEvent = new KeyboardEvent('keydown', { code: 'NumpadEnter' }); + Object.defineProperty(enterEvent, 'target', { value: document.body }); + document.dispatchEvent(enterEvent); + + await nextTick(); + + expect(onConfirm).toHaveBeenCalled(); + }); + + it(':confirmOnEnter - does not trigger confirm when target is input', async () => { + const onConfirm = vi.fn(); + const wrapper = mount(() => ( + + + + )); + await nextTick(); + + const input = wrapper.find('.test-input'); + const enterEvent = new KeyboardEvent('keydown', { code: 'Enter' }); + Object.defineProperty(enterEvent, 'target', { value: input.element }); + document.dispatchEvent(enterEvent); + + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it(':destroyOnClose - destroys content when dialog closes', async () => { + visible = ref(false); + const wrapper = mount(() => ( + + )); + visible.value = true; + await nextTick(); + + expect(wrapper.find('.t-dialog__ctx').exists()).toBeTruthy(); + + visible.value = false; + await nextTick(); + + expect(document.querySelector('.t-dialog__ctx')).toBeNull(); + }); + + it(':footer - renders default footer', async () => { + const footer = wrapper.find('.t-dialog__footer'); + await nextTick(); + expect(footer.exists()).toBeTruthy(); + expect(footer.findAll('button').length).toBe(2); + }); + + it(':footer - hides footer when false', async () => { + const wrapper = mount(() => ( + + )); + const footer = wrapper.find('.t-dialog__footer'); + await nextTick(); + expect(footer.exists()).toBeFalsy(); + }); + + it(':footer - renders custom footer with function', async () => { + const footerFn = () => ; + const wrapper = mount(() => ( + + )); + await nextTick(); + expect(wrapper.find('.custom-footer').exists()).toBeTruthy(); + }); + + it(':header - renders default header', async () => { + const wrapper = mount(() => ( + + )); + const header = wrapper.find('.t-dialog__header'); + await nextTick(); + expect(header.exists()).toBeTruthy(); + expect(header.text()).toBe('this is header'); + }); + + it(':header - hides header when false', async () => { + const wrapper = mount(() => ( + + )); + const header = wrapper.find('.t-dialog__header'); + await nextTick(); + expect(header.exists()).toBeFalsy(); + }); + + it(':header - renders custom header with function', async () => { + const headerFn = () =>
Custom Header
; + const wrapper = mount(() => ( + + )); + await nextTick(); + expect(wrapper.find('.custom-header').exists()).toBeTruthy(); + }); + + it(':lazy - does not render content when lazy=true and visible=false', async () => { + const invisibleVisible = ref(false); + const wrapper = mount(() => ); + await nextTick(); + expect(wrapper.find('.t-dialog__body').exists()).toBeFalsy(); + }); + + it(':lazy - renders content after first open when lazy=true', async () => { + const lazyVisible = ref(false); + const wrapper = mount(() => ); + await nextTick(); + + lazyVisible.value = true; + await nextTick(); + expect(wrapper.find('.t-dialog__body').exists()).toBeTruthy(); + + lazyVisible.value = false; + await nextTick(); + expect(wrapper.find('.t-dialog__body').exists()).toBeTruthy(); // 仍然存在 + }); + + it(':mode - renders normal dialog', async () => { + const wrapper = mount(() => ); + const ctx = wrapper.find('.t-dialog__ctx'); + await nextTick(); + expect(ctx.find('.t-dialog__position').exists()).toBeFalsy(); + }); + + it(':mode - renders modal dialog by default', async () => { + const ctx = wrapper.find('.t-dialog__ctx'); + expect(ctx.find('.t-dialog__position').exists()).toBeTruthy(); + }); + + it(':mode - renders modeless dialog', async () => { + const wrapper = mount(() => ( + + )); + const ctx = wrapper.find('.t-dialog__ctx'); + await nextTick(); + expect(ctx.exists()).toBeTruthy(); + expect(ctx.classes()).toContain('t-dialog__ctx--modeless'); + }); + + it(':mode - renders normal dialog', async () => { + const wrapper = mount(() => ); + const ctx = wrapper.find('.t-dialog__ctx'); + await nextTick(); + expect(ctx.find('.t-dialog__position').exists()).toBeFalsy(); + }); + + it(':mode - renders full-screen dialog', async () => { + const wrapper = mount(() => ( + + )); + const ctx = wrapper.find('.t-dialog__ctx'); + await nextTick(); + expect(ctx.find('.t-dialog__position_fullscreen').exists()).toBeTruthy(); + }); + + it(':placement - renders top placement by default', async () => { + const dialog = wrapper.find('.t-dialog'); + await nextTick(); + expect(dialog.classes()).toContain('t-dialog--top'); + }); + + it(':placement - renders by placement', async () => { + const placements = ['top', 'center', '']; + for (const placement of placements) { const wrapper = mount(() => ( - + )); const dialog = wrapper.find('.t-dialog'); await nextTick(); - expect(dialog.find('.t-button--theme-primary.t-is-loading.t-dialog__confirm').exists()).toBeFalsy(); - loading.value = true; - await nextTick(); - const updateDialog = wrapper.find('.t-dialog'); - expect(updateDialog.find('.t-button--theme-primary.t-is-loading.t-dialog__confirm').exists()).toBeTruthy(); - }); + if (placement === 'top') { + expect(dialog.classes()).toContain('t-dialog--top'); + } else if (placement === 'center') { + expect(dialog.classes()).toContain('t-dialog--center'); + } + } + }); + + it(':preventScrollThrough - prevents scroll through by default', async () => { + visible = ref(false); + mount(() => ); + visible.value = true; + await nextTick(); + const styleElements = document.body.querySelectorAll('style'); + expect(styleElements.length).toBeGreaterThan(0); + }); + + it(':showInAttachedElement - shows dialog in attached element', async () => { + const attachElement = document.createElement('div'); + attachElement.style.position = 'relative'; + document.body.appendChild(attachElement); + + mount(() => ( + attachElement} + showInAttachedElement + body="this is content" + > + )); + await nextTick(); + + expect(attachElement.children.length).toBeGreaterThan(0); + document.body.removeChild(attachElement); + }); + + it(':showOverlay - shows overlay by default', async () => { + const ctx = wrapper.find('.t-dialog__ctx'); + await nextTick(); + expect(ctx.find('.t-dialog__mask').exists()).toBeTruthy(); + }); + + it(':showOverlay - hides overlay when false', async () => { + const wrapper = mount(() => ( + + )); + const mask = wrapper.find('.t-dialog__mask'); + await nextTick(); + expect(mask.classes()).toContain('t-is-hidden'); + }); + + it(':theme - renders different themes', async () => { + const themeList = ['default', 'success', 'info', 'warning', 'danger', '']; - it('drag dialog', async () => { - const visible = ref(true); + for (const theme of themeList) { const wrapper = mount(() => ( - + )); - await nextTick(); const dialog = wrapper.find('.t-dialog'); - const dialogElement = dialog.element; - dialogElement.style.position = 'absolute'; - dialogElement.style.left = '100px'; - dialogElement.style.top = '100px'; - dialogElement.style.width = '500px'; - dialogElement.style.height = '300px'; - const initialLeft = parseInt(getComputedStyle(dialogElement).left, 10); - const initialTop = parseInt(getComputedStyle(dialogElement).top, 10); - const mousedownEvent = new MouseEvent('mousedown', { clientX: 100, clientY: 100 }); - const mousemoveEvent = new MouseEvent('mousemove', { clientX: 150, clientY: 150 }); - const mouseupEvent = new MouseEvent('mouseup'); - dialogElement.dispatchEvent(mousedownEvent); - document.dispatchEvent(mousemoveEvent); - document.dispatchEvent(mouseupEvent); await nextTick(); - const finalLeft = parseInt(getComputedStyle(dialogElement).left, 10); - const finalTop = parseInt(getComputedStyle(dialogElement).top, 10); - expect(finalLeft).not.toBe(initialLeft); - expect(finalTop).not.toBe(initialTop); - }); + expect(dialog.classes()).toContain(`t-dialog__modal-${theme}`); + } }); - describe(':events', () => { - it(':onCancel', async () => { - const visible = ref(true); - const fn = vi.fn(); - const wrapper = mount(() => ( - - this is content - - )); - const btn = wrapper.find('.t-dialog__footer .t-dialog__cancel'); - await nextTick(); - await btn.trigger('click'); - expect(fn).toBeCalled(); - }); + it(':top - sets custom top position', async () => { + const wrapper = mount(() => ); + const position = wrapper.find('.t-dialog__position'); + await nextTick(); + expect(getComputedStyle(position.element, null).paddingTop).toBe('200px'); + }); - it(':onConfirm', async () => { - const visible = ref(true); - const fn = vi.fn(); - const wrapper = mount(() => ( - - this is content - - )); - const btn = wrapper.find('.t-dialog__footer .t-dialog__confirm'); - await nextTick(); - await btn.trigger('click'); - expect(fn).toBeCalled(); - }); + it(':top - supports number type', async () => { + const wrapper = mount(() => ); + const position = wrapper.find('.t-dialog__position'); + await nextTick(); + expect(getComputedStyle(position.element, null).paddingTop).toBe('300px'); + }); - it(':onClose', async () => { - const visible = ref(true); - const fn = vi.fn(); - const wrapper = mount(() => ( - - this is content - - )); - const btn = wrapper.find('.t-dialog__close'); - await nextTick(); - await btn.trigger('click'); - expect(fn).toBeCalled(); - }); + it(':width - sets custom width with percentage', async () => { + const wrapper = mount(() => ); + const dialog = wrapper.find('.t-dialog'); + await nextTick(); + expect(getComputedStyle(dialog.element, null).width).toBe('80%'); + }); - it(':onCloseBtnClick', async () => { - const visible = ref(true); - const fn = vi.fn(); - const wrapper = mount(() => ( - - this is content - - )); - const btn = wrapper.find('.t-dialog__close'); - await nextTick(); - await btn.trigger('click'); - expect(fn).toBeCalled(); + it(':width - sets custom width with pixels', async () => { + const wrapper = mount(() => ); + const dialog = wrapper.find('.t-dialog'); + await nextTick(); + expect(getComputedStyle(dialog.element, null).width).toBe('500px'); + }); + + it(':width - supports number type', async () => { + const wrapper = mount(() => ); + const dialog = wrapper.find('.t-dialog'); + await nextTick(); + expect(getComputedStyle(dialog.element, null).width).toBe('600px'); + }); + + it(':zIndex - sets custom z-index', async () => { + const wrapper = mount(() => ); + const ctx = wrapper.find('.t-dialog__ctx'); + await nextTick(); + expect(getComputedStyle(ctx.element, null).zIndex).toBe('2022'); + }); + + it(':draggable - enables dragging for modeless dialog', async () => { + const wrapper = mount(() => ( + + )); + const dialog = wrapper.find('.t-dialog'); + await nextTick(); + expect(dialog.classes()).toContain('t-dialog--draggable'); + }); + + it(':dialogClassName - adds custom class', async () => { + const wrapper = mount(() => ( + + )); + const dialog = wrapper.find('.t-dialog'); + await nextTick(); + expect(dialog.classes()).toContain('custom-class'); + }); + + it(':dialogStyle - applies custom styles', async () => { + const wrapper = mount(() => ( + + )); + const dialog = wrapper.find('.t-dialog'); + await nextTick(); + expect(getComputedStyle(dialog.element, null).padding).toBe('99px'); + }); + + it('updates confirmBtnLoading reactively', async () => { + const loading = ref(false); + const wrapper = mount(() => ( + + )); + const dialog = wrapper.find('.t-dialog'); + await nextTick(); + expect(dialog.find('.t-button--theme-primary.t-is-loading.t-dialog__confirm').exists()).toBeFalsy(); + + loading.value = true; + await nextTick(); + const updateDialog = wrapper.find('.t-dialog'); + expect(updateDialog.find('.t-button--theme-primary.t-is-loading.t-dialog__confirm').exists()).toBeTruthy(); + }); + + it('supports drag functionality', async () => { + const wrapper = mount(() => ( + + )); + await nextTick(); + + const dialog = wrapper.find('.t-dialog'); + const dialogElement = dialog.element; + + // 设置初始样式 + dialogElement.style.position = 'absolute'; + dialogElement.style.left = '100px'; + dialogElement.style.top = '100px'; + dialogElement.style.width = '500px'; + dialogElement.style.height = '300px'; + + const initialLeft = parseInt(getComputedStyle(dialogElement).left, 10); + const initialTop = parseInt(getComputedStyle(dialogElement).top, 10); + + // 模拟拖拽事件 + const mousedownEvent = new MouseEvent('mousedown', { clientX: 100, clientY: 100 }); + const mousemoveEvent = new MouseEvent('mousemove', { clientX: 150, clientY: 150 }); + const mouseupEvent = new MouseEvent('mouseup'); + + dialogElement.dispatchEvent(mousedownEvent); + document.dispatchEvent(mousemoveEvent); + document.dispatchEvent(mouseupEvent); + + await nextTick(); + + const finalLeft = parseInt(getComputedStyle(dialogElement).left, 10); + const finalTop = parseInt(getComputedStyle(dialogElement).top, 10); + + expect(finalLeft).not.toBe(initialLeft); + expect(finalTop).not.toBe(initialTop); + }); + + it('mode prop: invalid value should trigger warning', () => { + const visible = ref(true); + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mount(() => ); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('placement prop: invalid value should trigger warning', () => { + const visible = ref(true); + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mount(() => ); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('theme prop: invalid value should trigger warning', () => { + const visible = ref(true); + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mount(() => ); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +describe('events', () => { + it('onCancel - triggers when cancel button is clicked', async () => { + const visible = ref(false); + const onCancel = vi.fn(); + const wrapper = mount(() => ( + + )); + + visible.value = true; + await nextTick(); + + const btn = wrapper.find('.t-dialog__footer .t-dialog__cancel'); + await nextTick(); + await btn.trigger('click'); + expect(onCancel).toHaveBeenCalled(); + }); + + it('onConfirm - triggers when confirm button is clicked', async () => { + const visible = ref(false); + const onConfirm = vi.fn(); + const wrapper = mount(() => ( + + )); + visible.value = true; + await nextTick(); + + const btn = wrapper.find('.t-dialog__footer .t-dialog__confirm'); + await nextTick(); + await btn.trigger('click'); + + expect(onConfirm).toHaveBeenCalled(); + }); + + it('onClose - triggers when dialog is closed', async () => { + const visible = ref(false); + const onClose = vi.fn(); + const wrapper = mount(() => ( + + )); + visible.value = true; + await nextTick(); + + const btn = wrapper.find('.t-dialog__close'); + await nextTick(); + await btn.trigger('click'); + + expect(onClose).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'close-btn' })); + }); + + it('onCloseBtnClick - triggers when close button is clicked', async () => { + const visible = ref(false); + const onCloseBtnClick = vi.fn(); + const wrapper = mount(() => ( + + )); + visible.value = true; + await nextTick(); + + const btn = wrapper.find('.t-dialog__close'); + await nextTick(); + await btn.trigger('click'); + + expect(onCloseBtnClick).toHaveBeenCalled(); + }); + + it('onEscKeydown - triggers when ESC key is pressed', async () => { + const visible = ref(false); + const onEscKeydown = vi.fn(); + mount(() => ); + visible.value = true; + await nextTick(); + + const escEvent = new KeyboardEvent('keydown', { + code: 'Escape', }); + Object.defineProperty(escEvent, 'target', { value: document.body }); // 保证 target 存在 + document.dispatchEvent(escEvent); + + expect(onEscKeydown).toHaveBeenCalled(); + }); + + it('onOverlayClick - triggers when overlay is clicked', async () => { + const visible = ref(true); + const onOverlayClick = vi.fn(); + const wrapper = mount(() => ( + + )); + await nextTick(); + + const mask = wrapper.find('.t-dialog__position'); + await mask.trigger('mousedown'); + await mask.trigger('mouseup'); + await mask.trigger('click'); + + expect(onOverlayClick).toHaveBeenCalled(); + }); + + it('closes dialog when cancel button is clicked', async () => { + const visible = ref(false); + const onClose = vi.fn(); + const wrapper = mount(() => ( + + )); + visible.value = true; + await nextTick(); + + const cancelBtn = wrapper.find('.t-dialog__cancel'); + await cancelBtn.trigger('click'); + + expect(onClose).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'cancel' })); + }); + + // it('onBeforeOpen - triggers before dialog is opened', async () => { + // const visible = ref(false); + // const onBeforeOpen = vi.fn(); + // const wrapper = mount(() => ( + // + // )); + // visible.value = true; + // await nextTick(); // 确保 DOM 更新 + // expect(onBeforeOpen).toHaveBeenCalled(); + // }); + + it('onOpened - triggers after dialog is opened', async () => { + const visible = ref(false); + const onOpened = vi.fn(); + const wrapper = mount(() => ( + + )); + + visible.value = true; + await nextTick(); + + // 模拟动画结束事件,触发 afterEnter 回调 + const transition = wrapper.findComponent({ name: 'Transition' }); + if (transition.exists()) { + await transition.vm.$emit('after-enter'); + } + + expect(onOpened).toHaveBeenCalled(); + }); + + // it('onBeforeClose - triggers before dialog is closed', async () => { + // const visible = ref(true); + // const onBeforeClose = vi.fn(); + // const wrapper = mount(() => ( + // + // )); + // visible.value = false; + // await nextTick(); + // expect(onBeforeClose).toHaveBeenCalled(); + // }); + + // it('onClosed - triggers after dialog is closed', async () => { + // const visible = ref(true); + // const onClosed = vi.fn(); + // const wrapper = mount(() => ( + // + // )); + // visible.value = false; + // await nextTick(); // 确保 DOM 更新 + // expect(onClosed).toHaveBeenCalled(); + // }); +}); + +describe('special scenarios', () => { + it('handles multiple dialogs with different z-index', async () => { + const visible1 = ref(false); + const visible2 = ref(false); + + const wrapper1 = mount(() => ); + const wrapper2 = mount(() => ); + + visible1.value = true; + visible2.value = true; + await nextTick(); + + expect(getComputedStyle(wrapper1.find('.t-dialog__ctx').element).zIndex).toBe('1000'); + expect(getComputedStyle(wrapper2.find('.t-dialog__ctx').element).zIndex).toBe('2000'); + }); + + it('handles keyboard events only for top dialog', async () => { + const visible1 = ref(true); + const visible2 = ref(true); + const onEscKeydown1 = vi.fn(); + const onEscKeydown2 = vi.fn(); + + mount(() => ( + + )); + mount(() => ( + + )); + + await nextTick(); + + // 修复 eventSrc 可能为 undefined 的情况 + const escEvent = new KeyboardEvent('keydown', { code: 'Escape' }); + Object.defineProperty(escEvent, 'target', { value: document.body }); // 保证 target 存在 + document.dispatchEvent(escEvent); + + // 只有顶层dialog应该响应ESC事件 + expect(onEscKeydown2).toHaveBeenCalled(); + expect(onEscKeydown1).not.toHaveBeenCalled(); + }); + + it('cleans up styles when dialog closes', async () => { + const visible = ref(true); + mount(() => ( + + )); + + await nextTick(); + const initialStyleCount = document.querySelectorAll('style').length; + + visible.value = false; + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 200)); // 等待清理 + + const finalStyleCount = document.querySelectorAll('style').length; + expect(finalStyleCount).toBeLessThanOrEqual(initialStyleCount); }); }); diff --git a/packages/components/dialog/__tests__/dialog.utils.test.tsx b/packages/components/dialog/__tests__/dialog.utils.test.tsx new file mode 100644 index 0000000000..f7f9f96a71 --- /dev/null +++ b/packages/components/dialog/__tests__/dialog.utils.test.tsx @@ -0,0 +1,341 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { getCSSValue, initDragEvent } from '../utils'; + +describe('getCSSValue', () => { + describe('number input', () => { + it('positive number', () => { + expect(getCSSValue(100)).toBe('100px'); + expect(getCSSValue(50)).toBe('50px'); + expect(getCSSValue(1)).toBe('1px'); + }); + + it('zero', () => { + expect(getCSSValue(0)).toBe('0px'); + }); + + it('negative number', () => { + expect(getCSSValue(-50)).toBe('-50px'); + expect(getCSSValue(-100)).toBe('-100px'); + }); + + it('decimal number', () => { + expect(getCSSValue(1.5)).toBe('1.5px'); + expect(getCSSValue(10.25)).toBe('10.25px'); + expect(getCSSValue(0.5)).toBe('0.5px'); + }); + + it('special number values', () => { + expect(getCSSValue(Infinity)).toBe('Infinitypx'); + expect(getCSSValue(-Infinity)).toBe('-Infinitypx'); + expect(getCSSValue(NaN)).toBe(NaN); + }); + }); + + describe('string input', () => { + it('numeric string', () => { + expect(getCSSValue('100')).toBe('100px'); + expect(getCSSValue('0')).toBe('0px'); + expect(getCSSValue('-50')).toBe('-50px'); + expect(getCSSValue('1.5')).toBe('1.5px'); + }); + + it('css unit string', () => { + expect(getCSSValue('100px')).toBe('100px'); + expect(getCSSValue('50%')).toBe('50%'); + expect(getCSSValue('1rem')).toBe('1rem'); + expect(getCSSValue('2em')).toBe('2em'); + expect(getCSSValue('auto')).toBe('auto'); + expect(getCSSValue('inherit')).toBe('inherit'); + }); + + it('empty string', () => { + expect(getCSSValue('')).toBe('0px'); + }); + + it('invalid string', () => { + expect(getCSSValue('abc')).toBe('abc'); + expect(getCSSValue('100abc')).toBe('100abc'); + expect(getCSSValue('px100')).toBe('px100'); + }); + }); + + describe('edge cases', () => { + it('null and undefined', () => { + expect(getCSSValue(null)).toBe('0px'); + expect(getCSSValue(undefined)).toBe(undefined); + }); + }); +}); + +describe('initDragEvent', () => { + let mockElement: HTMLElement; + let mockMouseDownEvent: MouseEvent; + let mockMouseMoveEvent: MouseEvent; + let mockMouseUpEvent: MouseEvent; + let addEventListenerSpy: any; + let removeEventListenerSpy: any; + + beforeEach(() => { + // 创建模拟DOM元素 + mockElement = document.createElement('div'); + + // 设置元素大小 + Object.defineProperty(mockElement, 'offsetWidth', { + writable: true, + value: 200, + }); + Object.defineProperty(mockElement, 'offsetHeight', { + writable: true, + value: 100, + }); + Object.defineProperty(mockElement, 'offsetLeft', { + writable: true, + value: 100, + }); + Object.defineProperty(mockElement, 'offsetTop', { + writable: true, + value: 50, + }); + + // 模拟style对象 + Object.assign(mockElement.style, { + position: '', + left: '', + top: '', + }); + + // 设置窗口大小 + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1024, + }); + Object.defineProperty(window, 'innerHeight', { + writable: true, + value: 768, + }); + + // 添加到DOM + document.body.appendChild(mockElement); + + // 创建模拟事件 + mockMouseDownEvent = new MouseEvent('mousedown', { + clientX: 150, + clientY: 75, + bubbles: true, + }); + + mockMouseMoveEvent = new MouseEvent('mousemove', { + clientX: 200, + clientY: 125, + bubbles: true, + }); + + mockMouseUpEvent = new MouseEvent('mouseup', { + bubbles: true, + }); + + // 监听事件绑定 + addEventListenerSpy = vi.spyOn(document, 'addEventListener'); + removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + describe('basic functionality', () => { + it('should initialize drag on mousedown', () => { + initDragEvent(mockElement); + + mockElement.dispatchEvent(mockMouseDownEvent); + + expect(addEventListenerSpy).toHaveBeenCalledWith('mousemove', expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith('mouseup', expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith('dragend', expect.any(Function)); + }); + + it('should remove event listeners on mouseup', () => { + initDragEvent(mockElement); + + // 触发mousedown + mockElement.dispatchEvent(mockMouseDownEvent); + + // 获取mouseup处理函数 + const mouseUpHandler = addEventListenerSpy.mock.calls.find((call: string[]) => call[0] === 'mouseup')[1]; + + // 触发mouseup + mouseUpHandler(mockMouseUpEvent); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('mousemove', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('mouseup', expect.any(Function)); + }); + + it('should set element position and style on drag', () => { + initDragEvent(mockElement); + + // 触发mousedown + mockElement.dispatchEvent(mockMouseDownEvent); + + // 获取mousemove处理函数 + const mouseMoveHandler = addEventListenerSpy.mock.calls.find((call: string[]) => call[0] === 'mousemove')[1]; + + // 触发mousemove + mouseMoveHandler(mockMouseMoveEvent); + + expect(mockElement.style.position).toBe('absolute'); + expect(mockElement.style.left).toBeTruthy(); + expect(mockElement.style.top).toBeTruthy(); + }); + }); + + describe('boundary constraints', () => { + it('should constrain to left boundary', () => { + initDragEvent(mockElement); + + mockElement.dispatchEvent(mockMouseDownEvent); + + const mouseMoveHandler = addEventListenerSpy.mock.calls.find((call: string[]) => call[0] === 'mousemove')[1]; + + // 创建超出左边界的事件 + const leftBoundaryEvent = new MouseEvent('mousemove', { + clientX: -100, + clientY: 75, + }); + + mouseMoveHandler(leftBoundaryEvent); + + expect(mockElement.style.left).toBe('0px'); + }); + + it('should constrain to top boundary', () => { + initDragEvent(mockElement); + + mockElement.dispatchEvent(mockMouseDownEvent); + + const mouseMoveHandler = addEventListenerSpy.mock.calls.find((call: string[]) => call[0] === 'mousemove')[1]; + + // 创建超出上边界的事件 + const topBoundaryEvent = new MouseEvent('mousemove', { + clientX: 150, + clientY: -100, + }); + + mouseMoveHandler(topBoundaryEvent); + + expect(mockElement.style.top).toBe('0px'); + }); + + it('should constrain to right boundary', () => { + initDragEvent(mockElement); + + mockElement.dispatchEvent(mockMouseDownEvent); + + const mouseMoveHandler = addEventListenerSpy.mock.calls.find((call: string[]) => call[0] === 'mousemove')[1]; + + // 创建超出右边界的事件 + const rightBoundaryEvent = new MouseEvent('mousemove', { + clientX: 2000, + clientY: 75, + }); + + mouseMoveHandler(rightBoundaryEvent); + + // 应该被限制在窗口宽度减去元素宽度的位置 + const expectedLeft = window.innerWidth - mockElement.offsetWidth; + expect(mockElement.style.left).toBe(`${expectedLeft}px`); + }); + + it('should constrain to bottom boundary', () => { + initDragEvent(mockElement); + + mockElement.dispatchEvent(mockMouseDownEvent); + + const mouseMoveHandler = addEventListenerSpy.mock.calls.find((call: string[]) => call[0] === 'mousemove')[1]; + + // 创建超出下边界的事件 + const bottomBoundaryEvent = new MouseEvent('mousemove', { + clientX: 150, + clientY: 2000, + }); + + mouseMoveHandler(bottomBoundaryEvent); + + // 应该被限制在窗口高度减去元素高度的位置 + const expectedTop = window.innerHeight - mockElement.offsetHeight; + expect(mockElement.style.top).toBe(`${expectedTop}px`); + }); + }); + + describe('edge cases', () => { + it('should not enable drag when element is larger than window', () => { + // 设置元素大小超过窗口大小 + Object.defineProperty(mockElement, 'offsetWidth', { + writable: true, + value: 2000, + }); + Object.defineProperty(mockElement, 'offsetHeight', { + writable: true, + value: 1000, + }); + + initDragEvent(mockElement); + + mockElement.dispatchEvent(mockMouseDownEvent); + + // 不应该绑定任何拖拽相关的事件监听器 + expect(addEventListenerSpy).not.toHaveBeenCalledWith('mousemove', expect.any(Function)); + expect(addEventListenerSpy).not.toHaveBeenCalledWith('mouseup', expect.any(Function)); + }); + + it('should handle missing window dimensions', () => { + // 模拟没有window.innerWidth的情况 + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: undefined, + }); + Object.defineProperty(document.documentElement, 'clientWidth', { + writable: true, + value: 1024, + }); + + expect(() => { + initDragEvent(mockElement); + mockElement.dispatchEvent(mockMouseDownEvent); + }).not.toThrow(); + }); + + it('should handle missing window innerHeight', () => { + // 模拟没有 window.innerHeight 的情况 + Object.defineProperty(window, 'innerHeight', { + writable: true, + value: undefined, + }); + Object.defineProperty(document.documentElement, 'clientHeight', { + writable: true, + value: 768, + }); + + expect(() => { + initDragEvent(mockElement); + mockElement.dispatchEvent(mockMouseDownEvent); + }).not.toThrow(); + }); + + it('should handle dragend event', () => { + initDragEvent(mockElement); + + mockElement.dispatchEvent(mockMouseDownEvent); + + // 获取dragend处理函数 + const dragEndHandler = addEventListenerSpy.mock.calls.find((call: string[]) => call[0] === 'dragend')[1]; + + // 触发dragend + const dragEndEvent = new Event('dragend'); + dragEndHandler(dragEndEvent); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('mousemove', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('mouseup', expect.any(Function)); + }); + }); +}); diff --git a/packages/components/dialog/dialog.tsx b/packages/components/dialog/dialog.tsx index 660e53d7fd..f84d956f04 100644 --- a/packages/components/dialog/dialog.tsx +++ b/packages/components/dialog/dialog.tsx @@ -260,6 +260,10 @@ export default defineComponent({ width: calc(100% - ${scrollWidth}px); } `; + + if (props.visible) { + addKeyboardEvent(props.visible); + } }); onBeforeUnmount(() => { diff --git a/packages/components/dialog/hooks/useAction.tsx b/packages/components/dialog/hooks/useAction.tsx index 3e7d73ab2b..3a8a06f6b9 100644 --- a/packages/components/dialog/hooks/useAction.tsx +++ b/packages/components/dialog/hooks/useAction.tsx @@ -129,7 +129,7 @@ export function useAction(action: BtnAction) { if (cancelBtn && ['string', 'object'].includes(typeof cancelBtn)) { return getButtonByProps(cancelBtn as string | ButtonProps, { defaultButtonProps, className }); } - // 渲染插槽 或 function 类型的 confirmBtn,属性优先级更高 + // 渲染插槽 或 function 类型的 cancelBtn,属性优先级更高 return renderTNodeJSX('cancelBtn'); }; return { getConfirmBtn, getCancelBtn }; diff --git a/packages/components/dialog/plugin.tsx b/packages/components/dialog/plugin.tsx index daa5dafb9f..30ef9de9be 100644 --- a/packages/components/dialog/plugin.tsx +++ b/packages/components/dialog/plugin.tsx @@ -13,7 +13,7 @@ const createDialog: DialogMethod = (props, context) => { let preClassName = className; const updateClassNameStyle = (className: string, style: DialogOptions['style']) => { - if (className) { + if (className && wrapper.firstElementChild) { if (preClassName && preClassName !== className) { wrapper.firstElementChild.classList.remove(...preClassName.split(' ').map((name) => name.trim())); } @@ -22,7 +22,7 @@ const createDialog: DialogMethod = (props, context) => { }); } - if (style) { + if (style && wrapper.firstElementChild) { (wrapper.firstElementChild as HTMLElement).style.cssText += style; }