Files
opendance-windows/build/integration/seed-catalog.mjs
T
zijunchiang cc8b6ba729 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)。
2026-09-18 03:05:51 +08:00

242 lines
13 KiB
JavaScript
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.
/**
* 为「Windows 客户端 × Web 服务端」跨端集成验收准备一份**真实**目录:
* 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM;
* 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。
*/
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: '跨端集成验收素材',
sourceUrl: 'https://example.com/opendance',
license: 'MIT',
author: 'OpenDance QA',
};
async function request(method, path, { json, form } = {}) {
const init = { method };
if (json !== undefined) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(json); }
if (form !== undefined) init.body = form;
const res = await fetch(`${API}${path}`, init);
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
return { status: res.status, body };
}
function fileForm(fields, filename, content, mime = 'application/octet-stream') {
const form = new FormData();
for (const [k, v] of Object.entries(fields)) form.append(k, String(v));
form.append('file', new Blob([content], { type: mime }), filename);
return form;
}
const must = (cond, what, extra = '') => {
if (!cond) { console.error(`✗ ${what} ${extra}`); process.exit(1); }
console.log(` ✓ ${what}`);
};
const run = async () => {
// ---------- 歌曲 ----------
const song = await request('POST', '/api/songs', {
json: { title: '霜雪千年', artist: '乐正绫 / 洛天依', album: '跨端验收', language: 'zh', durationMs: 96700, bpm: 120, difficulty: 3, ...SOURCE },
});
must(song.status === 201, '创建曲目', `${song.status} ${JSON.stringify(song.body)}`);
const songId = song.body.id;
// ---------- 动作:真实 VMD ----------
const vmd = readFileSync(`${SAMPLES}/【动作】霜雪千年.vmd`);
const motion = await request('POST', '/api/motions', {
form: fileForm({ name: '霜雪千年(真实 VMD)', songId, autoPreprocess: false, ...SOURCE }, '霜雪千年.vmd', vmd, 'application/octet-stream'),
});
must(motion.status === 201, '上传真实 VMD 动作', `${motion.status} ${JSON.stringify(motion.body)}`);
const motionId = motion.body.id;
must(motion.body.format === 'vmd', `识别格式 vmd(实际 ${motion.body.format})`);
const pre = await request('POST', `/api/motions/${motionId}/preprocess`);
must(pre.status === 200, '预处理为内部标准 33 点', `${pre.status} ${JSON.stringify(pre.body)}`);
console.log(` 预处理结果:${JSON.stringify(pre.body)}`);
// ---------- 模型:真实 PMX 压缩包 → VRM ----------
const pmxZip = readFileSync(`${SAMPLES}/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`);
const model = await request('POST', '/api/models', {
form: fileForm({ name: '初音未来(真实 PMX → VRM)', ...SOURCE }, 'miku.zip', pmxZip, 'application/zip'),
});
must(model.status === 201, '上传真实 PMX 压缩包', `${model.status} ${JSON.stringify(model.body)}`);
const modelId = model.body.id;
must(model.body.format === 'vrm', `转换产物格式 vrm(实际 ${model.body.format})`);
must(model.body.usable === true, '模型可用(humanoid 达标)', JSON.stringify(model.body.unusableReason));
console.log(` 转换说明:${model.body.conversionNote}`);
console.log(` humanoid:${model.body.stats?.humanoidMapped}/${model.body.stats?.humanoidTotal},可驱动=${model.body.stats?.drivable}`);
// ---------- 场景:真实 GLB(复用刚转换出的 VRM 几何)+ 物理参数 ----------
const glbRes = await fetch(`${API}${model.body.fileUrl}`);
const glbBytes = Buffer.from(await glbRes.arrayBuffer());
must(glbBytes.subarray(0, 4).toString('ascii') === 'glTF', `取回模型 GLB(${glbBytes.length} 字节)`);
// 作为场景上传时补一个按客户端 colliders:auto 约定命名的碰撞节点不方便,直接用 mesh 模式
const sceneConfig = {
physics: {
gravity: { x: 0, y: -3.5, z: 0 },
wind: {
base: { x: 2, y: 0, z: -1 },
gustAmplitude: 4,
gustFrequency: 0.5,
gustDirection: { x: 1, y: 0, z: 0 },
space: 'world',
},
ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 },
colliders: 'mesh',
},
environment: { ambientColor: '#112233', ambientIntensity: 2, fogDensity: 0.2 },
spawnPoints: [{ x: 1, y: 0, z: -2, yaw: 90 }],
camera: { mode: 'orbit', distance: 6, height: 2 },
};
const scene = await request('POST', '/api/scenes', {
form: fileForm({ name: '跨端验收场景(真实 GLB)', config: JSON.stringify(sceneConfig), ...SOURCE }, 'stage.glb', glbBytes, 'model/gltf-binary'),
});
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], ['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));
const approve = await request('POST', `/api/reviews/${kind}/${id}/approve`, {
json: kind === 'model' ? { reviewer: 'qa', comment: '通过', setPrimary: true } : { reviewer: 'qa', comment: '通过' },
});
must(approve.status === 200 || approve.status === 201, `审核通过 ${kind}#${id}`, `${approve.status} ${JSON.stringify(approve.body)}`);
}
// ---------- 目录导出 ----------
const health = await request('GET', '/health');
must(health.status === 200, 'GET /health(取契约版本写进期望值)', String(health.status));
const catalog = await request('GET', '/api/catalog/export');
must(catalog.status === 200, 'GET /api/catalog/export', String(catalog.status));
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)}`);
mkdirSync(dirname(EXPECT_PATH), { recursive: true });
writeFileSync(EXPECT_PATH, JSON.stringify({
songId, motionId, modelId, sceneId,
sceneConfig,
motionFrames: pre.body?.frameCount ?? null,
// 注意:**上传响应**里的 keypointFormat 还是 null(预处理后才写入),
// 必须取**预处理响应**的值 —— 这里踩过一次,期望值写成了 null。
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}`);
};
run().catch((e) => { console.error('ERR', e); process.exit(1); });