契约先行(三端逐字节一致,已同步 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 场景选择留待下一轮。
113 lines
4.3 KiB
TypeScript
113 lines
4.3 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
||
|
||
import { CatalogService } from '../src/catalog/catalog.service';
|
||
import { ModelFormat, PublishStatus, SourceType } from '../src/common/enums';
|
||
import type { CharacterModel } from '../src/entities/model.entity';
|
||
import { isUsableModel, toModelDto, unusableReason } from '../src/models/model.serializer';
|
||
|
||
/**
|
||
* v0.5.0「模型一律转 VRM」的可用性门槛。
|
||
*
|
||
* 规则(contracts/model-formats.md 1.1):所有上传模型都转 VRM;
|
||
* humanoid 不达标时回退交付 GLB —— 这种模型**可入库、可审核,但不可用**,
|
||
* **不进入 `catalog/export`**(用户明确选择"入库但标记不可用,不进已发布目录")。
|
||
*/
|
||
|
||
function model(overrides: Partial<CharacterModel> = {}): CharacterModel {
|
||
return {
|
||
id: 1,
|
||
name: 'model',
|
||
format: ModelFormat.Vrm,
|
||
vrmVersion: '1.0',
|
||
isPrimary: false,
|
||
fileAssetId: 1,
|
||
sourceAssetId: null,
|
||
thumbnailAssetId: null,
|
||
conversionNote: null,
|
||
stats: null,
|
||
sourceType: SourceType.Other,
|
||
sourceName: 'x',
|
||
sourceUrl: 'x',
|
||
license: 'x',
|
||
author: 'x',
|
||
status: PublishStatus.Published,
|
||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||
...overrides,
|
||
} as CharacterModel;
|
||
}
|
||
|
||
describe('模型可用性(usable)', () => {
|
||
it('VRM → 可用,且没有不可用原因', () => {
|
||
const vrm = model({ format: ModelFormat.Vrm });
|
||
expect(isUsableModel(vrm)).toBe(true);
|
||
expect(unusableReason(vrm)).toBeNull();
|
||
expect(toModelDto(vrm, { fileUrl: '/f', thumbnailUrl: null }).usable).toBe(true);
|
||
});
|
||
|
||
it('GLB(humanoid 不达标)→ 不可用,原因带 humanoid 覆盖度', () => {
|
||
const glb = model({
|
||
format: ModelFormat.Glb,
|
||
vrmVersion: null,
|
||
stats: { humanoidMapped: 0, humanoidTotal: 22 } as Record<string, unknown>,
|
||
});
|
||
expect(isUsableModel(glb)).toBe(false);
|
||
expect(unusableReason(glb)).toBe('humanoid 不达标(0/22),未产出 VRM');
|
||
const dto = toModelDto(glb, { fileUrl: '/f', thumbnailUrl: null });
|
||
expect(dto.usable).toBe(false);
|
||
expect(dto.unusableReason).toContain('0/22');
|
||
});
|
||
|
||
it('GLB 且没有 stats → 给出通用原因(不抛错)', () => {
|
||
const glb = model({ format: ModelFormat.Glb, vrmVersion: null, stats: null });
|
||
expect(unusableReason(glb)).toContain('未产出 VRM');
|
||
});
|
||
});
|
||
|
||
describe('目录导出只含可用模型', () => {
|
||
function buildService(captured: { modelsWhere?: unknown }): CatalogService {
|
||
const songs = { find: vi.fn().mockResolvedValue([]) };
|
||
const motions = { find: vi.fn().mockResolvedValue([]) };
|
||
const models = {
|
||
find: vi.fn().mockImplementation((options: { where: unknown }) => {
|
||
captured.modelsWhere = options.where;
|
||
return Promise.resolve([model({ id: 7, format: ModelFormat.Vrm })]);
|
||
}),
|
||
};
|
||
const songsService = { toDtos: vi.fn().mockResolvedValue([]) };
|
||
const motionsService = { toDtos: vi.fn().mockResolvedValue([]) };
|
||
const scenesService = { published: vi.fn().mockResolvedValue([]) };
|
||
const modelsService = {
|
||
toDtos: vi.fn().mockImplementation((rows: CharacterModel[]) =>
|
||
rows.map((row) => toModelDto(row, { fileUrl: '/f', thumbnailUrl: null })),
|
||
),
|
||
resolveDefaultModelId: vi.fn().mockResolvedValue(7),
|
||
};
|
||
const config = { contractVersions: { 'rest-api': '0.5.0' } };
|
||
return new CatalogService(
|
||
songs as never,
|
||
motions as never,
|
||
models as never,
|
||
songsService as never,
|
||
motionsService as never,
|
||
modelsService as never,
|
||
scenesService as never,
|
||
config as never,
|
||
);
|
||
}
|
||
|
||
it('查询条件限定 status=published 且 format=vrm', async () => {
|
||
const captured: { modelsWhere?: unknown } = {};
|
||
const bundle = await buildService(captured).exportBundle();
|
||
expect(captured.modelsWhere).toEqual({ status: PublishStatus.Published, format: ModelFormat.Vrm });
|
||
expect(bundle.models.map((m) => m.id)).toEqual([7]);
|
||
expect(bundle.models.every((m) => m.usable)).toBe(true);
|
||
});
|
||
|
||
it('目录指纹同样只统计可用模型(否则客户端会因"不可见变化"反复刷新)', async () => {
|
||
const captured: { modelsWhere?: unknown } = {};
|
||
await buildService(captured).version();
|
||
expect(captured.modelsWhere).toEqual({ status: PublishStatus.Published, format: ModelFormat.Vrm });
|
||
});
|
||
});
|