292 lines
12 KiB
JavaScript
292 lines
12 KiB
JavaScript
import { mount } from '@vue/test-utils'
|
||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||
import { createPinia, setActivePinia } from 'pinia'
|
||
import ModuleListConfigPanel from '@/views/module/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)
|
||
})
|
||
})
|