import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import Tree from '@/components/ui/tree/tree.vue' const treeData = [{ b_id: '1', b_name: '根节点', children: [{ b_id: '2', b_name: '子节点' }] }] const fieldNames = { key: 'b_id', title: 'b_name', children: 'children' } describe('Tree 右键菜单', () => { it('节点右键发出 contextmenu 事件(携带 node 与 key)', async () => { const wrapper = mount(Tree, { props: { treeData, fieldNames } }) await wrapper.find('.tree-node-content').trigger('contextmenu') const emitted = wrapper.emitted('contextmenu') expect(emitted).toHaveLength(1) expect(emitted[0][0].node.b_id).toBe('1') expect(emitted[0][0].key).toBe('1') expect(emitted[0][0].event).toBeTruthy() }) it('节点右键不会重复触发树空白处右键逻辑', async () => { const wrapper = mount(Tree, { props: { treeData, fieldNames } }) await wrapper.find('.tree-node-content').trigger('contextmenu') expect(wrapper.emitted('contextmenu')).toHaveLength(1) }) it('空白区域右键发出 contextmenu 事件(node 为 null)', async () => { const wrapper = mount(Tree, { props: { treeData, fieldNames } }) await wrapper.find('.tree').trigger('contextmenu') const emitted = wrapper.emitted('contextmenu') expect(emitted).toHaveLength(1) expect(emitted[0][0].node).toBeNull() expect(emitted[0][0].key).toBeNull() }) it('传入 contextMenu 时按节点渲染菜单(Teleport 到 body)并发出 contextMenuClick', async () => { const contextMenu = vi.fn((node) => node ? [ { key: 'add', label: '新增子项' }, { divider: true }, { key: 'delete', label: '删除', danger: true }, ] : [{ key: 'add-root', label: '新增顶级' }], ) const wrapper = mount(Tree, { props: { treeData, fieldNames, contextMenu } }) // 节点右键:渲染节点菜单 await wrapper.find('.tree-node-content').trigger('contextmenu') expect(contextMenu).toHaveBeenCalled() expect(contextMenu.mock.calls[0][0].b_id).toBe('1') let menu = document.body.querySelector('.tree-context-menu') expect(menu).toBeTruthy() expect(menu.textContent).toContain('新增子项') const divider = menu.querySelector('.tree-context-menu__divider') expect(divider).toBeTruthy() menu.querySelector('.tree-context-menu__item').click() expect(wrapper.emitted('contextMenuClick')).toHaveLength(1) expect(wrapper.emitted('contextMenuClick')[0][0].item.key).toBe('add') expect(wrapper.emitted('contextMenuClick')[0][0].node.b_id).toBe('1') // 点击后菜单关闭(等待离开动画结束) await new Promise((r) => setTimeout(r, 150)) expect(document.body.querySelector('.tree-context-menu')).toBeNull() // 空白右键:渲染顶级菜单 await wrapper.find('.tree').trigger('contextmenu') menu = document.body.querySelector('.tree-context-menu') expect(menu.textContent).toContain('新增顶级') }) it('disabled 菜单项点击不发出 contextMenuClick', async () => { const contextMenu = () => [{ key: 'x', label: '禁用项', disabled: true }] const wrapper = mount(Tree, { props: { treeData, fieldNames, contextMenu } }) await wrapper.find('.tree-node-content').trigger('contextmenu') const menu = document.body.querySelector('.tree-context-menu') menu.querySelector('.tree-context-menu__item').click() expect(wrapper.emitted('contextMenuClick')).toBeUndefined() }) it('点击菜单外部关闭菜单', async () => { const contextMenu = () => [{ key: 'x', label: '项目' }] const wrapper = mount(Tree, { props: { treeData, fieldNames, contextMenu } }) await wrapper.find('.tree-node-content').trigger('contextmenu') expect(document.body.querySelector('.tree-context-menu')).toBeTruthy() document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) await new Promise((r) => setTimeout(r, 0)) expect(document.body.querySelector('.tree-context-menu')).toBeNull() wrapper.unmount() }) }) describe('Tree 行内元素顺序', () => { /** * 回归点:箭头槽(非叶为真箭头、叶子为等宽占位)必须排在拖拽手柄之前。 * 放到手柄之后时,叶子行的手柄与标题之间会空出一整个箭头宽,读起来像多余空隙; * 排在前面则这段空白归入左侧缩进带,手柄紧跟标题(见 tree-node.vue 模板注释)。 */ const rowClasses = (row) => [...row.element.children].map((el) => el.className) it('拖拽开启时:箭头槽在拖拽手柄之前', () => { const wrapper = mount(Tree, { props: { treeData, fieldNames, draggable: true } }) const parentRow = wrapper.findAll('.tree-node')[0] expect(rowClasses(parentRow)).toEqual(['tree-node-switcher', 'tree-node-grip', 'tree-node-content']) }) it('叶子行的占位与手柄顺序一致,保证标题横坐标与非叶兄弟相同', () => { const wrapper = mount(Tree, { props: { treeData, fieldNames, draggable: true, defaultExpandedKeys: ['1'] }, }) const childRow = wrapper.findAll('.tree-node')[1] expect(rowClasses(childRow)).toEqual([ 'tree-node-switcher is-placeholder', 'tree-node-grip', 'tree-node-content', ]) }) })