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 { 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, }); 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 }); }); });