import { App, createApp, defineComponent, h, inject, nextTick, provide, reactive } from 'vue' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' const filterButtonHandlerMock = vi.fn((code?: string) => !code || code !== 'hidden-code') const someButtonHandlerMock = vi.fn(() => 1) const dropdownCommandKey = Symbol('dropdown-command') const ElDropdown = defineComponent({ name: 'ElDropdown', props: { trigger: { type: String, default: 'click' }, size: { type: String, default: '' } }, emits: ['command', 'visible-change', 'click'], setup (props, { slots, emit }) { provide(dropdownCommandKey, (command: string) => emit('command', command)) return () => h('div', { class: 'el-dropdown', 'data-trigger': props.trigger, 'data-size': props.size }, [ h('button', { class: 'stub-visible-open', onClick: () => emit('visible-change', true) }, 'open'), h('button', { class: 'stub-visible-close', onClick: () => emit('visible-change', false) }, 'close'), slots.default?.(), slots.dropdown?.() ]) } }) const ElButton = defineComponent({ name: 'ElButton', props: { disabled: { type: Boolean, default: false }, size: { type: String, default: 'default' }, link: { type: Boolean, default: false }, plain: { type: Boolean, default: false } }, emits: ['click', 'mouseenter', 'mouseleave'], setup (props, { slots, emit }) { return () => h('button', { class: 'el-button', disabled: props.disabled, 'data-size': props.size, 'data-link': String(props.link), 'data-plain': String(props.plain), onClick: (event: MouseEvent) => emit('click', event), onMouseenter: (event: MouseEvent) => emit('mouseenter', event), onMouseleave: () => emit('mouseleave') }, slots.default?.()) } }) const ElIcon = defineComponent({ name: 'ElIcon', props: { size: { type: [String, Number], default: undefined } }, setup (props, { slots }) { return () => h('span', { class: 'el-icon', 'data-size': String(props.size ?? '') }, slots.default?.()) } }) const ElDropdownMenu = defineComponent({ name: 'ElDropdownMenu', inheritAttrs: false, setup (_, { attrs, slots }) { return () => h('div', { class: ['el-dropdown-menu', attrs.class] }, slots.default?.()) } }) const ElDropdownItem = defineComponent({ name: 'ElDropdownItem', inheritAttrs: false, props: { disabled: { type: Boolean, default: false }, command: { type: String, default: '' }, divided: { type: Boolean, default: false }, icon: { type: [String, Object], default: undefined } }, setup (props, { attrs, slots }) { const dropdownCommand = inject void)>(dropdownCommandKey, undefined) return () => h('button', { class: ['el-dropdown-item', attrs.class, props.disabled ? 'is-disabled' : ''].filter(Boolean), disabled: props.disabled, 'data-command': props.command, 'data-divided': String(props.divided), 'data-test-cy': attrs['test-cy'], onClick: () => { if (!props.disabled) { dropdownCommand?.(props.command) } } }, slots.default?.()) } }) const ElTooltip = defineComponent({ name: 'ElTooltip', props: { content: { type: String, default: '' }, visible: { type: Boolean, default: false } }, setup (props) { return () => h('div', { class: 'el-tooltip', 'data-content': props.content, 'data-visible': String(props.visible) }) } }) vi.mock('../../../script/util', () => ({ filterButtonHandler: filterButtonHandlerMock, someButtonHandler: someButtonHandlerMock })) vi.mock('element-plus', () => ({ ElDropdown, ElButton, ElIcon, ElDropdownMenu, ElDropdownItem, ElTooltip })) vi.mock('@element-plus/icons-vue', () => { const ArrowDown = defineComponent({ name: 'ArrowDown', setup () { return () => h('span', { class: 'icon-arrow-down' }, 'arrow-down') } }) const MoreFilled = defineComponent({ name: 'MoreFilled', setup () { return () => h('span', { class: 'icon-more-filled' }, 'more-filled') } }) return { ArrowDown, MoreFilled } }) let InfoDropdown: any interface RenderResult { app: App el: HTMLDivElement events: { handleCommand: ReturnType } } const renderedApps: RenderResult[] = [] const originalPathname = window.location.pathname beforeAll(async () => { InfoDropdown = (await import('../../../components/infoDropdown/index.vue')).default }) const renderDropdown = async (rawRow: Record = {}): Promise => { const events = { handleCommand: vi.fn() } const el = document.createElement('div') document.body.appendChild(el) const row = reactive({ title: () => 'dropdown.title', dropdown: () => [ { name: 'first.action', command: 'first-command' }, { name: 'second.action', command: 'second-command' } ], ...rawRow }) const app = createApp({ render () { return h(InfoDropdown, { row, onHandleCommand: events.handleCommand }) } }) app.config.globalProperties.$t = (key?: string) => { const map: Record = { 'dropdown.title': '下拉操作', 'first.action': '操作一', 'second.action': '操作二', 'visible.action': '可见操作', more: '更多' } if (key === undefined) return undefined return map[key] || key } app.mount(el) await nextTick() const result = { app, el, events } renderedApps.push(result) return result } const getRoot = () => document.body const click = async (selector: string) => { const target = getRoot().querySelector(selector) as HTMLElement | null expect(target).not.toBeNull() target!.click() await nextTick() } afterEach(() => { while (renderedApps.length) { const current = renderedApps.pop()! current.app.unmount() current.el.remove() } document.body.innerHTML = '' filterButtonHandlerMock.mockReset() someButtonHandlerMock.mockReset() filterButtonHandlerMock.mockImplementation((code?: string) => !code || code !== 'hidden-code') someButtonHandlerMock.mockImplementation(() => 1) window.history.replaceState({}, '', originalPathname) }) describe('components/infoDropdown', () => { it('默认渲染标题、按钮配置和 ArrowDown 图标', async () => { await renderDropdown({ size: 'small', link: true, iconSize: 18 }) const dropdown = getRoot().querySelector('.el-dropdown') const button = getRoot().querySelector('.el-button') as HTMLButtonElement | null const icon = getRoot().querySelector('.el-icon') expect(dropdown?.getAttribute('data-trigger')).toBe('click') expect(button?.getAttribute('data-size')).toBe('small') expect(button?.getAttribute('data-link')).toBe('true') expect(button?.textContent).toContain('下拉操作') expect(icon?.getAttribute('data-size')).toBe('18') expect(getRoot().querySelector('.icon-arrow-down')).not.toBeNull() expect(someButtonHandlerMock).toHaveBeenCalledTimes(1) }) it('row.show 返回 false 时不渲染 dropdown', async () => { await renderDropdown({ show: () => false }) expect(getRoot().querySelector('.el-dropdown')).toBeNull() }) it('按钮数量为 0 时不渲染 dropdown', async () => { someButtonHandlerMock.mockReturnValue(0) await renderDropdown({ show: () => true }) expect(getRoot().querySelector('.el-dropdown')).toBeNull() expect(someButtonHandlerMock).toHaveBeenCalledTimes(1) }) it('visible-change 为 true 时渲染菜单,并按 item.show 与权限过滤条目', async () => { window.history.replaceState({}, '', '/table/list') await renderDropdown({ class: 'action-menu', dropdown: () => [ { name: 'visible.action', command: 'visible-command', code: 'visible-code', style: { color: 'red' } }, { name: 'hidden.action', command: 'hidden-command', show: () => false }, { name: 'second.action', command: 'second-command', code: 'hidden-code' } ] }) expect(getRoot().querySelector('.el-dropdown-menu')).toBeNull() await click('.stub-visible-open') const menu = getRoot().querySelector('.el-dropdown-menu') const items = getRoot().querySelectorAll('.el-dropdown-item') const label = getRoot().querySelector('.el-dropdown-item span') as HTMLElement | null expect(menu).not.toBeNull() expect(menu?.className).toContain('action-menu-drop-down') expect(items).toHaveLength(1) expect(label?.style.color).toBe('red') expect(items[0]?.getAttribute('data-test-cy')).toBe('visible-code') expect(items[0]?.getAttribute('data-command')).toBe('visible-command') expect(filterButtonHandlerMock).toHaveBeenCalledTimes(2) expect(filterButtonHandlerMock).toHaveBeenCalledWith('visible-code') expect(filterButtonHandlerMock).toHaveBeenCalledWith('hidden-code') await click('.stub-visible-close') expect(getRoot().querySelector('.el-dropdown-menu')).toBeNull() }) it('点击菜单项时同时触发 handleCommand 事件和 row.method', async () => { const method = vi.fn() const rendered = await renderDropdown({ method, dropdown: () => [{ name: 'first.action', command: 'first-command' }] }) await click('.stub-visible-open') await click('.el-dropdown-item') expect(rendered.events.handleCommand).toHaveBeenCalledTimes(1) expect(rendered.events.handleCommand).toHaveBeenCalledWith('first-command') expect(method).toHaveBeenCalledTimes(1) expect(method).toHaveBeenCalledWith('first-command') }) it('禁用菜单项不会触发命令', async () => { const method = vi.fn() const rendered = await renderDropdown({ method, dropdown: () => [{ name: 'first.action', command: 'first-command', disabled: () => true }] }) await click('.stub-visible-open') const item = getRoot().querySelector('.el-dropdown-item') as HTMLButtonElement | null expect(item?.disabled).toBe(true) item?.click() await nextTick() expect(rendered.events.handleCommand).not.toHaveBeenCalled() expect(method).not.toHaveBeenCalled() }) it('row.disabled 返回 true 时按钮为禁用态', async () => { await renderDropdown({ disabled: () => true }) const button = getRoot().querySelector('.el-button') as HTMLButtonElement | null expect(button?.disabled).toBe(true) }) it('支持 useTip、tooltip fallback、点击隐藏和默认 MoreFilled 图标', async () => { await renderDropdown({ title: undefined, useTip: true }) const button = getRoot().querySelector('.el-button') as HTMLButtonElement | null const tooltip = getRoot().querySelector('.el-tooltip') expect(button?.disabled).toBe(false) expect(button?.textContent).not.toContain('下拉操作') expect(tooltip?.getAttribute('data-content')).toBe('更多') expect(tooltip?.getAttribute('data-visible')).toBe('false') expect(getRoot().querySelector('.icon-more-filled')).not.toBeNull() button?.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })) await nextTick() expect(tooltip?.getAttribute('data-visible')).toBe('true') button?.click() await nextTick() expect(tooltip?.getAttribute('data-visible')).toBe('false') }) it('自定义图标会覆盖默认图标', async () => { const CustomIcon = defineComponent({ name: 'CustomIcon', setup () { return () => h('span', { class: 'icon-custom' }, 'custom-icon') } }) await renderDropdown({ icon: CustomIcon }) expect(getRoot().querySelector('.icon-custom')).not.toBeNull() expect(getRoot().querySelector('.icon-arrow-down')).toBeNull() expect(getRoot().querySelector('.icon-more-filled')).toBeNull() }) })