Files
opendance-web/web-server/test/assimp-locate.spec.ts
T
zijunchiang 2da02ee423 test(real-samples): 用真实素材端到端验证 ①模型→VRM 与 ②动作内部标准
`model-archive.spec.ts` / `mmd-vmd.spec.ts` 用的都是合成夹具与假 Assimp,
证明得了边界,证明不了「真素材能出片」。新增 opt-in 的
`test/real-samples.spec.ts`(6 项),样例或 Assimp 缺失时整体跳过,
CI 与无素材的开发机不受影响:

① 模型 → VRM(两个真实 PMX 压缩包)
   - 初音未来 1.0 MB → 1.10 MB VRM,humanoid 19/22,2 张贴图全内嵌;
   - 奥黛塔 28.1 MB → 26.08 MB VRM,humanoid 21/22,18 张贴图全内嵌,
     39 材质(36 项双面已补回);两者 drivable = true;
   - 断言 VRMC_vrm 1.0、所有 images 带 bufferView 且无 uri、无 MMD 专属
     材质扩展泄漏、蒙皮下标不越界,并**逐字节复算 POSITION 访问器的
     min/max**(glTF 唯一强制填写的边界,客户端据此做视锥剔除与 AABB;
     读访问器必须按 byteStride 走,否则交错缓冲里的 u16 会被读成乱码)。

② 动作内部标准 + 骨骼重绑定(真实 VMD,0.8 MB / 96.7 s)
   - maxFrame 2900 → 2901 帧、标准骨架 31/31 命中、双侧腿部 IK 链齐备;
   - MMD 骨骼名 → joint-map 候选名的重绑定结果逐一比对(15 个键);
   - 15 个直接关节的置信度恒为 1.0(绝不退化成 0.5 的推算值),
     其余 18 点为 0.5,缺失为 0;骨盆归零后投影 max|xy| = 0.85 ≤ 1;
   - NDJSON 往返一致、缩略图 PNG 有效、手腕摆幅非退化。

顺带修 `assimp-locate.spec.ts` 的非密闭性:其用例断言的是 PATH/常见系统
目录查找,但会继承外部 ASSIMP_BIN(assimp-lib.sh 中 ASSIMP_BIN 优先级高于
PATH),开发者按本项目文档设过 ASSIMP_BIN 时 npm test 会假失败。

规模 24 文件 / 289 项 → 25 文件 / 295 项。产物与已发布的 v0.7.4 无关
(发布包不含 web-server/test/),故不新发版本。
2026-09-17 04:11:24 +08:00

