// @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;
}) => (
),
}));
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();
});
});