test: 补目录导出交付逻辑与场景端到端冒烟(①/④ 的验收证据)
目标 ④(场景管理)此前只有"上传/配置收敛"的测试,**没有任何测试覆盖
「场景是否真的随 catalog/export 下发到客户端」**;①(非 VRM 不进目录)也只有
实现没有断言。补上两层:
1. 新增 test/catalog-export.spec.ts(12 项,纯 mock、不依赖数据库)
- ① 目录查询必须同时限定 status=published 与 format=vrm;
/catalog/version 的模型查询同样只算 VRM;
GLB/glTF/FBX 这类"未产出 VRM"的中间格式不得进入目录;
- ④ exportBundle().scenes 来自 ScenesService.published(),且
重力/风/地面/碰撞体等物理参数完整透传;无场景时是空数组而非 undefined;
- ④ 场景新增/改名/换 GLB/更新时间变化都必须改变 catalog/version 指纹
(否则客户端不会重新拉取);
- defaultModelId 透传与空值回退。
2. test/smoke.mjs 新增 [7] 场景管理端到端段(18 项断言),并在 [9] 清理:
- 上传 GLB 场景 → 一律 pending、GLB 直传不重编码、glbUrl 生成;
- 合法物理参数原样入库;越界参数(重力 -9999、摩擦 5)被夹紧到 -200/1;
非法枚举(colliders/相机模式)回退默认值;
- 缺少来源五元组 → 400;
- **待审场景不出现在客户端目录**;审核通过后才进入 scenes[],且带完整物理参数;
- 目录指纹随场景发布而变化;GET /api/scenes/:id 与 ?status= 过滤可用。
夹具可信性已单独验证:用**真实检查器**(inspectModelFile)解析 smoke 里同款
构造方式生成的场景 GLB,得到 meshes=1 / vertices=3 / triangles=1 / nodes=1,
证明该夹具是合法 GLB,而不是"写了个跑不通的脚本"。
顺带核查管理端构建产物:/scenes 路由、"场景管理"入口、"v0.7.2" 版本标签与
物理参数相关文案都在 bundle 中(Web 左下角版本号来自 package.json,见
scripts/check-version.mjs)。
验证:npm run verify 通过(契约六项一致、35 条路由一致、版本一致、
20 文件 / 269 项测试、nest build、启动冒烟)。
This commit is contained in:
+161
-4
@@ -246,8 +246,160 @@ async function main() {
|
||||
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] 负向用例');
|
||||
// ---------- 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' } });
|
||||
@@ -255,14 +407,19 @@ async function main() {
|
||||
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] 清理');
|
||||
// ---------- 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) {
|
||||
|
||||
Reference in New Issue
Block a user