/** * 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> { 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/') }) })