import { describe, it, expect, beforeEach } from 'vitest' import { addRowUpdate, applyTreePosition, buildSortedTree, buildTree, collectDescendantIds, computeTreeMove, decorateTreeNodes, findNodePosition, rebuildTreePositions, renumberSiblings, sortTree, } 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[0].id).toBe('b') }) it('副作用说明:向父节点对象注入 children 属性(当前实现行为)', () => { const list = [ { b_id: 1, b_parent_id: null }, { b_id: 2, b_parent_id: 1 }, ] buildTree(list) // 当前实现直接修改传入的节点对象(parent[childrenKey] = []), // 调用方若复用原数组需注意此副作用;若要求纯函数需后续改造 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 = [ { 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) }) }) describe('utils/tree 可编辑树:建树与节点装饰', () => { it('buildSortedTree 建树并排序,且不污染原始行(重复构建不累积子节点)', () => { const rows = [ { 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 = [ { 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 = [{ 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 = [] 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 = [] 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 = [ { b_id: 'a', b_depth: 0, b_path: '/a/', children: [{ b_id: 'a1', b_depth: 9, b_path: '/stale/', children: [] }], }, ] const updated = [] 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 = [ { 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 = [] 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 = [{ b_id: 'base', b_depth: 3, b_path: 'stale/', children: [] }] const updated = [] 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() { 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) => new Set(list) /** 造一棵两级树:a(含 a1 / a2)、b */ function makeMoveTree() { 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, id) { return rows.find((row) => row.b_id === id) } 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 = computeTreeMove(tree, { dragKey: 'a', dropKey: 'a', dropPosition: 'inside' }) expect(result.ok).toBe(false) expect(result.reason).toBe('self') expect(result.updatedRows).toBeUndefined() expect(tree[0].children.map((n) => n.b_id)).toEqual(['a1', 'a2']) }) it('拖拽或落点节点不存在时返回 notFound', () => { const tree = makeTree() expect( computeTreeMove(tree, { dragKey: 'ghost', dropKey: 'a', dropPosition: 'inside' }).reason, ).toBe('notFound') expect( computeTreeMove(tree, { dragKey: 'a', dropKey: 'ghost', dropPosition: 'inside' }).reason, ).toBe('notFound') }) it('把节点拖进自己的后代被判成环,回带调用方传入的提示文案', () => { const tree = makeTree() const result = computeTreeMove(tree, { dragKey: 'a', dropKey: 'a1', dropPosition: 'inside', messages: { cycle: '不能把模块移动到它自己的下级' }, }) expect(result.ok).toBe(false) 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 = 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 = computeTreeMove(tree, { dragKey: 'b', dropKey: '-1', dropPosition: 'inside', isUnsaved: (id) => id === '-1', messages: { unsavedTarget: '请先保存目标模块,再调整层级' }, }) expect(result.reason).toBe('notFound') // -1 不在树里 expect(result.ok).toBe(false) // 真正放进树里再验一次 const tree2 = makeTree() tree2[0].children.push({ b_id: '-1', b_parent_id: 'a', b_xh: 30, children: [] }) const result2 = 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 = computeTreeMove(tree, { dragKey: 'b', dropKey: 'a1', dropPosition: 'inside' }) expect(result.ok).toBe(true) 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 = [ { 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 = 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 = computeTreeMove(tree, { dragKey: 'a2', dropKey: 'a1', dropPosition: 'after' }) expect(result.ok).toBe(true) 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 = computeTreeMove(tree, { dragKey: 'b', dropKey: 'a2', dropPosition: 'before' }) expect(result.ok).toBe(true) 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 = 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 = computeTreeMove(tree, { dragKey: 'a2', dropKey: 'a1', dropPosition: 'before' }) expect(result.ok).toBe(true) 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 = computeTreeMove(tree, { dragKey: 'a1', dropKey: 'a2', dropPosition: 'before' }) expect(result.ok).toBe(true) expect(result.updatedRows).toEqual([]) }) }) describe('utils/theme', () => { beforeEach(() => { document.documentElement.style.cssText = '' document.documentElement.classList.remove('dark') }) it('applyPrimaryColor 写入 --fms-primary CSS 变量', async () => { const { applyPrimaryColor } = await import('@/theme/index') applyPrimaryColor('#ff0000') expect(document.documentElement.style.getPropertyValue('--fms-primary')).toBe('#ff0000') }) it('applyPrimaryColor 空值不写入', async () => { const { applyPrimaryColor } = await import('@/theme/index') applyPrimaryColor('') expect(document.documentElement.style.getPropertyValue('--fms-primary')).toBe('') }) it('applyDarkMode true 添加 dark class,false 移除', async () => { const { applyDarkMode } = await import('@/theme/index') applyDarkMode(true) expect(document.documentElement.classList.contains('dark')).toBe(true) applyDarkMode(false) expect(document.documentElement.classList.contains('dark')).toBe(false) }) })