Files
opendance-web/web-server/test/scene-config.spec.ts
T
opendance 584b7eaca9 feat(scenes): 场景管理后端 + 契约 0.5.0/0.7.0/0.4.0(T-WEB-015)
契约先行(三端逐字节一致,已同步 windows/android):
- rest-api -> 0.5.0:新增 GET/POST /api/scenes、GET/PATCH/DELETE /api/scenes/{id}
- data-models -> 0.7.0:新增 §7 Scene 与 7.1 SceneConfig(物理/环境/出生点/相机)
- database -> 0.4.0:新增 scenes 表(config/stats 用 MEDIUMTEXT,迁移可逆)

后端:
- entities/scene.entity.ts + 迁移 AddScenes1789650000000(scenes 表)
- scenes/scene-config.ts:SceneConfig 定义 + 默认值 + **边界收敛**(越界夹紧、
  非法枚举回退),保证客户端拿到的参数一定可解释;服务端只存参数不跑物理
- scenes/scenes.service.ts:上传(.glb/.gltf 直接导入;.zip/.pmx/.fbx 转 GLB,
  场景不做 humanoid 重绑定、不转 VRM)、列表/详情/更新/软删除、缩略图复用模型
  光栅化器、分析复用模型检查器、config 同时接受 JSON 字符串与平铺字段
- scenes/scenes.controller.ts + scenes.module.ts(app.module 注册)
- 审核复用 /api/reviews/:type/:id:TargetType.Scene 接入待审列表与状态流转
- catalog/export 增 scenes[],目录指纹纳入场景变更
- 枚举:TargetType.Scene、AssetKind.Scene / SceneThumbnail

测试:新增 test/scene-config.spec.ts 6 项(默认值/夹紧/枚举回退/出生点上限/颜色/
序列化);规模 19 文件 / 243 项;check-routes 35 条一致。版本 0.6.0。

注:管理端场景页(GLB 预览 + 物理参数可视化)与 Windows 场景选择留待下一轮。
2026-09-17 00:27:08 +08:00

108 lines
4.5 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 } from 'vitest';
import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS } from '../src/scenes/scene-config';
import { toSceneDto } from '../src/scenes/scene.serializer';
import { PublishStatus, SourceType } from '../src/common/enums';
import type { Scene } from '../src/entities/scene.entity';
/**
* 场景物理参数(T-WEB-015)。
*
* 服务端**只存参数、不跑物理**,所以这里锁两件事:
* 1. 越界/非法输入一律被收敛成可解释的值(客户端拿到就一定能用);
* 2. 缺省即 `SCENE_CONFIG_DEFAULTS`(重力 -9.8 等),且序列化结构稳定。
*/
describe('SceneConfig 归一化', () => {
it('空输入 -> 默认值(重力 -9.8、无风、跟随相机)', () => {
const config = normalizeSceneConfig(undefined);
expect(config).toEqual(SCENE_CONFIG_DEFAULTS);
expect(config.physics.gravity).toEqual({ x: 0, y: -9.8, z: 0 });
expect(config.physics.wind.gustAmplitude).toBe(0);
expect(config.camera.mode).toBe('follow');
});
it('越界数值被夹紧(重力 ±200、摩擦 0~1、频率 0.01~5、雾 0~1)', () => {
const config = normalizeSceneConfig({
physics: {
gravity: { x: 9999, y: -9999, z: 0 },
wind: { base: { x: 500, y: 0, z: 0 }, gustAmplitude: 999, gustFrequency: 99 },
ground: { friction: 5, restitution: -1, slopeLimit: 120 },
},
environment: { ambientIntensity: 99, fogDensity: 9 },
camera: { distance: 999, height: -5 },
});
expect(config.physics.gravity.x).toBe(200);
expect(config.physics.gravity.y).toBe(-200);
expect(config.physics.wind.base.x).toBe(100);
expect(config.physics.wind.gustAmplitude).toBe(100);
expect(config.physics.wind.gustFrequency).toBe(5);
expect(config.physics.ground.friction).toBe(1);
expect(config.physics.ground.restitution).toBe(0);
expect(config.physics.ground.slopeLimit).toBe(89);
expect(config.environment.ambientIntensity).toBe(4);
expect(config.environment.fogDensity).toBe(1);
expect(config.camera.distance).toBe(50);
expect(config.camera.height).toBe(0);
});
it('非法枚举回退默认;风坐标系只认 local/world', () => {
const config = normalizeSceneConfig({
physics: { colliders: 'banana', wind: { space: 'space' } },
camera: { mode: 'fly' },
environment: { ambientColor: 'red' },
});
expect(config.physics.colliders).toBe('auto');
expect(config.physics.wind.space).toBe('world');
expect(config.camera.mode).toBe('follow');
expect(config.environment.ambientColor).toBe(SCENE_CONFIG_DEFAULTS.environment.ambientColor);
const local = normalizeSceneConfig({ physics: { colliders: 'mesh', wind: { space: 'local' } } });
expect(local.physics.colliders).toBe('mesh');
expect(local.physics.wind.space).toBe('local');
});
it('出生点最多 16 个,坐标与朝向被夹紧', () => {
const many = Array.from({ length: 40 }, () => ({ x: 1e9, y: 0, z: 0, yaw: 1e9 }));
const config = normalizeSceneConfig({ spawnPoints: many });
expect(config.spawnPoints.length).toBe(16);
expect(config.spawnPoints[0].x).toBe(1000);
expect(config.spawnPoints[0].yaw).toBe(360);
});
it('合法 #RRGGBB 颜色被接受并小写化', () => {
const config = normalizeSceneConfig({ environment: { ambientColor: '#AABBCC' } });
expect(config.environment.ambientColor).toBe('#aabbcc');
});
});
describe('SceneDto 序列化', () => {
it('带出 config 与来源五元组;URL 由调用方注入', () => {
const scene = {
id: 3,
name: '黄昏广场',
glbAssetId: 11,
thumbnailAssetId: 12,
config: normalizeSceneConfig({ physics: { gravity: { x: 0, y: -3.5, z: 0 } } }),
stats: { triangles: 1234 },
fileSize: 456,
fileSha256: 'abc',
conversionNote: '直接导入(GLB,未做重编码)',
sourceType: SourceType.Other,
sourceName: 'x',
sourceUrl: 'y',
license: 'z',
author: 'a',
status: PublishStatus.Pending,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-02T00:00:00Z'),
} as unknown as Scene;
const dto = toSceneDto(scene, { glbUrl: '/static/scene/x.glb', thumbnailUrl: '/static/scene/x.png' });
expect(dto.id).toBe(3);
expect(dto.glbUrl).toBe('/static/scene/x.glb');
expect(dto.config?.physics.gravity.y).toBe(-3.5);
expect(dto.stats).toEqual({ triangles: 1234 });
expect(dto.status).toBe(PublishStatus.Pending);
expect(dto.createdAt).toBe('2026-01-01T00:00:00.000Z');
});
});