test(web): 新增演示数据播种脚本(供管理端与客户端联调)

This commit is contained in:
2026-09-14 20:08:20 +08:00
parent dbd9155b40
commit 0c133986b4
+183
View File
@@ -0,0 +1,183 @@
/**
* 演示数据播种脚本 —— 供管理端界面、Windows 客户端联调与集成测试使用。
*
* 与 smoke.mjs 不同:本脚本**保留**数据并把三类资产都置为 published,
* 使 GET /api/catalog/export 能返回可用于客户端联调的完整目录。
*
* 用法:
* API_BASE=http://127.0.0.1:3000 node test/seed.mjs
*
* 幂等:重复执行只会追加新记录,不会破坏既有数据。
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const API = process.env.API_BASE ?? 'http://127.0.0.1:3000';
const SOURCE = {
sourceType: 'original',
sourceName: 'OpenDance 官方示例库',
sourceUrl: 'https://example.com/opendance/samples',
license: 'CC-BY-4.0',
author: 'OpenDance Studio',
};
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 [key, value] of Object.entries(fields)) form.append(key, String(value));
form.append('file', new Blob([content], { type: mime }), filename);
return form;
}
function buildGlb(json) {
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
const padded = Buffer.concat([jsonBuf, Buffer.alloc((4 - (jsonBuf.length % 4)) % 4, 0x20)]);
const header = Buffer.alloc(12);
header.write('glTF', 0, 'ascii');
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + padded.length, 8);
const chunk = Buffer.alloc(8);
chunk.writeUInt32LE(padded.length, 0);
chunk.writeUInt32LE(0x4e4f534a, 4);
return Buffer.concat([header, chunk, padded]);
}
async function publish(targetType, id, reviewer = 'seed') {
const submit = await request('POST', `/api/reviews/${targetType}/${id}/submit`, { json: { reviewer } });
if (submit.status === 409) return submit; // 已处于目标状态
const approve = await request('POST', `/api/reviews/${targetType}/${id}/approve`, {
json: { reviewer, comment: '演示数据自动发布' },
});
return approve;
}
async function main() {
console.log(`\n== 播种演示数据 @ ${API} ==\n`);
const health = await request('GET', '/health');
if (health.status !== 200) {
console.error('服务端不可达,先启动:cd web-server && npm run start:prod');
process.exit(1);
}
console.log(`服务端正常,数据库 ${health.body.database.server}`);
// ---------- 曲目 ----------
const song = await request('POST', '/api/songs', {
json: {
title: '示例曲目 · 小星星(Demo)',
artist: 'OpenDance 示例',
album: '客户端联调样例',
language: 'instrumental',
durationMs: 96000,
bpm: 100,
difficulty: 2,
tags: ['示例', '联调', '儿童'],
...SOURCE,
},
});
if (song.status !== 201) {
console.error('创建曲目失败:', song.status, song.body);
process.exit(1);
}
const songId = song.body.id;
console.log(` 曲目 #${songId} ${song.body.title}`);
await publish('song', songId);
// ---------- 动作(BVH + 预处理) ----------
const bvh = readFileSync(join(here, 'fixtures', 'sample.bvh'));
const motion = await request('POST', '/api/motions', {
form: fileForm(
{ name: '示例动作 · 挥手深蹲(Demo)', songId, fps: 30, ...SOURCE },
'sample.bvh',
bvh,
'text/plain',
),
});
if (motion.status !== 201) {
console.error('上传动作失败:', motion.status, motion.body);
process.exit(1);
}
const motionId = motion.body.id;
console.log(` 动作 #${motionId} ${motion.body.name}`);
const pre = await request('POST', `/api/motions/${motionId}/preprocess`);
if (pre.status !== 200) {
console.error('预处理失败:', pre.status, pre.body);
process.exit(1);
}
console.log(` 预处理完成:${pre.body.keypointFrames} 帧 × 33 点,fps=${pre.body.fps}`);
await publish('motion', motionId);
// ---------- 模型(VRM 优先) ----------
const vrm = buildGlb({
asset: { version: '2.0', generator: 'OpenDance seed' },
extensions: { VRMC_vrm: { specVersion: '1.0' } },
meshes: [],
nodes: [],
scenes: [{ nodes: [] }],
scene: 0,
});
const model = await request('POST', '/api/models', {
form: fileForm(
{ name: '示例模型 · VRM 1.0 占位(Demo)', isPrimary: true, ...SOURCE },
'demo-avatar.vrm',
vrm,
'model/gltf-binary',
),
});
if (model.status !== 201) {
console.error('上传模型失败:', model.status, model.body);
process.exit(1);
}
const modelId = model.body.id;
console.log(` 模型 #${modelId} ${model.body.name}(vrmVersion=${model.body.vrmVersion})`);
await publish('model', modelId);
// ---------- 校验目录导出 ----------
const bundle = await request('GET', '/api/catalog/export');
const summary = {
songs: bundle.body.songs.length,
motions: bundle.body.motions.length,
models: bundle.body.models.length,
defaultModelId: bundle.body.defaultModelId,
};
console.log('\n已发布目录:', JSON.stringify(summary));
const scorable = bundle.body.motions.filter((m) => m.preprocessState === 'done');
if (scorable.length === 0) {
console.error('目录中没有可评分动作,客户端集成测试将被跳过');
process.exit(1);
}
console.log('\n完成。Windows 客户端现在可联调:');
console.log(` 可评分动作:${scorable.map((m) => `#${m.id}(${m.keypointFrames} 帧)`).join(', ')}`);
console.log(` 默认模型:#${bundle.body.defaultModelId}`);
console.log('');
}
main().catch((err) => {
console.error('播种失败:', err);
process.exit(1);
});