This commit is contained in:
oneao committed 2026-10-14 17:25:47 +08:00
1 parent 15e0ae5b67
commit e434374db5
71 files changed
+5726 -329

No files matched your search

@@ -0,0 +1,297 @@
/**
* BizTree 业务约定测试。
*
* 移植自 fms-vue 的 tests/components/fms-tree.spec.js(删掉 i18n 相关用例),
* DOM 断言换成本项目 G3Tree 的实际结构:class 前缀 g3,行上挂 role="treeitem" + aria-selected,
* 右键菜单 Teleport 到 body(.g3-tree__context-menu)。
*/
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { h, nextTick } from 'vue'
import BizTree from '@/components/biz-tree/BizTree.vue'
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))
describe('BizTree 业务约定', () => {
it('默认字段名按 b_id / b_name / children 渲染,无需传 fieldNames', () => {
const wrapper = mount(BizTree, { props: { treeData: makeTreeData() } })
expect(wrapper.find('.g3-tree-node__content').text()).toBe('根节点')
})
it('未传 fieldNames 时 b_name 缺失回退到 b_id', () => {
const wrapper = mount(BizTree, { props: { treeData: [{ b_id: 'm1' }] } })
expect(wrapper.find('.g3-tree-node__content').text()).toBe('m1')
})
it('fieldNames 可覆盖默认字段', () => {
const wrapper = mount(BizTree, {
props: {
treeData: [{ code: 'a', label: '甲' }],
fieldNames: { key: 'code', title: 'label' },
},
})
expect(wrapper.find('.g3-tree-node__content').text()).toBe('甲')
})
})
describe('BizTree #title 插槽', () => {
it('插槽透出解析后的 title', () => {
const calls: { title?: string }[] = []
mount(BizTree, {
props: { treeData: makeTreeData() },
slots: {
title: (scope: { title?: string }) => {
calls.push(scope)
return h('span', { class: 'node-name' }, scope.title)
},
},
})
expect(calls[0].title).toBe('根节点')
})
it('插槽拿到的 node 是业务树的活对象,改动能回写原数据并触发重渲染', async () => {
const data = makeTreeData()
const calls: { node: Record<string, unknown> }[] = []
const wrapper = mount(BizTree, {
props: { treeData: data },
slots: {
title: (scope: { node: Record<string, unknown> }) => {
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('BizTree 虚拟根(showRoot)', () => {
it('默认不注入根节点', () => {
const wrapper = mount(BizTree, { props: { treeData: makeTreeData() } })
expect(wrapper.findAll('.g3-tree-node')).toHaveLength(1)
expect(wrapper.find('.g3-tree-node__content').text()).toBe('根节点')
})
it('showRoot 注入「全部分类」根,默认展开且带折叠箭头', () => {
const wrapper = mount(BizTree, { props: { treeData: makeTreeData(), showRoot: true } })
const rows = wrapper.findAll('.g3-tree-node')
expect(rows).toHaveLength(2)
expect(rows[0].text()).toContain('全部分类')
// 有子节点:根不是叶子,渲染折叠箭头
expect(rows[0].classes()).not.toContain('is-leaf')
expect(rows[0].find('.g3-tree-node__switcher').exists()).toBe(true)
// 调用方的 expandedKeys 为空,根的子节点依然可见
expect(rows[1].text()).toContain('根节点')
})
it('业务树为空时虚拟根仍显示折叠箭头(分组节点不是叶子)', () => {
const wrapper = mount(BizTree, { props: { treeData: [], showRoot: true } })
const rows = wrapper.findAll('.g3-tree-node')
expect(rows).toHaveLength(1)
expect(rows[0].classes()).not.toContain('is-leaf')
expect(rows[0].find('.g3-tree-node__switcher').exists()).toBe(true)
})
it('根节点以 rootKey 作为 key,业务节点不带根 key', () => {
const wrapper = mount(BizTree, {
props: { treeData: makeTreeData(), showRoot: true, rootKey: 'ROOT' },
slots: {
title: (scope: { node: { b_id: string } }) =>
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(BizTree, {
props: { treeData: makeTreeData(), showRoot: true, rootTitle: '所有分类' },
})
expect(wrapper.find('.g3-tree-node__content').text()).toBe('所有分类')
})
it('受控 expandedKeys 已包含根 key 时不再重复追加', () => {
const wrapper = mount(BizTree, {
props: { treeData: makeTreeData(), showRoot: true, expandedKeys: ['__biz_tree_root__'] },
})
expect(wrapper.findAll('.g3-tree-node')).toHaveLength(2)
})
it('非受控 + showRoot 时根可收起再展开,业务节点仍可自行展开', async () => {
const wrapper = mount(BizTree, { props: { treeData: makeTreeData(), showRoot: true } })
expect(wrapper.findAll('.g3-tree-node')).toHaveLength(2)
// 第一个真实展开按钮属于虚拟根:点击收起整棵树
await wrapper.find('.g3-tree-node__switcher').trigger('click')
await nextTick()
expect(wrapper.findAll('.g3-tree-node')).toHaveLength(1)
// 回传的展开键是归一化后的业务键,不含 rootKey
expect(wrapper.emitted('update:expandedKeys')?.[0][0]).toEqual([])
// 再点一次展开,业务节点回来
await wrapper.find('.g3-tree-node__switcher').trigger('click')
await nextTick()
expect(wrapper.findAll('.g3-tree-node')).toHaveLength(2)
expect(wrapper.find('.g3-tree-node__content').text()).toBe('全部分类')
// 业务节点自己的展开按钮照常工作
await wrapper.findAll('.g3-tree-node__switcher')[1].trigger('click')
await nextTick()
expect(wrapper.findAll('.g3-tree-node')).toHaveLength(3)
})
it('非受控且无虚拟根时展开键交给 G3Tree,default-expand-all 照常生效', () => {
const wrapper = mount(BizTree, { props: { treeData: makeTreeData(), defaultExpandAll: true } })
expect(wrapper.findAll('.g3-tree-node')).toHaveLength(2)
})
})
describe('BizTree 虚拟根的选中约定(showRoot)', () => {
it('selectedKeys 为空时虚拟根呈现选中态,调用方无需传 rootKey', () => {
const wrapper = mount(BizTree, {
props: { treeData: makeTreeData(), showRoot: true, selectedKeys: [] },
})
// 第一个节点是虚拟根,应带上选中类
expect(wrapper.findAll('.g3-tree-node')[0].classes()).toContain('is-selected')
})
it('未传 selectedKeys 时同样把虚拟根当作选中(默认选中「全部」)', () => {
const wrapper = mount(BizTree, { props: { treeData: makeTreeData(), showRoot: true } })
expect(wrapper.findAll('.g3-tree-node')[0].classes()).toContain('is-selected')
})
it('选中业务节点时虚拟根不再高亮', () => {
const wrapper = mount(BizTree, {
props: { treeData: makeTreeData(), showRoot: true, selectedKeys: ['1'] },
})
const rows = wrapper.findAll('.g3-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(BizTree, {
props: { treeData: makeTreeData(), showRoot: true },
attrs: { onSelect },
})
await wrapper.findAll('.g3-tree-node__content')[0].trigger('click')
expect(onSelect).toHaveBeenCalledTimes(1)
// 归一化成 [],而不是 ['__biz_tree_root__']
expect(onSelect.mock.calls[0][0]).toEqual([])
})
it('点中业务节点时 select 原样回传该节点的 key', async () => {
const onSelect = vi.fn()
const wrapper = mount(BizTree, {
props: { treeData: makeTreeData(), showRoot: true },
attrs: { onSelect },
})
await wrapper.findAll('.g3-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(BizTree, {
props: { treeData: makeTreeData(), selectedKeys: [] },
attrs: { onSelect },
})
expect(wrapper.find('.g3-tree-node').classes()).not.toContain('is-selected')
await wrapper.find('.g3-tree-node__content').trigger('click')
expect(onSelect.mock.calls[0][0]).toEqual(['1'])
})
})
describe('BizTree 加载态(loading)', () => {
it('loading=false 时不渲染遮罩', () => {
const wrapper = mount(BizTree, { props: { treeData: makeTreeData() } })
expect(wrapper.find('.biz-tree__loading').exists()).toBe(false)
})
it('loading=true 时渲染遮罩,且树仍在渲染(不是 v-else 替换)', () => {
const wrapper = mount(BizTree, {
props: { treeData: makeTreeData(), loading: true },
})
expect(wrapper.find('.biz-tree__loading').exists()).toBe(true)
// 遮罩是覆盖层,业务节点照常存在,避免加载完成后尺寸跳动
expect(wrapper.find('.g3-tree-node__content').text()).toBe('根节点')
})
it('#loading 插槽可覆盖默认的 Spin', () => {
const wrapper = mount(BizTree, {
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(BizTree, {
props: { treeData: makeTreeData(), loading: false },
slots: { loading: () => h('span', { class: 'my-loading' }, '载入中') },
})
expect(wrapper.find('.my-loading').exists()).toBe(false)
})
})
describe('BizTree 事件透传', () => {
it('select 事件原样透传给调用方', async () => {
const onSelect = vi.fn()
const wrapper = mount(BizTree, { props: { treeData: makeTreeData() }, attrs: { onSelect } })
await wrapper.find('.g3-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(BizTree, { props: { treeData: makeTreeData() } })
await wrapper.find('.g3-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(BizTree, {
props: { treeData: makeTreeData(), contextMenu: nodeContextMenu },
attrs: { onContextMenuClick },
})
await wrapper.find('.g3-tree-node__content').trigger('contextmenu')
await nextTick()
// 菜单 Teleport 到 body,外加 Transition,断言前要先等一拍
const menu = document.body.querySelector('.g3-tree__context-menu')
if (!menu) throw new Error('右键菜单未渲染到 body')
expect(menu.textContent).toContain('新增子项')
;(menu.querySelector('.g3-tree__context-menu-item') as HTMLElement).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')
})
})
@@ -0,0 +1,65 @@
/**
* BizTreePanel:面板骨架把未声明属性经 $attrs 透传给 BizTree。
*
* 这里用渲染函数而非 props 传参,是为了还原模板编译后的真实形态:
* 页面写的是 `:selected-keys`(kebab),编译出来 vnode.props 的键名就是 'selected-keys',
* 而不是 'selectedKeys'。用 props 传 camelCase 会绕过 Vue 的键名归一化,测不出问题。
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { h } from 'vue'
import BizTreePanel from '@/components/biz-tree/BizTreePanel.vue'
const TREE = [
{
b_id: 'c1',
b_name: '分类一',
children: [{ b_id: 'd1', b_name: '数据一' }],
},
{ b_id: 'd2', b_name: '数据二' },
]
const renderPanel =
(extraProps: Record<string, unknown> = {}) =>
() =>
h(BizTreePanel, { treeData: TREE, showRoot: true, ...extraProps })
/** 按标题取节点行(children 渲染在行外的兄弟容器里,行自身的 text 只含本节点标题) */
const rowOf = (wrapper: VueWrapper, label: string) =>
wrapper.findAll('[role="treeitem"]').find((row) => row.text().includes(label))
describe('BizTreePanel 选中态(selected-keys 透传)', () => {
it('kebab 写的 selected-keys 能选中业务节点,虚拟根让位', () => {
const wrapper = mount({ render: renderPanel({ 'selected-keys': ['d2'] }) })
const root = wrapper.findAll('[role="treeitem"]')[0]
expect(root.text()).toContain('全部分类')
expect(root.attributes('aria-selected')).toBe('false')
expect(rowOf(wrapper, '数据二')?.attributes('aria-selected')).toBe('true')
})
it('分类节点同样按 selected-keys 高亮', () => {
const wrapper = mount({ render: renderPanel({ 'selected-keys': ['c1'] }) })
expect(rowOf(wrapper, '分类一')?.attributes('aria-selected')).toBe('true')
expect(wrapper.findAll('[role="treeitem"]')[0].attributes('aria-selected')).toBe('false')
})
it('selected-keys 为空时由虚拟根呈现选中态', () => {
const wrapper = mount({ render: renderPanel({ 'selected-keys': [] }) })
expect(wrapper.findAll('[role="treeitem"]')[0].attributes('aria-selected')).toBe('true')
})
it('点击节点抛出 select,交由调用方回写 selected-keys', async () => {
const selected: string[] = []
const onSelect = (keys: string[]) => selected.push(...keys)
const wrapper = mount({ render: renderPanel({ 'selected-keys': [], onSelect }) })
await rowOf(wrapper, '数据二')?.find('.g3-tree-node__content').trigger('click')
expect(selected).toEqual(['d2'])
})
})
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import { computeAccordionToggle } from '@/layouts/useSidebarMenu'
import type { SidebarMenuNode } from '@/types/menu'
/**
* 侧栏「手风琴模式」展开逻辑的回归用例。
*
* 背景(真实故障):点击二级分组(系统管理 → 日志)时,不是展开「日志」,
* 而是整个「系统管理」收起。根因是 toggleGroup 用了整表替换
* `openKeys = index === -1 ? [key] : []`,把祖先 key 一起删了;
* 而子分组的容器挂在父级的 `v-show="isGroupOpen(父key)"` 上,
* 祖先一丢,子分组连带整块 DOM 都被隐藏。
*/
/** 造一个叶子节点。放模块作用域:纯构造,不必每次调用重建。 */
function leaf(key: string, title: string): SidebarMenuNode {
return { key, title, path: `/${key}` }
}
/** 复刻 s_menu 真实结构:system → log → (log_login / log_audit) */
function makeTree(): SidebarMenuNode[] {
return [
{
key: 'approval',
title: '审批中心',
path: '/approval',
children: [leaf('approval_todo', '我的待办'), leaf('approval_cc', '抄送我的')],
},
{
key: 'system',
title: '系统管理',
path: '/system',
children: [
leaf('user', '用户管理'),
{
key: 'log',
title: '日志',
path: '/system/log',
children: [leaf('log_login', '登录日志'), leaf('log_audit', '操作日志')],
},
],
},
]
}
describe('computeAccordionToggle(手风琴展开)', () => {
it('展开二级分组时保留其祖先,父级不被收起(本次故障的回归点)', () => {
const tree = makeTree()
// 「系统管理」已展开,此时点击其子分组「日志」
const next = computeAccordionToggle(tree, ['system'], 'log')
expect(next).toContain('log')
// 关键断言:祖先必须还在,否则「日志」的容器会被 v-show 隐藏
expect(next).toContain('system')
})
it('展开二级分组时只收起同级的兄弟分组', () => {
// system 下放两个同级分组:打开 log 应关掉 report,但不能关掉 system
const withSibling: SidebarMenuNode[] = [
{
key: 'system',
title: '系统管理',
path: '/system',
children: [
{ key: 'log', title: '日志', path: '/system/log', children: [] },
{ key: 'report', title: '报表', path: '/system/report', children: [] },
],
},
]
const next = computeAccordionToggle(withSibling, ['system', 'report'], 'log')
expect(next).toContain('system')
expect(next).toContain('log')
expect(next).not.toContain('report')
})
it('展开顶级分组时收起其它顶级分组', () => {
const tree = makeTree()
const next = computeAccordionToggle(tree, ['approval'], 'system')
expect(next).toContain('system')
expect(next).not.toContain('approval')
})
it('再次点击已展开的分组会收起它自身(连同其子孙),但不动祖先', () => {
const tree = makeTree()
const next = computeAccordionToggle(tree, ['system', 'log'], 'log')
expect(next).not.toContain('log')
// 收起子分组不该连带收起父级
expect(next).toContain('system')
})
it('展开顶级分组不会误删其它顶级分组下手动展开的深层项之外的祖先', () => {
const tree = makeTree()
// 系统管理 与 日志 都开着,此时点开「审批中心」
const next = computeAccordionToggle(tree, ['system', 'log'], 'approval')
expect(next).toContain('approval')
expect(next).not.toContain('system')
// system 被收起时,其子孙也应一并清掉,避免下次展开时残留
expect(next).not.toContain('log')
})
})
@@ -0,0 +1,694 @@
/**
* utils/tree 的单元测试。
*
* 移植自 fms-vue 的 tests/unit/utils.spec.js(删掉其中的 utils/theme 段——本项目无关),
* 并按 TS strict 做了类型标注;另补 fms 未覆盖的六个函数的用例
* (findTreeNode / collectAllKeys / expandAncestors / filterTreeByText / insertTreeNode / removeTreeNode)。
*/
import { describe, it, expect } from 'vitest'
import {
addRowUpdate,
applyTreePosition,
buildSortedTree,
buildTree,
collectAllKeys,
collectDescendantIds,
computeTreeMove,
decorateTreeNodes,
expandAncestors,
filterTreeByText,
findNodePosition,
findTreeNode,
insertTreeNode,
rebuildTreePositions,
removeTreeNode,
renumberSiblings,
sortTree,
} from '@/utils/tree'
import type { TreeMoveResult, TreeRow } from '@/utils/tree'
describe('utils/tree buildTree', () => {
it('空数组返回空树', () => {
expect(buildTree([])).toEqual([])
})
it('无父节点的节点成为根', () => {
const list = [
{ b_id: 1, b_parent_id: null },
{ b_id: 2, b_parent_id: null },
]
const tree = buildTree(list)
expect(tree).toHaveLength(2)
expect(tree.map((n) => n.b_id)).toEqual([1, 2])
})
it('按 parentKey 挂载子节点', () => {
const list = [
{ b_id: 1, b_parent_id: null },
{ b_id: 2, b_parent_id: 1 },
{ b_id: 3, b_parent_id: 2 },
]
const tree = buildTree(list)
expect(tree).toHaveLength(1)
expect(tree[0].b_id).toBe(1)
expect(tree[0].children).toHaveLength(1)
expect(tree[0].children?.[0].b_id).toBe(2)
expect(tree[0].children?.[0].children?.[0].b_id).toBe(3)
})
it('父节点不存在的子节点提升为根(孤儿节点)', () => {
const list = [
{ b_id: 2, b_parent_id: 999 },
{ b_id: 3, b_parent_id: null },
]
const tree = buildTree(list)
expect(tree).toHaveLength(2)
})
it('支持自定义 idKey / parentKey / childrenKey', () => {
const list = [
{ id: 'a', pid: null },
{ id: 'b', pid: 'a' },
]
const tree = buildTree(list, 'id', 'pid', 'kids')
expect((tree[0].kids as TreeRow[])[0].id).toBe('b')
})
it('副作用说明:向父节点对象注入 children 属性(当前实现行为)', () => {
const list: TreeRow[] = [
{ b_id: 1, b_parent_id: null },
{ b_id: 2, b_parent_id: 1 },
]
buildTree(list)
// 当前实现直接修改传入的节点对象(parent[childrenKey] = []),
// 调用方若复用原数组需注意此副作用;需要纯函数请用 buildSortedTree
expect(Array.isArray(list[0].children)).toBe(true)
expect(list[0].children).toHaveLength(1)
})
})
describe('utils/tree sortTree', () => {
it('按 b_xh 升序排序', () => {
const tree = [{ b_xh: 3 }, { b_xh: 1 }, { b_xh: 2 }]
sortTree(tree)
expect(tree.map((n) => n.b_xh)).toEqual([1, 2, 3])
})
it('b_xh 相同按 b_id 排序', () => {
const tree = [
{ b_xh: 1, b_id: 'b' },
{ b_xh: 1, b_id: 'a' },
]
sortTree(tree)
expect(tree.map((n) => n.b_id)).toEqual(['a', 'b'])
})
it('递归排序子节点', () => {
const tree: TreeRow[] = [
{
b_xh: 1,
children: [{ b_xh: 2 }, { b_xh: 1 }],
},
]
sortTree(tree)
expect(tree[0].children?.map((n) => n.b_xh)).toEqual([1, 2])
})
it('返回原数组引用', () => {
const tree = [{ b_xh: 2 }, { b_xh: 1 }]
expect(sortTree(tree)).toBe(tree)
})
})
/** 两层示例树:a(含 a1,a1 含 a1x)、b */
function makeQueryTree(): TreeRow[] {
return [
{
b_id: 'a',
b_name: '甲',
b_code: 'alpha',
children: [
{
b_id: 'a1',
b_name: '甲一',
b_code: 'alpha-1',
children: [{ b_id: 'a1x', b_name: '子节点', b_code: 'alpha-1-x', children: [] }],
},
],
},
{ b_id: 'b', b_name: '乙', b_code: 'beta', children: [] },
]
}
describe('utils/tree 查询与过滤', () => {
it('findTreeNode 递归查找并返回树中的活节点', () => {
const tree = makeQueryTree()
const found = findTreeNode(tree, 'a1x')
expect(found?.b_name).toBe('子节点')
// 返回的是原节点引用,业务可以继续改它
expect(found).toBe(tree[0].children?.[0].children?.[0])
expect(findTreeNode(tree, 'ghost')).toBe(null)
})
it('findTreeNode 支持自定义 idKey / childrenKey', () => {
const tree: TreeRow[] = [{ code: 'p', kids: [{ code: 'c' }] }]
expect(findTreeNode(tree, 'c', 'code', 'kids')?.code).toBe('c')
})
it('collectAllKeys 收集全部层级 key 并统一转字符串', () => {
const tree: TreeRow[] = [
{ b_id: 1, children: [{ b_id: 2, children: [] }] },
{ b_id: 3, children: [] },
]
expect(collectAllKeys(tree)).toEqual(['1', '2', '3'])
expect(collectAllKeys([])).toEqual([])
})
it('expandAncestors 返回根到目标节点的祖先路径(不含自身)', () => {
const tree = makeQueryTree()
expect(expandAncestors(tree, 'a1x')).toEqual(['a', 'a1'])
// 根节点没有祖先
expect(expandAncestors(tree, 'a')).toEqual([])
expect(expandAncestors(tree, 'ghost')).toEqual([])
})
it('filterTreeByText 命中深层节点时保留祖先链路(剪掉兄弟)', () => {
const tree = makeQueryTree()
// a1x 的 b_code 独有;b_id 不参与匹配(默认只匹配 b_name / b_code)
const filtered = filterTreeByText(tree, 'alpha-1-x')
// 只保留 a → a1 → a1x 这条链路,兄弟节点被剪掉
expect(filtered).toHaveLength(1)
expect(filtered[0].b_id).toBe('a')
expect(filtered[0].children?.[0].b_id).toBe('a1')
expect(filtered[0].children?.[0].children?.[0].b_id).toBe('a1x')
})
it('filterTreeByText 命中父节点但子树未命中时,子树被剪空(命中不豁免后代过滤)', () => {
const tree = makeQueryTree()
// '甲一' 只命中 a1 的名字;a1x 未命中被剪掉,a1 自身保留
const filtered = filterTreeByText(tree, '甲一')
expect(filtered[0].children?.[0].b_id).toBe('a1')
expect(filtered[0].children?.[0].children).toEqual([])
})
it('filterTreeByText 匹配 b_code,空关键字返回原引用', () => {
const tree = makeQueryTree()
expect(filterTreeByText(tree, 'beta')[0].b_id).toBe('b')
expect(filterTreeByText(tree, ' ')).toBe(tree)
})
it('filterTreeByText 未命中返回空数组,且结果是浅拷贝视图', () => {
const tree = makeQueryTree()
expect(filterTreeByText(tree, '不存在的关键字')).toEqual([])
const filtered = filterTreeByText(tree, 'beta')
// 克隆节点、children 指向克隆後的数组:不是原始行对象
expect(filtered[0]).not.toBe(tree[1])
})
it('insertTreeNode 顶级插入(parentIdKey 为空)', () => {
const tree: TreeRow[] = [{ b_id: 'a', b_parent_id: null }]
insertTreeNode(tree, { b_id: 'b', b_parent_id: null })
expect(tree.map((n) => n.b_id)).toEqual(['a', 'b'])
})
it('insertTreeNode 挂到指定父节点下并按 sort 排序', () => {
const tree: TreeRow[] = [
{
b_id: 'a',
b_parent_id: null,
children: [{ b_id: 'a1', b_parent_id: 'a', b_xh: 10, children: [] }],
},
]
insertTreeNode(tree, { b_id: 'a2', b_parent_id: 'a', b_xh: 5 }, { sort: sortTree })
expect(tree[0].children?.map((n) => n.b_id)).toEqual(['a2', 'a1'])
})
it('removeTreeNode 移除根级与深层节点', () => {
const tree = makeQueryTree()
removeTreeNode(tree, 'b')
expect(tree.map((n) => n.b_id)).toEqual(['a'])
removeTreeNode(tree, 'a1x')
expect(tree[0].children?.[0].children).toHaveLength(0)
})
})
describe('utils/tree 可编辑树:建树与节点装饰', () => {
it('buildSortedTree 建树并排序,且不污染原始行(重复构建不累积子节点)', () => {
const rows: TreeRow[] = [
{ b_id: 'a', b_parent_id: null, b_xh: 20 },
{ b_id: 'b', b_parent_id: null, b_xh: 10 },
{ b_id: 'a1', b_parent_id: 'a', b_xh: 10 },
]
const tree = buildSortedTree(rows)
expect(tree.map((node) => node.b_id)).toEqual(['b', 'a'])
expect(tree[1].children?.map((node) => node.b_id)).toEqual(['a1'])
// children 挂在浅拷贝上,原始行保持干净
expect(rows[0].children).toBeUndefined()
expect(buildSortedTree(rows)[1].children).toHaveLength(1)
})
it('decorateTreeNodes 预载类型图标,并强制指定类型非叶子', () => {
const tree: TreeRow[] = [
{
b_id: 'a',
b_module_type: 'module',
children: [{ b_id: 'a1', b_module_type: 'data', children: [] }],
},
]
decorateTreeNodes(tree, {
typeField: 'b_module_type',
nonLeafValues: ['module'],
iconMap: { module: FolderIcon },
})
expect(tree[0].isLeaf).toBe(false)
expect(tree[0].icon).toBe(FolderIcon)
// 非 module 类型不改 isLeaf,图标表里没有的类型不写 icon
expect(tree[0].children?.[0].isLeaf).toBeUndefined()
expect(tree[0].children?.[0].icon).toBeUndefined()
})
})
describe('utils/tree 可编辑树:位置派生与就地变更', () => {
it('applyTreePosition 根节点的 b_depth 为 0、b_path 为 /编码/', () => {
const row = { b_id: 'a' }
applyTreePosition(row, null)
expect(row).toMatchObject({ b_depth: 0, b_path: '/a/' })
})
it('applyTreePosition 子节点继承父级路径并递增层级', () => {
const row = { b_id: 'a1' }
applyTreePosition(row, { b_depth: 2, b_path: '/a/b/' })
expect(row).toMatchObject({ b_depth: 3, b_path: '/a/b/a1/' })
})
it('findNodePosition 返回所在父数组与索引', () => {
const tree: TreeRow[] = [{ b_id: 'a', children: [{ b_id: 'a1' }, { b_id: 'a2' }] }]
expect(findNodePosition(tree, 'a')).toMatchObject({ index: 0 })
const found = findNodePosition(tree, 'a2')
expect(found?.index).toBe(1)
expect(found?.list.map((node) => node.b_id)).toEqual(['a1', 'a2'])
expect(findNodePosition(tree, 'not-exist')).toBe(null)
})
it('addRowUpdate 按 b_id 合并字段,避免同节点多条 update', () => {
const updated: TreeRow[] = []
addRowUpdate(updated, { b_id: 'a', b_xh: 20 })
addRowUpdate(updated, { b_id: 'a', b_parent_id: 'p' })
addRowUpdate(updated, { b_id: 'b', b_xh: 10 })
expect(updated).toEqual([
{ b_id: 'a', b_xh: 20, b_parent_id: 'p' },
{ b_id: 'b', b_xh: 10 },
])
})
it('renumberSiblings 按 10 步长重排并只收集变化的行', () => {
const a = { b_id: 'a', b_xh: 30 }
const b = { b_id: 'b', b_xh: 10 }
const updated: TreeRow[] = []
renumberSiblings([a, b], updated)
expect([a.b_xh, b.b_xh]).toEqual([10, 20])
expect(updated).toEqual([
{ b_id: 'a', b_xh: 10 },
{ b_id: 'b', b_xh: 20 },
])
})
it('rebuildTreePositions 整树收敛 b_depth / b_path,未变化的不重复收集', () => {
const tree: TreeRow[] = [
{
b_id: 'a',
b_depth: 0,
b_path: '/a/',
children: [{ b_id: 'a1', b_depth: 9, b_path: '/stale/', children: [] }],
},
]
const updated: TreeRow[] = []
rebuildTreePositions(tree, updated)
expect(tree[0].children?.[0]).toMatchObject({ b_depth: 1, b_path: '/a/a1/' })
// 根节点本来就是 0 / '/a/',不该产生一条无意义的 update
expect(updated).toEqual([{ b_id: 'a1', b_depth: 1, b_path: '/a/a1/' }])
})
it('rebuildTreePositions 根节点路径保留前导斜杠(回归:三份实现曾漂移)', () => {
const tree: TreeRow[] = [
{
b_id: 'base',
b_depth: 0,
b_path: '/base/',
children: [
{ b_id: 'base_otherdata', b_depth: 1, b_path: '/base/base_otherdata/', children: [] },
],
},
]
const updated: TreeRow[] = []
rebuildTreePositions(tree, updated)
expect(tree[0].b_path).toBe('/base/')
expect(tree[0].children?.[0].b_path).toBe('/base/base_otherdata/')
// 与库中既有格式一致,整棵树都不该被改动
expect(updated).toEqual([])
})
it('rebuildTreePositions 修正过期路径时补全前导斜杠', () => {
const tree: TreeRow[] = [{ b_id: 'base', b_depth: 3, b_path: 'stale/', children: [] }]
const updated: TreeRow[] = []
rebuildTreePositions(tree, updated)
expect(tree[0]).toMatchObject({ b_depth: 0, b_path: '/base/' })
expect(updated).toEqual([{ b_id: 'base', b_depth: 0, b_path: '/base/' }])
})
})
/** decorateTreeNodes 用的占位图标组件 */
const FolderIcon = () => null
/** 三层扁平行:a(含 a1、a2);a1 含 a1x;b 独立 */
function makeDescendantRows(): TreeRow[] {
return [
{ b_id: 'a', b_parent_id: null },
{ b_id: 'a1', b_parent_id: 'a' },
{ b_id: 'a2', b_parent_id: 'a' },
{ b_id: 'a1x', b_parent_id: 'a1' },
{ b_id: 'b', b_parent_id: null },
]
}
const asSet = (list: string[]) => new Set(list)
/** 造一棵两级树:a(含 a1 / a2)、b */
function makeMoveTree(): TreeRow[] {
return [
{
b_id: 'a',
b_parent_id: null,
b_xh: 10,
b_depth: 0,
b_path: '/a/',
children: [
{ b_id: 'a1', b_parent_id: 'a', b_xh: 10, b_depth: 1, b_path: '/a/a1/', children: [] },
{ b_id: 'a2', b_parent_id: 'a', b_xh: 20, b_depth: 1, b_path: '/a/a2/', children: [] },
],
},
{ b_id: 'b', b_parent_id: null, b_xh: 20, b_depth: 0, b_path: '/b/', children: [] },
]
}
/** 在 updatedRows 里按 b_id 取那一行 */
function rowById(rows: TreeRow[], id: string): TreeRow | undefined {
return rows.find((row) => String(row.b_id) === id)
}
type MoveOk = Extract<TreeMoveResult, { ok: true }>
type MoveFail = Extract<TreeMoveResult, { ok: false }>
/** 断言拖拽成功并收窄联合类型(strict 下后续可直接取 updatedRows / moved) */
function mustOk(result: TreeMoveResult): MoveOk {
expect(result.ok).toBe(true)
if (!result.ok) throw new Error(`期望拖拽成功,实际被拦截:${result.reason}`)
return result
}
/** 断言拖拽被拦截并收窄联合类型 */
function mustFail(result: TreeMoveResult): MoveFail {
expect(result.ok).toBe(false)
if (result.ok) throw new Error('期望拖拽被拦截,实际成功')
return result
}
describe('utils/tree collectDescendantIds:按父级关联展开整棵子树', () => {
it('收集自身与全部层级的下级,不限深度', () => {
const ids = collectDescendantIds(makeDescendantRows(), 'a')
expect(asSet(ids)).toEqual(new Set(['a', 'a1', 'a2', 'a1x']))
expect(ids).toHaveLength(4)
})
it('叶子节点只返回自身', () => {
expect(collectDescendantIds(makeDescendantRows(), 'a1x')).toEqual(['a1x'])
expect(collectDescendantIds(makeDescendantRows(), 'b')).toEqual(['b'])
})
it('结果含自身(选中一个节点 = 选中整棵子树)', () => {
const ids = collectDescendantIds(makeDescendantRows(), 'a1')
expect(ids).toContain('a1')
expect(asSet(ids)).toEqual(new Set(['a1', 'a1x']))
})
it('ID 数值与字符串混用时不漏(统一按字符串比较)', () => {
const rows = [
{ b_id: 1, b_parent_id: null },
{ b_id: 2, b_parent_id: 1 },
{ b_id: 3, b_parent_id: '2' },
]
// 数值 1 起头,也要能顺着字符串 '2' 找到 3
expect(asSet(collectDescendantIds(rows, 1))).toEqual(new Set(['1', '2', '3']))
})
it('rootId 不在数据中时返回只含自身的集合,不抛错', () => {
// 语义安全:拼成 IN ('不存在') 自然查不到数据,调用方无需额外判空
expect(collectDescendantIds(makeDescendantRows(), 'ghost')).toEqual(['ghost'])
expect(collectDescendantIds([], 'ghost')).toEqual(['ghost'])
expect(collectDescendantIds(null, 'ghost')).toEqual(['ghost'])
})
it('b_parent_id 成环的脏数据不会死循环(visited 兜底)', () => {
const rows = [
{ b_id: 'x', b_parent_id: 'y' },
{ b_id: 'y', b_parent_id: 'x' },
]
expect(asSet(collectDescendantIds(rows, 'x'))).toEqual(new Set(['x', 'y']))
})
it('自引用的脏数据也不会死循环', () => {
const rows = [{ b_id: 'z', b_parent_id: 'z' }]
expect(collectDescendantIds(rows, 'z')).toEqual(['z'])
})
it('字段名可用 options 覆盖(非 b_id / b_parent_id 的数据)', () => {
const rows = [
{ code: 'p', parent: null },
{ code: 'c', parent: 'p' },
]
const ids = collectDescendantIds(rows, 'p', { idKey: 'code', parentKey: 'parent' })
expect(asSet(ids)).toEqual(new Set(['p', 'c']))
})
})
describe('utils/tree computeTreeMove:拖拽移动的守卫与落库行', () => {
const makeTree = makeMoveTree
it('拖到自己身上被拒绝,且不产生任何变更', () => {
const tree = makeTree()
const result = mustFail(
computeTreeMove(tree, { dragKey: 'a', dropKey: 'a', dropPosition: 'inside' }),
)
expect(result.reason).toBe('self')
expect('updatedRows' in result).toBe(false)
expect(tree[0].children?.map((n) => n.b_id)).toEqual(['a1', 'a2'])
})
it('拖拽或落点节点不存在时返回 notFound', () => {
const tree = makeTree()
expect(
mustFail(computeTreeMove(tree, { dragKey: 'ghost', dropKey: 'a', dropPosition: 'inside' }))
.reason,
).toBe('notFound')
expect(
mustFail(computeTreeMove(tree, { dragKey: 'a', dropKey: 'ghost', dropPosition: 'inside' }))
.reason,
).toBe('notFound')
})
it('把节点拖进自己的后代被判成环,回带调用方传入的提示文案', () => {
const tree = makeTree()
const result = mustFail(
computeTreeMove(tree, {
dragKey: 'a',
dropKey: 'a1',
dropPosition: 'inside',
messages: { cycle: '不能把模块移动到它自己的下级' },
}),
)
expect(result.reason).toBe('cycle')
expect(result.message).toBe('不能把模块移动到它自己的下级')
// 树未被破坏
expect(tree[0].children?.map((n) => n.b_id)).toEqual(['a1', 'a2'])
})
it('inside 落到非容器类型上被拒绝', () => {
const tree = makeTree()
const result = mustFail(
computeTreeMove(tree, {
dragKey: 'b',
dropKey: 'a1',
dropPosition: 'inside',
typeField: 'b_module_type',
nonLeafValues: ['module'],
messages: { notContainer: '只能把模块放入模块类型的节点下' },
}),
)
expect(result.reason).toBe('notContainer')
expect(result.message).toBe('只能把模块放入模块类型的节点下')
})
it('inside 落到未保存的草稿节点上被拒绝', () => {
const tree = makeTree()
const result = mustFail(
computeTreeMove(tree, {
dragKey: 'b',
dropKey: '-1',
dropPosition: 'inside',
isUnsaved: (id) => id === '-1',
messages: { unsavedTarget: '请先保存目标模块,再调整层级' },
}),
)
expect(result.reason).toBe('notFound') // -1 不在树里
// 真正放进树里再验一次
const tree2 = makeTree()
tree2[0].children?.push({ b_id: '-1', b_parent_id: 'a', b_xh: 30, children: [] })
const result2 = mustFail(
computeTreeMove(tree2, {
dragKey: 'b',
dropKey: '-1',
dropPosition: 'inside',
isUnsaved: (id) => id === '-1',
messages: { unsavedTarget: '请先保存目标模块,再调整层级' },
}),
)
expect(result2.reason).toBe('unsavedTarget')
expect(result2.message).toBe('请先保存目标模块,再调整层级')
})
it('inside:换父成功,重算 b_parent_id / b_depth / b_path,父级子集与原子级都重排 b_xh', () => {
const tree = makeTree()
const result = mustOk(
computeTreeMove(tree, { dragKey: 'b', dropKey: 'a1', dropPosition: 'inside' }),
)
expect(result.parentChanged).toBe(true)
// a1 收下 b 作为子节点
const a1 = tree[0].children?.find((n) => n.b_id === 'a1')
expect(a1?.children?.map((n) => n.b_id)).toEqual(['b'])
// a 下不再有 b
expect(tree.map((n) => n.b_id)).toEqual(['a'])
// 迁移后的位置字段随新父级收敛
expect(result.moved).toMatchObject({
b_id: 'b',
b_parent_id: 'a1',
b_depth: 2,
b_path: '/a/a1/b/',
})
// b_parent_id 变化会单独补一条 update
expect(rowById(result.updatedRows, 'b')?.b_parent_id).toBe('a1')
// b 落到 a1 下成为唯一子节点 → 位置 10;a 下只剩 a1(原 10)、a2(原 20),
// 重排后取值不变,因此不再产生 update
expect(rowById(result.updatedRows, 'b')?.b_xh).toBe(10)
expect(rowById(result.updatedRows, 'a2')).toBeUndefined()
})
it('inside:落点原本没有 children 字段时补出空数组并摘掉 isLeaf', () => {
const tree: TreeRow[] = [
{ b_id: 'a', b_parent_id: null, b_xh: 10, b_depth: 0, b_path: '/a/', children: [] },
{ b_id: 'b', b_parent_id: null, b_xh: 20, b_depth: 0, b_path: '/b/', isLeaf: true },
]
const result = mustOk(
computeTreeMove(tree, { dragKey: 'a', dropKey: 'b', dropPosition: 'inside' }),
)
expect(result.ok).toBe(true)
const b = tree.find((n) => n.b_id === 'b')
expect(b?.children?.map((n) => n.b_id)).toEqual(['a'])
expect(b?.isLeaf).toBe(false)
})
it('after:同级换序,父级不变时不再产生 b_parent_id 更新', () => {
const tree = makeTree()
const result = mustOk(
computeTreeMove(tree, { dragKey: 'a2', dropKey: 'a1', dropPosition: 'after' }),
)
expect(result.parentChanged).toBe(false)
expect(tree[0].children?.map((n) => n.b_id)).toEqual(['a1', 'a2'])
// 位置本就是 10 / 20,重排后无变化 → 该节点不产生 update
expect(rowById(result.updatedRows, 'a2')).toBeUndefined()
expect(result.updatedRows).toEqual([])
})
it('before:跨父拖到目标之前,落到目标同级并接管目标父级', () => {
const tree = makeTree()
const result = mustOk(
computeTreeMove(tree, { dragKey: 'b', dropKey: 'a2', dropPosition: 'before' }),
)
expect(result.parentChanged).toBe(true)
expect(tree.map((n) => n.b_id)).toEqual(['a'])
expect(tree[0].children?.map((n) => n.b_id)).toEqual(['a1', 'b', 'a2'])
expect(result.moved).toMatchObject({ b_parent_id: 'a', b_depth: 1, b_path: '/a/b/' })
expect(rowById(result.updatedRows, 'b')?.b_parent_id).toBe('a')
})
it('同级 after 且拖到自身之后:索引修正后位置不变', () => {
const tree = makeTree()
// a1 拖到 a2 之后 => a1 落到末尾
const result = mustOk(
computeTreeMove(tree, { dragKey: 'a1', dropKey: 'a2', dropPosition: 'after' }),
)
expect(result.ok).toBe(true)
expect(tree[0].children?.map((n) => n.b_id)).toEqual(['a2', 'a1'])
})
it('同级 before 且拖到自身之前:索引修正后位置不变', () => {
const tree = makeTree()
const result = mustOk(
computeTreeMove(tree, { dragKey: 'a2', dropKey: 'a1', dropPosition: 'before' }),
)
expect(tree[0].children?.map((n) => n.b_id)).toEqual(['a2', 'a1'])
expect(rowById(result.updatedRows, 'a2')?.b_xh).toBe(10)
})
it('无变化场景给出空 updatedRows 而不是报错', () => {
const tree = makeTree()
// 把 a1 拖到 a2 之前,本来就是该位置
const result = mustOk(
computeTreeMove(tree, { dragKey: 'a1', dropKey: 'a2', dropPosition: 'before' }),
)
expect(result.updatedRows).toEqual([])
})
})