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:
@@ -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); });
|
||||
Reference in New Issue
Block a user