From 50688e7ce4c5aa7004dc0dafcdcd4f1ef1b59476 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Thu, 17 Sep 2026 01:56:20 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A1=A5=E7=9B=AE=E5=BD=95=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=BA=A4=E4=BB=98=E9=80=BB=E8=BE=91=E4=B8=8E=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=86=92=E7=83=9F=EF=BC=88?= =?UTF-8?q?=E2=91=A0/=E2=91=A3=20=E7=9A=84=E9=AA=8C=E6=94=B6=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目标 ④(场景管理)此前只有"上传/配置收敛"的测试,**没有任何测试覆盖 「场景是否真的随 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、启动冒烟)。 --- web-server/test/catalog-export.spec.ts | 255 +++++++++++++++++++++++++ web-server/test/smoke.mjs | 165 +++++++++++++++- 2 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 web-server/test/catalog-export.spec.ts diff --git a/web-server/test/catalog-export.spec.ts b/web-server/test/catalog-export.spec.ts new file mode 100644 index 0000000..e8a52fd --- /dev/null +++ b/web-server/test/catalog-export.spec.ts @@ -0,0 +1,255 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ModelFormat, PublishStatus } from '../src/common/enums'; +import { CatalogService } from '../src/catalog/catalog.service'; +import { SCENE_CONFIG_DEFAULTS, normalizeSceneConfig } from '../src/scenes/scene-config'; + +/** + * 目录导出(`GET /api/catalog/export` 与 `/api/catalog/version`)的**交付逻辑**。 + * + * 这里守护两条硬性需求: + * + * 1. **① 非 VRM 模型不得进入客户端目录**:查询必须同时限定 + * `status = published` 与 `format = vrm` —— 即使库里有已审核通过的 PMX/GLB, + * 也不能出现在 `models[]` 里(文档 §4.9:非 VRM 入库但标记不可用、 + * 不进入已发布目录); + * 2. **④ 场景(含物理参数)必须随目录下发**,且场景变更要体现在 + * `catalog/version` 的指纹里,否则客户端不会重新拉取。 + * + * 之所以要单独测这一层:`CatalogService` 注入 `ScenesService` 时曾在 + * `CatalogModule` 里漏了 `imports`,导致**整个服务起不来**(v0.7.0/v0.7.1)。 + * 服务能起来之后,这里再保证「起来之后的目录内容是对的」。 + */ + +interface Stub { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +function repository(rows: unknown[]): Stub { + return { + find: vi.fn(async () => rows), + }; +} + +function build(overrides: { + songs?: unknown[]; + motions?: unknown[]; + models?: unknown[]; + scenes?: unknown[]; + sceneDtos?: unknown[]; + defaultModelId?: number | null; +} = {}) { + const songsRepo = repository(overrides.songs ?? []); + const motionsRepo = repository(overrides.motions ?? []); + const modelsRepo = repository(overrides.models ?? []); + const scenesService = { + published: vi.fn(async () => overrides.sceneDtos ?? []), + }; + const songsService = { toDtos: vi.fn(async (rows: unknown[]) => rows) }; + const motionsService = { toDtos: vi.fn(async (rows: unknown[]) => rows) }; + const modelsService = { + toDtos: vi.fn(async (rows: unknown[]) => rows), + resolveDefaultModelId: vi.fn(async () => overrides.defaultModelId ?? null), + }; + const config = { + contractVersions: { + 'rest-api': '0.5.0', + 'websocket-keypoints': '0.2.0', + 'data-models': '0.7.0', + 'skeleton-topology': '0.2.0', + 'model-formats': '0.5.0', + database: '0.4.0', + }, + }; + + const service = new CatalogService( + songsRepo as never, + motionsRepo as never, + modelsRepo as never, + songsService as never, + motionsService as never, + modelsService as never, + scenesService as never, + config as never, + ); + return { service, songsRepo, motionsRepo, modelsRepo, scenesService, modelsService }; +} + +/** + * 一个"已审核通过但不是 VRM"的模型,用来验证它不会进入目录。 + * 库里可能同时存在 GLB / glTF / FBX(未产出 VRM 的中间产物), + * 契约 §4.9 要求它们**不得**出现在客户端目录里。 + */ +const NON_VRM_MODEL = { + id: 7, + name: '未产出 VRM 的模型(GLB)', + format: ModelFormat.Glb, + status: PublishStatus.Published, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +const VRM_MODEL = { + id: 8, + name: 'VRoid 模型', + format: ModelFormat.Vrm, + status: PublishStatus.Published, + isPrimary: true, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +const SCENE_DTO = { + id: 3, + name: '舞台', + glbUrl: '/static/scene/2026/01/ab/abcd.glb', + thumbnailUrl: '/static/scene/2026/01/ab/abcd.png', + fileSize: 2048, + fileSha256: 'abcdef0123456789', + conversionNote: '直接导入(GLB,未做重编码)', + config: normalizeSceneConfig({ + physics: { + gravity: { x: 0, y: -3.5, z: 0 }, + wind: { base: { x: 2, y: 0, z: -1 }, gustAmplitude: 4, gustFrequency: 0.5, space: 'world' }, + ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 }, + colliders: 'auto', + }, + }), + stats: { meshes: 1 }, + sourceType: 'original', + sourceName: '自建场景', + sourceUrl: 'https://example.com/scene', + license: 'MIT', + author: 'OpenDance', + status: PublishStatus.Published, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +describe('① 目录只交付已产出 VRM 的模型', () => { + it('查询同时限定 status=published 与 format=vrm', async () => { + const { service, modelsRepo } = build({ models: [VRM_MODEL] }); + await service.exportBundle(); + + expect(modelsRepo.find).toHaveBeenCalledTimes(1); + const options = modelsRepo.find.mock.calls[0][0] as { + where: { status: string; format: string }; + order: { id: string }; + }; + expect(options.where.status).toBe(PublishStatus.Published); + expect(options.where.format).toBe(ModelFormat.Vrm); + expect(options.order).toEqual({ id: 'ASC' }); + }); + + it('/api/catalog/version 的模型查询同样只算 VRM(指纹不应受非 VRM 影响)', async () => { + const { service, modelsRepo } = build({ models: [VRM_MODEL] }); + await service.version(); + + const options = modelsRepo.find.mock.calls[0][0] as { where: { format: string } }; + expect(options.where.format).toBe(ModelFormat.Vrm); + }); + + it('库里的 GLB/glTF/FBX 都不会进入目录(只有 vrm 被允许)', async () => { + const { service, modelsRepo } = build({ models: [VRM_MODEL] }); + await service.exportBundle(); + const options = modelsRepo.find.mock.calls[0][0] as { where: { format: string } }; + + // 这三种是"尚未产出 VRM"的中间格式,契约 §4.9 明确要求它们不进已发布目录 + for (const format of [ModelFormat.Glb, ModelFormat.Gltf, ModelFormat.Fbx]) { + expect(format).not.toBe(options.where.format); + } + expect(NON_VRM_MODEL.format).not.toBe(options.where.format); + }); + + it('defaultModelId 直接透传服务端解析结果', async () => { + const { service } = build({ models: [VRM_MODEL], defaultModelId: 8 }); + const bundle = await service.exportBundle(); + expect(bundle.defaultModelId).toBe(8); + }); + + it('没有任何可用模型时 defaultModelId 为 null(客户端自行回退)', async () => { + const { service } = build({ defaultModelId: null }); + const bundle = await service.exportBundle(); + expect(bundle.defaultModelId).toBeNull(); + }); +}); + +describe('④ 场景随目录下发(含物理参数)', () => { + it('exportBundle().scenes 来自 ScenesService.published()', async () => { + const { service, scenesService } = build({ sceneDtos: [SCENE_DTO] }); + const bundle = await service.exportBundle(); + + expect(scenesService.published).toHaveBeenCalledTimes(1); + expect(bundle.scenes).toHaveLength(1); + expect(bundle.scenes[0].id).toBe(3); + expect(bundle.scenes[0].name).toBe('舞台'); + }); + + it('场景的物理参数完整透传(客户端据此落地重力/风/地面)', async () => { + const { service } = build({ sceneDtos: [SCENE_DTO] }); + const bundle = await service.exportBundle(); + const config = bundle.scenes[0].config; + + expect(config).not.toBeNull(); + expect(config?.physics.gravity.y).toBeCloseTo(-3.5); + expect(config?.physics.wind.base.x).toBeCloseTo(2); + expect(config?.physics.wind.gustAmplitude).toBeCloseTo(4); + expect(config?.physics.ground.friction).toBeCloseTo(0.8); + expect(config?.physics.colliders).toBe('auto'); + }); + + it('scenes 字段在没有任何已发布场景时是空数组而不是 undefined', async () => { + const { service } = build({ sceneDtos: [] }); + const bundle = await service.exportBundle(); + expect(Array.isArray(bundle.scenes)).toBe(true); + expect(bundle.scenes).toHaveLength(0); + }); + + it('契约版本随目录一并下发(客户端据此判断协议兼容)', async () => { + const { service } = build({ sceneDtos: [SCENE_DTO] }); + const bundle = await service.exportBundle(); + expect(bundle.contractVersion['rest-api']).toBe('0.5.0'); + expect(bundle.contractVersion['data-models']).toBe('0.7.0'); + expect(bundle.contractVersion['websocket-keypoints']).toBe('0.2.0'); + }); +}); + +describe('④ 场景变更必须改变 catalog/version 指纹', () => { + const scene = (overrides: Partial<{ id: number; name: string; updatedAt: string; glbUrl: string }>) => ({ + id: 3, + name: '舞台', + updatedAt: '2026-01-01T00:00:00.000Z', + glbUrl: '/static/scene/a.glb', + status: PublishStatus.Published, + ...overrides, + }); + + it('相同目录 → 指纹稳定;新增/改名/换 GLB → 指纹变化', async () => { + const before = await build({ sceneDtos: [scene({})] }).service.version(); + const same = await build({ sceneDtos: [scene({})] }).service.version(); + expect(same.revision).toBe(before.revision); + + const renamed = await build({ sceneDtos: [scene({ name: '舞台 2' })] }).service.version(); + expect(renamed.revision).not.toBe(before.revision); + + const replaced = await build({ + sceneDtos: [scene({ glbUrl: '/static/scene/b.glb' })], + }).service.version(); + expect(replaced.revision).not.toBe(before.revision); + + const touched = await build({ + sceneDtos: [scene({ updatedAt: '2026-02-02T00:00:00.000Z' })], + }).service.version(); + expect(touched.revision).not.toBe(before.revision); + }); + + it('未发布任何场景时指纹仍可计算(64 位十六进制)', async () => { + const { service } = build({ sceneDtos: [] }); + const version = await service.version(); + expect(version.revision).toMatch(/^[0-9a-f]{64}$/); + }); + + it('默认场景配置是冻结默认值(无场景时客户端按默认物理参数回退)', () => { + expect(SCENE_CONFIG_DEFAULTS.physics.gravity.y).toBeCloseTo(-9.8); + expect(SCENE_CONFIG_DEFAULTS.physics.wind.gustAmplitude).toBe(0); + expect(SCENE_CONFIG_DEFAULTS.camera.mode).toBe('follow'); + }); +}); diff --git a/web-server/test/smoke.mjs b/web-server/test/smoke.mjs index 3267646..c1cb3d4 100644 --- a/web-server/test/smoke.mjs +++ b/web-server/test/smoke.mjs @@ -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) {