Files
workspace/code/g3soft-erp/web/tests/composables/useBizTree.spec.ts
T
2026-10-17 17:17:39 +08:00

405 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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/')
})
})