Files
opendance-web/web-server/test/catalog-export.spec.ts
T
zijunchiang 50688e7ce4 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、启动冒烟)。
2026-09-17 01:56:20 +08:00

256 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});