206 lines
8.3 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.
import { spawnSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import {
ASSIMP_FALLBACK_DIRS,
assimpInstallHint,
assimpMissingMessage,
detectAssimpBin,
resolveAssimpBinary,
} from '../src/common/assimp';
import { convertModelFile } from '../src/models/model-convert';
/**
* Assimp 缺失时的可诊断性。
*
* 背景(真实故障):服务器没装 Assimp,上传 PMX 压缩包只得到
* GLB 直出:导出失败(ENOENT):spawn assimp ENOENT
* glTF 直出:导出失败(ENOENT):spawn assimp ENOENT
* 用户看不出「缺什么、怎么装」。现在:
* * 转换前先定位二进制,找不到就一次性给出安装命令与查过的路径;
* * 定位逻辑覆盖 PATH 之外的常见目录(服务由 systemd 托管时 PATH 可能很窄)。
*/
let workRoot = '';
let fakeBin = '';
function findDeployLib(): string {
const candidates = [
resolve(process.cwd(), '..', 'deploy', 'assimp-lib.sh'),
resolve(process.cwd(), 'deploy', 'assimp-lib.sh'),
resolve(process.cwd(), '..', '..', 'deploy', 'assimp-lib.sh'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
throw new Error(`找不到 deploy/assimp-lib.sh,候选路径:\n${candidates.join('\n')}`);
}
const ASSIMP_LIB = findDeployLib();
/** 写一个最小的假 assimp:只实现 `version` */
function writeFakeAssimp(path: string, versionLine = 'Assimp version 6.0.4'): string {
mkdirSync(join(path, '..'), { recursive: true });
writeFileSync(path, `#!/bin/bash\n[ "\${1:-}" = "version" ] && echo "${versionLine}"\nexit 0\n`);
chmodSync(path, 0o755);
return path;
}
beforeAll(() => {
workRoot = mkdtempSync(join(tmpdir(), 'opendance-assimp-test-'));
fakeBin = writeFakeAssimp(join(workRoot, 'bin', 'assimp'));
});
afterAll(() => {
rmSync(workRoot, { recursive: true, force: true });
});
const savedPath = process.env.PATH;
afterEach(() => {
process.env.PATH = savedPath;
});
describe('Assimp 定位(resolveAssimpBinary)', () => {
it('能在 PATH 中找到可执行文件', () => {
process.env.PATH = `${join(workRoot, 'bin')}:/usr/bin:/bin`;
const result = resolveAssimpBinary('assimp');
expect(result.found).toBe(true);
expect(result.path).toBe(fakeBin);
});
it('显式绝对路径优先,不存在时 found=false 并给出查找记录', () => {
const missing = join(workRoot, 'no-such-assimp');
const result = resolveAssimpBinary(missing);
expect(result.found).toBe(false);
expect(result.searched).toContain(missing);
const present = resolveAssimpBinary(fakeBin);
expect(present.found).toBe(true);
expect(present.path).toBe(fakeBin);
});
it('PATH 里没有时仍会查常见系统目录(systemd 服务 PATH 可能很窄)', () => {
process.env.PATH = '/nonexistent-dir-only';
const result = resolveAssimpBinary('assimp');
// 结果取决于本机是否装了真实 assimp:两种都必须把常见目录查过
for (const dir of ASSIMP_FALLBACK_DIRS) {
expect(result.searched).toContain(join(dir, 'assimp'));
}
if (!result.found) expect(result.path).toBe('assimp');
});
});
describe('Assimp 缺失时的错误信息', () => {
it('包含二进制名、各发行版安装命令、替代办法与查过的路径', () => {
const message = assimpMissingMessage('assimp', ['/usr/bin/assimp', '/usr/local/bin/assimp']);
expect(message).toContain('assimp');
expect(message).toContain('apt-get install -y assimp-utils');
expect(message).toContain('dnf install -y assimp');
expect(message).toContain('/usr/bin/assimp');
expect(message).toContain('ASSIMP_BIN');
expect(message).toContain('.glb / .vrm');
});
it('安装提示各发行版一条命令', () => {
const hint = assimpInstallHint();
for (const distro of ['Debian / Ubuntu', 'Alpine', 'Arch']) {
expect(hint).toContain(distro);
}
});
});
describe('Assimp 可用性探测(detectAssimpBin)', () => {
it('能执行时返回版本与路径', async () => {
const probe = await detectAssimpBin(fakeBin);
expect(probe.available).toBe(true);
expect(probe.path).toBe(fakeBin);
expect(probe.version).toContain('6.0.4');
});
it('找不到时 available=false 且 error 说明未安装', async () => {
const probe = await detectAssimpBin(join(workRoot, 'no-such-assimp'));
expect(probe.available).toBe(false);
expect(probe.error).toContain('未安装');
expect(probe.searched.length).toBeGreaterThan(0);
});
});
describe('转换管线:Assimp 缺失时快速失败并给出可操作信息', () => {
it('convertModelFile 抛出的错误包含安装命令,且不再出现两次 spawn ENOENT', async () => {
let message = '';
try {
await convertModelFile(Buffer.from('PMX dummy'), 'Hatsune Miku.pmx', {
assimpBin: join(workRoot, 'no-such-assimp'),
timeoutMs: 5_000,
});
throw new Error('应当抛出异常');
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toContain('模型转换失败');
expect(message).toContain('apt-get install -y assimp-utils');
expect(message).toContain('ASSIMP_BIN');
expect(message).not.toContain('ENOENT');
});
});
describe('deploy/assimp-lib.sh:部署脚本共用的安装逻辑', () => {
function runLib(script: string, path?: string) {
// ASSIMP_BIN 在 assimp-lib.sh 里的优先级高于 PATH,而下面的用例断言的正是
// PATH / 常见系统目录的查找行为。若开发者 shell 或 deploy/.env 里恰好设了
// ASSIMP_BIN(本项目文档推荐的一种部署方式),继承下来就会让这些用例假失败,
// 因此这里显式剔除,只保留 PATH 这一条解析路径。
const { ASSIMP_BIN: _ignored, ...env } = process.env;
// 覆盖 PATH 的用例里必须用绝对路径调 bash,否则连 bash 都找不到
return spawnSync('/bin/bash', ['-c', `. "$1"; ${script}`, '_', ASSIMP_LIB], {
encoding: 'utf8',
timeout: 15000,
env: { ...env, ...(path ? { PATH: path } : {}) },
});
}
it('opendance_assimp_resolve 能在 PATH 中找到二进制', () => {
const result = runLib('opendance_assimp_resolve', `${join(workRoot, 'bin')}:/usr/bin:/bin`);
expect(result.stdout.trim()).toBe(fakeBin);
expect(result.status).toBe(0);
});
it('opendance_assimp_resolve 在找不到时返回非 0 且无输出', () => {
const result = runLib('opendance_assimp_resolve || echo MISSING', '/nonexistent-dir-only');
// 本机若真的装了 assimp(/usr/bin),则跳过这条断言的严格性
if (!existsSync('/usr/bin/assimp') && !existsSync('/usr/local/bin/assimp')) {
expect(result.stdout.trim()).toBe('MISSING');
}
expect(result.status).toBe(0);
});
it('opendance_assimp_install_cmd 按包管理器给出对应命令', () => {
const aptDir = join(workRoot, 'apt');
mkdirSync(aptDir, { recursive: true });
writeFileSync(join(aptDir, 'apt-get'), '#!/bin/bash\nexit 0\n');
chmodSync(join(aptDir, 'apt-get'), 0o755);
const result = runLib('opendance_assimp_install_cmd', `${aptDir}:/usr/bin:/bin`);
expect(result.stdout).toContain('apt-get install -y assimp-utils');
});
it('opendance_assimp_install 调用包管理器安装 assimp-utils(离线失败也不抛)', () => {
const aptDir = join(workRoot, 'apt');
const log = join(aptDir, 'calls.log');
rmSync(log, { force: true });
writeFileSync(join(aptDir, 'apt-get'), `#!/bin/bash\necho "apt-get $*" >> "${log}"\nexit 0\n`);
chmodSync(join(aptDir, 'apt-get'), 0o755);
const result = runLib('opendance_assimp_install', `${aptDir}:/usr/bin:/bin`);
expect(result.status).toBe(0);
const calls = existsSync(log) ? readFileSync(log, 'utf8') : '';
expect(calls).toContain('apt-get install -y -qq assimp-utils');
});
it('非常规目录需要写 ASSIMP_BIN,标准目录不需要', () => {
expect(runLib(`opendance_assimp_needs_env_path ${join(workRoot, 'bin', 'assimp')} && echo YES`).stdout).toContain('YES');
expect(runLib('opendance_assimp_needs_env_path /usr/bin/assimp || echo NO').stdout).toContain('NO');
});
});