Files
workspace/code/fms/fms-vue/tests/unit/module-list-config-panel-dnd.spec.js
T
oneao 05d0c7f304 chore(fms-vue): WIP 基线快照——收编工作区未提交的前端改动
不含 fms-api Java 侧改动与后续导航状态化阶段提交。主要内容:
- ui 组件库:date/dropdown/form/input/popover/select/tooltip 调整,
  新增 Segmented 组件;fms-table 可编辑单元格渲染器与 FileCard 更新
- 模块管理:列表配置加载自动补全、查询缺配徽标与一键补充、
  结构编辑内核 useStructureEditing/useBandLayout、子表表格编辑方式、
  分组标题多语言、I18nKeyCell 等(含配套 spec)
- 布局/主题/仪表盘/演示页小幅更新
2026-08-30 20:06:19 +08:00

292 lines
12 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 { describe, expect, it, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import ModuleListConfigPanel from '@/views/system/module-management/components/ModuleListConfigPanel.vue'
/**
* 列表配置面板与子表设计共用 useStructureEditing / ModuleStructureTree / TablePreview,
* 拖拽语义与 module-table-edit-panel-dnd.spec 同源;本文件聚焦:
* 1) 共用语义在列表侧(s_field_view 行)同样成立;
* 2) 列表侧 features 差异(无分组启停菜单、列节点无删除按钮);
* 3) 分组删除经 update-groups(groups, removedGroupIds) 上抛级联清理。
*/
const fields = [
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input', b_i18n: 'field.sys.name' },
{ b_id: '2', b_name: '年龄', b_field: 'age', b_type: 'number', b_i18n: 'field.sys.age' },
{ b_id: '3', b_name: '状态', b_field: 'status', b_type: 'select', b_i18n: 'field.sys.status' },
{ b_id: '4', b_name: '备注', b_field: 'remark', b_type: 'textarea', b_i18n: 'field.sys.remark' },
]
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 listConfig = [
{ b_id: 'v1', b_module_id: 'm1', b_field_id: '1', b_group_id: null, b_i18n: null, b_visible: 1, b_width: 120, b_xh: 10 },
{ b_id: 'v2', b_module_id: 'm1', b_field_id: '2', b_group_id: 'g1', b_i18n: null, b_visible: 1, b_width: 100, b_xh: 20 },
{ b_id: 'v3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_i18n: null, b_visible: 1, b_width: 160, b_xh: 30 },
]
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>
`,
}
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) : ''
}
}
function mountPanel(extraProps = {}) {
return mount(ModuleListConfigPanel, {
props: {
moduleId: 'm1',
moduleCode: 'sys_user',
listConfig: listConfig.map((r) => ({ ...r })),
fields,
groups: groups.map((g) => ({ ...g })),
...extraProps,
},
global: { stubs: { draggable: StubDraggable } },
})
}
function treeRow(wrapper, title) {
const row = wrapper.findAll('.mst-node').find((w) => w.text().includes(title))
expect(row, `未找到树节点:${title}`).toBeTruthy()
return row.element
}
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)
}
/* VTU trigger 会把 dragover/drop 构造为 DragEvent(坐标只有 getter),手工注入 */
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)
}
let hitElement = null
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 } },
})
}
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 })
}
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('ModuleListConfigPanel 结构编辑(与子表设计共用内核)', () => {
beforeEach(() => {
setActivePinia(createPinia())
document.elementFromPoint = () => hitElement
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()
// 姓名(v1,未分组) 拖到分组「扩展信息」(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')
expect(rows.map((r) => r.b_field_id)).toEqual(['2', '3', '1'])
})
it('列拖到「未分组」收纳节点:移出分组', () => {
const wrapper = mountPanel()
// 年龄(v2,属 g1) 拖到「未分组」
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()
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3'])
})
it('分组拖到分组带上(inside):嵌套(b_parent_id 指向目标)', () => {
const wrapper = mountPanel()
// 分组「扩展信息」(g2) 拖到分组「基本信息」(g1) 行中部
startMouseDrag(wrapper, '扩展信息')
moveMouseTo(wrapper, '基本信息', { clientY: 20 })
endMouseDrag(wrapper)
const groupUpdates = wrapper.emitted('updateGroups')
expect(groupUpdates?.length).toBeGreaterThan(0)
const nextGroups = groupUpdates.at(-1)[0]
expect(nextGroups.find((g) => g.b_id === 'g2').b_parent_id).toBe('g1')
})
it('删除分组:update-groups 带 removedGroupIds,组内行归未分组', () => {
const wrapper = mountPanel()
// 树上「基本信息」(g1) 节点常驻删除按钮
const row = wrapper.findAll('.mst-node').find((w) => w.text().includes('基本信息'))
expect(row).toBeTruthy()
const delBtn = row.findAll('button').find((b) => b.attributes('title') === '删除')
expect(delBtn).toBeTruthy()
delBtn.trigger('click')
const groupUpdates = wrapper.emitted('updateGroups')
expect(groupUpdates?.length).toBeGreaterThan(0)
const [nextGroups, removedIds] = groupUpdates.at(-1)
expect(nextGroups.map((g) => g.b_id)).toEqual(['g2'])
expect(removedIds).toEqual(['g1'])
// g1 下的行(年龄/状态)归未分组并重排
const rows = lastUpdate(wrapper)
expect(rows.every((r) => r.b_group_id == null)).toBe(true)
expect(rows.map((r) => r.b_xh)).toEqual([10, 20, 30])
})
it('列表侧 features:分组右键无「启停」,列节点有删除按钮(同表格设计)', async () => {
const wrapper = mountPanel()
// 列节点(姓名)渲染删除按钮
const colRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('姓名'))
expect(colRow).toBeTruthy()
expect(colRow.findAll('button').some((b) => b.attributes('title') === '删除')).toBe(true)
// 分组节点也有删除按钮
const groupRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('基本信息'))
expect(groupRow.findAll('button').some((b) => b.attributes('title') === '删除')).toBe(true)
})
it('树中列节点删除按钮:删除 = 隐藏(b_visible=0),行保留', async () => {
const wrapper = mountPanel()
const colRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('姓名'))
const delBtn = colRow.findAll('button').find((b) => b.attributes('title') === '删除')
expect(delBtn).toBeTruthy()
await delBtn.trigger('click')
const rows = lastUpdate(wrapper)
// 行不删(启用字段始终有配置行),仅置隐藏;预览该列置灰带「隐藏」标记
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3'])
expect(rows.find((r) => r.b_field_id === '1').b_visible).toBe(0)
})
it('预览表渲染多级表头:band 行含分组标题,列头含字段名', () => {
const wrapper = mountPanel()
const bands = wrapper.findAll('th.mm-tdesign__band')
expect(bands.map((th) => th.text())).toContain('基本信息')
// 未分组列(姓名)表头贯穿 band 行
const ungroupedCell = wrapper
.findAll('th.mm-tdesign__cell--ungrouped')
.find((th) => th.attributes('data-field-id') === '1')
expect(ungroupedCell).toBeTruthy()
expect(ungroupedCell.text()).toContain('姓名')
})
it('字段库字段点击加入:未加入字段点击后生成配置行并选中', async () => {
const wrapper = mountPanel()
const libItem = wrapper
.findAll('.mm-fdesign__lib-item')
.find((w) => w.attributes('data-field-id') === '4')
expect(libItem).toBeTruthy()
await libItem.trigger('click')
const rows = lastUpdate(wrapper)
// 新列归未分组区(未分组在前),备注为 textarea:默认隐藏、列宽 160
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '4', '2', '3'])
expect(rows.find((r) => r.b_field_id === '4').b_group_id).toBeNull()
expect(rows.find((r) => r.b_field_id === '4').b_visible).toBe(0)
expect(rows.find((r) => r.b_field_id === '4').b_width).toBe(160)
})
it('字段库字段拖入分组带(inside):加入该分组直属列末尾', () => {
const wrapper = mountPanel()
const libItem = wrapper
.findAll('.mm-fdesign__lib-item')
.find((w) => w.attributes('data-field-id') === '4')
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', '4'])
expect(rows.find((r) => r.b_field_id === '4').b_group_id).toBe('g1')
})
it('属性面板:选中列显示列属性(无状态开关),删除列 = 隐藏', async () => {
const wrapper = mountPanel()
// 树中点击列节点 → Inspector 显示列属性
const treeRow = wrapper.findAll('.mst-node').find((w) => w.text().includes('姓名'))
expect(treeRow).toBeTruthy()
await treeRow.trigger('click')
const inspector = wrapper.find('.mm-fdesign__inspector')
expect(inspector.exists()).toBe(true)
expect(inspector.text()).toContain('姓名')
// 列表侧无状态开关(显示/必填等是子表编辑语义)
expect(inspector.findAll('.mm-fdesign__status-chip')).toHaveLength(0)
// 删除列按钮:点击隐藏该列(行保留)
const delBtn = inspector.findAll('button').find((b) => b.text().includes('删除列'))
expect(delBtn).toBeTruthy()
await delBtn.trigger('click')
const rows = lastUpdate(wrapper)
expect(rows.map((r) => r.b_field_id)).toEqual(['1', '2', '3'])
expect(rows.find((r) => r.b_field_id === '1').b_visible).toBe(0)
})
})