import { App, ComponentPublicInstance, createApp, defineComponent, h, inject, nextTick, provide, reactive, ref } from 'vue' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('element-plus/theme-chalk/base.css', () => ({}), { virtual: true }) vi.mock('element-plus/theme-chalk/el-table.css', () => ({}), { virtual: true }) vi.mock('element-plus/theme-chalk/el-table-column.css', () => ({}), { virtual: true }) vi.mock('element-plus/theme-chalk/el-radio.css', () => ({}), { virtual: true }) vi.mock('element-plus/theme-chalk/el-input.css', () => ({}), { virtual: true }) vi.mock('element-plus/theme-chalk/el-button.css', () => ({}), { virtual: true }) vi.mock('element-plus/theme-chalk/el-tooltip.css', () => ({}), { virtual: true }) vi.mock('element-plus/theme-chalk/el-icon.css', () => ({}), { virtual: true }) vi.mock('element-plus/theme-chalk/el-loading.css', () => ({}), { virtual: true }) vi.mock('element-plus/es/components/table/style/css', () => ({}), { virtual: true }) vi.mock('element-plus/es/components/table-column/style/css', () => ({}), { virtual: true }) vi.mock('element-plus/es/components/radio/style/css', () => ({}), { virtual: true }) vi.mock('element-plus/es/components/input/style/css', () => ({}), { virtual: true }) vi.mock('element-plus/es/components/button/style/css', () => ({}), { virtual: true }) vi.mock('element-plus/es/components/tooltip/style/css', () => ({}), { virtual: true }) vi.mock('element-plus/es/components/icon/style/css', () => ({}), { virtual: true }) vi.mock('element-plus/es/components/loading/style/css', () => ({}), { virtual: true }) const filterButtonHandlerMock = vi.fn(() => true) const someButtonHandlerMock = vi.fn(() => 0) const sortableState: { callback: undefined | ((args: { newIndex: number, oldIndex: number }) => void) createColumnSort: ReturnType createRowSort: ReturnType destroySort: ReturnType } = { callback: undefined, createColumnSort: vi.fn(), createRowSort: vi.fn(), destroySort: vi.fn() } const settingHeadersState: { saveColumns: ReturnType columnsGroup: { list: any[] } entity: { prop: string[] } } = { saveColumns: vi.fn(), columnsGroup: { list: [] }, entity: { prop: [] } } const tableExposeState: { toggleRowSelection: ReturnType clearSelection: ReturnType getSelectionRows: ReturnType } = { toggleRowSelection: vi.fn(), clearSelection: vi.fn(), getSelectionRows: vi.fn(() => []) } const rowScopeKey = Symbol('row-scope') const ElTable = defineComponent({ name: 'ElTable', props: { data: { type: Array, default: () => [] }, stripe: { type: Boolean, default: undefined }, border: { type: Boolean, default: undefined }, height: { type: [String, Number], default: '' }, maxHeight: { type: [String, Number], default: '' }, showHeader: { type: Boolean, default: true } }, emits: ['current-change', 'row-dblclick', 'row-click', 'sort-change', 'select-all', 'select', 'header-dragend', 'expand-change'], setup (props, { slots, emit, expose }) { provide(rowScopeKey, { get row () { return props.data[0] || {} }, get index () { return 0 } }) expose({ toggleRowSelection: tableExposeState.toggleRowSelection, clearSelection: tableExposeState.clearSelection, getSelectionRows: tableExposeState.getSelectionRows }) return () => h('div', { class: 'el-table ep-table', 'data-length': String(props.data.length), 'data-stripe': String(props.stripe), 'data-border': String(props.border), 'data-height': String(props.height), 'data-max-height': String(props.maxHeight), 'data-show-header': String(props.showHeader) }, [ h('button', { class: 'emit-row-click', onClick: () => emit('row-click', props.data[0] || {}, { prop: 'name' }, { type: 'click' }) }), h('button', { class: 'emit-row-dblclick', onClick: () => emit('row-dblclick', props.data[0] || {}) }), h('button', { class: 'emit-current-change', onClick: () => emit('current-change', props.data[0] || {}) }), h('button', { class: 'emit-sort-change', onClick: () => emit('sort-change', { prop: 'name', order: 'ascending' }) }), h('button', { class: 'emit-select-all', onClick: () => emit('select-all', props.data as any[]), ref: (node: any) => { if (node) { node.__emitSelectAll = (selection: any[]) => emit('select-all', selection) } } }), h('button', { class: 'emit-select', onClick: () => emit('select', props.data as any[]) }), h('button', { class: 'emit-expand-change', onClick: () => emit('expand-change', props.data[0] || {}) }), h('button', { class: 'emit-header-dragend', onClick: () => emit('header-dragend', 20, 10, { minWidth: 30, width: 20 }) }), slots.default?.() ]) } }) const ElTableColumn = defineComponent({ name: 'ElTableColumn', inheritAttrs: false, props: { prop: { type: String, default: '' }, label: { type: String, default: '' }, type: { type: String, default: '' } }, setup (props, { slots, attrs }) { const rowScope = injectRowScope() return () => h('div', { class: 'el-table-column', 'data-prop': props.prop, 'data-label': props.label, 'data-type': props.type, 'data-width': String(attrs.width ?? ''), 'data-min-width': String(attrs.minWidth ?? '') }, [ h('div', { class: 'column-header' }, slots.header?.({ column: { label: props.label } }) || props.label), h('div', { class: 'column-default' }, slots.default?.({ row: rowScope.row, $index: rowScope.index, column: { property: props.prop } })) ]) } }) function injectRowScope () { const fallback = { row: {}, index: 0 } return inject(rowScopeKey, fallback) as typeof fallback } const ElRadio = defineComponent({ name: 'ElRadio', props: { modelValue: { type: [String, Number], default: '' }, label: { type: [String, Number], default: '' } }, emits: ['update:modelValue', 'click'], setup (props, { emit }) { return () => h('label', { class: 'el-radio', onClick: (event: MouseEvent) => emit('click', event) }, [ h('input', { class: 'el-radio__original', value: props.label, checked: props.modelValue === props.label, onClick: (event: MouseEvent) => { event.stopPropagation() emit('update:modelValue', props.label) emit('click', event) } }) ]) } }) const ElInput = defineComponent({ name: 'ElInput', props: { modelValue: { type: [String, Number], default: '' } }, emits: ['update:modelValue'], setup (props, { emit }) { return () => h('input', { class: 'el-input', value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value) }) } }) const ElButton = defineComponent({ name: 'ElButton', setup (_, { slots, attrs }) { return () => h('button', { class: 'el-button', 'data-test-cy': attrs['test-cy'], onClick: attrs.onClick as any }, slots.default?.()) } }) const ElTooltip = defineComponent({ name: 'ElTooltip', setup (_, { slots }) { return () => h('div', { class: 'el-tooltip' }, slots.default?.()) } }) const infoDropdownStub = defineComponent({ name: 'infoDropdown', props: { row: { type: Object, default: () => ({}) } }, setup () { return () => h('div', { class: 'info-dropdown-stub' }, 'dropdown') } }) const SettingHeadersStub = defineComponent({ name: 'SettingHeaders', props: { columns: { type: Array, default: () => [] } }, emits: ['setColumns'], setup (props, { emit, expose }) { settingHeadersState.columnsGroup.list = props.columns.map((item: any) => ({ label: item.label, value: item.prop })) settingHeadersState.entity.prop = props.columns.map((item: any) => item.prop) settingHeadersState.saveColumns = vi.fn() expose(settingHeadersState) return () => h('div', { class: 'setting-headers-stub' }, [ h('button', { class: 'emit-set-columns-reverse', onClick: () => emit('setColumns', [...props.columns].map((item: any) => item.prop).reverse()) }), h('button', { class: 'emit-set-columns-subset', onClick: () => emit('setColumns', props.columns.slice(0, 2).map((item: any) => item.prop)) }) ]) } }) const ColumnFilterStub = defineComponent({ name: 'ColumnFilter', setup () { return () => h('div', { class: 'column-filter-stub' }, 'filter') } }) vi.mock('../../../script/util', () => ({ filterButtonHandler: filterButtonHandlerMock, someButtonHandler: someButtonHandlerMock })) vi.mock('../../../components/table/sortable', () => ({ useSortable: (_props: any, _emits: any, _options: any, callback: any) => { sortableState.callback = callback return { createColumnSort: sortableState.createColumnSort, createRowSort: sortableState.createRowSort, destroySort: sortableState.destroySort } } })) vi.mock('../../../components/table/SettingHeaders.vue', () => ({ default: SettingHeadersStub })) vi.mock('../../../components/table/ColumnFilter2.vue', () => ({ default: ColumnFilterStub })) vi.mock('../../../components/infoDropdown/index.vue', () => ({ default: infoDropdownStub })) vi.mock('../../../components/table/formComponents', () => ({ default: {} })) vi.mock('@element-plus/icons-vue', () => ({ MoreFilled: defineComponent({ name: 'MoreFilled', setup () { return () => h('span', { class: 'icon-more-filled' }, 'more') } }), Sort: defineComponent({ name: 'Sort', setup () { return () => h('span', { class: 'icon-sort' }, 'sort') } }) })) vi.mock('element-plus', () => ({ ElTable, ElTableColumn, ElRadio, ElInput, ElButton, ElTooltip })) let InfoTable: any interface RenderResult { app: App el: HTMLDivElement tableRef: { value: ComponentPublicInstance | null } events: Record> props: { list: any[] columns: any[] options: Record operates: Record parameter: Record } } const renderedApps: RenderResult[] = [] beforeAll(async () => { InfoTable = (await import('../../../components/table/table.vue')).default }) beforeEach(() => { vi.useFakeTimers() sortableState.callback = undefined sortableState.createColumnSort.mockReset() sortableState.createRowSort.mockReset() sortableState.destroySort.mockReset() tableExposeState.toggleRowSelection.mockReset() tableExposeState.clearSelection.mockReset() tableExposeState.getSelectionRows.mockReset() tableExposeState.getSelectionRows.mockReturnValue([]) settingHeadersState.columnsGroup.list = [] settingHeadersState.entity.prop = [] settingHeadersState.saveColumns = vi.fn() filterButtonHandlerMock.mockReset() filterButtonHandlerMock.mockReturnValue(true) someButtonHandlerMock.mockReset() someButtonHandlerMock.mockReturnValue(0) }) const renderTable = async (rawProps: Partial = {}): Promise => { const events = { handleCurrentChange: vi.fn(), scrollLoad: vi.fn(), scrollLoadAllList: vi.fn(), dropEnd: vi.fn(), handleRowClick: vi.fn(), handleOneRowClick: vi.fn(), handleRowDblclick: vi.fn(), handleSelectionChange: vi.fn(), handleSortChange: vi.fn(), expandChange: vi.fn() } const el = document.createElement('div') document.body.appendChild(el) const props = { list: reactive(rawProps.list || [ { id: 1, name: 'Alpha' }, { id: 2, name: 'Beta' }, { id: 3, name: 'Gamma' } ]), columns: reactive(rawProps.columns || [ { prop: 'name', label: 'name' }, { prop: 'age', label: 'age' }, { prop: 'status', label: 'status' } ]), options: reactive(rawProps.options || { rowKey: 'id' }), operates: reactive(rawProps.operates || { list: [] }), parameter: reactive(rawProps.parameter || {}) } const tableRef = ref(null) const app = createApp({ setup () { return () => h(InfoTable, { ref: tableRef, list: props.list, columns: props.columns, options: props.options, operates: props.operates, parameter: props.parameter, onHandleCurrentChange: events.handleCurrentChange, onScrollLoad: events.scrollLoad, onScrollLoadAllList: events.scrollLoadAllList, onDropEnd: events.dropEnd, onHandleRowClick: events.handleRowClick, onHandleOneRowClick: events.handleOneRowClick, onHandleRowDblclick: events.handleRowDblclick, onHandleSelectionChange: events.handleSelectionChange, onHandleSortChange: events.handleSortChange, onExpandChange: events.expandChange }) } }) app.config.globalProperties.$t = (key: string) => key app.directive('loading', { mounted () {}, updated () {} }) app.mount(el) await nextTick() vi.advanceTimersByTime(500) await nextTick() const result = { app, el, tableRef, events, props } 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() } const emitSelectAll = async (selection: any[]) => { const target = getRoot().querySelector('.emit-select-all') as HTMLElement | null expect(target).not.toBeNull() ;(target as any).__emitSelectAll?.(selection) await nextTick() } const getColumnLabels = () => Array.from(getRoot().querySelectorAll('.el-table-column .column-header')).map(item => item.textContent?.trim()).filter(Boolean) afterEach(() => { while (renderedApps.length) { const current = renderedApps.pop()! current.app.unmount() current.el.remove() } document.body.innerHTML = '' vi.runOnlyPendingTimers() vi.useRealTimers() }) describe('components/table/table', () => { it('兼容默认 options 与 mutiSelect -> multiple', async () => { await renderTable({ options: { rowKey: 'id', mutiSelect: true } }) const table = getRoot().querySelector('.el-table') as HTMLElement | null expect(table?.getAttribute('data-stripe')).toBe('false') expect(table?.getAttribute('data-border')).toBe('true') expect(table?.getAttribute('data-show-header')).toBe('true') expect(getRoot().querySelector('.el-table-column[data-type="selection"]')).not.toBeNull() }) it('支持普通行点击、双击、当前行变化与排序事件桥接', async () => { const rendered = await renderTable() await click('.emit-row-click') await click('.emit-row-dblclick') await click('.emit-current-change') await click('.emit-sort-change') await click('.emit-expand-change') expect(rendered.events.handleOneRowClick).toHaveBeenCalledWith(rendered.props.list[0], { prop: 'name' }, { type: 'click' }) expect(rendered.events.handleRowDblclick).toHaveBeenCalledWith(rendered.props.list[0]) expect(rendered.events.handleCurrentChange).toHaveBeenCalledWith(rendered.props.list[0]) expect(rendered.events.handleSortChange).toHaveBeenCalledWith({ prop: 'name', order: 'ascending' }) expect(rendered.events.expandChange).toHaveBeenCalledWith(rendered.props.list[0]) }) it('单选模式下点击 input 不触发 handleRowClick,点击 radio 容器会触发并更新 currentRadio', async () => { const rendered = await renderTable({ options: { rowKey: 'id', chooseOne: true } }) const radioInput = getRoot().querySelector('.el-radio__original') as HTMLInputElement | null expect(radioInput).not.toBeNull() radioInput!.click() await nextTick() expect(rendered.events.handleRowClick).not.toHaveBeenCalled() expect((rendered.tableRef.value as any)?.currentRadio).toBe(1) const radioLabel = getRoot().querySelector('.el-radio') as HTMLElement | null radioLabel?.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() expect(rendered.events.handleRowClick).toHaveBeenCalledWith(rendered.props.list[0]) expect((rendered.tableRef.value as any)?.currentRadio).toBe(1) }) it('toggleSelection 支持多选与 clearSelection', async () => { const rendered = await renderTable({ options: { rowKey: 'id', multiple: true } }) ;(rendered.tableRef.value as any)?.toggleSelection([rendered.props.list[0], rendered.props.list[1]]) await nextTick() expect(tableExposeState.toggleRowSelection).toHaveBeenCalledTimes(2) expect(tableExposeState.toggleRowSelection).toHaveBeenNthCalledWith(1, rendered.props.list[0], true) expect(tableExposeState.toggleRowSelection).toHaveBeenNthCalledWith(2, rendered.props.list[1], true) ;(rendered.tableRef.value as any)?.toggleSelection([]) await nextTick() expect(tableExposeState.clearSelection).toHaveBeenCalledTimes(1) }) it('handleSelectAll 在 selectable 混合可选与不可选时回传全部已选项', async () => { const rendered = await renderTable({ list: [ { id: 1, name: 'A' }, { id: 2, name: 'B' } ], options: { rowKey: 'id', multiple: true, selectable: (row: any) => row.id === 1 } }) tableExposeState.getSelectionRows.mockReturnValue(rendered.props.list) await emitSelectAll(rendered.props.list) expect(tableExposeState.toggleRowSelection).toHaveBeenCalledTimes(2) expect(rendered.events.handleSelectionChange).toHaveBeenCalledWith(rendered.props.list) }) it('handleSelectAll 在无可选项时清空选中', async () => { const rendered = await renderTable({ list: [ { id: 2, name: 'B' }, { id: 4, name: 'D' } ], options: { rowKey: 'id', multiple: true, selectable: (row: any) => row.id % 2 === 1 } }) tableExposeState.getSelectionRows.mockReturnValue(rendered.props.list) await emitSelectAll(rendered.props.list) expect(tableExposeState.clearSelection).toHaveBeenCalledTimes(1) expect(rendered.events.handleSelectionChange).toHaveBeenCalledWith([]) }) it('headers 变更后会联动展示列顺序', async () => { await renderTable({ options: { rowKey: 'id', isHideSet: false } }) expect(getColumnLabels()).toEqual(expect.arrayContaining(['name', 'age', 'status'])) await click('.emit-set-columns-reverse') expect(getColumnLabels().slice(1, 4)).toEqual(['status', 'age', 'name']) }) it('setColumnsGroup 会同步 showColumns 与 SettingHeaders 状态', async () => { await renderTable({ options: { rowKey: 'id', isHideSet: false, isColumnDrop: true } }) expect(sortableState.createColumnSort).toHaveBeenCalled() sortableState.callback?.({ oldIndex: 0, newIndex: 2 }) await nextTick() expect(getColumnLabels().slice(1, 4)).toEqual(['age', 'status', 'name']) expect(settingHeadersState.columnsGroup.list.map(item => item.value)).toEqual(['age', 'status', 'name']) expect(settingHeadersState.entity.prop).toEqual(['age', 'status', 'name']) expect(settingHeadersState.saveColumns).toHaveBeenCalledTimes(1) }) it('isAllList + listSize 初始化时按分片展示,并在滚动时追加数据与透传事件', async () => { const rendered = await renderTable({ list: [ { id: 1, name: 'A' }, { id: 2, name: 'B' }, { id: 3, name: 'C' } ], options: { rowKey: 'id', isAllList: true, listSize: 2 } }) const table = getRoot().querySelector('.el-table') as HTMLDivElement | null expect(table?.getAttribute('data-length')).toBe('2') Object.defineProperties(table as HTMLDivElement, { scrollTop: { value: 20, configurable: true }, scrollHeight: { value: 100, configurable: true }, clientHeight: { value: 90, configurable: true } }) table?.dispatchEvent(new Event('scroll', { bubbles: true })) vi.advanceTimersByTime(50) await nextTick() expect(rendered.events.scrollLoadAllList).toHaveBeenCalledTimes(1) expect(rendered.events.scrollLoadAllList).toHaveBeenCalledWith(expect.any(Event), rendered.props.list) expect((getRoot().querySelector('.el-table') as HTMLElement | null)?.getAttribute('data-length')).toBe('3') }) it('非 isAllList 模式下滚动透传 scrollLoad 事件', async () => { const rendered = await renderTable({ options: { rowKey: 'id', isAllList: false } }) const table = getRoot().querySelector('.el-table') as HTMLDivElement | null table?.dispatchEvent(new Event('scroll', { bubbles: true })) vi.advanceTimersByTime(50) await nextTick() expect(rendered.events.scrollLoad).toHaveBeenCalledTimes(1) expect(rendered.events.scrollLoad).toHaveBeenCalledWith(expect.any(Event)) }) })