import { App, ComponentPublicInstance, createApp, defineComponent, h, nextTick, reactive, ref } from 'vue' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const storageState: Record = {} const serverApiMock = vi.fn() const requestPayloadMock = vi.fn((payload: any) => payload) const buildParameterMock = vi.fn((payload: any) => payload) const treeApis: Array<{ setCurrentKey: ReturnType getCurrentNode: ReturnType getNode: ReturnType filter: ReturnType append: ReturnType }> = [] const Search = defineComponent({ name: 'Search', setup () { return () => h('span', { class: 'icon-search' }, 'search') } }) const MoreFilled = defineComponent({ name: 'MoreFilled', setup () { return () => h('span', { class: 'icon-more-filled' }, 'more') } }) const ElIcon = defineComponent({ name: 'ElIcon', setup (_, { slots }) { return () => h('span', { class: 'el-icon' }, slots.default?.()) } }) const ElTooltip = defineComponent({ name: 'ElTooltip', props: { content: { type: String, default: '' }, disabled: { type: Boolean, default: false } }, setup (props, { slots }) { return () => h('div', { class: 'el-tooltip', 'data-content': props.content, 'data-disabled': String(props.disabled) }, slots.default?.()) } }) const ElInput = defineComponent({ name: 'ElInput', inheritAttrs: false, props: { modelValue: { type: String, default: '' }, placeholder: { type: String, default: '' } }, emits: ['update:modelValue', 'input', 'blur', 'click'], setup (props, { emit, attrs, expose }) { const inputRef = reactive({ focus: vi.fn() }) expose(inputRef) return () => h('input', { class: ['el-input', attrs.class], value: props.modelValue, placeholder: props.placeholder, onInput: (event: Event) => { const value = (event.target as HTMLInputElement).value emit('update:modelValue', value) emit('input', value) }, onBlur: () => emit('blur'), onClick: (event: MouseEvent) => emit('click', event) }) } }) const ElOption = defineComponent({ name: 'ElOption', props: { label: { type: String, default: '' }, value: { type: String, default: '' } }, setup (props) { return () => h('div', { class: 'el-option', 'data-label': props.label, 'data-value': props.value }, props.label) } }) const ElSelect = defineComponent({ name: 'ElSelect', props: { modelValue: { type: String, default: '' }, remoteMethod: { type: Function, default: undefined } }, emits: ['update:modelValue', 'change'], setup (props, { emit, slots }) { return () => h('div', { class: 'el-select' }, [ h('button', { class: 'stub-select-remote', onClick: () => props.remoteMethod?.('keyword') }, 'remote'), h('button', { class: 'stub-select-change', onClick: () => { emit('update:modelValue', 'selected-value') emit('change', 'selected-value') } }, 'change'), slots.default?.() ]) } }) const buildNodeModel = (data: any, parent: any = null, level = 1, nodeMap = new Map()) => { const node = { data, parent, level, expanded: false, loaded: true, loadList: [], expand: vi.fn(() => { node.expanded = true }) } nodeMap.set(data.id, node) ;(data.children || []).forEach((child: any) => buildNodeModel(child, node, level + 1, nodeMap)) return nodeMap } const ElTree = defineComponent({ name: 'ElTree', props: { data: { type: Array, default: () => [] }, nodeKey: { type: String, default: 'id' }, lazy: { type: Boolean, default: false }, load: { type: Function, default: undefined }, allowDrag: { type: Function, default: undefined }, allowDrop: { type: Function, default: undefined } }, emits: ['node-click', 'node-contextmenu', 'check', 'node-drop', 'node-drag-start', 'node-expand'], setup (props, { emit, slots, expose }) { const nodeMap = new Map() let currentKey = '' const rootNode = { level: 0, data: null, parent: null, loadList: [], params: undefined, reqType: undefined, resolved: undefined as any, resolve: vi.fn((value: any) => { rootNode.resolved = value }) } const rebuild = () => { nodeMap.clear() ;(props.data as any[]).forEach(item => buildNodeModel(item, null, 1, nodeMap)) } rebuild() const api = { setCurrentKey: vi.fn((key: string) => { currentKey = key }), getCurrentNode: vi.fn(() => nodeMap.get(currentKey)?.data), getNode: vi.fn((target: any) => { if (target && typeof target === 'object') { return nodeMap.get(target[props.nodeKey]) } return nodeMap.get(target) }), filter: vi.fn(), append: vi.fn(), rootNode, invokeLoad: async (node: any) => { const targetNode = node ?? rootNode return await new Promise((resolve) => { props.load?.(targetNode, (value: any) => { targetNode.resolved = value resolve(value) }) }) }, invokeAllowDrag: (node: any) => props.allowDrag?.(node), invokeAllowDrop: (sourceNode: any, targetNode: any, type: string) => props.allowDrop?.(sourceNode, targetNode, type), emitNodeDragStart: (sourceNode: any, targetNode: any = null, position: string = 'before') => { emit('node-drag-start', sourceNode, targetNode, position) }, emitNodeDrop: (sourceNode: any, targetNode: any = null, position: string = 'before') => { emit('node-drop', sourceNode, targetNode, position) } } treeApis.push(api) expose(api) const renderNodes = (list: any[]) => list.map(item => { const node = nodeMap.get(item[props.nodeKey]) return h('div', { class: 'tree-node', 'data-id': item[props.nodeKey] }, [ h('button', { class: 'tree-node-trigger', 'data-id': item[props.nodeKey], onClick: () => emit('node-click', item, node) }, `click-${item[props.nodeKey]}`), slots.default?.({ node, data: item }), ...(item.children?.length ? renderNodes(item.children) : []) ]) }) return () => { rebuild() return h('div', { class: 'el-tree' }, renderNodes(props.data as any[])) } } }) vi.mock('@element-plus/icons-vue', () => ({ Search, MoreFilled })) vi.mock('element-plus', () => ({ ElTree, ElInput, ElSelect, ElOption, ElTooltip, ElIcon })) vi.mock('../../../script/index', () => ({ __getItem: vi.fn((key: string) => storageState[key]), __setItem: vi.fn((key: string, value: any) => { storageState[key] = value }), requestPayload: requestPayloadMock, buildParameter: buildParameterMock, serverApi: serverApiMock, ObjectInterface: Object })) let InfoTree: any let dropHooks: any let PagingModule: any interface RenderResult { app: App el: HTMLDivElement vm: ComponentPublicInstance & { dropStart?: Function dropEnd?: Function loadNode?: Function loadPaging?: any loadCurrentApi?: { api: any } loadCurrentNode?: { node: any } treeRef?: any } treeProps: Record events: { treeCurrentData: ReturnType remoteSelect: ReturnType remoteSelectChange: ReturnType editBlur: ReturnType dropStart: ReturnType dropEnd: ReturnType nodeExpand: ReturnType checkChange: ReturnType handleCustom: ReturnType } } const renderedApps: RenderResult[] = [] beforeAll(async () => { const treeHooksModule = await import('../../../components/infoTree/treeHooks') dropHooks = treeHooksModule.dropHooks InfoTree = (await import('../../../components/infoTree/customTree.vue')).default PagingModule = (await import('../../../components/infoTree/paging')).default }) beforeEach(() => { Object.keys(storageState).forEach((key) => delete storageState[key]) treeApis.length = 0 serverApiMock.mockReset() requestPayloadMock.mockClear() buildParameterMock.mockClear() }) const renderInfoTree = async (props: Record = {}): Promise => { const events = { treeCurrentData: vi.fn(), remoteSelect: vi.fn(), remoteSelectChange: vi.fn(), editBlur: vi.fn(), dropStart: vi.fn(), dropEnd: vi.fn(), nodeExpand: vi.fn(), checkChange: vi.fn(), handleCustom: vi.fn() } const el = document.createElement('div') document.body.appendChild(el) const treeProps = reactive({ dataTree: [], ...props, onTreeCurrentData: events.treeCurrentData, onRemoteSelect: events.remoteSelect, onRemoteSelectChange: events.remoteSelectChange, onEditBlur: events.editBlur, onDropStart: events.dropStart, onDropEnd: events.dropEnd, onNodeExpand: events.nodeExpand, onCheckChange: events.checkChange, onHandleCustom: events.handleCustom }) const app = createApp({ setup () { return () => h(InfoTree, treeProps) } }) app.config.globalProperties.$t = (key?: string) => key const vm = app.mount(el) await nextTick() await nextTick() const result = { app, el, vm, treeProps, events } renderedApps.push(result) return result } const createTreeNode = (data: any, options: Partial = {}) => { const node = { data, level: options.level ?? 1, parent: options.parent ?? null, loadList: options.loadList ?? [], params: options.params, reqType: options.reqType, loaded: options.loaded ?? true, expand: options.expand ?? vi.fn(() => { node.loaded = true }), resolve: options.resolve ?? vi.fn(), resolved: options.resolved } return node } const flushTicks = async (count = 2) => { for (let index = 0; index < count; index++) { await nextTick() } } const getRoot = () => document.body const inputValue = async (selector: string, value: string) => { const input = getRoot().querySelector(selector) as HTMLInputElement | null expect(input).not.toBeNull() input!.value = value input!.dispatchEvent(new Event('input', { bubbles: true })) await nextTick() } const click = async (selector: string) => { const target = getRoot().querySelector(selector) as HTMLElement | null expect(target).not.toBeNull() target!.click() await nextTick() } afterEach(() => { vi.useRealTimers() while (renderedApps.length) { const current = renderedApps.pop()! current.app.unmount() current.el.remove() } document.body.innerHTML = '' }) describe('components/infoTree/customTree.vue', () => { it('dropHooks 在 dropPeer 模式下只允许同级非 inner 放置', () => { const config = { dropPeer: vi.fn(() => true), allowDrag: vi.fn(() => true), allowDrop: vi.fn(() => true) } const { allowDrag, allowDrop } = dropHooks(config, {}) expect(allowDrag({ id: 'drag-node' })).toBe(true) expect(config.allowDrag).toHaveBeenCalledWith({ id: 'drag-node' }) expect(allowDrop({ level: 2 }, { level: 2 }, 'before')).toBe(true) expect(allowDrop({ level: 2 }, { level: 3 }, 'before')).toBe(false) expect(allowDrop({ level: 2 }, { level: 2 }, 'inner')).toBe(false) expect(config.allowDrop).not.toHaveBeenCalled() }) it('dropHooks 在非 dropPeer 模式下回退到 allowDrop 或默认规则', () => { const allowDropSpy = vi.fn(() => false) const { allowDrop: customAllowDrop } = dropHooks({ dropPeer: vi.fn(() => false), allowDrop: allowDropSpy }, {}) expect(customAllowDrop({ level: 1 }, { level: 4 }, 'inner')).toBe(false) expect(allowDropSpy).toHaveBeenCalledWith({ level: 1 }, { level: 4 }, 'inner') const { allowDrop: defaultAllowDrop } = dropHooks({ dropPeer: vi.fn(() => false) }, {}) expect(defaultAllowDrop({ level: 1 }, { level: 1 }, 'before')).toBe(false) expect(defaultAllowDrop({ level: 1 }, { level: 2 }, 'before')).toBe(true) expect(defaultAllowDrop({ level: 1 }, { level: 1 }, 'inner')).toBe(true) }) it('初始化时会选中第一个有效节点并触发 treeCurrentData', async () => { const dataTree = [{ id: 'root-1', name: 'Flows', selected: 0, children: [{ id: 'child-1', name: 'Datasets', selected: 1, parentId: 'root-1', children: [] }] }] const rendered = await renderInfoTree() rendered.treeProps.dataTree = dataTree await flushTicks() expect(rendered.events.treeCurrentData).toHaveBeenCalledTimes(1) expect(rendered.events.treeCurrentData).toHaveBeenCalledWith(dataTree[0].children[0], false) expect(treeApis[0].setCurrentKey).toHaveBeenCalledWith('child-1') expect(getRoot().textContent).toContain('dataset') }) it('显示筛选输入框后会在防抖结束时调用 tree.filter', async () => { vi.useFakeTimers() await renderInfoTree({ dataTree: [{ id: 'node-1', name: 'Alpha', children: [] }], isShowFilterText: () => true }) await inputValue('.el-input', 'alp') vi.advanceTimersByTime(200) await nextTick() expect(treeApis[0].filter).toHaveBeenCalledTimes(1) expect(treeApis[0].filter).toHaveBeenCalledWith('alp') }) it('远程搜索会透传 remoteSelect 与 remoteSelectChange 事件', async () => { const rendered = await renderInfoTree({ dataTree: [{ id: 'node-1', name: 'Alpha', children: [] }], isRemote: true, filterType: [{ id: 'name', label: '名称', value: 'name' }] }) await click('.stub-select-remote') await click('.stub-select-change') expect(rendered.events.remoteSelect).toHaveBeenCalledTimes(1) expect(rendered.events.remoteSelect).toHaveBeenCalledWith('keyword', true) expect(rendered.events.remoteSelectChange).toHaveBeenCalledTimes(1) expect(rendered.events.remoteSelectChange).toHaveBeenCalledWith('selected-value') }) it('开启编辑时双击节点名称可编辑并在失焦时触发 editBlur', async () => { const dataTree = [{ id: 'node-1', name: 'Old Name', children: [] }] const rendered = await renderInfoTree({ dataTree, config: { draggable: false, expandAll: false, isEdit: true } }) const name = getRoot().querySelector('.name') as HTMLElement | null expect(name).not.toBeNull() name!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) await nextTick() await inputValue('.input-tree', 'New Name') const editInput = getRoot().querySelector('.input-tree') as HTMLInputElement | null expect(editInput).not.toBeNull() editInput!.dispatchEvent(new Event('blur', { bubbles: true })) await nextTick() expect(rendered.events.editBlur).toHaveBeenCalledTimes(1) expect(rendered.events.editBlur).toHaveBeenCalledWith( expect.objectContaining({ id: 'node-1', name: 'New Name' }), 'Old Name' ) expect(dataTree[0].name).toBe('New Name') }) it('拖拽开始与结束时会透传事件并按缓存恢复默认节点', async () => { storageState.nodeData = { [window.location.pathname]: { nodeId: 'child-1', nodePId: 'root-1' } } const dataTree = [{ id: 'root-1', name: 'Root', children: [{ id: 'child-1', name: 'Child', parentId: 'root-1', children: [] }] }] const rendered = await renderInfoTree({ dataTree, initCurrent: false }) const sourceNode = createTreeNode(dataTree[0].children[0], { level: 2, parent: createTreeNode(dataTree[0], { level: 1 }) }) const targetNode = createTreeNode(dataTree[0], { level: 1 }) treeApis[0].setCurrentKey.mockClear() rendered.events.dropStart.mockClear() rendered.events.dropEnd.mockClear() treeApis[0].emitNodeDragStart(sourceNode, targetNode, 'before') await flushTicks(1) expect(rendered.events.dropStart).toHaveBeenCalledWith({ sourceNode, targetNode, position: 'before' }) expect(treeApis[0].setCurrentKey).toHaveBeenCalledWith('child-1') treeApis[0].setCurrentKey.mockClear() treeApis[0].emitNodeDrop(sourceNode, targetNode, 'after') await flushTicks() expect(rendered.events.dropEnd).toHaveBeenCalledWith({ sourceNode, targetNode, position: 'after' }) expect(treeApis[0].setCurrentKey).toHaveBeenCalledWith('child-1') }) it('懒加载时根节点优先直接返回 dataTree,子节点走 loadChildrenNodes', async () => { const loadChildrenNodes = vi.fn((node, resolve, nodeSources) => { resolve([{ id: 'lazy-child', name: 'Lazy Child', leaf: true }]) expect(nodeSources).toEqual([{ id: 'parent-1', name: 'Parent', children: undefined }]) }) const dataTree = [{ id: 'parent-1', name: 'Parent', children: undefined }] await renderInfoTree({ dataTree, loadChildrenNodes, initCurrent: false }) const rootResolved = await treeApis[0].invokeLoad(undefined) expect(rootResolved).toEqual(dataTree) const childNode = createTreeNode(dataTree[0], { level: 1, parent: null }) const childResolved = await treeApis[0].invokeLoad(childNode) expect(loadChildrenNodes).toHaveBeenCalledTimes(1) expect(childResolved).toEqual([{ id: 'lazy-child', name: 'Lazy Child', leaf: true }]) }) it('已有 children 且未强制刷新时,懒加载直接返回现有子节点', async () => { const loadChildrenNodes = vi.fn() const dataTree = [{ id: 'parent-1', name: 'Parent', children: [{ id: 'child-1', name: 'Existing Child' }] }] await renderInfoTree({ dataTree, loadChildrenNodes, defaultProps: { children: 'children', label: 'name', nodeKey: 'id', isForceRefresh: false }, initCurrent: false }) const node = createTreeNode(dataTree[0], { level: 1, parent: null }) const resolved = await treeApis[0].invokeLoad(node) expect(resolved).toEqual(dataTree[0].children) expect(loadChildrenNodes).not.toHaveBeenCalled() }) it('点击 load more 节点时会触发分页请求并追加新的懒加载结果', async () => { vi.useFakeTimers() serverApiMock.mockImplementation(({ success }) => { success({ list: [{ id: 'child-2', name: 'Child 2' }], totalPage: 3 }) }) const parentNode = createTreeNode({ id: 'parent-1', name: 'Parent' }, { level: 1, loadList: [{ id: 'child-1', name: 'Child 1' }, { id: 'load' }], params: { pageNum: 1 }, reqType: 'offset', resolve: vi.fn() }) const loadNode = createTreeNode({ id: 'load', name: '加载更多' }, { level: 2, parent: parentNode }) const rendered = await renderInfoTree({ dataTree: [{ id: 'parent-1', name: 'Parent', children: [] }], initCurrent: false }) expect(rendered.vm).toBeTruthy() const paging = new PagingModule({ loadCurrentApi: { api: 'queryChildrenApi' } }, 'id') paging.clickMore({ id: 'load' }, loadNode) expect(serverApiMock).toHaveBeenCalledTimes(1) expect(serverApiMock).toHaveBeenCalledWith(expect.objectContaining({ interface: 'queryChildrenApi', params: { pageNum: 2 } })) expect(requestPayloadMock).toHaveBeenCalledWith({ pageNum: 2 }) expect(parentNode.loadList).toEqual([ { id: 'child-1', name: 'Child 1', leaf: true }, { id: 'child-2', name: 'Child 2', leaf: true }, { id: 'load', leaf: true } ]) expect(parentNode.resolve).toHaveBeenCalledWith(parentNode.loadList) }) it('Paging.handleBack 在数组结果和非叶子节点下保持边界行为稳定', () => { const paging = new PagingModule({ loadCurrentApi: { api: null } }, 'id') const arrayNode = createTreeNode({ id: 'array-parent' }, { resolve: vi.fn() }) paging.handleBack(arrayNode, [{ id: 'child-1' }], true) expect(arrayNode.resolve).toHaveBeenCalledWith([{ id: 'child-1' }]) const objectNode = createTreeNode({ id: 'object-parent' }, { params: { offset: 0, limit: 1 }, loadList: [], resolve: vi.fn() }) paging.handleBack(objectNode, { data: [{ id: 'child-2' }], totalElements: 1 }, false) expect(objectNode.loadList).toEqual([{ id: 'child-2' }]) expect(objectNode.resolve).toHaveBeenCalledWith([{ id: 'child-2' }]) }) it('点击节点后会触发 treeCurrentData 并在 nodeCheck 模式下记录当前节点', async () => { vi.useFakeTimers() const dataTree = [{ id: 'root-1', name: 'Root', children: [{ id: 'child-1', name: 'Child', parentId: 'root-1', children: [] }] }] const rendered = await renderInfoTree({ dataTree, nodeCheck: true, initCurrent: false }) rendered.events.treeCurrentData.mockClear() await click('.tree-node-trigger[data-id="child-1"]') vi.advanceTimersByTime(300) await nextTick() expect(rendered.events.treeCurrentData).toHaveBeenCalledTimes(1) expect(rendered.events.treeCurrentData).toHaveBeenCalledWith( dataTree[0].children[0], true, [dataTree[0].children[0], dataTree[0]], expect.objectContaining({ data: dataTree[0].children[0] }) ) expect(storageState.nodeData[window.location.pathname]).toEqual({ nodeId: 'child-1', nodePId: 'root-1' }) }) })