test(web): 新增演示数据播种脚本(供管理端与客户端联调)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user