目标 ④(场景管理)此前只有"上传/配置收敛"的测试,**没有任何测试覆盖
「场景是否真的随 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、启动冒烟)。
256 lines
9.6 KiB
TypeScript
256 lines
9.6 KiB
TypeScript
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');
|
||
});
|
||
});
|