20260827230636

This commit is contained in:
oneao committed 2026-08-27 23:06:36 +08:00
1 parent 3824af0f12
commit 35ca496b5f
15 files changed
+2313 -321

No files matched your search

@@ -0,0 +1,265 @@
import { mount } from '@vue/test-utils'
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',
},
})
}
// 按标题文本定位树节点行(分组树整树展开);专用组件节点行类名 .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, { clientY = 20, dataTransfer } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true })
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)
}
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())
// 行高 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,未分组) 行中部
fireDrag(treeRow(wrapper, '年龄'), 'dragstart')
const dt = new FakeDataTransfer()
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.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) 行上缘
fireDrag(treeRow(wrapper, '状态'), 'dragstart')
const dt = new FakeDataTransfer()
fireDrag(treeRow(wrapper, '备注'), 'dragover', { dataTransfer: dt, clientY: 5 })
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 === '3').b_group_id).toBe('g2')
})
it('列拖到分组节点(中部 inside):移入该分组直属列末尾', () => {
const wrapper = mountPanel()
// 姓名(c:1,未分组) 拖到分组「扩展信息」(g2) 行中部 → 加入 g2 直属列末尾(备注后)
fireDrag(treeRow(wrapper, '姓名'), 'dragstart')
const dt = new FakeDataTransfer()
fireDrag(treeRow(wrapper, '扩展信息'), 'dragover', { dataTransfer: dt, clientY: 20 })
fireDrag(treeRow(wrapper, '扩展信息'), 'drop', { dataTransfer: dt })
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 置空,追加到未分组区末尾(姓名后)
fireDrag(treeRow(wrapper, '年龄'), 'dragstart')
const dt = new FakeDataTransfer()
fireDrag(treeRow(wrapper, '未分组'), 'dragover', { dataTransfer: dt, clientY: 20 })
fireDrag(treeRow(wrapper, '未分组'), 'drop', { dataTransfer: dt })
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 之后(同级换位)
fireDrag(treeRow(wrapper, '基本信息'), 'dragstart')
const dt = new FakeDataTransfer()
fireDrag(treeRow(wrapper, '备注'), 'dragover', { dataTransfer: dt, clientY: 20 })
fireDrag(treeRow(wrapper, '备注'), 'drop', { dataTransfer: dt })
// 分组重排: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 子分组
fireDrag(treeRow(wrapper, '扩展信息'), 'dragstart')
const dt = new FakeDataTransfer()
fireDrag(treeRow(wrapper, '基本信息'), 'dragover', { dataTransfer: dt, clientY: 20 })
fireDrag(treeRow(wrapper, '基本信息'), 'drop', { dataTransfer: dt })
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('实时换位:dragover 即提交,drop 不重复', () => {
// 拖过即换:拖拽经过目标行时立即换位,drop 阶段不再重复提交
const wrapper = mountPanel()
const dt = new FakeDataTransfer()
// 年龄拖到姓名上(after)
fireDrag(treeRow(wrapper, '年龄'), 'dragstart')
fireDrag(treeRow(wrapper, '姓名'), 'dragover', { dataTransfer: dt, clientY: 20 })
// dragover 已触发换位:update 已发出
expect(wrapper.emitted('update')?.length).toBeGreaterThanOrEqual(1)
const updateCount = wrapper.emitted('update').length
// drop 不再新增 update
fireDrag(treeRow(wrapper, '姓名'), 'drop', { dataTransfer: dt })
expect(wrapper.emitted('update').length).toBe(updateCount)
})
})
@@ -0,0 +1,97 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import Tree from '@/components/ui/tree/tree.vue'
/**
* 模拟 Chrome 的 DataTransfer 行为:
* - dragover / dragenter 阶段:getData() 对所有类型返回空字符串(隐私保护),但 types / effectAllowed 可用
* - drop 阶段:getData() 正常返回 dragstart 中写入的数据
*/
function makeDataTransfer({ types = [], get = () => '' } = {}) {
return {
effectAllowed: 'copy',
dropEffect: 'none',
types,
getData: vi.fn((t) => get(t)),
setData: vi.fn(),
}
}
const treeData = [
{
key: 'g:1',
title: '分组1',
isLeaf: false,
children: [{ key: 'c:1', title: '列1', isLeaf: true }],
},
]
describe('Tree 外部拖拽(字段库 → 分组)', () => {
beforeEach(() => {
// 让 calcDropPosition 能算出 'inside'(行高 40,鼠标落在中点)
Element.prototype.getBoundingClientRect = () => ({
top: 0,
bottom: 40,
left: 0,
right: 200,
width: 200,
height: 40,
x: 0,
y: 0,
toJSON: () => ({}),
})
})
it('dragover 阶段 getData 为空(Chrome 限制)时,基于 types 识别外部源并设置拖放指示', async () => {
const externalDragKey = vi.fn((dt) => {
const types = Array.from(dt?.types || [])
if (!types.includes('application/fms-field')) return null
const fid = dt?.getData?.('application/fms-field')
return fid ? `external-field:${fid}` : `external-field:__pending__`
})
const wrapper = mount(Tree, { props: { treeData, draggable: true, externalDragKey } })
const nodeRow = wrapper.find('.tree-node')
// dragover:getData 读不到值(模拟 Chrome),仅 types 可用
const dt = makeDataTransfer({ types: ['application/fms-field'], get: () => '' })
await nodeRow.trigger('dragover', { dataTransfer: dt, clientY: 20 })
expect(externalDragKey).toHaveBeenCalled()
// dragKey 已被解析 → 节点行出现放置指示(说明拖拽已被识别,可正常落放)
expect(nodeRow.classes()).toContain('drop-inside')
})
it('dragover 阶段仅用 getData 判断时(修复前实现),拖放指示不出现', async () => {
// 旧实现:只靠 getData 判断拖拽源 → Chrome 下 dragover 读不到 → dragKey 不设置
const externalDragKey = vi.fn((dt) => {
const fid = dt?.getData?.('application/fms-field')
return fid ? `external-field:${String(fid)}` : null
})
const wrapper = mount(Tree, { props: { treeData, draggable: true, externalDragKey } })
const nodeRow = wrapper.find('.tree-node')
const dt = makeDataTransfer({ types: ['application/fms-field'], get: () => '' })
await nodeRow.trigger('dragover', { dataTransfer: dt, clientY: 20 })
// 没有任何 drop-* 指示类
expect(nodeRow.classes().some((c) => c.startsWith('drop-'))).toBe(false)
})
it('drop 阶段 getData 可用时,发出正确的 external-field 拖拽键', async () => {
const externalDragKey = (dt) => {
const types = Array.from(dt?.types || [])
if (!types.includes('application/fms-field')) return null
const fid = dt?.getData?.('application/fms-field')
return fid ? `external-field:${fid}` : `external-field:__pending__`
}
const wrapper = mount(Tree, { props: { treeData, draggable: true, externalDragKey } })
const nodeRow = wrapper.find('.tree-node')
// dragover(读不到)→ drop(能读到 '42')
const overDT = makeDataTransfer({ types: ['application/fms-field'], get: () => '' })
await nodeRow.trigger('dragover', { dataTransfer: overDT, clientY: 20 })
const dropDT = makeDataTransfer({
types: ['application/fms-field'],
get: (t) => (t === 'application/fms-field' ? '42' : ''),
})
await nodeRow.trigger('drop', { dataTransfer: dropDT })
const drops = wrapper.emitted('drop')
expect(drops).toBeTruthy()
expect(drops[0][0]).toMatchObject({ dragKey: 'external-field:42', dropKey: 'g:1' })
})
})