405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
/**
|
||
* useBizTree:树取数与草稿保护编排。
|
||
*
|
||
* 重点覆盖两块最容易回归、也最难靠肉眼验证的行为:
|
||
* 1. 重载时保留未落库草稿(含父节点已消失时降级为顶级 + 重算 b_depth / b_path);
|
||
* 2. 默认建树 / 装饰口径,以及每个可覆盖的口子(load / transform / condition / onError)。
|
||
*/
|
||
|
||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||
import { toRaw } from 'vue'
|
||
import { useBizTree } from '@/composables/useBizTree'
|
||
import type { TreeRow } from '@/utils/tree'
|
||
|
||
/** 把 loadDataApi 换掉:本文件只测编排,不打真接口 */
|
||
const loadDataApi = vi.fn()
|
||
vi.mock('@/services/api', () => ({
|
||
loadDataApi: (...args: unknown[]) => loadDataApi(...args),
|
||
}))
|
||
|
||
/**
|
||
* 扁平行:模块树形态(b_parent_id 是权威关系)。
|
||
* b_depth / b_path 是库里的派生列,真实行都带着 —— mock 也要带,
|
||
* 否则位置派生(applyTreePosition)会按「父级 depth 缺失 = 0」算出浅一层的错值。
|
||
*/
|
||
const ROWS = [
|
||
{ b_id: 'm1', b_parent_id: null, b_name: '模块一', b_module_type: 'module', b_xh: 20, b_depth: 0, b_path: '/m1/' },
|
||
{ b_id: 'm2', b_parent_id: null, b_name: '模块二', b_module_type: 'module', b_xh: 10, b_depth: 0, b_path: '/m2/' },
|
||
{ b_id: 'd1', b_parent_id: 'm1', b_name: '数据一', b_module_type: 'data', b_xh: 10, b_depth: 1, b_path: '/m1/d1/' },
|
||
{ b_id: 'd2', b_parent_id: 'd1', b_name: '数据二', b_module_type: 'data', b_xh: 10, b_depth: 2, b_path: '/m1/d1/d2/' },
|
||
]
|
||
|
||
/**
|
||
* 按 condition 过滤的假后端。
|
||
*
|
||
* ⚠️ 必须真的按条件筛,不能一律返回全表:懒加载的核心行为就是「按父级分次取」,
|
||
* mock 若不筛,根请求也会带回子孙,懒加载路径等于没被验证(一开始就是这么写的)。
|
||
* 只认本文件用到的两种条件:父级为空(根)、`b_parent_id = 'X'`(某节点的子节点)。
|
||
*/
|
||
function fakeBackend(condition: string): Array<Record<string, unknown>> {
|
||
const parentEq = /b_parent_id\s*=\s*'([^']*)'/.exec(condition)
|
||
if (parentEq) {
|
||
return ROWS.filter((row) => String(row.b_parent_id ?? '') === parentEq[1]).map((r) => ({ ...r }))
|
||
}
|
||
if (/b_parent_id\s+IS\s+NULL/i.test(condition)) {
|
||
return ROWS.filter((row) => row.b_parent_id == null).map((row) => ({ ...row }))
|
||
}
|
||
// 全量(懒加载关掉时)
|
||
return ROWS.map((row) => ({ ...row }))
|
||
}
|
||
|
||
beforeEach(() => {
|
||
loadDataApi.mockReset()
|
||
loadDataApi.mockImplementation((_table: string, condition: string) =>
|
||
Promise.resolve(fakeBackend(condition ?? '')),
|
||
)
|
||
})
|
||
|
||
describe('默认建树与装饰', () => {
|
||
it('按 b_xh 排序建树,子节点挂到父级下', async () => {
|
||
const tree = useBizTree({ table: 's_module', lazy: false })
|
||
await tree.reload()
|
||
|
||
// b_xh 10 的 m2 排在 20 的 m1 前面
|
||
expect(tree.treeData.value.map((node) => node.b_id)).toEqual(['m2', 'm1'])
|
||
const m1 = tree.findNode('m1') as TreeRow
|
||
expect((m1.children as TreeRow[]).map((child) => child.b_id)).toEqual(['d1'])
|
||
})
|
||
|
||
it('全量模式:默认取数走 loadDataApi(table, condition, orderBy)', async () => {
|
||
const tree = useBizTree({
|
||
table: 's_module',
|
||
condition: 'b_canuse = 1',
|
||
orderBy: 'b_id ASC',
|
||
lazy: false,
|
||
})
|
||
await tree.reload()
|
||
|
||
expect(loadDataApi).toHaveBeenCalledWith('s_module', 'b_canuse = 1', 'b_id ASC')
|
||
})
|
||
|
||
it('传 typeField 时按 nonLeafValues / iconMap 装饰', async () => {
|
||
const Icon = { name: 'StubIcon' }
|
||
const tree = useBizTree({
|
||
table: 's_module',
|
||
typeField: 'b_module_type',
|
||
nonLeafValues: ['module'],
|
||
iconMap: { module: Icon as never },
|
||
lazy: false,
|
||
})
|
||
await tree.reload()
|
||
|
||
const m1 = tree.findNode('m1') as TreeRow
|
||
// module 可承载子节点 → 强制非叶子(暂无子节点也显示箭头)
|
||
expect(m1.isLeaf).toBe(false)
|
||
// treeData 是 ref,读出来的节点是深度代理,icon 组件被包了一层 Proxy,
|
||
// 所以比对象身份要用 toBe 之前先取原始对象(页面侧同样按引用消费,行为一致)
|
||
expect(toRaw(m1).icon).toBe(Icon)
|
||
// data 不在 nonLeafValues 里,不强制
|
||
expect((tree.findNode('d1') as TreeRow).isLeaf).toBeUndefined()
|
||
})
|
||
|
||
it('不传 typeField 时不做装饰(isLeaf / icon 都不动)', async () => {
|
||
const tree = useBizTree({ table: 's_module' })
|
||
await tree.reload()
|
||
|
||
const m1 = tree.findNode('m1') as TreeRow
|
||
expect(m1.isLeaf).toBeUndefined()
|
||
expect(m1.icon).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('可覆盖的口子', () => {
|
||
it('load 接管后不再调 loadDataApi', async () => {
|
||
const tree = useBizTree({
|
||
table: 's_module',
|
||
load: async () => [{ b_id: 'x', b_parent_id: null, b_name: '自定义', b_xh: 1 }],
|
||
})
|
||
await tree.reload()
|
||
|
||
expect(loadDataApi).not.toHaveBeenCalled()
|
||
expect(tree.treeData.value.map((node) => node.b_id)).toEqual(['x'])
|
||
})
|
||
|
||
it('全量模式:load 能拿到 table / condition / orderBy 上下文', async () => {
|
||
const spy = vi.fn().mockResolvedValue([])
|
||
const tree = useBizTree({ table: 's_menu', condition: 'b_canuse = 1', load: spy, lazy: false })
|
||
await tree.reload()
|
||
|
||
expect(spy).toHaveBeenCalledWith({
|
||
table: 's_menu',
|
||
condition: 'b_canuse = 1',
|
||
orderBy: 'b_xh ASC, b_id ASC',
|
||
})
|
||
})
|
||
|
||
it('懒加载模式:首屏只查根节点,condition 不参与(按父级为空的根条件)', async () => {
|
||
const tree = useBizTree({ table: 's_module', condition: 'b_canuse = 1' })
|
||
await tree.reload()
|
||
|
||
expect(loadDataApi).toHaveBeenCalledTimes(1)
|
||
const [, condition] = loadDataApi.mock.calls[0] as [string, string]
|
||
expect(condition).toContain('b_parent_id IS NULL')
|
||
expect(condition).not.toBe('b_canuse = 1')
|
||
})
|
||
|
||
it('transform 接管建树(接口已返回嵌套结构时原样使用)', async () => {
|
||
const nested = [{ b_id: 'root', b_name: '根', b_parent_id: null, children: [] }]
|
||
const tree = useBizTree({ table: 't', transform: () => nested as never })
|
||
await tree.reload()
|
||
|
||
expect(tree.treeData.value).toEqual(nested)
|
||
})
|
||
|
||
it('transform 能读到装饰配置,便于自行复用', async () => {
|
||
const spy = vi.fn().mockReturnValue([])
|
||
const tree = useBizTree({ typeField: 'b_type', nonLeafValues: ['dir'], transform: spy })
|
||
await tree.reload()
|
||
|
||
expect(spy).toHaveBeenCalledWith(
|
||
expect.any(Array),
|
||
expect.objectContaining({ typeField: 'b_type', nonLeafValues: ['dir'] }),
|
||
)
|
||
})
|
||
|
||
it('onError 接管后 loading 仍会复位,且不抛给调用方', async () => {
|
||
const onError = vi.fn()
|
||
loadDataApi.mockRejectedValue(new Error('boom'))
|
||
const tree = useBizTree({ table: 't', onError })
|
||
|
||
await expect(tree.reload()).resolves.toBeDefined()
|
||
expect(onError).toHaveBeenCalledTimes(1)
|
||
expect(tree.loading.value).toBe(false)
|
||
})
|
||
|
||
it('未传 onError 时默认弹提示(错误不吞掉,loading 复位)', async () => {
|
||
loadDataApi.mockRejectedValue(new Error('boom'))
|
||
const tree = useBizTree({ table: 't', label: '模块' })
|
||
|
||
await tree.reload()
|
||
expect(tree.loading.value).toBe(false)
|
||
expect(tree.treeData.value).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('草稿保护(preserveDrafts)', () => {
|
||
it('重载时草稿子树不丢,并保持未保存标记', async () => {
|
||
const tree = useBizTree({ table: 's_module' })
|
||
await tree.reload()
|
||
|
||
// 模拟「新增待保存」:本地插一个临时负数 id 的草稿
|
||
const draft = {
|
||
b_id: '-1',
|
||
b_parent_id: null,
|
||
b_name: '',
|
||
b_module_type: 'module',
|
||
b_xh: 10,
|
||
} as TreeRow
|
||
tree.treeData.value.push(draft)
|
||
tree.markUnsaved('-1')
|
||
|
||
await tree.reload()
|
||
|
||
expect(tree.findNode('-1')).not.toBeNull()
|
||
expect(tree.unsavedIds.has('-1')).toBe(true)
|
||
})
|
||
|
||
it('全量模式:草稿父节点已消失时降级为顶级,并重算 b_depth / b_path', async () => {
|
||
const tree = useBizTree({ table: 's_module', lazy: false })
|
||
await tree.reload()
|
||
|
||
// 草稿挂在一个不存在的父级下(模拟父级已被改编码 / 删除)
|
||
tree.treeData.value.push({
|
||
b_id: '-2',
|
||
b_parent_id: 'ghost',
|
||
b_name: '孤儿草稿',
|
||
b_module_type: 'module',
|
||
b_xh: 10,
|
||
b_depth: 3,
|
||
b_path: '/ghost/-2/',
|
||
} as TreeRow)
|
||
tree.markUnsaved('-2')
|
||
|
||
await tree.reload()
|
||
|
||
const node = tree.findNode('-2') as TreeRow
|
||
expect(node.b_parent_id).toBeNull()
|
||
expect(node.b_depth).toBe(0)
|
||
expect(node.b_path).toBe('/-2/')
|
||
})
|
||
|
||
it('懒加载模式:父节点尚未加载时草稿暂存,不擅自降级为顶级', async () => {
|
||
const tree = useBizTree({ table: 's_module' })
|
||
await tree.reload()
|
||
|
||
// 'd1' 不是根节点:首屏只拉了根(m1 / m2),d1 此刻还不在树上。
|
||
// 懒加载分不清「父节点不存在」和「父节点没展开」,所以不能像全量模式那样
|
||
// 直接把 b_parent_id 抹成 null —— 那会静默丢掉层级,等展开再挂上才对。
|
||
tree.treeData.value.push({
|
||
b_id: '-4',
|
||
b_parent_id: 'd1',
|
||
b_name: '深层草稿',
|
||
b_module_type: 'data',
|
||
b_xh: 10,
|
||
} as TreeRow)
|
||
tree.markUnsaved('-4')
|
||
|
||
await tree.reload()
|
||
|
||
// 父节点没加载到 → 草稿先存着,不出现在树上,但标记还在
|
||
expect(tree.findNode('-4')).toBeNull()
|
||
expect(tree.unsavedIds.has('-4')).toBe(true)
|
||
})
|
||
|
||
it('懒加载模式:父节点展开后,暂存的草稿自动挂回去', async () => {
|
||
const tree = useBizTree({ table: 's_module' })
|
||
await tree.reload()
|
||
|
||
tree.treeData.value.push({
|
||
b_id: '-5',
|
||
b_parent_id: 'd1',
|
||
b_name: '等父节点的草稿',
|
||
b_module_type: 'data',
|
||
b_xh: 10,
|
||
} as TreeRow)
|
||
tree.markUnsaved('-5')
|
||
await tree.reload()
|
||
|
||
// 逐层展开 m1 → d1,草稿应在 d1 的子节点加载后出现
|
||
await tree.loadChildren(tree.findNode('m1') as TreeRow)
|
||
const d1 = tree.findNode('d1') as TreeRow
|
||
await tree.loadChildren(d1)
|
||
|
||
const draft = tree.findNode('-5') as TreeRow
|
||
expect(draft).not.toBeNull()
|
||
expect(draft.b_parent_id).toBe('d1')
|
||
expect(draft.b_depth).toBe(2)
|
||
})
|
||
|
||
it('preserveDrafts=false 时草稿被丢弃', async () => {
|
||
const tree = useBizTree({ table: 's_module', preserveDrafts: false })
|
||
await tree.reload()
|
||
|
||
tree.treeData.value.push({ b_id: '-3', b_parent_id: null, b_xh: 10 } as TreeRow)
|
||
tree.markUnsaved('-3')
|
||
|
||
await tree.reload()
|
||
|
||
expect(tree.findNode('-3')).toBeNull()
|
||
})
|
||
|
||
it('重载丢弃的草稿其未保存标记也一并清掉', async () => {
|
||
const tree = useBizTree({ table: 's_module', preserveDrafts: false })
|
||
await tree.reload()
|
||
tree.markUnsaved('no-such-node')
|
||
|
||
await tree.reload()
|
||
|
||
expect(tree.unsavedIds.has('no-such-node')).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('节点查询与草稿标记', () => {
|
||
it('findNode 对空 id 返回 null(不把空串当合法键)', async () => {
|
||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||
const tree = useBizTree({ table: 't', lazy: false })
|
||
await tree.reload()
|
||
|
||
expect(tree.findNode(null)).toBeNull()
|
||
expect(tree.findNode(undefined)).toBeNull()
|
||
expect(tree.findNode('')).toBeNull()
|
||
})
|
||
|
||
it('findNode 能查到深层子节点', async () => {
|
||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||
const tree = useBizTree({ table: 't', lazy: false })
|
||
await tree.reload()
|
||
|
||
expect((tree.findNode('d1') as TreeRow).b_name).toBe('数据一')
|
||
})
|
||
|
||
it('collectSubtreeIds 收集自身与全部后代', async () => {
|
||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||
const tree = useBizTree({ table: 't', lazy: false })
|
||
await tree.reload()
|
||
|
||
// m1 → d1 → d2,三层都要收进来
|
||
expect([...tree.collectSubtreeIds(tree.findNode('m1') as TreeRow)].sort()).toEqual([
|
||
'd1',
|
||
'd2',
|
||
'm1',
|
||
])
|
||
})
|
||
|
||
it('hasSavedDescendant:子孙里有已落库节点才为 true', async () => {
|
||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||
const tree = useBizTree({ table: 't', lazy: false })
|
||
await tree.reload()
|
||
|
||
// d1 未标草稿 → 视为已落库
|
||
expect(tree.hasSavedDescendant(tree.findNode('m1') as TreeRow)).toBe(true)
|
||
|
||
// 整条子孙链都标成草稿(d1 与更深一层的 d2)后,m1 才算没有已落库的下级
|
||
tree.markUnsaved('d1')
|
||
tree.markUnsaved('d2')
|
||
expect(tree.hasSavedDescendant(tree.findNode('m1') as TreeRow)).toBe(false)
|
||
})
|
||
|
||
it('unmarkUnsavedSubtree 连带清掉整棵子树的草稿标记', async () => {
|
||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||
const tree = useBizTree({ table: 't', lazy: false })
|
||
await tree.reload()
|
||
|
||
tree.markUnsaved('m1')
|
||
tree.markUnsaved('d1')
|
||
tree.unmarkUnsavedSubtree(tree.findNode('m1') as TreeRow)
|
||
|
||
expect(tree.unsavedIds.has('m1')).toBe(false)
|
||
expect(tree.unsavedIds.has('d1')).toBe(false)
|
||
})
|
||
|
||
it('unmarkUnsaved 只清单个节点,不动其子节点', async () => {
|
||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||
const tree = useBizTree({ table: 't', lazy: false })
|
||
await tree.reload()
|
||
|
||
tree.markUnsaved('m1')
|
||
tree.markUnsaved('d1')
|
||
tree.unmarkUnsaved('m1')
|
||
|
||
expect(tree.unsavedIds.has('m1')).toBe(false)
|
||
expect(tree.unsavedIds.has('d1')).toBe(true)
|
||
})
|
||
|
||
it('unmarkUnsavedSubtree 传空值时不报错', () => {
|
||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||
const tree = useBizTree({ table: 't', lazy: false })
|
||
expect(() => tree.unmarkUnsavedSubtree(null)).not.toThrow()
|
||
})
|
||
|
||
it('syncSubtreePosition 递归重算整棵子树的派生列', () => {
|
||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||
const tree = useBizTree({ table: 't', lazy: false })
|
||
const parent = { b_id: 'p', b_depth: 0, b_path: '/p/' } as TreeRow
|
||
const child = { b_id: 'c', b_parent_id: 'p' } as TreeRow
|
||
const grandchild = { b_id: 'g', b_parent_id: 'c' } as TreeRow
|
||
child.children = [grandchild]
|
||
;(parent as TreeRow).children = [child]
|
||
|
||
tree.syncSubtreePosition(child as never, parent)
|
||
|
||
expect(child.b_depth).toBe(1)
|
||
expect(child.b_path).toBe('/p/c/')
|
||
expect(grandchild.b_depth).toBe(2)
|
||
expect(grandchild.b_path).toBe('/p/c/g/')
|
||
})
|
||
})
|