/** * 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 type MoveFail = Extract /** 断言拖拽成功并收窄联合类型(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([]) }) })