import { App, createApp, defineComponent, h, nextTick, reactive } from 'vue' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' const confirmMock = vi.fn((_: string, callback?: () => void) => callback?.()) const warningMock = vi.fn() const jqueryOnMock = vi.fn() const ElTabsStub = defineComponent({ name: 'ElTabs', props: { modelValue: { type: String, default: '' }, tabPosition: { type: String, default: 'top' }, type: { type: String, default: undefined }, addable: { type: Boolean, default: false }, editable: { type: Boolean, default: false }, beforeLeave: { type: Function, default: undefined } }, emits: ['update:modelValue', 'tab-click', 'tab-remove', 'tab-add'], setup (props, { slots, emit }) { const clickTab = async (name: string) => { props.beforeLeave?.(name, props.modelValue) emit('update:modelValue', name) emit('tab-click', { paneName: name }, { target: { tagName: 'DIV' } }) } const collectPanes = (children: any, result: any[] = []) => { if (!children) return result if (Array.isArray(children)) { children.forEach((child) => collectPanes(child, result)) return result } if (Array.isArray(children.children)) { collectPanes(children.children, result) } if (children.props && Object.prototype.hasOwnProperty.call(children.props, 'name')) { result.push(children) } return result } return () => { const panes = collectPanes(slots.default?.() || []) return h('div', { class: 'el-tabs infoTabs', 'data-model-value': props.modelValue, 'data-tab-position': props.tabPosition, 'data-type': props.type || '' }, [ h('div', { class: 'el-tabs__header' }, [ ...panes.map((pane: any) => { const name = pane.props?.name const closable = pane.props?.closable const disabled = pane.props?.disabled const labelSlot = pane.children?.label return h('div', { class: ['el-tabs__item', props.modelValue === name ? 'is-active' : '', disabled ? 'is-disabled' : ''].filter(Boolean), id: `tab-${name}`, 'data-name': name, onClick: () => { if (!disabled) { clickTab(name) } } }, [ labelSlot ? labelSlot() : name, closable ? h('button', { class: 'is-icon-close', onClick: (event: MouseEvent) => { event.stopPropagation() emit('tab-remove', name) } }, 'x') : null ]) }), props.addable || props.editable ? h('button', { class: 'el-tabs__new-tab', onClick: () => emit('tab-add') }, '+') : null ]), h('div', { class: 'el-tabs__content' }, panes) ]) } } }) const ElTabPaneStub = defineComponent({ name: 'ElTabPane', props: { name: { type: String, default: '' }, closable: { type: Boolean, default: false }, disabled: { type: Boolean, default: false } }, setup (_, { attrs, slots }) { return () => h('section', { class: ['el-tab-pane', attrs.class], 'data-name': attrs.name as string }, slots.default?.()) } }) const ElInputStub = defineComponent({ name: 'ElInput', props: { modelValue: { type: String, default: '' }, placeholder: { type: String, default: '' } }, emits: ['update:modelValue', 'blur'], setup (props, { emit }) { return () => h('input', { class: 'el-input__inner', value: props.modelValue, placeholder: props.placeholder, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value), onBlur: () => emit('blur') }) } }) const ElBadgeStub = defineComponent({ name: 'ElBadge', props: { value: { type: [String, Number], default: undefined } }, setup (props, { slots }) { return () => h('span', { class: 'el-badge', 'data-value': props.value === undefined ? '' : String(props.value) }, slots.default?.()) } }) const RouterViewStub = defineComponent({ name: 'RouterView', setup () { return () => h('div', { class: 'router-view-stub' }, 'router-view') } }) vi.mock('vue-router', () => ({ useRoute: vi.fn(() => ({})), useRouter: vi.fn(() => ({})), RouterView: RouterViewStub })) vi.mock('../../../script/index', () => ({ confirm: confirmMock })) vi.mock('../../../script/util', () => ({ confirm: confirmMock })) vi.mock('element-plus', () => ({ ElTabs: ElTabsStub, ElTabPane: ElTabPaneStub, ElInput: ElInputStub, ElBadge: ElBadgeStub, ElMessage: { warning: warningMock } })) vi.mock('jquery', () => ({ default: vi.fn(() => ({ on: jqueryOnMock })) })) let InfoTab: any interface RenderResult { app: App el: HTMLDivElement configTab: Record events: { dblclick: ReturnType paneSlotComponents: ReturnType editCallback: ReturnType addedCallback: ReturnType } } const renderedApps: RenderResult[] = [] beforeAll(async () => { InfoTab = (await import('../../../components/infoTab/index.vue')).default }) const createConfigTab = () => reactive({ data: { Tab1: { label: 'Tab 1', name: 'Tab1', params: { id: '0' } }, Tab2: { label: 'Tab 2', name: 'Tab2', params: { id: '1' } }, Tab3: { label: 'Tab 3', name: 'Tab3', params: { id: '2' }, closable: true } }, activeComponent: 'Tab1' }) const renderInfoTab = async (overrideConfig: Record = {}): Promise => { const events = { dblclick: vi.fn(), paneSlotComponents: vi.fn(), editCallback: vi.fn(), addedCallback: vi.fn() } const el = document.createElement('div') document.body.appendChild(el) const configTab = reactive({ ...createConfigTab(), ...overrideConfig }) const app = createApp({ render () { return h(InfoTab, { configTab, onDblclick: events.dblclick, onPaneSlotComponents: events.paneSlotComponents, onEditCallback: events.editCallback, onAddedCallback: events.addedCallback }, { default: ({ value }: any) => h('div', { class: 'tab-slot', 'data-tab': value.name }, `${value.name} content`) }) } }) app.component('ElTabs', ElTabsStub) app.component('ElTabPane', ElTabPaneStub) app.component('ElInput', ElInputStub) app.component('ElBadge', ElBadgeStub) app.component('RouterView', RouterViewStub) app.component('el-tabs', ElTabsStub) app.component('el-tab-pane', ElTabPaneStub) app.component('el-input', ElInputStub) app.component('el-badge', ElBadgeStub) app.component('router-view', RouterViewStub) app.config.globalProperties.$t = (key: string) => key app.mount(el) await nextTick() const result = { app, el, configTab, events } renderedApps.push(result) return result } const getRoot = () => document.body const click = async (selector: string) => { const element = getRoot().querySelector(selector) as HTMLElement | null expect(element).not.toBeNull() element!.click() await nextTick() } afterEach(() => { while (renderedApps.length) { const current = renderedApps.pop()! current.app.unmount() current.el.remove() } document.body.innerHTML = '' vi.clearAllMocks() vi.useRealTimers() }) describe('components/infoTab', () => { it('正常渲染 tab 标签、内容和初始激活项', async () => { await renderInfoTab() const tabs = getRoot().querySelectorAll('.el-tabs__item') expect(tabs).toHaveLength(3) expect(tabs[0]?.textContent).toContain('Tab 1') expect(tabs[1]?.textContent).toContain('Tab 2') expect(tabs[2]?.textContent).toContain('Tab 3') expect(tabs[0]?.className).toContain('is-active') expect(getRoot().querySelectorAll('.tab-slot')).toHaveLength(3) expect(jqueryOnMock).toHaveBeenCalledTimes(1) }) it('点击 tab 后调用 configTab.method 并切换激活项', async () => { vi.useFakeTimers() const method = vi.fn() await renderInfoTab({ method }) await click('.el-tabs__item[data-name="Tab2"]') vi.runAllTimers() await nextTick() expect(method).toHaveBeenCalledTimes(1) expect(method).toHaveBeenCalledWith('Tab2') expect(getRoot().querySelector('.el-tabs')?.getAttribute('data-model-value')).toBe('Tab2') expect(getRoot().querySelector('.el-tabs__item[data-name="Tab2"]')?.className).toContain('is-active') }) it('调用新增能力时切换到 addMethod 返回的新 tab', async () => { const addMethod = vi.fn((num: number) => { const key = `Tab${num + 4}` return key }) const rendered = await renderInfoTab({ addable: true, addMethod }) await click('.el-tabs__new-tab') expect(addMethod).toHaveBeenCalledTimes(1) expect(addMethod).toHaveBeenCalledWith(0) expect(getRoot().querySelector('.el-tabs')?.getAttribute('data-model-value')).toBe('Tab4') }) it('删除 tab 时更新数据源并触发 removeMethod', async () => { const method = vi.fn() const removeMethod = vi.fn() const rendered = await renderInfoTab({ activeComponent: 'Tab3', method, removeMethod }) await click('.el-tabs__item[data-name="Tab3"] .is-icon-close') expect(rendered.configTab.data.Tab3).toBeUndefined() expect(removeMethod).toHaveBeenCalledTimes(1) expect(removeMethod).toHaveBeenCalledWith('Tab3') expect(method).toHaveBeenCalledTimes(1) expect(method).toHaveBeenCalledWith({ name: 'Tab2' }) expect(getRoot().querySelectorAll('.el-tabs__item')).toHaveLength(2) expect(getRoot().querySelector('.el-tabs')?.getAttribute('data-model-value')).toBe('Tab2') }) it('secondaryDelete 为 true 时先走 confirm 再删除 tab', async () => { const removeMethod = vi.fn() const rendered = await renderInfoTab({ secondaryDelete: true, removeMethod }) await click('.el-tabs__item[data-name="Tab3"] .is-icon-close') expect(confirmMock).toHaveBeenCalledTimes(1) expect(confirmMock).toHaveBeenCalledWith('confirmDelete', expect.any(Function)) expect(rendered.configTab.data.Tab3).toBeUndefined() expect(removeMethod).toHaveBeenCalledWith('Tab3') }) it('达到 addMax 时提示上限且不调用 addMethod', async () => { const addMethod = vi.fn() await renderInfoTab({ addable: true, addMax: 3, addMethod }) await click('.el-tabs__new-tab') expect(warningMock).toHaveBeenCalledTimes(1) expect(warningMock).toHaveBeenCalledWith('addTabMax') expect(addMethod).not.toHaveBeenCalled() }) })