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/system/module-management/components/ModuleTableEditPanel.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: `
`, } 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) }) })