test(integration): 跨端验收补两类契约边界(不可用模型 / 场景 config 缺字段)

正常路径之外的**字段边界**同样只在跨端跑起来才暴露。配合 Web v0.7.5 增加:

1. 不可用模型:用 Assimp 把真实 PMX 转成 GLB 后入库(等价"管线只产出 GLB"),
   Web 侧断言 usable=false、原因准确(骨架达标时不得说"humanoid 不达标")、
   不在 catalog/export、defaultModelId 不变;Windows 侧(包内探针)断言
   bundle.models 里没有任何非 VRM 模型、按 id 找不到该模型、defaultModelId 仍指向可用 VRM。
2. 场景 config 只给 {physics:{gravity:{y:-9.8}}}:Web 侧断言服务端补齐默认
   (碰撞体 auto、地面 0.6/45°、静风、相机 follow、环境光 1、出生点 1 条);
   Windows 侧断言客户端解析出的**同一组默认值**逐项一致。

实测(Web v0.7.5 × Windows v0.2.5 包内 pck,真实 MySQL):
  ✓ usable=false 且原因为「未产出 VRM(格式为 GLB;humanoid 覆盖 19/22 已达标)…」
  ✓ 目录里的模型全是 VRM/不可用模型 #2 不在目录里/defaultModelId 仍为 1
  ✓ 最简配置:重力 -9.8 保留,碰撞体 auto、地面 0.6/45°、静风、相机 follow、
    环境光 1.0、出生点 1 条,两端逐项一致
跨端集成验收全部通过。

