Browse Source
* test(@vben-core/popup-ui): add modal mouse and keyboard regression tests Co-Authored-By: EvoX <evox@evomap.ai> * fix(apps): pass withCredentials as request config, not body, for refresh/logout Co-Authored-By: EvoX <evox@evomap.ai> * style(@vben-core/popup-ui): apply oxfmt formatting to modal interaction tests Co-Authored-By: EvoX <evox@evomap.ai> --------- Co-authored-by: EvoX <evox@evomap.ai>pull/8317/head
committed by
GitHub
6 changed files with 243 additions and 20 deletions
@ -0,0 +1,203 @@ |
|||||
|
import type { App } from 'vue'; |
||||
|
|
||||
|
import { createApp, defineComponent, h, nextTick, onMounted } from 'vue'; |
||||
|
|
||||
|
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants'; |
||||
|
|
||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
|
import { useVbenModal } from '../use-modal'; |
||||
|
|
||||
|
vi.mock('@vben-core/preferences', () => ({ |
||||
|
usePreferences: () => ({ |
||||
|
globalEscapeShortcutKey: { value: true }, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
let activeApp: App | undefined; |
||||
|
|
||||
|
interface MountOptions { |
||||
|
closeOnClickModal?: boolean; |
||||
|
closeOnPressEscape?: boolean; |
||||
|
onCancel?: () => void; |
||||
|
onConfirm?: () => void; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 挂载一个受控的 Modal 组件,用于验证鼠标点击与键盘交互后的关闭行为和事件副作用。 |
||||
|
*/ |
||||
|
async function mountModal(options: MountOptions = {}) { |
||||
|
const mainContent = document.createElement('main'); |
||||
|
mainContent.id = ELEMENT_ID_MAIN_CONTENT; |
||||
|
mainContent.innerHTML = '<div><div></div></div>'; |
||||
|
document.body.append(mainContent); |
||||
|
|
||||
|
let capturedApi: ReturnType<typeof useVbenModal>[1] | undefined; |
||||
|
const Consumer = defineComponent(() => { |
||||
|
const [Modal, modalApi] = useVbenModal({ |
||||
|
appendToMain: true, |
||||
|
cancelText: 'Cancel', |
||||
|
confirmText: 'Confirm', |
||||
|
closeOnClickModal: options.closeOnClickModal ?? true, |
||||
|
closeOnPressEscape: options.closeOnPressEscape ?? true, |
||||
|
title: 'Interaction modal', |
||||
|
...(options.onCancel ? { onCancel: options.onCancel } : {}), |
||||
|
...(options.onConfirm ? { onConfirm: options.onConfirm } : {}), |
||||
|
}); |
||||
|
capturedApi = modalApi; |
||||
|
onMounted(() => { |
||||
|
modalApi.open(); |
||||
|
}); |
||||
|
return () => h(Modal); |
||||
|
}); |
||||
|
const host = document.createElement('div'); |
||||
|
document.body.append(host); |
||||
|
|
||||
|
activeApp = createApp(() => h(Consumer)); |
||||
|
activeApp.mount(host); |
||||
|
await nextTick(); |
||||
|
await nextTick(); |
||||
|
// reka-ui 在 setTimeout(0) 之后才把 document 级的 pointerdown 监听挂上,
|
||||
|
// 等待一个宏任务,确保遮罩交互在监听器就绪后触发。
|
||||
|
await new Promise((resolve) => setTimeout(resolve, 0)); |
||||
|
|
||||
|
if (!capturedApi) { |
||||
|
throw new Error('modal api was not captured'); |
||||
|
} |
||||
|
return { modalApi: capturedApi }; |
||||
|
} |
||||
|
|
||||
|
function buttonByText(text: string): HTMLElement | undefined { |
||||
|
return [...document.querySelectorAll('button')].find( |
||||
|
(element) => element.textContent?.trim() === text, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function pressEscape() { |
||||
|
document.dispatchEvent( |
||||
|
new KeyboardEvent('keydown', { |
||||
|
bubbles: true, |
||||
|
cancelable: true, |
||||
|
key: 'Escape', |
||||
|
}), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function pointerDownOverlay() { |
||||
|
const overlay = document.querySelector('[data-dismissable-modal]'); |
||||
|
if (!(overlay instanceof HTMLElement)) { |
||||
|
return false; |
||||
|
} |
||||
|
overlay.dispatchEvent( |
||||
|
new PointerEvent('pointerdown', { bubbles: true, cancelable: true }), |
||||
|
); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
async function settle() { |
||||
|
await nextTick(); |
||||
|
await new Promise((resolve) => setTimeout(resolve, 80)); |
||||
|
} |
||||
|
|
||||
|
afterEach(() => { |
||||
|
activeApp?.unmount(); |
||||
|
activeApp = undefined; |
||||
|
document.body.innerHTML = ''; |
||||
|
vi.restoreAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
describe('vben modal interactions', () => { |
||||
|
it('fires confirm exactly once on confirm click and does not auto-close', async () => { |
||||
|
const onConfirm = vi.fn(); |
||||
|
const { modalApi } = await mountModal({ onConfirm }); |
||||
|
const button = buttonByText('Confirm'); |
||||
|
|
||||
|
expect(button).toBeInstanceOf(HTMLElement); |
||||
|
if (!(button instanceof HTMLElement)) return; |
||||
|
button.dispatchEvent(new MouseEvent('click', { bubbles: true })); |
||||
|
await nextTick(); |
||||
|
|
||||
|
expect(onConfirm).toHaveBeenCalledTimes(1); |
||||
|
expect(modalApi.store.state.isOpen).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('closes the modal when the confirm handler closes the api', async () => { |
||||
|
const onConfirm = vi.fn(); |
||||
|
const apiRef: { |
||||
|
current?: ReturnType<typeof useVbenModal>[1]; |
||||
|
} = {}; |
||||
|
const { modalApi } = await mountModal({ |
||||
|
onConfirm: () => { |
||||
|
onConfirm(); |
||||
|
void apiRef.current?.close(); |
||||
|
}, |
||||
|
}); |
||||
|
apiRef.current = modalApi; |
||||
|
const button = buttonByText('Confirm'); |
||||
|
|
||||
|
expect(button).toBeInstanceOf(HTMLElement); |
||||
|
if (!(button instanceof HTMLElement)) return; |
||||
|
button.dispatchEvent(new MouseEvent('click', { bubbles: true })); |
||||
|
|
||||
|
expect(onConfirm).toHaveBeenCalledTimes(1); |
||||
|
await expect.poll(() => modalApi.store.state.isOpen).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('closes on cancel click when no cancel hook is provided', async () => { |
||||
|
const { modalApi } = await mountModal(); |
||||
|
const button = buttonByText('Cancel'); |
||||
|
|
||||
|
expect(button).toBeInstanceOf(HTMLElement); |
||||
|
if (!(button instanceof HTMLElement)) return; |
||||
|
button.dispatchEvent(new MouseEvent('click', { bubbles: true })); |
||||
|
|
||||
|
await expect.poll(() => modalApi.store.state.isOpen).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('fires cancel exactly once and does not auto-close when a cancel hook exists', async () => { |
||||
|
const onCancel = vi.fn(); |
||||
|
const { modalApi } = await mountModal({ onCancel }); |
||||
|
const button = buttonByText('Cancel'); |
||||
|
|
||||
|
expect(button).toBeInstanceOf(HTMLElement); |
||||
|
if (!(button instanceof HTMLElement)) return; |
||||
|
button.dispatchEvent(new MouseEvent('click', { bubbles: true })); |
||||
|
await nextTick(); |
||||
|
|
||||
|
expect(onCancel).toHaveBeenCalledTimes(1); |
||||
|
expect(modalApi.store.state.isOpen).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('closes when Escape is pressed', async () => { |
||||
|
const { modalApi } = await mountModal(); |
||||
|
|
||||
|
pressEscape(); |
||||
|
|
||||
|
await expect.poll(() => modalApi.store.state.isOpen).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('stays open on Escape when closeOnPressEscape is false', async () => { |
||||
|
const { modalApi } = await mountModal({ closeOnPressEscape: false }); |
||||
|
|
||||
|
pressEscape(); |
||||
|
await settle(); |
||||
|
|
||||
|
expect(modalApi.store.state.isOpen).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('closes when the dismissable overlay is pointer-downed', async () => { |
||||
|
const { modalApi } = await mountModal(); |
||||
|
|
||||
|
expect(pointerDownOverlay()).toBe(true); |
||||
|
await expect.poll(() => modalApi.store.state.isOpen).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('stays open on overlay pointer-down when closeOnClickModal is false', async () => { |
||||
|
const { modalApi } = await mountModal({ closeOnClickModal: false }); |
||||
|
|
||||
|
expect(pointerDownOverlay()).toBe(true); |
||||
|
await settle(); |
||||
|
|
||||
|
expect(modalApi.store.state.isOpen).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue