import { describe, expect, it } from 'vitest'; import { filterBoneTree } from '../admin/src/lib/bone-tree'; import type { BoneNode } from '../admin/src/api/types'; /** * 只测 ModelReviewPanel 使用的纯函数 filterBoneTree。 * 骨骼树是「扁平数组 + depth 缩进」渲染的,过滤时若丢掉祖先,树就会断裂, * 因此这里重点覆盖「命中 + 祖先链」的保留逻辑。 */ function bone(input: Partial & Pick): BoneNode { return { index: 0, parentName: null, childCount: 0, inSkin: false, humanoid: null, ...input, }; } /** * 一棵典型的人形骨骼树(先序遍历,父在前): * - Hips → Spine → Chest → {LeftUpperArm→LeftLowerArm→LeftHand→LeftHand_IK, RightUpperArm, J_Bip_C_上半身→辅助骨骼_末端} * - LeftHand_IK / J_Bip_C_上半身 分支 / Root_End 都是不参与蒙皮的辅助节点。 */ const TREE: BoneNode[] = [ bone({ index: 0, name: 'Hips', depth: 0, parentName: null, childCount: 3, inSkin: true, humanoid: 'hips' }), bone({ index: 1, name: 'Spine', depth: 1, parentName: 'Hips', childCount: 1, inSkin: true, humanoid: 'spine' }), bone({ index: 2, name: 'Chest', depth: 2, parentName: 'Spine', childCount: 3, inSkin: true, humanoid: 'chest' }), bone({ index: 3, name: 'LeftUpperArm', depth: 3, parentName: 'Chest', childCount: 1, inSkin: true, humanoid: 'leftUpperArm' }), bone({ index: 4, name: 'LeftLowerArm', depth: 4, parentName: 'LeftUpperArm', childCount: 1, inSkin: true, humanoid: 'leftLowerArm' }), bone({ index: 5, name: 'LeftHand', depth: 5, parentName: 'LeftLowerArm', childCount: 1, inSkin: true, humanoid: 'leftHand' }), bone({ index: 6, name: 'LeftHand_IK', depth: 6, parentName: 'LeftHand', childCount: 0, inSkin: false }), bone({ index: 7, name: 'RightUpperArm', depth: 3, parentName: 'Chest', childCount: 0, inSkin: true, humanoid: 'rightUpperArm' }), bone({ index: 8, name: 'J_Bip_C_上半身', depth: 3, parentName: 'Chest', childCount: 1, inSkin: false }), bone({ index: 9, name: '辅助骨骼_末端', depth: 4, parentName: 'J_Bip_C_上半身', childCount: 0, inSkin: false }), bone({ index: 10, name: 'Root_End', depth: 1, parentName: 'Hips', childCount: 0, inSkin: false }), ]; function names(nodes: readonly BoneNode[]): string[] { return nodes.map((node) => node.name); } describe('filterBoneTree:搜索保留祖先链', () => { it('命中叶子节点时保留完整祖先链(含不参与蒙皮的祖先)', () => { const result = filterBoneTree(TREE, '辅助骨骼_末端'); expect(names(result)).toEqual(['Hips', 'Spine', 'Chest', 'J_Bip_C_上半身', '辅助骨骼_末端']); // 祖先链上的节点一个都不能少,也不能混入无关分支 expect(names(result)).not.toContain('LeftHand'); expect(names(result)).not.toContain('Root_End'); }); it('多个命中时取并集:各自的祖先只出现一次,且保持父在前', () => { const result = filterBoneTree(TREE, 'Arm'); expect(names(result)).toEqual([ 'Hips', 'Spine', 'Chest', 'LeftUpperArm', 'LeftLowerArm', 'RightUpperArm', ]); expect(names(result)).not.toContain('LeftHand'); expect(new Set(names(result)).size).toBe(result.length); }); it('大小写不敏感,且支持中英混排子串', () => { expect(names(filterBoneTree(TREE, 'leftlowerarm'))).toEqual([ 'Hips', 'Spine', 'Chest', 'LeftUpperArm', 'LeftLowerArm', ]); expect(names(filterBoneTree(TREE, 'BIP_C_上半身'))).toEqual([ 'Hips', 'Spine', 'Chest', 'J_Bip_C_上半身', ]); expect(names(filterBoneTree(TREE, ' 左 '))).toEqual([]); }); }); describe('filterBoneTree:仅显示蒙皮骨骼', () => { it('空关键词 + onlySkin:只留蒙皮骨骼(其祖先同为蒙皮),辅助节点被排除', () => { const result = filterBoneTree(TREE, '', true); expect(names(result)).toEqual([ 'Hips', 'Spine', 'Chest', 'LeftUpperArm', 'LeftLowerArm', 'LeftHand', 'RightUpperArm', ]); expect(names(result)).not.toContain('LeftHand_IK'); expect(names(result)).not.toContain('J_Bip_C_上半身'); expect(names(result)).not.toContain('Root_End'); }); it('onlySkin 只筛选「命中节点」,非蒙皮节点命中时不保留', () => { expect(names(filterBoneTree(TREE, 'IK', true))).toEqual([]); // 同一个关键字在关闭开关时能命中 expect(names(filterBoneTree(TREE, 'IK', false))).toEqual([ 'Hips', 'Spine', 'Chest', 'LeftUpperArm', 'LeftLowerArm', 'LeftHand', 'LeftHand_IK', ]); }); }); describe('filterBoneTree:空关键词与边界', () => { it('空关键词返回全量(顺序不变,且是新数组)', () => { const result = filterBoneTree(TREE, ''); expect(result).toEqual(TREE); expect(result).toHaveLength(TREE.length); expect(result).not.toBe(TREE); expect(filterBoneTree(TREE, ' ')).toEqual(TREE); }); it('无命中返回空数组', () => { expect(filterBoneTree(TREE, '不存在的骨骼')).toEqual([]); expect(filterBoneTree([], 'anything')).toEqual([]); }); it('depth 异常时退回 parentName 查找,祖先链依然完整', () => { const flat: BoneNode[] = [ bone({ index: 0, name: 'root', depth: 0, parentName: null }), bone({ index: 1, name: 'child', depth: 0, parentName: 'root' }), bone({ index: 2, name: 'leaf', depth: 0, parentName: 'child' }), ]; expect(names(filterBoneTree(flat, 'leaf'))).toEqual(['root', 'child', 'leaf']); }); });