Files
opendance-web/web-server/test/model-usability.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

113 lines
4.3 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 { 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 });
});
});