d5743cd4b8
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
## 目的 资源画布支持引擎(Cocos Creator)资源的**只读预览**:能被发现、登记、进入画布,并按类型出预览。 ## 主要改动 - 发现层识别 Cocos 资源并区分 `model` / `binary` 两个发现类别;`.meta` 等导入侧车文件仍只可发现 - 登记层按扩展名写入**既有** canonical kind(模型/场景/预制体 → `scene`,动画 → `character-animation`,材质/特效 → `code`,图集与容器 → `document`),不新增 manifest 契约字段 - 引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不再进入发现结果(仅当目录确实是 Cocos 工程),同名目录在非引擎工程里照常列出 - 资源画布新增三个卡面分支:模型缩略图(整页共用一个 WebGL 上下文)、结构摘要(Cocos 序列化资源)、类型卡(客户端解不了的容器) - 新增模型放大预览浮层:左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角 - 模型预览上限 32 MiB;缩略图按布局尺寸 2 倍超采样,缓存键改用稳定身份 - 引擎图像容器(tga/tif/tiff/hdr)在原生侧转码成 PNG 后复用既有图片预览链路 - 修复资源卡状态边框吃掉内容盒导致的卡面与角标位移 - 同步 Agent 提示词与 AGC 技能文档,并补里程碑与踩坑文档 ## 验证 - `cargo check`、`cargo fmt --check` - Rust 定向:`cargo test … cocos` 9 条、`resource_inspect::tests` 7 条、`agent_asset_import_tests` 9 条、生成目录过滤用例 - 前端:`resource` + `project` 套件 52 文件 / 546 用例;`appSurface` 450 通过 / 20 跳过;app `tsc --noEmit` - `npm run check:encoding`、`git diff --check`、`npm run check:doc-index` - 真机:在真实客户端内打开本地 Cocos 夹具工程,逐栏核对模型缩略图 / 结构摘要 / TGA 转码 / 类型卡,并量过卡片在指针移开、悬停、选中三态下几何完全一致 ## 未验证 - 模型缩略图与放大预览的真机视觉只覆盖自带夹具工程;多文件 glTF(外部 .bin/贴图)与超大模型仍是类型卡 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/413
462 lines
13 KiB
TypeScript
462 lines
13 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';
|
|
|
|
// 浮层外壳(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: 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(
|
|
<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();
|
|
});
|
|
});
|