Files
zijunchiang 53bf182476 feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览
需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
  - model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
    **骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
  - model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
    生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
  - vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
  - gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
  (服务端判定的"可驱动" = 客户端实际能否驱动)

需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
  →(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
  其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
  失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf

需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
  humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
  **骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
  仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
  POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程

其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
  顶层开发书更新为 v1.4

验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
2026-09-15 20:37:53 +08:00

506 lines
19 KiB
TypeScript
Raw Permalink 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.
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterAll, describe, expect, it } from 'vitest';
import {
HUMANOID_BONES,
HUMANOID_CANDIDATES,
HUMANOID_REQUIRED,
STRIPPABLE_PREFIXES,
boneNameVariants,
inspectModelFile,
normalizeBoneName,
} from '../src/models/model-inspector';
import type { ModelStats } from '../src/models/model-inspector';
/**
* 模型检查器测试。
*
* 全部夹具都在代码里构造(GLB 二进制 / glTF JSON),不依赖外部文件、不联网。
* 另有一条「与客户端一致」用例直接读 `opendance-windows/src/model/humanoid_map.gd`
* 的源码做逐键比对 —— 客户端改了名字映射,这里必须立刻失败。
*/
const dir = mkdtempSync(join(tmpdir(), 'opendance-inspector-'));
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
function write(name: string, content: Buffer): string {
const path = join(dir, name);
writeFileSync(path, content);
return path;
}
/** 构造合法 GLB:12 字节头 + JSON chunk(+ 可选 BIN chunk),chunk 按 4 字节对齐 */
function buildGlb(json: unknown, bin?: Buffer): Buffer {
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
const jsonChunk = Buffer.concat([jsonBuf, Buffer.alloc((4 - (jsonBuf.length % 4)) % 4, 0x20)]);
const chunks: Buffer[] = [];
const header = Buffer.alloc(12);
header.write('glTF', 0, 'ascii');
header.writeUInt32LE(2, 4);
chunks.push(header);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(jsonChunk.length, 0);
jsonHeader.writeUInt32LE(0x4e4f534a, 4); // 'JSON'
chunks.push(jsonHeader, jsonChunk);
if (bin && bin.length > 0) {
const binChunk = Buffer.concat([bin, Buffer.alloc((4 - (bin.length % 4)) % 4)]);
const binHeader = Buffer.alloc(8);
binHeader.writeUInt32LE(binChunk.length, 0);
binHeader.writeUInt32LE(0x004e4942, 4); // 'BIN\0'
chunks.push(binHeader, binChunk);
}
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const out = Buffer.concat(chunks, total);
out.writeUInt32LE(total, 8);
return out;
}
/** 所有统计数字必须是有限整数(不允许 NaN / Infinity / 小数) */
function expectIntegerStats(stats: ModelStats): void {
const numericKeys: Array<keyof ModelStats> = [
'fileSize',
'meshes',
'primitives',
'vertices',
'triangles',
'materials',
'textures',
'images',
'animations',
'morphTargets',
'nodes',
'bones',
'skins',
'humanoidMapped',
'humanoidTotal',
'maxBoneDepth',
];
for (const key of numericKeys) {
const value = stats[key];
expect(Number.isInteger(value), `${String(key)} 必须是有限整数,实际为 ${String(value)}`).toBe(true);
}
for (const bone of stats.boneTree) {
expect(Number.isInteger(bone.index)).toBe(true);
expect(Number.isInteger(bone.depth)).toBe(true);
expect(Number.isInteger(bone.childCount)).toBe(true);
}
}
/* ------------------------------------------------------- 1. GLB 基础统计 + 骨骼树 */
/** VRoid 风格的最小夹具:1 mesh(POSITION=3 / indices=3)、1 material、1 image、1 skin */
function basicGlb(): Buffer {
return buildGlb(
{
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [
{ name: 'J_Bip_C_Hips', children: [1] },
{ name: 'J_Bip_L_UpperArm', children: [2] },
{ name: 'J_Bip_L_LowerArm' },
],
skins: [{ joints: [0, 1, 2] }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }],
accessors: [{ count: 3 }, { count: 3 }],
materials: [{}],
images: [{}],
},
// 顺带覆盖 BIN chunk:解析器应跳过它而不受影响
Buffer.from([1, 2, 3, 4, 5, 6]),
);
}
describe('GLB 基础统计与骨骼树', () => {
it('顶点/面数/材质/skin 与 depth、parentName、childCount 正确', async () => {
const glb = basicGlb();
const stats = await inspectModelFile(write('basic.glb', glb));
expect(stats.container).toBe('glb');
expect(stats.vrmVersion).toBeNull();
expect(stats.fileSize).toBe(glb.length);
expect(stats.vertices).toBe(3);
expect(stats.triangles).toBe(1);
expect(stats.materials).toBe(1);
expect(stats.images).toBe(1);
expect(stats.textures).toBe(0);
expect(stats.meshes).toBe(1);
expect(stats.primitives).toBe(1);
expect(stats.skins).toBe(1);
expect(stats.nodes).toBe(3);
expect(stats.bones).toBe(3);
expect(stats.warnings).toEqual([]);
expect(stats.boneTree).toHaveLength(3);
expect(
stats.boneTree.map((bone) => ({
name: bone.name,
depth: bone.depth,
parentName: bone.parentName,
childCount: bone.childCount,
inSkin: bone.inSkin,
})),
).toEqual([
{ name: 'J_Bip_C_Hips', depth: 0, parentName: null, childCount: 1, inSkin: true },
{ name: 'J_Bip_L_UpperArm', depth: 1, parentName: 'J_Bip_C_Hips', childCount: 1, inSkin: true },
{ name: 'J_Bip_L_LowerArm', depth: 2, parentName: 'J_Bip_L_UpperArm', childCount: 0, inSkin: true },
]);
expect(stats.maxBoneDepth).toBe(2);
// 三根节点都应按名字映射上 humanoid 规范骨
expect(stats.boneTree.map((bone) => bone.humanoid)).toEqual(['hips', 'leftUpperArm', 'leftLowerArm']);
expect(stats.humanoidMapped).toBeGreaterThanOrEqual(3);
expect(stats.humanoidTotal).toBe(22);
expectIntegerStats(stats);
});
it('传入 fileSize 时优先采用传入值', async () => {
const stats = await inspectModelFile(write('sized.glb', basicGlb()), 123456);
expect(stats.fileSize).toBe(123456);
});
it('没有 scenes 时以「无父节点」为根,多根各自 depth=0', async () => {
const doc = {
asset: { version: '2.0' },
nodes: [
{ name: 'Hips', children: [1] },
{ name: 'Spine', children: [2] },
{ name: 'Chest' },
{ name: 'Prop' },
],
};
const stats = await inspectModelFile(write('multi-root.gltf', Buffer.from(JSON.stringify(doc))));
expect(stats.container).toBe('gltf');
expect(stats.boneTree.map((bone) => [bone.name, bone.depth])).toEqual([
['Hips', 0],
['Spine', 1],
['Chest', 2],
['Prop', 0],
]);
expect(stats.maxBoneDepth).toBe(2);
});
it('同名冲突取先出现的节点(父在前顺序)', async () => {
const doc = {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ name: 'Hips', children: [1] }, { name: 'Spine', children: [2] }, { name: 'Hips' }],
};
const stats = await inspectModelFile(write('duplicate.gltf', Buffer.from(JSON.stringify(doc))));
const hips = stats.boneTree.find((bone) => bone.humanoid === 'hips');
expect(hips?.index).toBe(0);
expect(stats.boneTree[2].humanoid).toBeNull();
});
});
/* ------------------------------------------------------------- 2. VRM 识别 */
describe('VRM 识别', () => {
it('VRMC_vrm 扩展 → container=vrm / vrmVersion=1.0', async () => {
const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRMC_vrm: { specVersion: '1.0' } } });
const stats = await inspectModelFile(write('vrm1.vrm', glb));
expect(stats.container).toBe('vrm');
expect(stats.vrmVersion).toBe('1.0');
});
it('VRM 0.x 扩展(无 specVersion)→ vrmVersion=0.x', async () => {
const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRM: { meta: { title: 't' } } } });
const stats = await inspectModelFile(write('vrm0.vrm', glb));
expect(stats.container).toBe('vrm');
expect(stats.vrmVersion).toBe('0.x');
});
it('缺 meta 的 VRM 会给出提示', async () => {
const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRMC_vrm: { specVersion: '1.0' } } });
const stats = await inspectModelFile(write('vrm1-nometa.vrm', glb));
expect(stats.warnings.join(';')).toContain('meta');
});
});
/* --------------------------------------------- 3. 16 根必需 humanoid 骨 */
/** 用 VRoid 命名覆盖驱动客户端所需的 16 根 */
const REQUIRED_NODE_NAMES = [
'J_Bip_C_Hips',
'J_Bip_C_Spine',
'J_Bip_C_Neck',
'J_Bip_C_Head',
'J_Bip_L_UpperArm',
'J_Bip_L_LowerArm',
'J_Bip_L_Hand',
'J_Bip_R_UpperArm',
'J_Bip_R_LowerArm',
'J_Bip_R_Hand',
'J_Bip_L_UpperLeg',
'J_Bip_L_LowerLeg',
'J_Bip_L_Foot',
'J_Bip_R_UpperLeg',
'J_Bip_R_LowerLeg',
'J_Bip_R_Foot',
];
/** 串成一条链:所有骨骼都进 skin.joints,父子关系保证 depth 递增 */
function buildHumanoidDocument(names: string[]): unknown {
return {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: names.map((name, index) => (index + 1 < names.length ? { name, children: [index + 1] } : { name })),
skins: [{ joints: names.map((_, index) => index) }],
};
}
describe('必需 16 根 humanoid 骨', () => {
it('16 根齐全 → drivable=true、missingRequiredBones=[]', async () => {
const doc = buildHumanoidDocument(REQUIRED_NODE_NAMES);
const path = write('humanoid-complete.gltf', Buffer.from(JSON.stringify(doc)));
const stats = await inspectModelFile(path);
expect(stats.container).toBe('gltf');
expect(stats.nodes).toBe(16);
expect(stats.bones).toBe(16);
expect(stats.humanoidMapped).toBe(16);
expect(stats.missingRequiredBones).toEqual([]);
expect(stats.drivable).toBe(true);
expect(stats.boneTree.every((bone) => bone.humanoid !== null)).toBe(true);
expect(stats.maxBoneDepth).toBe(15);
expectIntegerStats(stats);
});
it('缺 leftFoot → drivable=false、missingRequiredBones 只含 leftFoot', async () => {
const names = REQUIRED_NODE_NAMES.filter((name) => name !== 'J_Bip_L_Foot');
const doc = buildHumanoidDocument(names);
const path = write('humanoid-no-left-foot.gltf', Buffer.from(JSON.stringify(doc)));
const stats = await inspectModelFile(path);
expect(stats.drivable).toBe(false);
expect(stats.missingRequiredBones).toEqual(['leftFoot']);
expect(stats.humanoidMapped).toBe(15);
});
});
/* --------------------------------------------------- 4. 非 glTF 容器降级 */
describe('非 glTF 容器', () => {
it('假 fbx 字节 → container=unknown、有 warnings、不抛错', async () => {
const fbx = Buffer.concat([
Buffer.from('Kaydara FBX Binary \x00', 'latin1'),
Buffer.from([0x1a, 0x00, 0x00, 0x00, 0x00, 0x00]),
]);
const stats = await inspectModelFile(write('fake.fbx', fbx));
expect(stats.container).toBe('unknown');
expect(stats.warnings.length).toBeGreaterThan(0);
expect(stats.humanoidMapped).toBe(0);
expect(stats.drivable).toBe(false);
expect(stats.missingRequiredBones).toEqual([...HUMANOID_REQUIRED]);
expectIntegerStats(stats);
});
it('假 pmx 字节 → container=unknown、有 warnings', async () => {
const pmx = Buffer.concat([Buffer.from('PMX ', 'ascii'), Buffer.from([0x00, 0x00, 0x00, 0x50])]);
const stats = await inspectModelFile(write('fake.pmx', pmx));
expect(stats.container).toBe('unknown');
expect(stats.warnings.length).toBeGreaterThan(0);
});
it('合法 JSON 但不是 glTF → container=unknown', async () => {
const path = write('package.json', Buffer.from(JSON.stringify({ name: 'x', version: '1.0.0' })));
const stats = await inspectModelFile(path);
expect(stats.container).toBe('unknown');
expect(stats.warnings.join(';')).toContain('glTF');
});
});
/* ------------------------------------------------------ 5. 截断与健壮性 */
describe('截断与健壮性', () => {
it('截断一半的 GLB → 不抛错、container=unknown、warnings 非空', async () => {
const full = basicGlb();
const truncated = full.subarray(0, Math.floor(full.length / 2));
const stats = await inspectModelFile(write('truncated.glb', Buffer.from(truncated)));
expect(stats.container).toBe('unknown');
expect(stats.warnings.length).toBeGreaterThan(0);
expectIntegerStats(stats);
});
it('不是 GLB 且 JSON 截断 → 不抛错', async () => {
const path = write('broken.gltf', Buffer.from('{"asset":{"version":"2.0"'));
const stats = await inspectModelFile(path);
expect(stats.container).toBe('unknown');
expect(stats.warnings.length).toBeGreaterThan(0);
});
it('文件不存在 → 不抛错,记 warning', async () => {
const stats = await inspectModelFile(join(dir, 'no-such-model.glb'));
expect(stats.container).toBe('unknown');
expect(stats.warnings[0]).toContain('读取文件失败');
expect(stats.fileSize).toBe(0);
expect(stats.bones).toBe(0);
});
it('文件不存在但传了 fileSize → 仍返回该大小', async () => {
const stats = await inspectModelFile(join(dir, 'no-such-model-2.glb'), 2048);
expect(stats.fileSize).toBe(2048);
expect(stats.warnings.length).toBeGreaterThan(0);
});
it('缺 accessor / 空文档也不产生 NaN', async () => {
const doc = {
asset: { version: '2.0' },
meshes: [{ primitives: [{ attributes: { POSITION: 99 }, indices: 42 }, {}] }],
accessors: [{ count: 'not-a-number' }],
};
const stats = await inspectModelFile(write('bad-accessor.gltf', Buffer.from(JSON.stringify(doc))));
expect(stats.vertices).toBe(0);
expect(stats.triangles).toBe(0);
expect(stats.primitives).toBe(2);
expect(stats.warnings.length).toBeGreaterThan(0);
expectIntegerStats(stats);
});
it('GLB magic 正确但版本不是 2 → 降级 unknown', async () => {
const glb = basicGlb();
glb.writeUInt32LE(1, 4);
const stats = await inspectModelFile(write('glb-v1.glb', glb));
expect(stats.container).toBe('unknown');
expect(stats.warnings.join(';')).toContain('版本');
});
});
/* -------------------------------------------------------- 7. morphTargets */
describe('morphTargets 统计', () => {
it('所有 primitive 的 targets 数量之和', async () => {
const glb = buildGlb({
asset: { version: '2.0' },
meshes: [
{
primitives: [
{ attributes: { POSITION: 0 }, targets: [{ POSITION: 1 }, { POSITION: 2 }] },
{ attributes: { POSITION: 0 } },
],
},
],
accessors: [{ count: 6 }, { count: 6 }, { count: 6 }],
});
const stats = await inspectModelFile(write('morph.glb', glb));
expect(stats.morphTargets).toBe(2);
expect(stats.primitives).toBe(2);
expect(stats.vertices).toBe(12);
expect(stats.triangles).toBe(4);
expectIntegerStats(stats);
});
});
/* ------------------------------- 6. 与客户端 humanoid_map.gd 的一致性 */
/** gd 文件在客户端仓库里;按「从 web-server 运行」为主,同时兼容从仓库根运行 */
function findHumanoidMap(): string {
const candidates = [
join(__dirname, '..', '..', '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'),
resolve(process.cwd(), '..', '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'),
resolve(process.cwd(), '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'),
resolve(process.cwd(), 'opendance-windows', 'src', 'model', 'humanoid_map.gd'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
throw new Error(`找不到 opendance-windows/src/model/humanoid_map.gd,候选路径:\n${candidates.join('\n')}`);
}
const HUMANOID_MAP_PATH = findHumanoidMap();
const GD_SOURCE = readFileSync(HUMANOID_MAP_PATH, 'utf8');
function parseGdStringList(raw: string): string[] {
return raw
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0)
.map((item) => item.replace(/^"|"$/g, ''));
}
/** 抽 `const NAME ... = [ "a", "b", ... ]` 形式的字符串数组 */
function extractGdStringList(source: string, constName: string): string[] {
const match = new RegExp(`const\\s+${constName}\\s*:[^=]*=\\s*\\[([\\s\\S]*?)\\]`).exec(source);
if (!match) throw new Error(`humanoid_map.gd 里找不到 ${constName}`);
return parseGdStringList(match[1]);
}
/** 抽 `const CANDIDATES := { "canonical": ["a", "b"], ... }` */
function extractGdCandidates(source: string): Record<string, string[]> {
const match = /const\s+CANDIDATES\s*:=\s*\{([\s\S]*?)\n\}/.exec(source);
if (!match) throw new Error('humanoid_map.gd 里找不到 CANDIDATES');
const out: Record<string, string[]> = {};
const entry = /"([A-Za-z0-9_]+)"\s*:\s*\[([^\]]*)\]/g;
let hit: RegExpExecArray | null;
while ((hit = entry.exec(match[1])) !== null) {
out[hit[1]] = parseGdStringList(hit[2]);
}
return out;
}
describe(`与客户端 humanoid_map.gd 一致(${HUMANOID_MAP_PATH})`, () => {
it('CANDIDATES 逐键一致(键顺序与候选顺序都要一致)', () => {
const gd = extractGdCandidates(GD_SOURCE);
expect(Object.keys(gd).length).toBeGreaterThan(0);
// 键顺序也要一致:服务端按 HUMANOID_BONES 顺序做映射与提示
expect(Object.keys(HUMANOID_CANDIDATES)).toEqual(Object.keys(gd));
for (const canonical of Object.keys(gd)) {
expect(HUMANOID_CANDIDATES[canonical], `候选表 ${canonical} 与客户端不一致`).toEqual(gd[canonical]);
}
});
it('HUMANOID_BONES 与客户端一致,humanoidTotal 就是它的长度', () => {
expect([...HUMANOID_BONES]).toEqual(extractGdStringList(GD_SOURCE, 'HUMANOID_BONES'));
expect(HUMANOID_BONES.length).toBe(22);
});
it('STRIPPABLE_PREFIXES 与客户端一致', () => {
expect([...STRIPPABLE_PREFIXES]).toEqual(extractGdStringList(GD_SOURCE, 'STRIPPABLE_PREFIXES'));
});
it('必需 16 根都能在客户端候选表里找到,且互不重复', () => {
const gd = extractGdCandidates(GD_SOURCE);
expect(HUMANOID_REQUIRED).toHaveLength(16);
expect(new Set(HUMANOID_REQUIRED).size).toBe(16);
for (const bone of HUMANOID_REQUIRED) {
expect(Object.keys(gd)).toContain(bone);
}
});
});
describe('normalize / variants 行为与客户端一致', () => {
it('VRoid / Mixamo / Bip01 三种前缀的展开与缩写', () => {
expect(normalizeBoneName('J_Bip_L_UpperArm')).toBe('jbiplupperarm');
expect(boneNameVariants('J_Bip_L_UpperArm')).toEqual(['lupperarm', 'lupperarm', 'leftupperarm']);
expect(normalizeBoneName('mixamorig:LeftArm')).toBe('mixamorigleftarm');
expect(boneNameVariants('mixamorig:LeftArm')).toEqual(['leftarm', 'larm', 'leftarm']);
expect(boneNameVariants('Bip01 L Thigh')).toEqual(['lthigh', 'lthigh', 'leftthigh']);
expect(boneNameVariants('J_Bip_C_Hips')).toEqual(['chips', 'chips', 'chips']);
});
it('不剥离 J_Aim_ 等扭转辅助骨前缀(否则会撞上真正的骨骼)', () => {
expect(boneNameVariants('J_Aim_L_UpperLeg')).toEqual([
'jaimlupperleg',
'jaimlupperleg',
'jaimlupperleg',
]);
const allCandidates = Object.values(HUMANOID_CANDIDATES).flat();
expect(allCandidates).not.toContain('jaimlupperleg');
});
});