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'); }); });