Files
opendance-windows/build/integration/seed-catalog.mjs
T
zijunchiang a78a536476 feat(integration): 跨端集成验收(包内客户端 × 真实 Web 服务端)+ 修两个真实缺陷(v0.2.3)
单测打的是假 HTTP 夹具,Web 端测试只验自己那一侧 —— 两端各自绿灯不代表契约字段
真的对得上。本版加了跨端集成验收,并在首次运行就抓到两个真实缺陷。

一、跨端集成验收(T-WIN-019)
  * build/integration/probe-integration.gd:只加载**包内**代码
    (`godot --main-pack <pck> --script <探针>` + res://),打真实服务端并逐项断言:
    /health 六个契约版本、catalog/version 指纹、catalog/export 解析出的四类资源与
    defaultModelId;**场景物理参数逐项比对**(重力/基础风/阵风/风坐标系/地面摩擦坡度/
    碰撞体/出生点/相机/环境)+ Wind.sample(0) + 场景 GLB 可下载;
    动作 NDJSON → 客户端解析帧数 == 服务端帧数、33 点、每帧服务端关节角、
    推导帧率 30 fps、时长 96.67 s;缩略图 PNG;模型 VRM 可下载。
  * build/integration/seed-catalog.mjs:用**真实素材**播种目录
    (示例文件/ 下的真 VMD → 内部标准 33 点;真 PMX → VRM;场景 GLB + 物理参数),
    并把期望值(含从 /health 取的契约版本)写成 JSON。
  * build/accept-integration.sh:编排「服务端可达 → 播种 → 包内探针 → 汇报」。
  实测(v0.2.3 包内 pck × 真实 Web v0.7.4 + 真实 MySQL):全部通过。

二、探针抓到的两个真实缺陷
  1. `CatalogTypes.Motion` 缺 `thumbnail_url`:服务端 catalog/export 从
     data-models 0.6.0 起一直在发 thumbnailUrl(Model/Scene 都有),客户端默默丢掉。
     已补字段与解析(缺失为 ""),新增 tests/test_catalog_types.gd(4 项 20 断言)
     用**服务端真实报文形状**锁住三个资源类型的覆盖,并显式记录一处刻意不消费的差异
     (Model.usable/unusableReason —— 目录只下发可用模型,恒为 true)。
  2. `ApiClient._request()` 对**所有**响应无条件 `raw.get_string_from_utf8()`:
     数据本身没错(二进制路径返回原始字节),但每下载一个文件都会打印
     「Unicode parsing error … Invalid UTF-8 leading byte (N)」噪声,并把整个文件
     (模型可达数十 MB)解码成 String 拷贝后丢弃。改为只在需要文本时解码。

三、顺带修掉测试里的写死契约版本
  tests/test_api_integration.gd 断言 rest-api/websocket-keypoints/skeleton-topology
  均为 "0.1.0"。该用例只在服务端可达时才跑,平时一直 skip,所以契约升到 0.5.0 后
  没有任何地方发现它。改为从仓库 contracts/VERSION 读期望值并与 /health 比对
  (顺带成为「客户端契约快照 vs 服务端」的跨端校验)。

证据:verify.sh 全绿(820 通过 / 0 失败,比上版 +46,因为服务端在线时 API 集成组
真的跑了);包内 pck 跨端集成探针全部通过;check-version/PE 版本资源 = 0.2.3。
2026-09-17 05:18:03 +08:00

143 lines
7.4 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, readFileSync, writeFileSync } from 'node:fs';
import { dirname } 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';
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;
// ---------- 审核发布 ----------
for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId]]) {
// 契约:**上传即 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 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 ?? {},
}, null, 2));
console.log(`\n期望值已写入 ${EXPECT_PATH}`);
};
run().catch((e) => { console.error('ERR', e); process.exit(1); });