// @vitest-environment jsdom import { render, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; // 浮层外壳(portal + 焦点陷阱)不在本用例关注面内:与文档预览浮层的用例同口径只渲染子节点。 vi.mock('../src/components/modal/ThemedModal', () => ({ ThemedModal: ({ children, ariaLabel, }: { children: ReactNode; ariaLabel: string; }) => (
{children}
), })); import { projectResourceCardPreviewKind, projectResourceCardPreviewReadsContent, projectResourceCocosStructureSummary, projectResourceMediaPreviewCategory, projectResourceStructuredPreviewText, } from '../src/view/project-development/resourceCardPreviewModel'; import { ResourceModelPreview } from '../src/view/project-development/ResourceModelPreview'; import { ResourceModelPreviewDialog } from '../src/view/project-development/ResourceModelPreviewDialog'; import { resourceModelThumbnailCacheKey, resourceModelThumbnailIdentity, } from '../src/view/project-development/resourceModelThumbnail'; import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; import { projectedResourceKind, projectResourceTypeLabel, } from '../src/view/project-development/resourceProjectionModel'; type EngineCase = { path: string; mediaType: string; kind: string; projectedKind: 'art' | 'audio' | 'code' | 'document'; previewKind: | 'model' | 'structured' | 'binary' | 'media-image' | 'audio' | 'document' | 'code'; typeLabel: string; readsContent: boolean; }; /** * Cocos Creator 资源在资源画布上的**准入 + 卡面分支 + 类型角标**三合一决策表。 * * 这张表是前后端三份扩展名表(原生发现 / 原生登记 / 前端投影)的交叉契约:表里任何一行 * 对不上,都会表现为「登记得了但画布不显示」或「显示了但预览是坏图」。 */ const ENGINE_CASES: EngineCase[] = [ { path: 'assets/model/hero.glb', mediaType: 'model/gltf-binary', kind: 'scene', projectedKind: 'art', previewKind: 'model', typeLabel: '模型', readsContent: true, }, { path: 'assets/model/hero.gltf', mediaType: 'model/gltf+json', kind: 'scene', projectedKind: 'art', previewKind: 'model', typeLabel: '模型', readsContent: true, }, { path: 'assets/model/hero.fbx', mediaType: 'application/octet-stream', kind: 'scene', projectedKind: 'art', previewKind: 'model', typeLabel: '模型', readsContent: true, }, { path: 'assets/model/hero.mesh', mediaType: 'application/json', kind: 'scene', projectedKind: 'art', previewKind: 'structured', typeLabel: '模型', readsContent: true, }, { path: 'assets/model/hero.skeleton', mediaType: 'application/json', kind: 'scene', projectedKind: 'art', previewKind: 'structured', typeLabel: '骨骼动画', readsContent: true, }, { path: 'assets/anim/walk.anim', mediaType: 'application/json', kind: 'character-animation', projectedKind: 'document', previewKind: 'structured', typeLabel: '动画', readsContent: true, }, { path: 'assets/anim/graph.animgraph', mediaType: 'application/json', kind: 'character-animation', projectedKind: 'document', previewKind: 'structured', typeLabel: '动画', readsContent: true, }, { path: 'assets/scene/main.scene', mediaType: 'application/json', kind: 'scene', projectedKind: 'document', previewKind: 'structured', typeLabel: '场景', readsContent: true, }, { path: 'assets/scene/enemy.prefab', mediaType: 'application/json', kind: 'scene', projectedKind: 'document', previewKind: 'structured', typeLabel: '预制体', readsContent: true, }, { path: 'assets/map/level.tmx', mediaType: 'application/xml', kind: 'scene', projectedKind: 'document', previewKind: 'structured', typeLabel: '瓦片地图', readsContent: true, }, { path: 'assets/mtl/hero.mtl', mediaType: 'application/json', kind: 'code', projectedKind: 'code', previewKind: 'structured', typeLabel: '材质', readsContent: true, }, { path: 'assets/shader/glow.effect', mediaType: 'text/plain', kind: 'code', projectedKind: 'code', previewKind: 'structured', typeLabel: '特效', readsContent: true, }, { path: 'assets/atlas/hero.plist', mediaType: 'application/xml', kind: 'document', projectedKind: 'document', previewKind: 'structured', typeLabel: '图集', readsContent: true, }, { path: 'assets/font/bitmap.fnt', mediaType: 'text/plain', kind: 'document', projectedKind: 'document', previewKind: 'structured', typeLabel: '位图字体', readsContent: true, }, { path: 'assets/tex/grass.tga', mediaType: 'image/x-tga', kind: 'image', projectedKind: 'art', previewKind: 'media-image', typeLabel: '图片', readsContent: true, }, { path: 'assets/tex/height.hdr', mediaType: 'image/vnd.radiance', kind: 'image', projectedKind: 'art', previewKind: 'media-image', typeLabel: '图片', readsContent: true, }, { path: 'assets/tex/hero.texture', mediaType: 'application/octet-stream', kind: 'document', projectedKind: 'document', previewKind: 'binary', typeLabel: '纹理', readsContent: false, }, { path: 'assets/spine/hero.skel', mediaType: 'application/octet-stream', kind: 'document', projectedKind: 'document', previewKind: 'binary', typeLabel: '骨骼动画', readsContent: false, }, { path: 'assets/audio/voice.pcm', mediaType: 'audio/pcm', kind: 'audio', projectedKind: 'audio', previewKind: 'binary', typeLabel: '音频片段', readsContent: false, }, ]; describe('Cocos 资源在资源画布上的准入与预览契约', () => { it('每个引擎资源都能进画布、落到预期卡面分支与类型角标', () => { for (const entry of ENGINE_CASES) { const resource = { id: entry.path, path: entry.path, mediaType: entry.mediaType, subtype: entry.kind, }; expect( projectedResourceKind({ path: entry.path, mediaType: entry.mediaType, kind: entry.kind, }), `${entry.path} 必须能进画布`, ).toBe(entry.projectedKind); expect(projectResourceCardPreviewKind(resource), entry.path).toBe( entry.previewKind, ); expect(projectResourceTypeLabel(resource), entry.path).toBe( entry.typeLabel, ); expect( projectResourceCardPreviewReadsContent(resource), `${entry.path} 是否能读字节`, ).toBe(entry.readsContent); } }); it('模型走 model 读取分支、图片容器走 art,二进制容器不读字节', () => { expect( projectResourceMediaPreviewCategory({ id: 'glb', path: 'assets/model/hero.glb', mediaType: 'model/gltf-binary', subtype: 'scene', }), ).toBe('model'); expect( projectResourceMediaPreviewCategory({ id: 'tga', path: 'assets/tex/grass.tga', mediaType: 'image/x-tga', subtype: 'image', }), ).toBe('art'); }); }); describe('Cocos 序列化资源的结构摘要', () => { it('从序列化数组里抽出根类型、节点数、时长与资源引用', () => { const summary = projectResourceCocosStructureSummary( JSON.stringify([ { __type__: 'cc.AnimationClip', _name: 'walk', _duration: 1.25 }, { __type__: 'cc.Node', _name: 'root' }, { __type__: 'cc.Node', _name: 'child' }, { __uuid__: 'abc' }, ]), ); expect(summary).toContain('cc.AnimationClip'); expect(summary).toContain('2 个节点'); expect(summary).toContain('1.25 秒'); expect(summary).toContain('walk'); expect(summary).toContain('1 处资源引用'); }); it('不是序列化数组时不做结构摘要,回退到前几行文本', () => { const effect = 'CCEffect %{\n techniques: []\n}'; expect(projectResourceCocosStructureSummary(effect)).toBeNull(); expect(projectResourceStructuredPreviewText(effect)).toContain('CCEffect'); }); it('二进制变体(原生读不到正文)返回空串,卡面画类型卡', () => { expect(projectResourceStructuredPreviewText(undefined)).toBe(''); }); }); describe('模型卡渲染失败时的降级', () => { it('缩略图缓存键按稳定身份计算,不随 blob URL 变化', () => { const identity = resourceModelThumbnailIdentity({ resourceKey: 'asset:hero', path: 'assets/model/hero.glb', byteLen: 1024, }); const first = resourceModelThumbnailCacheKey({ mediaType: 'model/gltf-binary', identity, width: 320, height: 240, }); const second = resourceModelThumbnailCacheKey({ mediaType: 'model/gltf-binary', identity, width: 320, height: 240, }); expect(first).toBe(second); // 同一份资源换了内容(字节数变化)或换了尺寸,都必须重新渲染。 expect( resourceModelThumbnailCacheKey({ mediaType: 'model/gltf-binary', identity: resourceModelThumbnailIdentity({ resourceKey: 'asset:hero', path: 'assets/model/hero.glb', byteLen: 2048, }), width: 320, height: 240, }), ).not.toBe(first); expect( resourceModelThumbnailCacheKey({ mediaType: 'model/gltf-binary', identity, width: 480, height: 360, }), ).not.toBe(first); }); it('渲染不出缩略图时显示类型卡,不冒泡成预览失败', async () => { // jsdom 没有 WebGL:这正是"渲染不可用"的真实路径,用来钉住降级行为。 render( , ); expect(screen.getByText('模型 · GLB')).toBeDefined(); await waitFor(() => { expect( document.querySelector('[data-model-preview-status="failed"]'), ).not.toBeNull(); }); }); }); describe('模型放大预览浮层', () => { const modelResource: ProjectResource = { id: 'asset:model', path: 'assets/model/cube.glb', label: 'cube.glb', mediaType: 'model/gltf-binary', category: 'scene', subtype: 'scene', manifestAssetId: 'asset:model', sourceLabel: '', taskTitle: null, producerTaskId: null, externalResourceId: null, referenceResourceIds: [], dependencies: [], dependencyDepth: 0, }; it('打开即按详情理由请求字节,并给出与建模软件一致的视角操作提示', () => { const onRequestPreview = vi.fn(); render( undefined} />, ); expect(onRequestPreview).toHaveBeenCalledWith( modelResource, 'asset:model|assets/model/cube.glb|1548', 'detail', ); expect(screen.getByRole('status').textContent).toContain('正在加载模型'); expect(document.body.textContent).toContain('左键拖拽旋转'); expect(document.body.textContent).toContain('滚轮缩放'); expect(document.body.textContent).toContain('复位视角'); }); it('渲染器不可用时明确说明,不假装已经渲染出模型', async () => { // jsdom 没有 WebGL:这正是「无法渲染」的真实路径。 render( undefined} onClose={() => undefined} />, ); await waitFor(() => { expect( document.querySelector('[data-model-viewer-status="failed"]'), ).not.toBeNull(); }); expect(document.body.textContent).toContain('无法渲染三维预览'); }); it('预览失败时给出错误与重试,不留下空白浮层', () => { render( undefined} onClose={() => undefined} />, ); expect(screen.getByRole('alert').textContent).toContain('模型预览只支持'); expect(screen.getByText('重试')).toBeDefined(); }); });