20260915221013
This commit is contained in:
1 parent
1f56cc3efb
commit
dce29f250e
54 files changed
+3732
-574
No files matched your search
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
addRowUpdate,
|
||||
applyTreePosition,
|
||||
buildSortedTree,
|
||||
buildTree,
|
||||
collectDescendantIds,
|
||||
computeTreeMove,
|
||||
decorateTreeNodes,
|
||||
findNodePosition,
|
||||
rebuildTreePositions,
|
||||
@@ -79,7 +81,10 @@ describe('utils/tree sortTree', () => {
|
||||
})
|
||||
|
||||
it('b_xh 相同按 b_id 排序', () => {
|
||||
const tree = [{ b_xh: 1, b_id: 'b' }, { b_xh: 1, b_id: 'a' }]
|
||||
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'])
|
||||
})
|
||||
@@ -119,7 +124,6 @@ describe('utils/tree 可编辑树:建树与节点装饰', () => {
|
||||
})
|
||||
|
||||
it('decorateTreeNodes 预载类型图标,并强制指定类型非叶子', () => {
|
||||
const Folder = () => null
|
||||
const tree = [
|
||||
{
|
||||
b_id: 'a',
|
||||
@@ -131,11 +135,11 @@ describe('utils/tree 可编辑树:建树与节点装饰', () => {
|
||||
decorateTreeNodes(tree, {
|
||||
typeField: 'b_module_type',
|
||||
nonLeafValues: ['module'],
|
||||
iconMap: { module: Folder },
|
||||
iconMap: { module: FolderIcon },
|
||||
})
|
||||
|
||||
expect(tree[0].isLeaf).toBe(false)
|
||||
expect(tree[0].icon).toBe(Folder)
|
||||
expect(tree[0].icon).toBe(FolderIcon)
|
||||
// 非 module 类型不改 isLeaf,图标表里没有的类型不写 icon
|
||||
expect(tree[0].children[0].isLeaf).toBeUndefined()
|
||||
expect(tree[0].children[0].icon).toBeUndefined()
|
||||
@@ -241,6 +245,287 @@ describe('utils/tree 可编辑树:位置派生与就地变更', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** 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 = ''
|
||||
|
||||
Reference in new issue
Block a user