/** * 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 模型', ...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('上传后状态为待审核', modelRes.body?.status === 'pending', String(modelRes.body?.status)); // 契约 data-models §4 / rest-api POST /api/models:**上传一律 pending,且不设默认模型**; // 默认模型只能由「审核通过时带 setPrimary=true」或 POST /api/models/{id}/primary 设定。 check('上传阶段不置默认模型(isPrimary=false)', modelRes.body?.isPrimary === false, String(modelRes.body?.isPrimary)); 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)}`); // 待审列表默认分页:显式放大 pageSize,避免库里已有大量待审项时新条目被挤出第一页 const pending = await request('GET', '/api/reviews?status=pending&pageSize=200'); check( '待审列表包含该曲目', pending.status === 200 && pending.body?.items?.some((i) => i.targetType === 'song' && i.targetId === songId), `HTTP ${pending.status},total=${pending.body?.total},条数=${pending.body?.items?.length},${JSON.stringify(pending.body).slice(0, 200)}`, ); 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' } }); // 默认模型按契约在此设定:审核通过时带 setPrimary=true(等价于 POST /api/models/:id/primary) const modelApprove = await request('POST', `/api/reviews/model/${modelId}/approve`, { json: { reviewer: 'qa', setPrimary: true }, }); check( '模型审核通过并设为默认(setPrimary=true)', modelApprove.status === 200 && modelApprove.body?.status === 'published', `HTTP ${modelApprove.status} ${JSON.stringify(modelApprove.body).slice(0, 160)}`, ); const modelAfter = await request('GET', `/api/models/${modelId}`); check('审核通过后 isPrimary 已置位', modelAfter.body?.isPrimary === true, String(modelAfter.body?.isPrimary)); // ---------- 6. 目录导出 ---------- console.log('\n[6] 客户端目录导出'); const bundle = await request('GET', '/api/catalog/export'); check( 'GET /api/catalog/export 返回 200', bundle.status === 200, `HTTP ${bundle.status} ${JSON.stringify(bundle.body).slice(0, 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 ?? ''), `HTTP ${version.status} ${JSON.stringify(version.body).slice(0, 200)}`, ); // ---------- 7. T-WEB-015 场景管理(GLB + 物理参数) ---------- console.log('\n[7] T-WEB-015 场景管理(GLB + 物理参数)'); 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: 'auto', }, environment: { ambientColor: '#112233', ambientIntensity: 2, fogDensity: 0.2 }, spawnPoints: [{ x: 1, y: 0, z: -2, yaw: 90 }], camera: { mode: 'orbit', distance: 6, height: 2 }, }; // 场景 GLB:一个 mesh(POSITION=3)+ 一个按客户端 `colliders: auto` 命名约定 // 命名的节点(*_col)。分析/缩略图失败是非致命的,下面的断言只依赖入库结果。 const sceneGlb = buildGlb({ asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }], nodes: [{ name: 'Floor_col' }], meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }], accessors: [{ count: 3 }], }); const sceneRes = await request('POST', '/api/scenes', { form: fileForm( { name: `冒烟场景 ${Date.now()}`, config: JSON.stringify(sceneConfig), ...SOURCE }, 'stage.glb', sceneGlb, 'model/gltf-binary', ), }); check('POST /api/scenes 返回 201', sceneRes.status === 201, `实际 ${sceneRes.status} ${JSON.stringify(sceneRes.body)}`); const sceneId = sceneRes.body?.id; check('上传场景一律待审核', sceneRes.body?.status === 'pending', String(sceneRes.body?.status)); check( 'GLB 直传不重编码(conversionNote 说明)', typeof sceneRes.body?.conversionNote === 'string' && sceneRes.body.conversionNote.includes('直接导入'), String(sceneRes.body?.conversionNote), ); check( 'glbUrl 已生成', typeof sceneRes.body?.glbUrl === 'string' && sceneRes.body.glbUrl.startsWith('/static/'), String(sceneRes.body?.glbUrl), ); // 物理参数入库即收敛:合法值原样保留 const sceneCfg = sceneRes.body?.config; check('重力按配置入库', sceneCfg?.physics?.gravity?.y === -3.5, JSON.stringify(sceneCfg?.physics?.gravity)); check('风基础向量入库', sceneCfg?.physics?.wind?.base?.x === 2, JSON.stringify(sceneCfg?.physics?.wind)); check( '地面摩擦/坡度入库', sceneCfg?.physics?.ground?.friction === 0.8 && sceneCfg?.physics?.ground?.slopeLimit === 30, JSON.stringify(sceneCfg?.physics?.ground), ); check( '出生点与相机模式入库', sceneCfg?.spawnPoints?.[0]?.yaw === 90 && sceneCfg?.camera?.mode === 'orbit', JSON.stringify({ spawn: sceneCfg?.spawnPoints, camera: sceneCfg?.camera }), ); // 物理参数入库即收敛:越界夹紧、非法枚举回退 const clampRes = await request('POST', '/api/scenes', { form: fileForm( { name: `冒烟越界场景 ${Date.now()}`, config: JSON.stringify({ physics: { gravity: { y: -9999 }, ground: { friction: 5 }, colliders: '不存在的枚举' }, camera: { mode: '无此模式' }, }), ...SOURCE, }, 'stage-clamped.glb', sceneGlb, 'model/gltf-binary', ), }); const clampId = clampRes.body?.id; check( '越界参数被夹紧(不会把非法物理下发到客户端)', clampRes.body?.config?.physics?.gravity?.y === -200 && clampRes.body?.config?.physics?.ground?.friction === 1, JSON.stringify(clampRes.body?.config?.physics), ); check( '非法枚举回退默认值', clampRes.body?.config?.physics?.colliders === 'auto' && clampRes.body?.config?.camera?.mode === 'follow', JSON.stringify({ colliders: clampRes.body?.config?.physics?.colliders, camera: clampRes.body?.config?.camera?.mode }), ); const sceneMissingSource = await request('POST', '/api/scenes', { form: fileForm({ name: '缺来源' }, 'x.glb', sceneGlb, 'model/gltf-binary'), }); check('场景缺少来源五元组 → 400', sceneMissingSource.status === 400, `实际 ${sceneMissingSource.status}`); // 未审核前不得进入客户端目录 const versionBeforeScene = await request('GET', '/api/catalog/version'); const bundleBeforeScene = await request('GET', '/api/catalog/export'); check( '待审场景不出现在客户端目录', !bundleBeforeScene.body?.scenes?.some((s) => s.id === sceneId), JSON.stringify(bundleBeforeScene.body?.scenes?.map((s) => s.id)), ); await request('POST', `/api/reviews/scene/${sceneId}/submit`, { json: { reviewer: 'qa' } }); const sceneApprove = await request('POST', `/api/reviews/scene/${sceneId}/approve`, { json: { reviewer: 'qa', comment: '通过' }, }); check( '场景审核通过 → published', sceneApprove.status === 200 && sceneApprove.body?.status === 'published', `实际 ${sceneApprove.status} ${JSON.stringify(sceneApprove.body)}`, ); const bundleAfterScene = await request('GET', '/api/catalog/export'); const publishedScene = bundleAfterScene.body?.scenes?.find((s) => s.id === sceneId); check( '发布后场景进入客户端目录 scenes[]', Boolean(publishedScene), JSON.stringify(bundleAfterScene.body?.scenes?.map((s) => s.id)), ); check( '目录里的场景带完整物理参数', publishedScene?.config?.physics?.gravity?.y === -3.5 && publishedScene?.config?.physics?.wind?.gustAmplitude === 4, JSON.stringify(publishedScene?.config?.physics), ); check( '目录里的场景带 GLB 与缩略图字段', typeof publishedScene?.glbUrl === 'string' && 'thumbnailUrl' in (publishedScene ?? {}), JSON.stringify({ glbUrl: publishedScene?.glbUrl, thumbnailUrl: publishedScene?.thumbnailUrl }), ); const versionAfterScene = await request('GET', '/api/catalog/version'); check( '目录指纹纳入场景变更(客户端会因此重新拉取)', versionAfterScene.body?.revision !== versionBeforeScene.body?.revision, `${String(versionBeforeScene.body?.revision).slice(0, 12)} -> ${String(versionAfterScene.body?.revision).slice(0, 12)}`, ); const sceneDetail = await request('GET', `/api/scenes/${sceneId}`); check('GET /api/scenes/:id 返回详情', sceneDetail.status === 200 && sceneDetail.body?.id === sceneId); const sceneList = await request('GET', '/api/scenes?status=published'); check( '场景列表可按状态过滤', sceneList.status === 200 && sceneList.body?.items?.some((s) => s.id === sceneId), `实际 ${sceneList.status}`, ); // ---------- 8. 负向用例 ---------- console.log('\n[8] 负向用例'); 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}`); // ---------- 9. 清理 ---------- console.log('\n[9] 清理'); 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}`); if (sceneId) { const delScene = await request('DELETE', `/api/scenes/${sceneId}`); check('DELETE /api/scenes/:id → 204', delScene.status === 204, `实际 ${delScene.status}`); } if (clampId) await request('DELETE', `/api/scenes/${clampId}`); 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); });