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) }) })