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。
This commit is contained in:
2026-09-17 05:18:03 +08:00
parent e4e534f2a2
commit a78a536476
15 changed files with 889 additions and 23 deletions
+142
View File
@@ -0,0 +1,142 @@
/**
* 为「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); });