import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { mount } from '@vue/test-utils' import { h, nextTick } from 'vue' import FmsTree from '@/components/fms-tree/FmsTree.vue' import { setMessages, clearMessages } from '@/stores/i18n' const makeTreeData = () => [ { b_id: '1', b_name: '根节点', b_canuse: 1, children: [{ b_id: '2', b_name: '子节点' }], }, ] const nodeContextMenu = () => [{ key: 'add', label: '新增子项' }] const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) beforeEach(() => { clearMessages() }) afterEach(() => { clearMessages() }) describe('FmsTree 业务约定', () => { it('默认字段名按 b_id / b_name / children 渲染,无需传 fieldNames', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData() } }) expect(wrapper.find('.tree-node-content').text()).toBe('根节点') }) it('未传 fieldNames 时 b_name 缺失回退到 b_id', () => { const wrapper = mount(FmsTree, { props: { treeData: [{ b_id: 'm1' }] } }) expect(wrapper.find('.tree-node-content').text()).toBe('m1') }) it('fieldNames 可覆盖默认字段', () => { const wrapper = mount(FmsTree, { props: { treeData: [{ code: 'a', label: '甲' }], fieldNames: { key: 'code', title: 'label' }, }, }) expect(wrapper.find('.tree-node-content').text()).toBe('甲') }) it('b_i18n 有译文时标题取译文', () => { setMessages({ 'module.customer': '客户管理' }) const wrapper = mount(FmsTree, { props: { treeData: [{ b_id: 'm1', b_name: 'Customer', b_i18n: 'module.customer' }] }, }) expect(wrapper.find('.tree-node-content').text()).toBe('客户管理') }) it('translateTitle=false 时标题不做翻译', () => { setMessages({ 'module.customer': '客户管理' }) const wrapper = mount(FmsTree, { props: { treeData: [{ b_id: 'm1', b_name: 'Customer', b_i18n: 'module.customer' }], translateTitle: false, }, }) expect(wrapper.find('.tree-node-content').text()).toBe('Customer') }) }) describe('FmsTree #title 插槽', () => { it('插槽透出翻译后的 title', () => { const calls = [] mount(FmsTree, { props: { treeData: makeTreeData() }, slots: { title: (scope) => { calls.push(scope) return h('span', { class: 'node-name' }, scope.title) }, }, }) expect(calls[0].title).toBe('根节点') }) it('插槽拿到的 node 是业务树的活对象,改动能回写原数据并触发重渲染', async () => { const data = makeTreeData() const calls = [] const wrapper = mount(FmsTree, { props: { treeData: data }, slots: { title: (scope) => { calls.push(scope) return h('span', { class: 'node-used' }, String(scope.node.b_canuse)) }, }, }) expect(wrapper.find('.node-used').text()).toBe('1') const node = calls[0].node node.b_canuse = 0 node.b_name = '改名' await nextTick() expect(data[0].b_canuse).toBe(0) expect(data[0].b_name).toBe('改名') expect(wrapper.find('.node-used').text()).toBe('0') }) }) describe('FmsTree 虚拟根(showRoot)', () => { it('默认不注入根节点', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData() } }) expect(wrapper.findAll('.tree-node')).toHaveLength(1) expect(wrapper.find('.tree-node-content').text()).toBe('根节点') }) it('showRoot 注入「全部分类」根,默认展开且带折叠箭头', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true } }) const rows = wrapper.findAll('.tree-node') expect(rows).toHaveLength(2) expect(rows[0].text()).toContain('全部分类') // 有子节点:根不是叶子,渲染折叠箭头 expect(rows[0].classes()).not.toContain('is-leaf') expect(rows[0].find('.tree-node-switcher').exists()).toBe(true) // 调用方的 expandedKeys 为空,根的子节点依然可见 expect(rows[1].text()).toContain('根节点') }) it('业务树为空时虚拟根仍显示折叠箭头(分组节点不是叶子)', () => { const wrapper = mount(FmsTree, { props: { treeData: [], showRoot: true } }) const rows = wrapper.findAll('.tree-node') expect(rows).toHaveLength(1) expect(rows[0].classes()).not.toContain('is-leaf') expect(rows[0].find('.tree-node-switcher').exists()).toBe(true) }) it('根节点以 rootKey 作为 key,业务节点不带根 key', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true, rootKey: 'ROOT' }, slots: { title: (scope) => h('span', { class: 'node-id' }, String(scope.node.b_id)) }, }) const ids = wrapper.findAll('.node-id').map((el) => el.text()) expect(ids).toEqual(['ROOT', '1']) }) it('rootTitle 可自定义根标题', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true, rootTitle: '所有分类' }, }) expect(wrapper.find('.tree-node-content').text()).toBe('所有分类') }) it('受控 expandedKeys 已包含根 key 时不再重复追加', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true, expandedKeys: ['__fms_tree_root__'] }, }) expect(wrapper.findAll('.tree-node')).toHaveLength(2) }) it('非受控 + showRoot 时根可收起再展开,业务节点仍可自行展开', async () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true } }) expect(wrapper.findAll('.tree-node')).toHaveLength(2) // 第一个真实展开按钮属于虚拟根:点击收起整棵树 await wrapper.find('.tree-node-switcher').trigger('click') await nextTick() expect(wrapper.findAll('.tree-node')).toHaveLength(1) expect(wrapper.emitted('update:expandedKeys')[0][0]).not.toContain('__fms_tree_root__') // 再点一次展开,业务节点回来 await wrapper.find('.tree-node-switcher').trigger('click') await nextTick() expect(wrapper.findAll('.tree-node')).toHaveLength(2) expect(wrapper.find('.tree-node-content').text()).toBe('全部分类') // 业务节点自己的展开按钮照常工作 await wrapper.findAll('.tree-node-switcher')[1].trigger('click') await nextTick() expect(wrapper.findAll('.tree-node')).toHaveLength(3) }) it('非受控且无虚拟根时展开键交给 ui/Tree,default-expand-all 照常生效', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), defaultExpandAll: true } }) expect(wrapper.findAll('.tree-node')).toHaveLength(2) }) }) describe('FmsTree 虚拟根的选中约定(showRoot)', () => { it('selectedKeys 为空时虚拟根呈现选中态,调用方无需传 rootKey', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true, selectedKeys: [] }, }) // 第一个节点是虚拟根,应带上选中类 expect(wrapper.findAll('.tree-node')[0].classes()).toContain('is-selected') }) it('未传 selectedKeys 时同样把虚拟根当作选中(默认选中「全部」)', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true } }) expect(wrapper.findAll('.tree-node')[0].classes()).toContain('is-selected') }) it('选中业务节点时虚拟根不再高亮', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true, selectedKeys: ['1'] }, }) const rows = wrapper.findAll('.tree-node') expect(rows[0].classes()).not.toContain('is-selected') expect(rows[1].classes()).toContain('is-selected') }) it('点中虚拟根时 select 回传空数组,调用方看不到 rootKey', async () => { const onSelect = vi.fn() const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true }, attrs: { onSelect }, }) await wrapper.findAll('.tree-node-content')[0].trigger('click') expect(onSelect).toHaveBeenCalledTimes(1) // 归一化成 [],而不是 ['__fms_tree_root__'] expect(onSelect.mock.calls[0][0]).toEqual([]) }) it('点中业务节点时 select 原样回传该节点的 key', async () => { const onSelect = vi.fn() const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true }, attrs: { onSelect }, }) await wrapper.findAll('.tree-node-content')[1].trigger('click') expect(onSelect.mock.calls[0][0]).toEqual(['1']) }) it('无虚拟根时不改写 selectedKeys,也不改写 select 回传值', async () => { const onSelect = vi.fn() const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), selectedKeys: [] }, attrs: { onSelect }, }) expect(wrapper.find('.tree-node').classes()).not.toContain('is-selected') await wrapper.find('.tree-node-content').trigger('click') expect(onSelect.mock.calls[0][0]).toEqual(['1']) }) }) describe('FmsTree 加载态(loading)', () => { it('loading=false 时不渲染遮罩', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData() } }) expect(wrapper.find('.fms-tree__loading').exists()).toBe(false) }) it('loading=true 时渲染遮罩,且树仍在渲染(不是 v-else 替换)', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), loading: true }, }) expect(wrapper.find('.fms-tree__loading').exists()).toBe(true) // 遮罩是覆盖层,业务节点照常存在,避免加载完成后尺寸跳动 expect(wrapper.find('.tree-node-content').text()).toBe('根节点') }) it('#loading 插槽可覆盖默认的 Spin', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), loading: true }, slots: { loading: () => h('span', { class: 'my-loading' }, '载入中') }, }) expect(wrapper.find('.my-loading').text()).toBe('载入中') }) it('loading=false 时 #loading 插槽不渲染', () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), loading: false }, slots: { loading: () => h('span', { class: 'my-loading' }, '载入中') }, }) expect(wrapper.find('.my-loading').exists()).toBe(false) }) }) describe('FmsTree 事件透传', () => { it('select 事件原样透传给调用方', async () => { const onSelect = vi.fn() const wrapper = mount(FmsTree, { props: { treeData: makeTreeData() }, attrs: { onSelect } }) await wrapper.find('.tree-node-content').trigger('click') expect(onSelect).toHaveBeenCalledTimes(1) expect(onSelect.mock.calls[0][0]).toEqual(['1']) expect(onSelect.mock.calls[0][1].node.b_id).toBe('1') }) it('展开折叠抛出 update:expandedKeys', async () => { const wrapper = mount(FmsTree, { props: { treeData: makeTreeData() } }) await wrapper.find('.tree-node-switcher').trigger('click') expect(wrapper.emitted('update:expandedKeys')).toHaveLength(1) expect(wrapper.emitted('update:expandedKeys')[0][0]).toEqual(['1']) }) it('右键菜单点击事件透传给调用方', async () => { const onContextMenuClick = vi.fn() const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), contextMenu: nodeContextMenu }, attrs: { onContextMenuClick }, }) await wrapper.find('.tree-node-content').trigger('contextmenu') const menu = document.body.querySelector('.tree-context-menu') expect(menu.textContent).toContain('新增子项') menu.querySelector('.tree-context-menu__item').click() await flush() expect(onContextMenuClick).toHaveBeenCalledTimes(1) expect(onContextMenuClick.mock.calls[0][0].item.key).toBe('add') expect(onContextMenuClick.mock.calls[0][0].node.b_id).toBe('1') }) })