Files
workspace/code/fms/fms-vue/tests/unit/module-table-edit-panel-dnd.spec.js
T
2026-09-03 17:30:39 +08:00

444 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import ModuleTableEditPanel from '@/views/module/module-management/components/table-edit/TableEditPanel.vue'
/**
* 专用结构树组件 ModuleStructureTree 的状态驱动拖拽,在结构树内完成全部落点
* 语义计算后通过事件交给面板 commit;本文件聚焦这些语义事件的正确性。
*/
class FakeDataTransfer {
constructor() {
this.store = new Map()
this.types = []
this.effectAllowed = 'none'
this.dropEffect = 'none'
}
setData(type, value) {
if (!this.types.includes(type)) this.types.push(type)
this.store.set(type, String(value))
}
getData(type) {
return this.store.has(type) ? this.store.get(type) : ''
}
}
const fields = [
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input' },
{ b_id: '2', b_name: '年龄', b_field: 'age', b_type: 'number' },
{ b_id: '3', b_name: '状态', b_field: 'status', b_type: 'select' },
{ b_id: '4', b_name: '备注', b_field: 'remark', b_type: 'textarea' },
{ b_id: '5', b_name: '金额', b_field: 'amount', b_type: 'money' },
]
const groups = [
{ b_id: 'g1', b_module_id: 'm1', b_parent_id: null, b_title: '基本信息', b_xh: 10, b_canuse: 1 },
{ b_id: 'g2', b_module_id: 'm1', b_parent_id: null, b_title: '扩展信息', b_xh: 20, b_canuse: 1 },
]
const editConfig = [
{ b_id: 'e1', b_module_id: 'm1', b_field_id: '1', b_group_id: null, b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 10 },
{ b_id: 'e2', b_module_id: 'm1', b_field_id: '2', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 100, b_xh: 20 },
{ b_id: 'e3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 160, b_xh: 30 },
{ b_id: 'e4', b_module_id: 'm1', b_field_id: '4', b_group_id: 'g2', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 220, b_xh: 40 },
]
function mountPanel() {
return mount(ModuleTableEditPanel, {
props: {
moduleId: 'm1',
editConfig: editConfig.map((r) => ({ ...r })),
listConfig: [],
queryConfig: [],
fields,
groups: groups.map((g) => ({ ...g })),
editMode: 'table',
},
global: { stubs: { draggable: StubDraggable } },
})
}
// 按标题文本定位树节点行(分组树整树展开);专用组件节点行类名 .mst-node
function treeRow(wrapper, title) {
const row = wrapper.findAll('.mst-node').find((w) => w.text().includes(title))
expect(row, `未找到树节点:${title}`).toBeTruthy()
return row.element
}
/* VTU 的 trigger 会把 dragover/drop 构造为 DragEvent(clientY 只有 getter),
自定义坐标会被静默丢弃;手工构造事件并用 defineProperty 注入坐标。 */
function fireDrag(el, type, { clientX = 10, clientY = 20, dataTransfer } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(ev, 'clientX', { value: clientX })
Object.defineProperty(ev, 'clientY', { value: clientY })
Object.defineProperty(ev, 'dataTransfer', { value: dataTransfer })
el.dispatchEvent(ev)
}
// 同理 pointerdown/pointermove 映射为 MouseEvent(clientX 只读),手工注入坐标
function firePointer(el, type, { clientX = 0, pointerId = 1 } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(ev, 'clientX', { value: clientX })
Object.defineProperty(ev, 'pointerId', { value: pointerId })
el.dispatchEvent(ev)
}
/* ---- 内部拖拽改为 vuedraggable(SortableJS force-fallback)驱动,与主表设计器一致 ----
* jsdom 不实现 elementFromPoint(命中检测),测试里 mock 它返回预先指定的目标行元素;
* onSortWindowMove 据此 closest('.mst-node') 定位落点。
* vuedraggable 组件用 StubDraggable 替换,测试通过 emit start/end 来模拟拖拽生命周期。 */
const StubDraggable = {
name: 'draggable',
props: {
list: { type: Array, default: () => [] },
itemKey: { type: String, default: undefined },
},
emits: ['start', 'end'],
template: `
<div class="stub-draggable">
<template v-for="el in list" :key="el[itemKey || 'key']">
<slot name="item" :element="el" />
</template>
<slot name="footer" />
</div>
`,
}
let hitElement = null
function fireMouse(el, type, { clientX = 0, clientY = 20, button = 0 } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(ev, 'clientX', { value: clientX })
Object.defineProperty(ev, 'clientY', { value: clientY })
Object.defineProperty(ev, 'button', { value: button })
el.dispatchEvent(ev)
}
// 在源节点 grip 上开始拖拽(emit start 到 draggable 组件)
function startMouseDrag(wrapper, srcTitle) {
const row = treeRow(wrapper, srcTitle)
const nodeKey = row.getAttribute('data-key')
expect(nodeKey, `节点缺少 data-key:${srcTitle}`).toBeTruthy()
wrapper.findComponent({ name: 'draggable' }).vm.$emit('start', {
item: { dataset: { key: nodeKey } },
})
}
// 移动鼠标到目标节点行(clientY 决定落点 before/inside/after)
function moveMouseTo(wrapper, targetTitle, { clientX = 0, clientY = 20 } = {}) {
const row = treeRow(wrapper, targetTitle)
hitElement = row.querySelector('.mst-node__label') || row
fireMouse(window, 'mousemove', { clientX, clientY })
}
// 松手提交(emit end 到 draggable 组件)
function endMouseDrag(wrapper) {
wrapper.findComponent({ name: 'draggable' }).vm.$emit('end')
}
function lastUpdate(wrapper) {
const updates = wrapper.emitted('update')
expect(updates?.length).toBeGreaterThan(0)
return updates.at(-1)[0]
}
describe('ModuleTableEditPanel 分组树拖拽语义', () => {
beforeEach(() => {
// 面板(数据视图)内嵌 FmsTable,其主题读取 app store
setActivePinia(createPinia())
// jsdom 无命中检测,mock elementFromPoint 返回测试指定的目标行元素
document.elementFromPoint = () => hitElement
// 行高 40:clientY 5 → before,20 → inside,35 → after
Element.prototype.getBoundingClientRect = () => ({
top: 0,
bottom: 40,
left: 0,
right: 200,
width: 200,
height: 40,
x: 0,
y: 0,
toJSON: () => ({}),
})
Element.prototype.scrollIntoView = vi.fn()
})
it('列拖到列上(中部 inside)直接换位:插到目标列后并沿用其分组,不做入组嵌套', () => {
const wrapper = mountPanel()
// 年龄(c:2,属 g1) 拖到 姓名(c:1,未分组) 行中部
startMouseDrag(wrapper, '年龄')
moveMouseTo(wrapper, '姓名', { clientY: 20 })
endMouseDrag(wrapper)
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
expect(rows.map((r) => r.b_xh)).toEqual([10, 20, 30, 40])
// 年龄跟随目标列分组(未分组),而不是嵌套进任何分组
expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBeNull()
})
it('列拖到列上(上缘 before)换到目标列前,跨组移动时沿用目标列分组', () => {
const wrapper = mountPanel()
// 状态(c:3,属 g1) 拖到 备注(c:4,属 g2) 行上缘
startMouseDrag(wrapper, '状态')
moveMouseTo(wrapper, '备注', { clientY: 5 })
endMouseDrag(wrapper)
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
expect(rows.find((r) => r.b_field_id === '3').b_group_id).toBe('g2')
})
it('列拖到分组节点(中部 inside):移入该分组直属列末尾', () => {
const wrapper = mountPanel()
// 姓名(c:1,未分组) 拖到分组「扩展信息」(g2) 行中部 → 加入 g2 直属列末尾(备注后)
startMouseDrag(wrapper, '姓名')
moveMouseTo(wrapper, '扩展信息', { clientY: 20 })
endMouseDrag(wrapper)
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '1').b_group_id).toBe('g2')
// g2 列:备注 → 姓名;整体前序:年龄/状态(g1) → 备注/姓名(g2)
expect(rows.map((r) => r.b_field_id)).toEqual(['2', '3', '4', '1'])
})
it('列拖到「未分组」收纳节点:移出分组并追加到未分组区', () => {
const wrapper = mountPanel()
// 年龄(c:2,属 g1) 拖到「未分组」节点 → b_group_id 置空,追加到未分组区末尾(姓名后)
startMouseDrag(wrapper, '年龄')
moveMouseTo(wrapper, '未分组', { clientY: 20 })
endMouseDrag(wrapper)
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBeNull()
// 未分组:姓名 → 年龄;其后 g1(状态)、g2(备注)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
})
it('字段库字段拖入分组带(inside):加入该分组直属列末尾', () => {
const wrapper = mountPanel()
// 字段库「金额」(未加入) 拖到分组「基本信息」行中部
const libItem = wrapper
.findAll('.mm-fdesign__lib-item')
.find((w) => w.attributes('data-field-id') === '5')
expect(libItem).toBeTruthy()
const dt = new FakeDataTransfer()
fireDrag(libItem.element, 'dragstart', { dataTransfer: dt })
fireDrag(treeRow(wrapper, '基本信息'), 'dragover', { dataTransfer: dt, clientY: 20 })
fireDrag(treeRow(wrapper, '基本信息'), 'drop', { dataTransfer: dt })
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '5', '4'])
expect(rows.find((r) => r.b_field_id === '5').b_group_id).toBe('g1')
expect(rows.map((r) => r.b_xh)).toEqual([10, 20, 30, 40, 50])
})
it('字段库已加入字段拖到分组带:跨组移动(复用列拖拽语义)', () => {
const wrapper = mountPanel()
// 字段库「备注」(已加入 g2) 拖到分组「基本信息」行中部
const libItem = wrapper
.findAll('.mm-fdesign__lib-item')
.find((w) => w.attributes('data-field-id') === '4')
const dt = new FakeDataTransfer()
fireDrag(libItem.element, 'dragstart', { dataTransfer: dt })
fireDrag(treeRow(wrapper, '基本信息'), 'dragover', { dataTransfer: dt, clientY: 20 })
fireDrag(treeRow(wrapper, '基本信息'), 'drop', { dataTransfer: dt })
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
expect(rows.find((r) => r.b_field_id === '4').b_group_id).toBe('g1')
})
it('分组拖到列上(inside)直接换位:变为该列所属分组的后置兄弟,不嵌套', () => {
const wrapper = mountPanel()
// 分组「基本信息」(g1) 拖到 备注(c:4,属 g2) 行中部 → g1 移到 g2 之后(同级换位)
startMouseDrag(wrapper, '基本信息')
moveMouseTo(wrapper, '备注', { clientY: 20 })
endMouseDrag(wrapper)
// 分组重排:g2 在前(10)、g1 在后(20),g1 仍是顶级分组
const groupUpdates = wrapper.emitted('updateGroups')
expect(groupUpdates?.length).toBeGreaterThan(0)
const nextGroups = groupUpdates.at(-1)[0]
const g1 = nextGroups.find((g) => g.b_id === 'g1')
const g2 = nextGroups.find((g) => g.b_id === 'g2')
expect(g1.b_parent_id).toBeNull()
expect(g1.b_xh).toBe(20)
expect(g2.b_xh).toBe(10)
// 列序跟随分组树前序:姓名(未分组) → 备注(g2) → 年龄/状态(g1)
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '4', '2', '3'])
})
it('分组拖到分组带上(inside)仍为嵌套语义', () => {
const wrapper = mountPanel()
// 分组「扩展信息」(g2) 拖到分组「基本信息」(g1) 行中部 → 成为 g1 子分组
startMouseDrag(wrapper, '扩展信息')
moveMouseTo(wrapper, '基本信息', { clientY: 20 })
endMouseDrag(wrapper)
const groupUpdates = wrapper.emitted('updateGroups')
const nextGroups = groupUpdates.at(-1)[0]
expect(nextGroups.find((g) => g.b_id === 'g2').b_parent_id).toBe('g1')
})
it('预览表列头右缘拖拽仅调列宽,松手才提交一次', () => {
const wrapper = mountPanel()
const resizer = wrapper
.findAll('th.mm-tdesign__cell')
.find((th) => th.attributes('data-field-id') === '2')
.find('.mm-tdesign__resizer')
expect(resizer).toBeTruthy()
firePointer(resizer.element, 'pointerdown', { clientX: 200 })
firePointer(resizer.element, 'pointermove', { clientX: 230 })
// 拖拽过程中不提交
expect(wrapper.emitted('update')).toBeUndefined()
firePointer(resizer.element, 'pointerup')
const rows = lastUpdate(wrapper)
// 年龄初始宽 100,右移 30px → 130
expect(rows.find((r) => r.b_field_id === '2').b_width).toBe(130)
})
it('预览表列头拖拽:列落列换位并沿用目标列分组', () => {
const wrapper = mountPanel()
const cell = (fid) =>
wrapper
.findAll('th.mm-tdesign__cell')
.find((th) => th.attributes('data-field-id') === fid)
const src = cell('2') // 年龄(g1)
const target = cell('4') // 备注(g2)
expect(src).toBeTruthy()
expect(target).toBeTruthy()
const dt = new FakeDataTransfer()
// 拖到目标列左半区(before)
fireDrag(src.element, 'dragstart', { dataTransfer: dt, clientX: 10, clientY: 20 })
fireDrag(target.element, 'dragover', { dataTransfer: dt, clientX: 5, clientY: 20 })
fireDrag(target.element, 'drop', { dataTransfer: dt, clientX: 5, clientY: 20 })
const rows = lastUpdate(wrapper)
// 年龄移到备注前并进入 g2;顺序:姓名(未分组) → 状态(g1) → 年龄/备注(g2)
expect(rows.find((r) => r.b_field_id === '2').b_group_id).toBe('g2')
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '3', '2', '4'])
})
it('预览表列头拖拽:拖到分组带移入该分组直属列末尾', () => {
const wrapper = mountPanel()
const src = wrapper
.findAll('th.mm-tdesign__cell')
.find((th) => th.attributes('data-field-id') === '1') // 姓名(未分组)
const band = wrapper
.findAll('th.mm-tdesign__band')
.find((th) => th.attributes('data-band-id') === 'g2')
expect(src).toBeTruthy()
expect(band).toBeTruthy()
const dt = new FakeDataTransfer()
fireDrag(src.element, 'dragstart', { dataTransfer: dt, clientX: 10, clientY: 20 })
fireDrag(band.element, 'dragover', { dataTransfer: dt, clientX: 50, clientY: 20 })
fireDrag(band.element, 'drop', { dataTransfer: dt, clientX: 50, clientY: 20 })
const rows = lastUpdate(wrapper)
expect(rows.find((r) => r.b_field_id === '1').b_group_id).toBe('g2')
expect(rows.map((r) => r.b_field_id)).toEqual(['2', '3', '4', '1'])
})
it('预览表:全部分组时,拖到「未分组」落点把列拖出分组', async () => {
// 4 列全部在 g1(无未分组列)
const rows = [
{ b_id: 'e1', b_module_id: 'm1', b_field_id: '1', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 10 },
{ b_id: 'e2', b_module_id: 'm1', b_field_id: '2', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 20 },
{ b_id: 'e3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 30 },
{ b_id: 'e4', b_module_id: 'm1', b_field_id: '4', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 40 },
]
const wrapper = mount(ModuleTableEditPanel, {
props: {
moduleId: 'm1',
editConfig: rows.map((r) => ({ ...r })),
listConfig: [],
queryConfig: [],
fields,
groups: [groups[0]].map((g) => ({ ...g })),
editMode: 'table',
},
global: { stubs: { draggable: StubDraggable } },
})
const src = wrapper
.findAll('th.mm-tdesign__cell')
.find((th) => th.attributes('data-field-id') === '1')
expect(src).toBeTruthy()
// 无未分组列 → 拖拽开始后才出现「未分组」落点
expect(wrapper.find('.mm-tdesign__ungrouped-drop').exists()).toBe(false)
const dt = new FakeDataTransfer()
fireDrag(src.element, 'dragstart', { dataTransfer: dt, clientX: 10, clientY: 20 })
await nextTick()
const dropZone = wrapper.find('.mm-tdesign__ungrouped-drop')
expect(dropZone.exists()).toBe(true)
fireDrag(dropZone.element, 'dragover', { dataTransfer: dt, clientX: 10, clientY: 20 })
fireDrag(dropZone.element, 'drop', { dataTransfer: dt, clientX: 10, clientY: 20 })
const rowsOut = lastUpdate(wrapper)
expect(rowsOut.find((r) => r.b_field_id === '1').b_group_id).toBeNull()
expect(rowsOut.map((r) => r.b_field_id)).toEqual(['1', '2', '3', '4'])
})
it('悬停亮指示,松手才提交列拖拽', () => {
// 悬停阶段仅亮落点指示,mouseup 时提交一次变更,不再重复
const wrapper = mountPanel()
// 年龄拖到姓名上(after)
startMouseDrag(wrapper, '年龄')
moveMouseTo(wrapper, '姓名', { clientY: 20 })
// mousemove 不触发换位:update 未发出
expect(wrapper.emitted('update')).toBeUndefined()
// mouseup 提交一次
endMouseDrag(wrapper)
expect(wrapper.emitted('update')?.length).toBe(1)
})
it('预览表头:未分组列头在 band 行贯穿整段高度,不留空白', () => {
// 4 列:2 列分组(g1),2 列未分组
const rows = [
{ b_id: 'e1', b_module_id: 'm1', b_field_id: '1', b_group_id: null, b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 10 },
{ b_id: 'e2', b_module_id: 'm1', b_field_id: '2', b_group_id: null, b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 20 },
{ b_id: 'e3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 30 },
{ b_id: 'e4', b_module_id: 'm1', b_field_id: '4', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 40 },
]
const wrapper = mount(ModuleTableEditPanel, {
props: {
moduleId: 'm1',
editConfig: rows.map((r) => ({ ...r })),
listConfig: [],
queryConfig: [],
fields,
// 仅一个分组 g1(基本信息),避免空分组 g2 产生额外占位列
groups: [groups[0]].map((g) => ({ ...g })),
editMode: 'table',
},
global: { stubs: { draggable: StubDraggable } },
})
// 未分组列头在 band 行渲染:2 个 cell,rowspan 贯穿全部表头行(band 行 + 列头行)
const ungroupedCells = wrapper.findAll('th.mm-tdesign__cell--ungrouped')
expect(ungroupedCells).toHaveLength(2)
ungroupedCells.forEach((th) => expect(th.attributes('rowspan')).toBe('2'))
expect(ungroupedCells[0].attributes('data-field-id')).toBe('1')
expect(ungroupedCells[1].attributes('data-field-id')).toBe('2')
// 未分组列头不是空带:渲染了字段名
expect(ungroupedCells[0].text()).toContain('姓名')
expect(ungroupedCells[1].text()).toContain('年龄')
// 分组带正常渲染
const bandCells = wrapper.findAll('th.mm-tdesign__band')
expect(bandCells).toHaveLength(1)
expect(bandCells[0].text()).toContain('基本信息')
// 列头行只渲染分组列(未分组列已在 band 行)
// 注意:只查预览表(.mm-tdesign__table),数据视图的 FmsTable 经 v-show 仍驻留 DOM
const theadRows = wrapper.findAll('.mm-tdesign__table thead tr')
const headerRow = theadRows.at(-1)
const headerCells = headerRow.findAll('th.mm-tdesign__cell')
expect(headerCells.map((th) => th.attributes('data-field-id'))).toEqual(['3', '4'])
// 总列数不变(colgroup 仍为 4 列)
expect(wrapper.findAll('colgroup col')).toHaveLength(4)
})
})