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: `
`,
}
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)
})
})