import { App, createApp, defineComponent, h, nextTick, reactive } from 'vue' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' const InfoFormStub = defineComponent({ name: 'InfoForm', props: { config: { type: Object, required: true }, modelData: { type: Object, required: true } }, setup (props) { return () => { const buttonRule = props.config.rule?.[0] const operations = buttonRule?.operation || [] return h('div', { class: 'info-form-stub', 'data-disabled': String(typeof props.config.disabled === 'function' ? props.config.disabled() : false) }, operations.map((operation: any, index: number) => { const visible = typeof operation.show === 'function' ? operation.show() : operation.show !== false const loading = typeof operation.loading === 'function' ? operation.loading() : false return h('button', { key: `${operation.label}-${index}`, class: 'step-action', 'data-label': operation.label, 'data-loading': String(loading), style: { display: visible ? '' : 'none' }, disabled: loading, onClick: () => operation.methods?.() }, operation.label) })) } } }) const ElStepsStub = defineComponent({ name: 'ElSteps', props: { active: { type: Number, default: 0 }, direction: { type: String, default: 'horizontal' }, alignCenter: { type: Boolean, default: true }, simple: { type: Boolean, default: false } }, setup (props, { slots }) { return () => h('div', { class: 'el-steps-stub', 'data-active': String(props.active), 'data-direction': props.direction, 'data-align-center': String(props.alignCenter), 'data-simple': String(props.simple) }, slots.default?.()) } }) const ElStepStub = defineComponent({ name: 'ElStep', props: { title: { type: String, default: '' }, description: { type: String, default: '' }, icon: { type: String, default: '' } }, emits: ['click'], setup (props, { emit }) { return () => h('button', { class: 'el-step-stub', 'data-title': props.title, onClick: () => emit('click') }, props.title) } }) vi.mock('../../../components/infoForm/infoForm.vue', () => ({ default: InfoFormStub })) vi.mock('element-plus', () => ({ ElSteps: ElStepsStub, ElStep: ElStepStub })) let InfoSteps: any interface RenderResult { app: App el: HTMLDivElement methods: Record loading: { isLoading: boolean } } const renderedApps: RenderResult[] = [] beforeAll(async () => { InfoSteps = (await import('../../../components/steps/InfoSteps.vue')).default }) const steps = [ { title: '步骤一', id: 'step-1' }, { title: '步骤二', id: 'step-2' }, { title: '步骤三', id: 'step-3' } ] const renderSteps = async (rawProps: Record = {}): Promise => { const loading = rawProps.loading || reactive({ isLoading: false }) const methods = { next: vi.fn(() => true), prev: vi.fn(), complete: vi.fn(), cancel: vi.fn(), nativeActive: vi.fn(() => true), ...(rawProps.methods || {}) } const props = { config: { direction: 'horizontal', buttonAligning: 'left', ...(rawProps.config || {}) }, steps, isShowButton: rawProps.isShowButton ?? true, methods, loading, defaultIndex: rawProps.defaultIndex ?? 1 } const el = document.createElement('div') document.body.appendChild(el) const app = createApp({ render () { return h(InfoSteps, props, { default: ({ active }: { active: number }) => h('div', { class: 'slot-active', 'data-active': String(active) }, `active:${active}`) }) } }) app.config.globalProperties.$t = (key: string) => key app.component('InfoForm', InfoFormStub) app.component('info-form', InfoFormStub) app.component('ElSteps', ElStepsStub) app.component('el-steps', ElStepsStub) app.component('ElStep', ElStepStub) app.component('el-step', ElStepStub) app.mount(el) await nextTick() const result = { app, el, methods, loading } renderedApps.push(result) return result } const getRoot = () => document.body const getVisibleButtons = () => Array.from(getRoot().querySelectorAll('.step-action')).filter((button) => { return (button as HTMLElement).style.display !== 'none' }) as HTMLButtonElement[] const getVisibleButtonLabels = () => getVisibleButtons().map((button) => button.getAttribute('data-label')) const clickButtonByLabel = async (label: string) => { const button = getVisibleButtons().find((item) => item.getAttribute('data-label') === label) expect(button).toBeDefined() button!.click() await nextTick() } const clickStep = async (index: number) => { const step = getRoot().querySelectorAll('.el-step-stub')[index] as HTMLButtonElement | undefined expect(step).toBeDefined() step!.click() await nextTick() } const getSlotActive = () => getRoot().querySelector('.slot-active')?.getAttribute('data-active') const flushPromises = async () => { await Promise.resolve() await nextTick() } afterEach(() => { while (renderedApps.length) { const current = renderedApps.pop()! current.app.unmount() current.el.remove() } document.body.innerHTML = '' }) describe('components/steps/InfoSteps', () => { it('根据 defaultIndex 初始化 active,并同步到 el-steps 与默认插槽', async () => { await renderSteps({ defaultIndex: 2 }) expect(getRoot().querySelector('.el-steps-stub')?.getAttribute('data-active')).toBe('1') expect(getSlotActive()).toBe('2') expect(getVisibleButtonLabels()).toEqual(['cancel', 'prev', 'next']) }) it('透传 direction 和 simple 到 el-steps', async () => { await renderSteps({ config: { direction: 'vertical', simple: true } }) const stepsEl = getRoot().querySelector('.el-steps-stub') expect(stepsEl?.getAttribute('data-direction')).toBe('vertical') expect(stepsEl?.getAttribute('data-simple')).toBe('true') expect(getRoot().querySelector('.info-steps')?.classList.contains('vertical-steps')).toBe(true) }) it('next 返回 true 时进入下一步', async () => { const rendered = await renderSteps({ methods: { next: vi.fn(() => true) } }) await clickButtonByLabel('next') expect(rendered.methods.next).toHaveBeenCalledTimes(1) expect(getSlotActive()).toBe('2') }) it('next 返回 false 时保持当前步骤不变', async () => { const rendered = await renderSteps({ methods: { next: vi.fn(() => false) } }) await clickButtonByLabel('next') expect(rendered.methods.next).toHaveBeenCalledTimes(1) expect(getSlotActive()).toBe('1') }) it('next 返回 Promise 时在 resolve true 后进入下一步,resolve false 时不前进', async () => { const promiseNext = vi .fn<() => Promise>() .mockResolvedValueOnce(true) .mockResolvedValueOnce(false) const rendered = await renderSteps({ methods: { next: promiseNext } }) await clickButtonByLabel('next') await flushPromises() expect(rendered.methods.next).toHaveBeenCalledTimes(1) expect(getSlotActive()).toBe('2') await clickButtonByLabel('prev') expect(getSlotActive()).toBe('1') await clickButtonByLabel('next') await flushPromises() expect(rendered.methods.next).toHaveBeenCalledTimes(2) expect(getSlotActive()).toBe('1') }) it('prev 调用外部方法并返回上一步', async () => { const rendered = await renderSteps({ defaultIndex: 3 }) await clickButtonByLabel('prev') expect(rendered.methods.prev).toHaveBeenCalledTimes(1) expect(getSlotActive()).toBe('2') }) it('在最后一步点击完成时调用 complete,并响应 loading', async () => { const loading = reactive({ isLoading: true }) await renderSteps({ defaultIndex: 3, loading }) expect(getVisibleButtonLabels()).toEqual(['cancel', 'prev', 'complete']) expect(getRoot().querySelector('[data-label="complete"]')?.getAttribute('data-loading')).toBe('true') expect(getRoot().querySelector('.info-form-stub')?.getAttribute('data-disabled')).toBe('true') loading.isLoading = false await nextTick() await clickButtonByLabel('complete') expect(getRoot().querySelector('[data-label="complete"]')?.getAttribute('data-loading')).toBe('false') expect(getRoot().querySelector('.info-form-stub')?.getAttribute('data-disabled')).toBe('false') }) it('点击取消时调用 cancel', async () => { const rendered = await renderSteps() await clickButtonByLabel('cancel') expect(rendered.methods.cancel).toHaveBeenCalledTimes(1) }) it('nativeActive 返回 true 时允许点击步骤切换,返回 false 时保持当前步骤', async () => { const nativeActive = vi .fn<(step: object, num: number) => boolean>() .mockReturnValueOnce(true) .mockReturnValueOnce(false) await renderSteps({ methods: { nativeActive } }) await clickStep(2) expect(nativeActive).toHaveBeenNthCalledWith(1, steps[2], 3) expect(getSlotActive()).toBe('3') await clickStep(0) expect(nativeActive).toHaveBeenNthCalledWith(2, steps[0], 1) expect(getSlotActive()).toBe('3') }) it('未提供 nativeActive 时点击步骤不跳转', async () => { await renderSteps({ methods: { nativeActive: undefined } }) await clickStep(1) expect(getSlotActive()).toBe('1') }) it('isShowButton 为 false 时隐藏按钮区域', async () => { await renderSteps({ isShowButton: false }) expect(getRoot().querySelector('.info-form-stub')).toBeNull() expect(getRoot().querySelectorAll('.step-action')).toHaveLength(0) expect(getSlotActive()).toBe('1') }) })