This commit is contained in:
oneao committed 2026-10-17 17:17:39 +08:00
1 parent 025d3232c4
commit 1d7d75eeba
98 files changed
+4144 -12063

No files matched your search

@@ -0,0 +1,404 @@
/**
* 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/')
})
})