195 lines
7.8 KiB
JavaScript
195 lines
7.8 KiB
JavaScript
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||
import { nextTick } from 'vue'
|
||
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 行内元素顺序', () => {
|
||
/**
|
||
* 回归点:行内只有「箭头槽 → 标题」,不再有拖拽手柄列 ——
|
||
* 拖拽改由长按整行发起,行与行之间不存在「有手柄 / 无手柄」的宽度差异。
|
||
* 箭头槽是全行唯一的固定槽:非叶为真箭头、叶子为等宽占位,
|
||
* 保证同层标题左边界一致(去掉叶子占位会让叶子标题左移一格)。
|
||
*/
|
||
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-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-content'])
|
||
})
|
||
|
||
it('不可拖拽的节点与可拖拽节点行内结构一致', () => {
|
||
const wrapper = mount(Tree, {
|
||
props: {
|
||
fieldNames,
|
||
draggable: true,
|
||
defaultExpandedKeys: ['1'],
|
||
treeData: [
|
||
{
|
||
b_id: '1',
|
||
b_name: '全部分类',
|
||
draggable: false,
|
||
children: [{ b_id: '2', b_name: '子节点' }],
|
||
},
|
||
],
|
||
},
|
||
})
|
||
const rows = wrapper.findAll('.tree-node')
|
||
expect(rowClasses(rows[0])).toEqual(['tree-node-switcher', 'tree-node-content'])
|
||
expect(rowClasses(rows[1])).toEqual(['tree-node-switcher is-placeholder', 'tree-node-content'])
|
||
})
|
||
})
|
||
|
||
describe('Tree 长按拖拽', () => {
|
||
const rowOf = (wrapper, index = 0) => wrapper.findAll('.tree-node')[index]
|
||
const press = (row) => row.trigger('mousedown', { button: 0, clientX: 0, clientY: 0 })
|
||
|
||
afterEach(() => vi.useRealTimers())
|
||
|
||
it('长按到位后才置为可拖拽,短按保持普通行', async () => {
|
||
vi.useFakeTimers()
|
||
const wrapper = mount(Tree, { props: { treeData, fieldNames, draggable: true } })
|
||
const row = rowOf(wrapper)
|
||
|
||
await press(row)
|
||
// 未到长按时长:仍是普通行,短按/双击/多选点击的语义不受影响
|
||
expect(row.attributes('draggable')).toBe('false')
|
||
|
||
vi.advanceTimersByTime(400)
|
||
await nextTick()
|
||
expect(row.attributes('draggable')).toBe('true')
|
||
wrapper.unmount()
|
||
})
|
||
|
||
it('长按期间位移超过余量则放弃本次拖拽', async () => {
|
||
vi.useFakeTimers()
|
||
const wrapper = mount(Tree, { props: { treeData, fieldNames, draggable: true } })
|
||
const row = rowOf(wrapper)
|
||
|
||
await press(row)
|
||
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 0 }))
|
||
vi.advanceTimersByTime(400)
|
||
await nextTick()
|
||
expect(row.attributes('draggable')).toBe('false')
|
||
wrapper.unmount()
|
||
})
|
||
|
||
it('未开启 draggable 或节点标记 draggable:false 时不进入拖拽', async () => {
|
||
vi.useFakeTimers()
|
||
const plain = mount(Tree, { props: { treeData, fieldNames } })
|
||
await press(rowOf(plain))
|
||
vi.advanceTimersByTime(500)
|
||
await nextTick()
|
||
expect(rowOf(plain).attributes('draggable')).toBe('false')
|
||
plain.unmount()
|
||
|
||
const locked = mount(Tree, {
|
||
props: {
|
||
fieldNames,
|
||
draggable: true,
|
||
treeData: [{ b_id: '1', b_name: '根节点', draggable: false, children: [] }],
|
||
},
|
||
})
|
||
await press(rowOf(locked))
|
||
vi.advanceTimersByTime(500)
|
||
await nextTick()
|
||
expect(rowOf(locked).attributes('draggable')).toBe('false')
|
||
locked.unmount()
|
||
})
|
||
})
|