import { App, ComponentPublicInstance, createApp, defineComponent, h, nextTick, reactive, ref } from 'vue' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const InfoCardStub = defineComponent({ name: 'InfoCard', inheritAttrs: false, props: { config: { type: Object, default: () => ({}) }, title: { type: String, default: '' }, border: { type: Boolean, default: false } }, setup (props, { attrs, slots }) { return () => h('div', { class: ['infoCard', attrs.class, { border: props.border }] }, [ props.config?.customHeader ? h('div', { class: 'custom' }, slots.header?.()) : h('h4', {}, props.title), h('div', { class: 'block' }, slots.default?.()) ]) } }) const tableState = reactive({ latestProps: null as null | Record, latestPartitionRef: null as null | { value: boolean } }) const TableBtnStub = defineComponent({ name: 'TableBtn', props: { list: { type: Array, default: () => [] }, columns: { type: Array, default: () => [] }, operates: { type: Object, default: () => ({}) }, options: { type: Object, default: () => ({}) } }, emits: ['handleSelectionChange', 'handleCurrentChange', 'handleOneRowClick', 'scrollLoadAllList', 'dropEnd'], setup (props, { emit, expose }) { const partition = ref(false) expose({ partition }) return () => { tableState.latestPartitionRef = partition tableState.latestProps = { list: props.list, columns: props.columns, operates: props.operates, options: props.options, partition: partition.value } return h('div', { class: 'table-btn-stub', 'data-partition': String(partition.value) }, [ ...(props.list as Array>).map((row) => h('div', { class: 'table-row', 'data-row-id': String(row.id) }, row.name)), h('button', { class: 'emit-selection', onClick: () => emit('handleSelectionChange', props.list.length ? [props.list[0]] : []) }, 'emit-selection'), h('button', { class: 'emit-current', onClick: () => emit('handleCurrentChange', props.list.length > 1 ? props.list[1] : props.list[0] || null) }, 'emit-current'), h('button', { class: 'emit-row-click', onClick: () => emit('handleOneRowClick', props.list[0] || null, { prop: 'name' }, { type: 'click' }) }, 'emit-row-click'), h('button', { class: 'emit-load-more', onClick: () => emit('scrollLoadAllList', { source: 'stub-load' }) }, 'emit-load-more'), h('button', { class: 'emit-drop-end', onClick: () => emit('dropEnd', { from: 0, to: 1 }) }, 'emit-drop-end') ]) } } }) const FuzzyMatchingStub = defineComponent({ name: 'FuzzyMatching', props: { totalData: { type: Array, default: () => [] }, field: { type: String, default: 'name' } }, emits: ['getfuzzyMatching'], setup (props, { emit, expose }) { const keyword = ref('') const filterResult = () => { const fields = props.field.split(',').filter(Boolean) return (props.totalData as Array>).filter((item) => { return fields.some((field) => { const value = String(field.split('.').reduce((current: any, key) => current?.[key], item) ?? '') return value.includes(keyword.value) }) }) } expose({ keyword, filterResult }) return () => h('div', { class: 'fuzzy-matching-stub' }, [ h('input', { class: 'fuzzy-input', value: keyword.value, placeholder: '请输入内容', onInput: (event: Event) => { keyword.value = (event.target as HTMLInputElement).value } }), h('button', { class: 'trigger-fuzzy', onClick: () => emit('getfuzzyMatching', filterResult()) }, 'trigger-fuzzy') ]) } }) const ElTooltipStub = defineComponent({ name: 'ElTooltip', props: { content: { type: String, default: '' } }, setup (props, { slots }) { return () => h('div', { class: 'el-tooltip', 'data-content': props.content }, slots.default?.()) } }) const ElButtonStub = defineComponent({ name: 'ElButton', inheritAttrs: false, props: { disabled: { type: Boolean, default: false }, type: { type: String, default: '' }, icon: { type: [String, Object], default: '' }, size: { type: String, default: 'small' } }, setup (props, { attrs, slots }) { return () => h('button', { class: ['el-button', attrs.class], disabled: props.disabled, 'data-type': props.type, 'data-size': props.size, onClick: attrs.onClick as any }, slots.default?.()) } }) const ElIconStub = defineComponent({ name: 'ElIcon', inheritAttrs: false, setup (_, { attrs, slots }) { return () => h('button', { class: ['el-icon', attrs.class], onClick: attrs.onClick as any }, slots.default?.()) } }) const createNamedIcon = (name: string) => defineComponent({ name, setup () { return () => h('span', { class: [`icon-${name.toLowerCase()}`] }, name) } }) vi.mock('../../../components/infoCard/infoCard.vue', () => ({ default: InfoCardStub })) vi.mock('../../../components/table/table.vue', () => ({ default: TableBtnStub })) vi.mock('../../../components/table/pagination.vue', () => ({ default: defineComponent({ name: 'Pagination', setup () { return () => h('div', { class: 'pagination-stub' }) } }) })) vi.mock('../../../components/fuzzyMatching/fuzzyMatching.vue', () => ({ default: FuzzyMatchingStub })) vi.mock('../../../script/util', () => ({ deepCopy: (obj: Record) => JSON.parse(JSON.stringify(obj)), getFieldValue: (obj: Record, field: string) => { return field.split('.').reduce((current, key) => current?.[key], obj) }, deleteObject: (value: string, array: Array>, attr: string) => { for (let index = array.length - 1; index >= 0; index--) { const currentValue = attr.split('.').reduce((current, key) => current?.[key], array[index]) if (currentValue === value) { array.splice(index, 1) } } } })) vi.mock('@element-plus/icons-vue', () => ({ Delete: createNamedIcon('Delete'), Sort: createNamedIcon('Sort'), Plus: createNamedIcon('Plus'), Finished: createNamedIcon('Finished') })) let TransferTable: any interface RenderResult { app: App el: HTMLDivElement props: Record events: Record> host: ComponentPublicInstance } const renderedApps: RenderResult[] = [] beforeAll(async () => { TransferTable = (await import('../../../components/transferTable/index.vue')).default }) const createBaseProps = () => ({ title: '测试标题', searchField: 'name', listData: [ { id: 1, name: 'Beta', filed1: 'B-1', filed2: 'B-2' }, { id: 2, name: 'Alpha', filed1: 'A-1', filed2: 'A-2' }, { id: 3, name: 'Gamma', filed1: 'G-1', filed2: 'G-2' } ], columns: [ { prop: 'name', label: '名称' }, { prop: 'filed1', label: '字段1' } ], operations: { list: [], mutiSelect: true, height: '280px' }, topOperations: { isTwoRow: true, add: true, allAdd: true, delete: true, isSortField: true }, tableConfig: { rowKey: 'id', isAllList: true, isDrop: true } }) const renderTransferTable = async (customProps: Record = {}) => { vi.useFakeTimers() tableState.latestProps = null const props = reactive({ ...createBaseProps(), ...customProps }) const events = { deleted: vi.fn(), handleSelectData: vi.fn(), loadMore: vi.fn(), dropEnd: vi.fn(), handleOneRowClick: vi.fn(), handleFilterTableData: vi.fn() } const hostRef = ref(null) const el = document.createElement('div') document.body.appendChild(el) const app = createApp({ setup () { return { props, hostRef, events } }, render () { return h(TransferTable, { ...this.props, ref: 'hostRef', onDeleted: this.events.deleted, onHandleSelectData: this.events.handleSelectData, onLoadMore: this.events.loadMore, onDropEnd: this.events.dropEnd, onHandleOneRowClick: this.events.handleOneRowClick, onHandleFilterTableData: this.events.handleFilterTableData }) } }) app.config.globalProperties.$t = (key: string) => { const map: Record = { add: '新增', addAll: '全部添加', delete: '删除', sort: '排序', selectedData: '已选数据', pleaseInput: '请输入内容' } return map[key] || key } app.component('ElTooltip', ElTooltipStub) app.component('ElButton', ElButtonStub) app.component('ElIcon', ElIconStub) app.mount(el) await nextTick() vi.runAllTimers() await nextTick() const result = { app, el, props, events, host: hostRef.value! } renderedApps.push(result) return result } const getTextRows = () => { return Array.from(document.body.querySelectorAll('.table-row')).map((node) => node.textContent?.trim()) } const flushComponent = async () => { await nextTick() await nextTick() } const clickByIcon = async (iconClass: string) => { const icon = document.body.querySelector(iconClass) as HTMLElement | null expect(icon).not.toBeNull() ;(icon?.closest('.el-icon') as HTMLButtonElement).click() await nextTick() } beforeEach(() => { document.body.innerHTML = '' }) afterEach(() => { while (renderedApps.length) { const current = renderedApps.pop()! current.app.unmount() current.el.remove() } tableState.latestProps = null tableState.latestPartitionRef = null document.body.innerHTML = '' vi.clearAllMocks() vi.useRealTimers() }) describe('components/transferTable', () => { it('渲染标题并在挂载后合并表格配置', async () => { await renderTransferTable() expect(document.body.querySelector('.titleMore')?.textContent).toContain('测试标题') expect(document.body.querySelector('.fuzzy-input')).not.toBeNull() expect(tableState.latestProps).not.toBeNull() expect(tableState.latestProps?.options.rowKey).toBe('id') expect(tableState.latestProps?.options.isDrop).toBe(true) expect(tableState.latestProps?.options.height).toBe('280px') expect(tableState.latestProps?.list).toHaveLength(3) }) it('选择后支持新增当前选中项与全部添加', async () => { const rendered = await renderTransferTable() ;(document.body.querySelector('.emit-selection') as HTMLButtonElement).click() await nextTick() await clickByIcon('.icon-plus') expect(rendered.events.handleSelectData).toHaveBeenCalledTimes(2) expect(rendered.events.handleSelectData).toHaveBeenNthCalledWith(1, [ { id: 1, name: 'Beta', filed1: 'B-1', filed2: 'B-2' } ]) await clickByIcon('.icon-finished') expect(rendered.events.handleSelectData).toHaveBeenCalledTimes(3) expect(rendered.events.handleSelectData).toHaveBeenNthCalledWith(3, tableState.latestProps?.list) }) it('删除已选中项后更新结果列表并抛出 deleted 事件', async () => { const rendered = await renderTransferTable() ;(document.body.querySelector('.emit-selection') as HTMLButtonElement).click() await nextTick() await clickByIcon('.icon-delete') expect(getTextRows()).toEqual(['Alpha', 'Gamma']) expect(rendered.props.listData).toEqual([ { id: 2, name: 'Alpha', filed1: 'A-1', filed2: 'A-2' }, { id: 3, name: 'Gamma', filed1: 'G-1', filed2: 'G-2' } ]) expect(rendered.events.deleted).toHaveBeenCalledTimes(1) expect(rendered.events.deleted).toHaveBeenCalledWith(rendered.props.listData) }) it('点击排序图标时按 searchField 在升序和降序间切换', async () => { await renderTransferTable() expect(getTextRows()).toEqual(['Beta', 'Alpha', 'Gamma']) await clickByIcon('.icon-sort') expect(getTextRows()).toEqual(['Alpha', 'Beta', 'Gamma']) await clickByIcon('.icon-sort') expect(getTextRows()).toEqual(['Gamma', 'Beta', 'Alpha']) }) it('模糊过滤后更新表格数据并抛出 handleFilterTableData 事件', async () => { const rendered = await renderTransferTable() const input = document.body.querySelector('.fuzzy-input') as HTMLInputElement input.value = 'Alpha' input.dispatchEvent(new Event('input', { bubbles: true })) await nextTick() ;(document.body.querySelector('.trigger-fuzzy') as HTMLButtonElement).click() await flushComponent() expect(getTextRows()).toEqual(['Alpha']) expect(rendered.events.handleFilterTableData).toHaveBeenCalledTimes(1) expect(rendered.events.handleFilterTableData).toHaveBeenCalledWith([ { id: 2, name: 'Alpha', filed1: 'A-1', filed2: 'A-2' } ]) }) it('透传表格的行点击、加载更多和拖拽结束事件', async () => { const rendered = await renderTransferTable() ;(document.body.querySelector('.emit-row-click') as HTMLButtonElement).click() ;(document.body.querySelector('.emit-load-more') as HTMLButtonElement).click() ;(document.body.querySelector('.emit-drop-end') as HTMLButtonElement).click() await nextTick() expect(rendered.events.handleOneRowClick).toHaveBeenCalledTimes(1) expect(rendered.events.handleOneRowClick).toHaveBeenCalledWith( rendered.props.listData[0], { prop: 'name' }, { type: 'click' } ) expect(rendered.events.loadMore).toHaveBeenCalledTimes(1) expect(rendered.events.loadMore).toHaveBeenCalledWith( { source: 'stub-load' }, tableState.latestProps?.list ) expect(rendered.events.dropEnd).toHaveBeenCalledTimes(1) expect(rendered.events.dropEnd).toHaveBeenCalledWith({ from: 0, to: 1 }) }) it('当前行变化后新增按钮返回单条当前行数据', async () => { const rendered = await renderTransferTable() ;(document.body.querySelector('.emit-current') as HTMLButtonElement).click() await nextTick() await clickByIcon('.icon-plus') expect(rendered.events.handleSelectData).toHaveBeenCalledWith([ { id: 2, name: 'Alpha', filed1: 'A-1', filed2: 'A-2' } ]) }) })