Files
opendance-web/web-server/test/scene-config.spec.ts
T
opendance f2e231c0b2 feat(admin): 场景管理页——列表/上传/物理参数编辑 + 3D 预览(T-WEB-015 补齐)
- 新增 admin/src/pages/Scenes.tsx(路由 /scenes + 侧栏「场景管理」入口):
  * 列表:40px 缩略图(点击进详情)、物理摘要、体积、状态、软删除;
  * 上传表单:GLB 文件 + 名称 + 来源五元组 + 物理参数(重力、风基础向量、
    阵风幅度/频率、地面摩擦/坡度、碰撞体、环境光),multipart 走 POST /api/scenes;
  * 详情:复用 VrmPreview(懒加载 three.js 分包)预览场景 GLB + 参数可视化编辑
    并通过 PATCH /api/scenes/:id 保存;
- 新增 admin/src/lib/scene-physics.ts:DEFAULT_SCENE_CONFIG(与契约 7.1、服务端
  scene-config.ts 单一真源,测试断言两端一致)与 physicsSummary();
- 场景页两栏自适应样式;types/endpoints 补齐场景五接口。
- 测试:场景用例增至 9 项;规模 19 文件 / 245 项。版本 0.7.0。
2026-09-17 00:32:05 +08:00

140 lines
5.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 { DEFAULT_SCENE_CONFIG, physicsSummary } from '../admin/src/lib/scene-physics';
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');
});
});
describe('场景列表的物理摘要(管理端)', () => {
it('展示重力与风速,并在有阵风时标注幅度/频率', () => {
const calm = physicsSummary(normalizeSceneConfig(undefined));
expect(calm).toContain('重力 -9.8');
expect(calm).toContain('风 0.0m/s');
expect(calm).not.toContain('阵风');
const windy = physicsSummary(
normalizeSceneConfig({
physics: {
wind: {
base: { x: 3, y: 0, z: 4 },
gustAmplitude: 2.5,
gustFrequency: 0.5,
},
},
}),
);
expect(windy).toContain('风 5.0m/s');
expect(windy).toContain('阵风 2.5m/s@0.5Hz');
});
it('没有 config 时显示占位符(不抛错)', () => {
expect(physicsSummary(null)).toBe('—');
});
it('管理端默认值与服务端一致(契约 7.1 单一真源)', () => {
expect(DEFAULT_SCENE_CONFIG).toEqual(SCENE_CONFIG_DEFAULTS);
});
});