import { defineComponent, h, nextTick, ref, watch } from 'vue' import { mount } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' const treeInstances: Array<{ props: Record api: Record }> = [] const buildTreeNodes = ( list: Array>, labelKey: string, nodeKey: string, parent: any = null, nodeMap: Map = new Map() ) => { return list.map((item) => { const node = { data: item, label: item[labelKey], checked: false, indeterminate: false, parent, childNodes: [] as any[] } nodeMap.set(item[nodeKey], node) node.childNodes = buildTreeNodes(item.children || [], labelKey, nodeKey, node, nodeMap) return node }) } const ElTreeStub = defineComponent({ name: 'ElTree', props: { data: { type: Array, default: () => [] }, defaultExpandAll: { type: Boolean, default: false }, defaultExpandedKeys: { type: Array, default: () => [] }, accordion: { type: Boolean, default: false }, checkStrictly: { type: Boolean, default: false }, highlightCurrent: { type: Boolean, default: false }, showCheckbox: { type: Boolean, default: false }, nodeKey: { type: String, default: 'id' }, expandOnClickNode: { type: Boolean, default: true }, props: { type: Object, default: () => ({ children: 'children', label: 'name' }) } }, emits: ['check', 'node-click'], setup (props, { emit, slots, expose }) { const checkedNodes = ref([]) const halfCheckedNodes = ref([]) const nodeMap = new Map() const rebuild = () => { nodeMap.clear() buildTreeNodes( props.data as Array>, props.props.label || 'name', props.nodeKey, null, nodeMap ) } watch(() => [props.data, props.props, props.nodeKey], rebuild, { deep: true, immediate: true }) const api = { getCheckedNodes: vi.fn(() => checkedNodes.value), getHalfCheckedNodes: vi.fn(() => halfCheckedNodes.value), getNode: vi.fn((target: any) => { if (target && typeof target === 'object') { return nodeMap.get(target[props.nodeKey]) } return nodeMap.get(target) }), remove: vi.fn(), insertBefore: vi.fn(), insertAfter: vi.fn(), setChecked: vi.fn((id: any, checked: boolean) => { const node = nodeMap.get(id) if (node) { node.checked = checked const exists = checkedNodes.value.some(item => item[props.nodeKey] === id) if (checked && !exists) { checkedNodes.value = [...checkedNodes.value, node.data] } if (!checked && exists) { checkedNodes.value = checkedNodes.value.filter(item => item[props.nodeKey] !== id) } } }), __setCheckedNodes: (nodes: any[]) => { checkedNodes.value = nodes }, __setHalfCheckedNodes: (nodes: any[]) => { halfCheckedNodes.value = nodes } } treeInstances.push({ props: props as any, api }) expose(api) const renderNodes = (list: Array>) => list.map((item) => { const key = item[props.nodeKey] const node = nodeMap.get(key) return h('div', { class: 'tree-node', 'data-id': String(key) }, [ h('button', { class: 'check-trigger', 'data-id': String(key), onClick: () => emit('check', item) }, `check-${String(key)}`), h('button', { class: 'node-trigger', 'data-id': String(key), onClick: () => emit('node-click', item, node) }, `click-${String(key)}`), slots.default?.({ node, data: item }), ...(item.children?.length ? renderNodes(item.children) : []) ]) }) return () => h('div', { class: 'el-tree-stub', 'data-default-expand-all': String(props.defaultExpandAll), 'data-default-expanded-keys': JSON.stringify(props.defaultExpandedKeys), 'data-accordion': String(props.accordion), 'data-check-strictly': String(props.checkStrictly), 'data-highlight-current': String(props.highlightCurrent), 'data-show-checkbox': String(props.showCheckbox), 'data-node-key': props.nodeKey, 'data-expand-on-click-node': String(props.expandOnClickNode) }, renderNodes(props.data as Array>)) } }) vi.mock('element-plus', () => ({ ElTree: ElTreeStub })) import Tree from '../../../components/tree/index.vue' const renderTree = (options: { props?: Record slots?: Record } = {}) => { return mount(Tree, { props: options.props, slots: options.slots, global: { config: { globalProperties: { $t: (key: string) => `translated:${key}` } } } }) } afterEach(() => { treeInstances.length = 0 }) describe('components/tree', () => { it('基础渲染时使用默认配置并展示国际化标签', () => { const wrapper = renderTree({ props: { treeData: { data: [{ id: 'node-1', name: 'tree.label', children: [] }], selectData: [] } } }) const tree = wrapper.find('.el-tree-stub') expect(tree.exists()).toBe(true) expect(tree.attributes('data-default-expand-all')).toBe('true') expect(tree.attributes('data-show-checkbox')).toBe('true') expect(tree.attributes('data-node-key')).toBe('id') expect(tree.attributes('data-expand-on-click-node')).toBe('true') expect(tree.attributes('data-highlight-current')).toBe('true') expect(wrapper.text()).toContain('translated:tree.label') }) it('支持 attrs 与具名插槽覆盖默认行为', () => { const wrapper = renderTree({ props: { treeData: { data: [{ code: 'node-2', title: '原始标题', children: [] }], selectData: [] }, defaultProps: { i18n: false, children: 'children', label: 'title' }, attrs: { showAll: false, expanded: ['node-2'], showCheckbox: false, nodeKey: 'code', expandClickNode: false } }, slots: { column: ({ node }: any) => h('span', { class: 'custom-column' }, `slot:${node.label}`) } }) const tree = wrapper.find('.el-tree-stub') expect(tree.attributes('data-default-expand-all')).toBe('false') expect(tree.attributes('data-default-expanded-keys')).toBe('["node-2"]') expect(tree.attributes('data-show-checkbox')).toBe('false') expect(tree.attributes('data-node-key')).toBe('code') expect(tree.attributes('data-expand-on-click-node')).toBe('false') expect(wrapper.find('.custom-tree-node').exists()).toBe(false) expect(wrapper.find('.custom-column').text()).toBe('slot:原始标题') }) it('勾选节点时会透传选中与半选结果', async () => { const wrapper = renderTree({ props: { treeData: { data: [{ id: 'node-1', name: 'Node 1', children: [] }], selectData: [] }, attrs: { includeHalfChecked: true } } }) treeInstances[0].api.__setCheckedNodes([{ id: 'node-1', halfSelect: true }]) treeInstances[0].api.__setHalfCheckedNodes([{ id: 'node-half', halfSelect: true }]) await wrapper.find('.check-trigger[data-id="node-1"]').trigger('click') const events = wrapper.emitted('selectData') expect(events).toHaveLength(1) expect(events?.[0]?.[0]).toEqual([ { id: 'node-1', halfSelect: true }, { id: 'node-half', halfSelect: false } ]) expect(events?.[0]?.[1]).toEqual({ id: 'node-1', name: 'Node 1', children: [] }) expect(events?.[0]?.[2]).toEqual(expect.objectContaining({ data: { id: 'node-1', name: 'Node 1', children: [] } })) }) it('点击节点会触发 nodeClick,选中回填会调用 setChecked 并维护父节点半选状态', async () => { const wrapper = renderTree({ props: { treeData: { data: [{ id: 'parent-1', name: 'Parent', children: [{ id: 'child-1', name: 'Child', children: [] }] }], selectData: ['child-1'] }, attrs: { checkStrictly: true, customCheck: true } } }) await nextTick() expect(treeInstances[0].api.setChecked).toHaveBeenCalledWith('child-1', true) const childNode = treeInstances[0].api.getNode('child-1') expect(childNode.parent.indeterminate).toBe(true) await wrapper.find('.node-trigger[data-id="child-1"]').trigger('click') const nodeClickEvents = wrapper.emitted('nodeClick') expect(nodeClickEvents).toHaveLength(1) expect(nodeClickEvents?.[0]?.[0]).toEqual({ id: 'child-1', name: 'Child', children: [] }) expect(nodeClickEvents?.[0]?.[1]).toEqual(expect.objectContaining({ data: { id: 'child-1', name: 'Child', children: [] } })) }) it('暴露 remove 与 insert 方法供外部调用', () => { const wrapper = renderTree({ props: { treeData: { data: [{ id: 'node-1', name: 'Node 1', children: [] }], selectData: [] } } }) ;(wrapper.vm as any).remove({ id: 'node-1' }) ;(wrapper.vm as any).insert({ id: 'node-2' }, { id: 'node-1' }, 'before') ;(wrapper.vm as any).insert({ id: 'node-3' }, { id: 'node-1' }) expect(treeInstances[0].api.remove).toHaveBeenCalledWith({ id: 'node-1' }) expect(treeInstances[0].api.insertBefore).toHaveBeenCalledWith({ id: 'node-2' }, { id: 'node-1' }) expect(treeInstances[0].api.insertAfter).toHaveBeenCalledWith({ id: 'node-3' }, { id: 'node-1' }) }) })