import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { registerFeature, useAreaSelection, StkTable } from 'stk-table-vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
// 与 main.js 一致:注册 stk-table 的按需 feature(行编辑依赖 area-selection)
registerFeature(useAreaSelection)
const COLS = 20
function makeColumns() {
const cols = []
for (let i = 0; i < COLS; i++) {
cols.push({ title: `列 ${i}`, dataIndex: `c${i}`, width: 120, editor: { type: 'input' } })
}
return cols
}
function makeRows(n) {
return Array.from({ length: n }, (_, i) => {
const row = { id: i + 1 }
for (let j = 0; j < COLS; j++) row[`c${j}`] = `${i + 1}-${j}`
return row
})
}
describe('fms-table 虚拟滚动验证', () => {
beforeEach(() => setActivePinia(createPinia()))
it('5000 行时仅渲染可视区行数(虚拟滚动切片生效)', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(5000), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const trCount = wrapper.findAll('.stk-tbody-main tr').length
console.log('=== 5000 行时 DOM 实际渲染的
数量:', trCount)
expect(trCount).toBeGreaterThan(0)
expect(trCount).toBeLessThan(100)
wrapper.unmount()
})
it('20 行时全量渲染(数据量小于阈值关闭虚拟滚动,不再吞掉最后一行)', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const trCount = wrapper.findAll('.stk-tbody-main tr').length
console.log('=== 20 行时 DOM 实际渲染的
数量:', trCount)
expect(trCount).toBe(20)
wrapper.unmount()
})
it('数据量从 20 行增长到 5000 行后,DOM 行数保持为可视区行数', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const before = wrapper.findAll('.stk-tbody-main tr').length
await wrapper.setProps({ dataSource: makeRows(5000) })
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const after = wrapper.findAll('.stk-tbody-main tr').length
console.log(`=== 20 行时渲染 ${before} 行,改为 5000 行后渲染 ${after} 行`)
expect(before).toBeGreaterThan(0)
expect(after).toBeGreaterThan(0)
// 核心断言:5000 行数据时 DOM 只保留可视区行数,不会渲染全部 5000 行
expect(after).toBeLessThan(100)
wrapper.unmount()
})
// 性能优化回归(item #1):非编辑态下可编辑列/纯展示列走 StkTable 原生 td 渲染,
// 不注入 customCell 函数式组件;只有进入编辑后才挂载编辑器。
it('非编辑态:全部单元格走原生 table-cell-wrapper 渲染,零编辑器组件', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const tds = wrapper.findAll('.stk-tbody-main td[data-col-key]')
expect(tds.length).toBeGreaterThan(0)
// 没有活动编辑行 → 不注入任何编辑器渲染
expect(wrapper.find('.fms-cell-editor-root').exists()).toBe(false)
// 每个数据单元格都走库原生 table-cell-wrapper 路径(无 fms-cell-display 包裹)
const nativeWrappers = wrapper.findAll('.stk-tbody-main .table-cell-wrapper')
expect(nativeWrappers.length).toBe(tds.length)
wrapper.unmount()
})
// 性能优化回归(item #1):进入编辑后仅活动编辑行挂载编辑器,其余行保持原生渲染
it('进入编辑后:仅活动编辑行挂载编辑器,其余行保持原生渲染', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const trs = wrapper.findAll('.stk-tbody-main tr')
const td = trs[1].find('td[data-col-key]')
await td.trigger('mousedown', { button: 0 })
await nextTick()
await td.trigger('mouseup')
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const editingRow = wrapper.findAll('.stk-tbody-main tr')[1]
const editorCountInEditingRow = editingRow.findAll('.fms-cell-editor-root').length
expect(editorCountInEditingRow).toBeGreaterThan(0)
expect(editorCountInEditingRow).toBeLessThanOrEqual(makeColumns().length)
// 其余行不挂载编辑器(保持原生渲染,滚动时行间切换不重建整表)
trs.forEach((tr, index) => {
if (index === 1) return
expect(tr.findAll('.fms-cell-editor-root').length).toBe(0)
})
wrapper.unmount()
})
it('点击单元格进入单行编辑(area-selection 注册后生效,编辑器渲染)', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
// 点击第 2 行第 1 个数据列(跳过 virtualX 的占位 td,选中带 data-col-key 的单元格)
const trs = wrapper.findAll('.stk-tbody-main tr')
const td = trs[1].find('td[data-col-key]')
console.log('=== td outerHTML:', td.element.outerHTML.slice(0, 200))
await td.trigger('mousedown', { button: 0 })
await nextTick()
console.log('=== mousedown 后 td data-cs-s:', td.attributes()['data-cs-s'])
await td.trigger('mouseup')
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const editor = wrapper.find('.fms-cell-editor-root')
console.log('=== 点击后编辑器是否渲染:', editor.exists())
expect(editor.exists()).toBe(true)
wrapper.unmount()
})
// 列宽/列序拖拽依赖 v-model:columns 回写:未绑定的父组件(如 dashboard 单向 :columns)
// 拖了松手就回弹,因此未绑定时直接关闭这两个功能(不渲染把手),绑定时保持开启。
it('未绑定 v-model:columns 时关闭列宽拖拽与列头拖拽(无处回写)', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const stkTable = wrapper.findComponent(StkTable)
expect(stkTable.props('colResizable')).toBe(false)
expect(stkTable.props('headerDrag')).toBe(false)
wrapper.unmount()
})
it('绑定 v-model:columns 时列宽拖拽与列头拖拽保持开启', async () => {
const wrapper = mount(FmsTable, {
props: {
columns: makeColumns(),
dataSource: makeRows(20),
rowKey: 'id',
seq: false,
// 模拟 v-model:columns:提供 update:columns 监听器
'onUpdate:columns': () => {},
},
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const stkTable = wrapper.findComponent(StkTable)
// 开启时统一传 { disabled } 回调(库只支持这种按列控制方式):普通列可拖,
// 列上显式写 resizable: false 的(操作列 / 展开列)不拖
const colResizable = stkTable.props('colResizable')
expect(colResizable).toMatchObject({ disabled: expect.any(Function) })
expect(colResizable.disabled({ dataIndex: 'c0', width: 120 })).toBe(false)
expect(colResizable.disabled({ dataIndex: '__actions__', resizable: false })).toBe(true)
expect(stkTable.props('headerDrag')).toBeTruthy()
wrapper.unmount()
})
it('autoFitWidth 默认关闭表格自动铺满宽度', async () => {
const wrapper = mount(FmsTable, {
props: {
columns: makeColumns(),
dataSource: makeRows(20),
rowKey: 'id',
seq: false,
'onUpdate:columns': () => {},
},
})
await nextTick()
expect(wrapper.find('.fms-table').classes()).not.toContain('fms-table--auto-fit-width')
expect(wrapper.findComponent(StkTable).props('colResizable')).toMatchObject({
disabled: expect.any(Function),
})
wrapper.unmount()
})
it('autoFitWidth=true 时关闭列宽拖拽并使用自适应布局', async () => {
const wrapper = mount(FmsTable, {
props: {
columns: makeColumns(),
dataSource: makeRows(20),
rowKey: 'id',
autoFitWidth: true,
'onUpdate:columns': () => {},
},
})
await nextTick()
expect(wrapper.find('.fms-table').classes()).toContain('fms-table--auto-fit-width')
expect(wrapper.findComponent(StkTable).props('colResizable')).toBe(false)
wrapper.unmount()
})
it('序号列和复选框列允许调整宽度且回写后保持', async () => {
const wrapper = mount(FmsTable, {
props: {
columns: [{ title: '名称', dataIndex: 'name', width: 120 }],
dataSource: [{ id: 1, name: '测试' }],
rowKey: 'id',
rowSelection: { selectedRowKeys: [], onChange: () => {} },
'onUpdate:columns': () => {},
},
})
await nextTick()
const table = wrapper.findComponent(StkTable)
const columns = table.props('columns')
const seq = columns.find((column) => column.type === 'seq')
const selection = columns.find((column) => column.__fmsSelection)
expect(seq?.headerAlign).toBe('center')
expect(seq?.resizable).not.toBe(false)
expect(selection?.resizable).not.toBe(false)
await table.vm.$emit(
'update:columns',
columns.map((column) =>
column.type === 'seq'
? { ...column, width: '72px' }
: column.__fmsSelection
? { ...column, width: '56px' }
: column,
),
)
await nextTick()
const updated = wrapper.findComponent(StkTable).props('columns')
expect(updated.find((column) => column.type === 'seq')?.width).toBe(72)
expect(updated.find((column) => column.__fmsSelection)?.width).toBe(56)
wrapper.unmount()
})
})