顺带修掉播种脚本自身的顺序错误(把依赖 catalog 的断言插到了 catalog 取回之前),
以及把断言改成「先取目录再断言」。不影响发布产物(build/ 已排除出 pck)。
This commit is contained in:
2026-09-18 03:05:51 +08:00
parent e0ba4ea833
commit cc8b6ba729
4 changed files with 173 additions and 4 deletions
+102 -3
View File
@@ -3,14 +3,17 @@
* 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM;
* 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { dirname, join } from 'node:path';
const API = process.env.API_BASE ?? 'http://127.0.0.1:3100';
// 样例目录与期望值输出路径都可通过环境变量覆盖(默认取仓库根的 示例文件/)
const SAMPLES = process.env.OPENDANCE_SAMPLES_DIR
?? new URL('../../../示例文件', import.meta.url).pathname;
const EXPECT_PATH = process.env.EXPECT_PATH ?? '/tmp/opendance-catalog-expect.json';
/** 用于把真实 PMX 转成 GLB(模拟"转换只产出 GLB"的入库形态) */
const ASSIMP_BIN = process.env.ASSIMP_BIN ?? '/usr/bin/assimp';
const SOURCE = {
sourceType: 'original',
sourceName: '跨端集成验收素材',
@@ -102,8 +105,67 @@ const run = async () => {
must(scene.status === 201, '上传场景 GLB', `${scene.status} ${JSON.stringify(scene.body)}`);
const sceneId = scene.body.id;
// ---------- 边界 A:不可用模型(只有 GLB、没有 VRM 容器)----------
// 形态等价于"管线只产出了 GLB":契约 model-formats §1.1 要求这类模型
// **可入库、可审核,但 usable=false,且不进入 catalog/export**(① 的硬性要求)。
let unusable = null;
try {
const pmxZip = `${SAMPLES}/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`;
const entries = execFileSync('unzip', ['-Z1', pmxZip], { encoding: 'utf8' })
.split('\n').map((line) => line.trim())
.filter((line) => line.toLowerCase().endsWith('.pmx'));
if (entries.length === 0) throw new Error('压缩包里没有 .pmx');
const pmxPath = join('/tmp', 'opendance-seed-model.pmx');
const glbPath = join('/tmp', 'opendance-seed-model.glb');
execFileSync('unzip', ['-p', pmxZip, entries[0]], { stdio: ['ignore', openSync(pmxPath, 'w'), 'ignore'] });
execFileSync(ASSIMP_BIN, ['export', pmxPath, glbPath], { stdio: 'ignore' });
const glb = readFileSync(glbPath);
must(glb.subarray(0, 4).toString('ascii') === 'glTF', `Assimp 转出 GLB(${glb.length} 字节)`);
const created = await request('POST', '/api/models', {
form: fileForm({ name: '不可用模型(仅 GLB,未产出 VRM)', ...SOURCE }, 'unusable.glb', glb, 'model/gltf-binary'),
});
must(created.status === 201, '上传仅 GLB 的模型', `${created.status} ${JSON.stringify(created.body)}`);
const id = created.body.id;
must(created.body.format === 'glb', `入库格式为 glb(实际 ${created.body.format})`);
must(created.body.usable === false, '该模型标记为不可用(usable=false)',
JSON.stringify(created.body.unusableReason));
const approve = await request('POST', `/api/reviews/model/${id}/approve`, {
json: { reviewer: 'qa', comment: '通过但不设为默认' },
});
must(approve.status === 200 || approve.status === 201, '审核通过(不设为默认模型)', String(approve.status));
const detail = await request('GET', `/api/models/${id}`);
const reason = String(detail.body?.unusableReason ?? '');
const drivable = detail.body?.stats?.drivable === true;
must(reason.includes('未产出 VRM'), `不可用原因说明"未产出 VRM":${reason}`);
// 归因准确性(T-WEB-017):骨架已达标时**不得**说"humanoid 不达标"
if (drivable) {
must(!reason.includes('humanoid 不达标'),
`骨架达标(drivable=true,humanoid ${detail.body?.stats?.humanoidMapped}/${detail.body?.stats?.humanoidTotal})`
+ `时不得归因于 humanoid:${reason}`);
} else {
console.log(` · 该 GLB 的 drivable=false(humanoid ${detail.body?.stats?.humanoidMapped}/`
+ `${detail.body?.stats?.humanoidTotal}),原因归因于 humanoid 属正确`);
}
unusable = { id, reason, drivable };
rmSync(pmxPath, { force: true });
rmSync(glbPath, { force: true });
} catch (error) {
console.log(` · 跳过不可用模型场景(${error.message})—— 需要 unzip 与 ASSIMP_BIN`);
}
// ---------- 边界 B:场景 config 只给最少字段,服务端必须补齐默认 ----------
const minimalConfig = { physics: { gravity: { y: -9.8 } } };
const minimalScene = await request('POST', '/api/scenes', {
form: fileForm(
{ name: '最简配置场景(只有 gravity)', config: JSON.stringify(minimalConfig), ...SOURCE },
'minimal.glb', glbBytes, 'model/gltf-binary',
),
});
must(minimalScene.status === 201, '上传最简配置场景', `${minimalScene.status} ${JSON.stringify(minimalScene.body)}`);
const minimalSceneId = minimalScene.body.id;
// ---------- 审核发布 ----------
for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId]]) {
for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId], ['scene', minimalSceneId]]) {
// 契约:**上传即 pending**(draft 才需要显式 submit),所以 409「已处于 pending」是正常的
const submit = await request('POST', `/api/reviews/${kind}/${id}/submit`, { json: { reviewer: 'qa' } });
must([200, 201, 409].includes(submit.status), `提交审核 ${kind}#${id}`, String(submit.status));
@@ -122,6 +184,31 @@ const run = async () => {
const data = catalog.body;
console.log(` 目录:songs=${data.songs?.length} motions=${data.motions?.length} models=${data.models?.length} scenes=${data.scenes?.length}`);
must(data.scenes?.length >= 1, '目录含场景(④ 的 scenes[])');
// 不可用模型不得进入客户端目录(①),且默认模型不受影响
const modelsInCatalog = (catalog.body?.models ?? []);
const nonVrm = modelsInCatalog.filter((m) => m.format !== 'vrm');
must(nonVrm.length === 0, '目录里的模型全是 VRM(不可用模型不进目录)', JSON.stringify(nonVrm));
if (unusable) {
must(!modelsInCatalog.some((m) => m.id === unusable.id), `不可用模型 #${unusable.id} 不在目录里`);
must(catalog.body?.defaultModelId === modelId,
`defaultModelId 仍是可用的 VRM 模型(实际 ${catalog.body?.defaultModelId},期望 ${modelId})`);
}
// 最简配置场景:服务端必须补齐默认值(契约 0.5.0 的字段边界)
const minimalEntry = (catalog.body?.scenes ?? []).find((s) => s.id === minimalSceneId);
must(minimalEntry !== undefined, `最简配置场景 #${minimalSceneId} 在目录里`);
const mc = minimalEntry?.config ?? {};
must(mc.physics?.gravity?.y === -9.8, `重力保留(${JSON.stringify(mc.physics?.gravity)})`);
must(mc.physics?.colliders === 'auto', `碰撞体补默认 auto(实际 ${mc.physics?.colliders})`);
must(mc.physics?.ground?.friction === 0.6 && mc.physics?.ground?.slopeLimit === 45,
`地面补默认 0.6/45°(实际 ${JSON.stringify(mc.physics?.ground)})`);
must(mc.physics?.wind?.gustAmplitude === 0 && mc.physics?.wind?.base?.x === 0,
`风补默认(静风):${JSON.stringify(mc.physics?.wind)}`);
must(mc.camera?.mode === 'follow', `相机补默认 follow(实际 ${mc.camera?.mode})`);
must(mc.environment?.ambientIntensity === 1, `环境光补默认 1(实际 ${mc.environment?.ambientIntensity})`);
must(mc.spawnPoints === undefined || mc.spawnPoints.length <= 1,
`出生点补默认(最多一条):${JSON.stringify(mc.spawnPoints)}`);
const version = await request('GET', '/api/catalog/version');
console.log(` 目录指纹:${JSON.stringify(version.body)}`);
@@ -135,6 +222,18 @@ const run = async () => {
keypointFormat: pre.body?.keypointFormat ?? null,
catalogRevision: version.body?.revision ?? null,
contractVersions: health.body?.contractVersion ?? {},
unusableModelId: unusable?.id ?? null,
unusableDrivable: unusable?.drivable ?? null,
minimalSceneId,
minimalSceneExpect: {
gravityY: -9.8,
colliders: 'auto',
groundFriction: 0.6,
groundSlopeLimit: 45,
windCalm: true,
cameraMode: 'follow',
ambientIntensity: 1,
},
}, null, 2));
console.log(`\n期望值已写入 ${EXPECT_PATH}`);
};