594063a1e0
- 按 vitest 0.34 的参数元组口径改写 mock 泛型 - 修正 noUncheckedIndexedAccess 下的下标访问 - 补齐与当前类型脱节的测试桩字段 - 按 scripts/*.mjs 的运行时契约补局部类型声明
496 lines
14 KiB
TypeScript
496 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import type { ReactNode } from 'react';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { GameCreationAppAssetKind } from '../../../packages/shared/src/contracts/gameCreationApp';
|
|
|
|
// 浮层外壳(portal + 焦点陷阱)不在本用例关注面内:与文档预览浮层的用例同口径只渲染子节点。
|
|
vi.mock('../src/components/modal/ThemedModal', () => ({
|
|
ThemedModal: ({
|
|
children,
|
|
ariaLabel,
|
|
}: {
|
|
children: ReactNode;
|
|
ariaLabel: string;
|
|
}) => (
|
|
<section role="dialog" aria-label={ariaLabel}>
|
|
{children}
|
|
</section>
|
|
),
|
|
}));
|
|
|
|
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: GameCreationAppAssetKind;
|
|
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,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* 预览分支只读 `path` / `mediaType` / `subtype`,但预览模型的入参是完整的
|
|
* `ProjectResource`;这里补齐投影产物的其余必填字段,构造与被测链路同形状的对象。
|
|
*/
|
|
function resourceFixture(input: {
|
|
id: string;
|
|
path: string;
|
|
mediaType: string;
|
|
subtype: string;
|
|
}): ProjectResource {
|
|
return {
|
|
id: input.id,
|
|
category: 'unclassified',
|
|
subtype: input.subtype,
|
|
label: input.path.split('/').pop() ?? input.path,
|
|
path: input.path,
|
|
mediaType: input.mediaType,
|
|
sourceLabel: '',
|
|
taskTitle: null,
|
|
manifestAssetId: null,
|
|
producerTaskId: null,
|
|
externalResourceId: null,
|
|
referenceResourceIds: [],
|
|
dependencies: [],
|
|
dependencyDepth: 0,
|
|
};
|
|
}
|
|
|
|
describe('Cocos 资源在资源画布上的准入与预览契约', () => {
|
|
it('每个引擎资源都能进画布、落到预期卡面分支与类型角标', () => {
|
|
for (const entry of ENGINE_CASES) {
|
|
const resource = resourceFixture({
|
|
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(
|
|
resourceFixture({
|
|
id: 'glb',
|
|
path: 'assets/model/hero.glb',
|
|
mediaType: 'model/gltf-binary',
|
|
subtype: 'scene',
|
|
}),
|
|
),
|
|
).toBe('model');
|
|
expect(
|
|
projectResourceMediaPreviewCategory(
|
|
resourceFixture({
|
|
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(
|
|
<ResourceModelPreview
|
|
sourceUrl="blob:model"
|
|
mediaType="model/gltf-binary"
|
|
identity="asset:hero|assets/model/hero.glb|1024"
|
|
fallbackLabel="模型 · GLB"
|
|
/>,
|
|
);
|
|
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(
|
|
<ResourceModelPreviewDialog
|
|
resource={modelResource}
|
|
identity="asset:model|assets/model/cube.glb|1548"
|
|
preview={{ status: 'loading' }}
|
|
onRequestPreview={onRequestPreview}
|
|
onClose={() => 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(
|
|
<ResourceModelPreviewDialog
|
|
resource={modelResource}
|
|
identity="asset:model|assets/model/cube.glb|1548"
|
|
preview={{
|
|
status: 'loaded',
|
|
preview: {
|
|
path: modelResource.path,
|
|
mediaType: 'model/gltf-binary',
|
|
byteLen: 1548,
|
|
sourceUrl: 'blob:cube',
|
|
},
|
|
}}
|
|
onRequestPreview={() => undefined}
|
|
onClose={() => undefined}
|
|
/>,
|
|
);
|
|
await waitFor(() => {
|
|
expect(
|
|
document.querySelector('[data-model-viewer-status="failed"]'),
|
|
).not.toBeNull();
|
|
});
|
|
expect(document.body.textContent).toContain('无法渲染三维预览');
|
|
});
|
|
|
|
it('预览失败时给出错误与重试,不留下空白浮层', () => {
|
|
render(
|
|
<ResourceModelPreviewDialog
|
|
resource={modelResource}
|
|
identity="asset:model|assets/model/cube.glb|1548"
|
|
preview={{
|
|
status: 'failed',
|
|
error: '模型预览只支持 GLB、glTF 或 FBX 文件',
|
|
retryable: true,
|
|
}}
|
|
onRequestPreview={() => undefined}
|
|
onClose={() => undefined}
|
|
/>,
|
|
);
|
|
expect(screen.getByRole('alert').textContent).toContain('模型预览只支持');
|
|
expect(screen.getByText('重试')).toBeDefined();
|
|
});
|
|
});
|