docs(web): 任务说明、DSH 约束与交付文档
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* OpenDance Studio Web 服务端 —— 端到端冒烟验收脚本。
|
||||
*
|
||||
* 覆盖 T-WEB-003 ~ T-WEB-009 的主要接口:
|
||||
* 歌曲 CRUD → 动作上传 → 关键点预处理 → 关键点下载
|
||||
* → 模型上传(VRM 优先)→ 审核流转 → 目录导出 → 负向用例
|
||||
*
|
||||
* 用法:
|
||||
* API_BASE=http://127.0.0.1:3000 node test/smoke.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';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const failures = [];
|
||||
|
||||
function check(name, condition, detail = '') {
|
||||
if (condition) {
|
||||
passed += 1;
|
||||
console.log(` ✓ ${name}`);
|
||||
} else {
|
||||
failed += 1;
|
||||
failures.push(`${name}${detail ? ` — ${detail}` : ''}`);
|
||||
console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
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, headers: res.headers };
|
||||
}
|
||||
|
||||
const SOURCE = {
|
||||
sourceType: 'original',
|
||||
sourceName: '冒烟测试来源',
|
||||
sourceUrl: 'https://example.com/source',
|
||||
license: 'MIT',
|
||||
author: 'OpenDance QA',
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 构造合法的 GLB(magic + version + length + JSON chunk) */
|
||||
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 chunkHeader = Buffer.alloc(8);
|
||||
chunkHeader.writeUInt32LE(padded.length, 0);
|
||||
chunkHeader.writeUInt32LE(0x4e4f534a, 4); // 'JSON'
|
||||
return Buffer.concat([header, chunkHeader, padded]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n== OpenDance Studio Web 服务端冒烟验收 @ ${API} ==\n`);
|
||||
|
||||
// ---------- 0. 健康检查 ----------
|
||||
console.log('[0] 健康检查');
|
||||
const health = await request('GET', '/health');
|
||||
check('GET /health 返回 200', health.status === 200, `实际 ${health.status}`);
|
||||
check('数据库已连接', health.body?.database?.connected === true, JSON.stringify(health.body?.database));
|
||||
check('迁移无待执行项', health.body?.database?.migrationsPending === 0);
|
||||
check('契约版本已暴露', Boolean(health.body?.contractVersion?.['rest-api']));
|
||||
|
||||
// ---------- 1. T-WEB-003 歌曲 CRUD ----------
|
||||
console.log('\n[1] T-WEB-003 歌曲 CRUD');
|
||||
const created = await request('POST', '/api/songs', {
|
||||
json: {
|
||||
title: `冒烟曲目 ${Date.now()}`,
|
||||
artist: 'QA 歌手',
|
||||
album: 'QA 专辑',
|
||||
language: 'zh',
|
||||
durationMs: 180000,
|
||||
bpm: 128.5,
|
||||
difficulty: 3,
|
||||
tags: ['测试', 'dance'],
|
||||
...SOURCE,
|
||||
},
|
||||
});
|
||||
check('POST /api/songs 返回 201', created.status === 201, `实际 ${created.status} ${JSON.stringify(created.body)}`);
|
||||
const songId = created.body?.id;
|
||||
check('返回 id 为数字', typeof songId === 'number', `实际 ${typeof songId}`);
|
||||
check('来源五元组回显完整', created.body?.sourceName === SOURCE.sourceName && created.body?.license === 'MIT');
|
||||
check('tags 正确往返', Array.isArray(created.body?.tags) && created.body.tags.length === 2);
|
||||
check('bpm 为数字而非字符串', typeof created.body?.bpm === 'number', `实际 ${typeof created.body?.bpm}`);
|
||||
|
||||
const list = await request('GET', '/api/songs?q=冒烟&page=1&pageSize=5');
|
||||
check('GET /api/songs 分页结构正确', list.status === 200 && Array.isArray(list.body?.items) && typeof list.body?.total === 'number');
|
||||
check('模糊查询命中新建曲目', list.body?.items?.some((s) => s.id === songId));
|
||||
|
||||
const audioForm = new FormData();
|
||||
audioForm.append('file', new Blob([Buffer.from('ID3fake-audio-bytes')], { type: 'audio/mpeg' }), 'test.mp3');
|
||||
const audio = await request('POST', `/api/songs/${songId}/audio`, { form: audioForm });
|
||||
check('POST /api/songs/:id/audio 上传成功', audio.status === 201, `实际 ${audio.status}`);
|
||||
check('audioUrl 已生成', typeof audio.body?.audioUrl === 'string' && audio.body.audioUrl.startsWith('/static/'), String(audio.body?.audioUrl));
|
||||
|
||||
const patched = await request('PATCH', `/api/songs/${songId}`, { json: { difficulty: 5 } });
|
||||
check('PATCH /api/songs/:id 更新成功', patched.status === 200 && patched.body?.difficulty === 5);
|
||||
|
||||
const badSource = await request('PATCH', `/api/songs/${songId}`, { json: { license: 'GPL' } });
|
||||
check('PATCH 只给部分来源字段 → 400', badSource.status === 400, `实际 ${badSource.status}`);
|
||||
|
||||
// ---------- 2. T-WEB-004 动作上传 ----------
|
||||
console.log('\n[2] T-WEB-004 动作上传 + 来源字段');
|
||||
const bvh = readFileSync(join(here, 'fixtures', 'sample.bvh'));
|
||||
const motionRes = await request('POST', '/api/motions', {
|
||||
form: fileForm(
|
||||
{ name: '冒烟动作-挥手深蹲', songId, autoPreprocess: false, ...SOURCE },
|
||||
'sample.bvh',
|
||||
bvh,
|
||||
'text/plain',
|
||||
),
|
||||
});
|
||||
check('POST /api/motions 返回 201', motionRes.status === 201, `实际 ${motionRes.status} ${JSON.stringify(motionRes.body)}`);
|
||||
const motionId = motionRes.body?.id;
|
||||
check('自动识别格式为 bvh', motionRes.body?.format === 'bvh', String(motionRes.body?.format));
|
||||
check('fileSize 已记录', typeof motionRes.body?.fileSize === 'number' && motionRes.body.fileSize > 0);
|
||||
check('预处理初始状态为 none', motionRes.body?.preprocessState === 'none');
|
||||
|
||||
const missingSource = await request('POST', '/api/motions', {
|
||||
form: fileForm({ name: '缺少来源' }, 'sample.bvh', bvh),
|
||||
});
|
||||
check('缺少来源五元组 → 400', missingSource.status === 400, `实际 ${missingSource.status}`);
|
||||
|
||||
const badExt = await request('POST', '/api/motions', {
|
||||
form: fileForm({ name: '非法格式', ...SOURCE }, 'evil.exe', bvh),
|
||||
});
|
||||
check('非法扩展名 → 400', badExt.status === 400, `实际 ${badExt.status}`);
|
||||
|
||||
// ---------- 3. T-WEB-006 关键点预处理 ----------
|
||||
console.log('\n[3] T-WEB-006 BVH → MediaPipe33 预处理');
|
||||
const pre = await request('POST', `/api/motions/${motionId}/preprocess`);
|
||||
check('POST /api/motions/:id/preprocess 成功', pre.status === 200, `实际 ${pre.status} ${JSON.stringify(pre.body)}`);
|
||||
check('preprocessState = done', pre.body?.preprocessState === 'done', String(pre.body?.preprocessError));
|
||||
check('keypointFormat 为 mediapipe33@0.1.0', pre.body?.keypointFormat === 'mediapipe33@0.1.0');
|
||||
check('keypointFrames = 60', pre.body?.keypointFrames === 60, String(pre.body?.keypointFrames));
|
||||
check('fps ≈ 30', Math.abs((pre.body?.fps ?? 0) - 30) < 0.01, String(pre.body?.fps));
|
||||
|
||||
const kp = await fetch(`${API}/api/motions/${motionId}/keypoints`);
|
||||
const kpText = await kp.text();
|
||||
const lines = kpText.trim().split('\n');
|
||||
check('GET /api/motions/:id/keypoints 返回 200', kp.status === 200, `实际 ${kp.status}`);
|
||||
check('NDJSON 共 60 行', lines.length === 60, `实际 ${lines.length}`);
|
||||
const firstFrame = JSON.parse(lines[0]);
|
||||
check('每帧 33 个关键点', firstFrame.points?.length === 33, `实际 ${firstFrame.points?.length}`);
|
||||
check('每帧 9 个关节角', firstFrame.angles?.length === 9, `实际 ${firstFrame.angles?.length}`);
|
||||
const xs = firstFrame.points.map((p) => p[0]);
|
||||
const ys = firstFrame.points.map((p) => p[1]);
|
||||
check('关键点 x 归一化在 [0,1]', xs.every((v) => v >= 0 && v <= 1), `min=${Math.min(...xs)} max=${Math.max(...xs)}`);
|
||||
check('关键点 y 归一化在 [0,1]', ys.every((v) => v >= 0 && v <= 1), `min=${Math.min(...ys)} max=${Math.max(...ys)}`);
|
||||
const confs = new Set(firstFrame.points.map((p) => p[3]));
|
||||
check('置信度分层(直接映射 1 / 推算 0.5)', confs.has(1) || confs.has(0.5), [...confs].join(','));
|
||||
check('关节角余弦落在 [-1,1]', firstFrame.angles.every((v) => v >= -1.0001 && v <= 1.0001));
|
||||
|
||||
// 关键点随时间变化(确认不是静止帧)
|
||||
const lastFrame = JSON.parse(lines[lines.length - 1]);
|
||||
const moved = firstFrame.points.some((p, i) => Math.abs(p[0] - lastFrame.points[i][0]) > 0.001);
|
||||
check('关键点序列随时间变化', moved);
|
||||
|
||||
// ---------- 4. T-WEB-005 模型上传(VRM 优先) ----------
|
||||
console.log('\n[4] T-WEB-005 模型上传 + VRM 优先 + 来源字段');
|
||||
const vrmFile = buildGlb({
|
||||
asset: { version: '2.0', generator: 'OpenDance smoke test' },
|
||||
extensions: { VRMC_vrm: { specVersion: '1.0' } },
|
||||
});
|
||||
|
||||
const modelRes = await request('POST', '/api/models', {
|
||||
form: fileForm({ name: '冒烟 VRM 模型', isPrimary: true, ...SOURCE }, 'avatar.vrm', vrmFile, 'model/gltf-binary'),
|
||||
});
|
||||
check('POST /api/models 返回 201', modelRes.status === 201, `实际 ${modelRes.status} ${JSON.stringify(modelRes.body)}`);
|
||||
const modelId = modelRes.body?.id;
|
||||
check('识别 VRM 版本为 1.0', modelRes.body?.vrmVersion === '1.0', String(modelRes.body?.vrmVersion));
|
||||
check('isPrimary 已置位', modelRes.body?.isPrimary === true);
|
||||
|
||||
const modelMissingSource = await request('POST', '/api/models', {
|
||||
form: fileForm({ name: '缺少来源' }, 'x.vrm', vrmFile),
|
||||
});
|
||||
check('模型缺少来源五元组 → 400', modelMissingSource.status === 400, `实际 ${modelMissingSource.status}`);
|
||||
|
||||
const modelsList = await request('GET', '/api/models?format=vrm');
|
||||
check('按格式过滤模型列表', modelsList.status === 200 && modelsList.body?.items?.every((m) => m.format === 'vrm'));
|
||||
check('列表含来源信息(管理端展示用)', Boolean(modelsList.body?.items?.[0]?.sourceUrl && modelsList.body?.items?.[0]?.author));
|
||||
|
||||
// ---------- 5. T-WEB-007 审核流程 ----------
|
||||
console.log('\n[5] T-WEB-007 上传审核流程');
|
||||
const submit = await request('POST', `/api/reviews/song/${songId}/submit`, { json: { reviewer: 'qa' } });
|
||||
check('提交审核 → pending', submit.status === 200 && submit.body?.status === 'pending', `实际 ${submit.status} ${JSON.stringify(submit.body)}`);
|
||||
|
||||
const pending = await request('GET', '/api/reviews?status=pending');
|
||||
check('待审列表包含该曲目', pending.body?.items?.some((i) => i.targetType === 'song' && i.targetId === songId));
|
||||
|
||||
const badReject = await request('POST', `/api/reviews/song/${songId}/reject`, { json: { reviewer: 'qa' } });
|
||||
check('驳回未填理由 → 400', badReject.status === 400, `实际 ${badReject.status}`);
|
||||
|
||||
const approve = await request('POST', `/api/reviews/song/${songId}/approve`, { json: { reviewer: 'qa', comment: '通过' } });
|
||||
check('审核通过 → published', approve.status === 200 && approve.body?.status === 'published');
|
||||
|
||||
const again = await request('POST', `/api/reviews/song/${songId}/approve`, { json: { reviewer: 'qa' } });
|
||||
check('重复审核 → 409', again.status === 409, `实际 ${again.status}`);
|
||||
|
||||
const history = await request('GET', `/api/reviews/song/${songId}/history`);
|
||||
check('审核流水已记录 2 条', Array.isArray(history.body) && history.body.length === 2, `实际 ${history.body?.length}`);
|
||||
|
||||
await request('POST', `/api/reviews/motion/${motionId}/submit`, { json: { reviewer: 'qa' } });
|
||||
await request('POST', `/api/reviews/motion/${motionId}/approve`, { json: { reviewer: 'qa' } });
|
||||
await request('POST', `/api/reviews/model/${modelId}/submit`, { json: { reviewer: 'qa' } });
|
||||
await request('POST', `/api/reviews/model/${modelId}/approve`, { json: { reviewer: 'qa' } });
|
||||
|
||||
// ---------- 6. 目录导出 ----------
|
||||
console.log('\n[6] 客户端目录导出');
|
||||
const bundle = await request('GET', '/api/catalog/export');
|
||||
check('GET /api/catalog/export 返回 200', bundle.status === 200);
|
||||
check('仅含已发布曲目', bundle.body?.songs?.some((s) => s.id === songId));
|
||||
check('defaultModelId 解析为 VRM 模型', bundle.body?.defaultModelId === modelId, String(bundle.body?.defaultModelId));
|
||||
check('bundle 含契约版本', Boolean(bundle.body?.contractVersion?.['data-models']));
|
||||
|
||||
const version = await request('GET', '/api/catalog/version');
|
||||
check('GET /api/catalog/version 返回目录指纹', /^[0-9a-f]{64}$/.test(version.body?.revision ?? ''), String(version.body?.revision).slice(0, 16));
|
||||
|
||||
// ---------- 7. 负向用例 ----------
|
||||
console.log('\n[7] 负向用例');
|
||||
const notFound = await request('GET', '/api/songs/99999999');
|
||||
check('不存在的曲目 → 404', notFound.status === 404);
|
||||
const badEnum = await request('POST', '/api/songs', { json: { title: 'x', artist: 'y', ...SOURCE, sourceType: 'nope' } });
|
||||
check('非法 sourceType → 400', badEnum.status === 400, `实际 ${badEnum.status}`);
|
||||
const badUrl = await request('POST', '/api/songs', { json: { title: 'x', artist: 'y', ...SOURCE, sourceUrl: 'not-a-url' } });
|
||||
check('非法 sourceUrl → 400', badUrl.status === 400, `实际 ${badUrl.status}`);
|
||||
|
||||
// ---------- 8. 清理 ----------
|
||||
console.log('\n[8] 清理');
|
||||
const del = await request('DELETE', `/api/songs/${songId}`);
|
||||
check('DELETE /api/songs/:id → 204', del.status === 204, `实际 ${del.status}`);
|
||||
const afterDelete = await request('GET', '/api/songs?q=冒烟');
|
||||
check('软删除后列表不再包含', !afterDelete.body?.items?.some((s) => s.id === songId));
|
||||
await request('DELETE', `/api/motions/${motionId}`);
|
||||
await request('DELETE', `/api/models/${modelId}`);
|
||||
|
||||
console.log(`\n== 结果:${passed} 通过 / ${failed} 失败 ==`);
|
||||
if (failed > 0) {
|
||||
console.log('\n失败项:');
|
||||
for (const f of failures) console.log(` - ${f}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('全部通过 ✅\n');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('\n冒烟测试异常终止:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user