Files
opendance-web/web-server/test/model-usability.spec.ts
T
opendance 39bfd390e3 feat(models): 模型一律转 VRM(契约 0.5.0)+ 可用性门槛 + 管理端版本号展示
按《DSH 开发文档 v1.6》4.9 与第十一章实现,Web 端第一步:

1. 契约先行(先改 contracts/ 再同步三端):
   * model-formats.md -> 0.5.0:模型主力格式改为 VRM,所有上传模型一律转 VRM
     (.pmx/.fbx: assimp -> GLB -> VRM;glb/gltf: 包装为 VRM);
     新增 humanoid 硬门槛与"不达标回退 GLB + usable=false"的交付表;
   * data-models.md -> 0.5.0:Model 新增 usable / unusableReason,明确
     usable=false 不进入 catalog/export;format 说明改为主力 vrm;
   * VERSION 与 contract-versions.ts 同步(三端契约文件逐字节一致)。
2. 实现:
   * MODEL_CONVERT_FORMAT 默认改为 vrm(deploy/.env.example 同步);
   * 新增 isUsableModel()/unusableReason():usable 等价于"已产出 VRM",
     原因从 stats 里带出 humanoid 覆盖度;
   * catalog/export 与 catalog/version 指纹都只统计 format=vrm 的已发布模型,
     resolveDefaultModelId 同样只在可用模型中选;
   * 管理端:模型列表与审核面板显示「不可用」及原因;
   * 管理端左下角展示当前版本号(构建期从 web-server/package.json 注入
     __APP_VERSION__,与 GET /health 同源),并去掉写死的"契约 rest-api 0.1.0"。
3. 测试:新增 test/model-usability.spec.ts 5 项;规模 16 文件 / 225 项。
4. 版本 0.3.0。
2026-09-17 00:05:02 +08:00

111 lines
4.2 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 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,
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 });
});
});