diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 8953c9d0b..09b1daf1e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -829,6 +829,13 @@ const ResourceCard = memo(function ResourceCard({ }`} data-resource-card-id={resource.id} data-preview-kind={kind} + // 占位分支对「还没读 / 读失败 / 压根不适用」给的是同一个图标、且卡面没有文字提示, + // 现场无法区分「为什么没有图」。这里把预览状态与失败原因暴露到 DOM, + // 让排障只需读一个属性,而不是去猜是调度没发请求还是原生拒绝了读取。 + data-preview-status={preview.status} + data-preview-error={ + preview.status === 'failed' ? preview.error : undefined + } data-used-by-current-version={usedByCurrentVersion ? 'true' : undefined} style={ { diff --git a/apps/ai-game-creator-shell/tests/resourcePreviewPredicateContract.test.ts b/apps/ai-game-creator-shell/tests/resourcePreviewPredicateContract.test.ts new file mode 100644 index 000000000..ad7af2464 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourcePreviewPredicateContract.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, test } from 'vitest'; + +import { projectResourceCardPreviewKind } from '../src/view/project-development/resourceCardPreviewModel'; +import { projectResourceTypeLabel } from '../src/view/project-development/resourceProjectionModel'; + +/** + * 派发决策必须由**可验证的内容证据**(mediaType,缺失时退路径扩展名)决定, + * 登记标签 `kind` 只能在两者都给不出结论时兜底。 + * + * 这条不变量是本轮「标着图片却只有占位图标」缺陷的直接护栏:当时 `kind:"ui"` 与 + * mediaType 同权,界面规格的 `application/json` 被 `artKind`(含 `ui`)顶成 art, + * 于是角标按 art 显示「图片」,而预览调度走图像分支、又因 mediaType 不是图像兜底成 + * `placeholder` 且**永不发起读取** —— 卡片既不报错也没有图。 + * 只要有人再让 `kind` 抢在 mediaType 前面生效,这里就会红。 + */ +describe('派发决策的内容证据优先级不变量', () => { + const MEDIA_TYPES: readonly string[] = [ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + 'image/svg+xml', + 'video/mp4', + 'audio/mpeg', + 'application/json', + 'application/yaml', + 'text/html', + 'text/markdown', + 'text/typescript', + 'application/octet-stream', + 'application/zip', + ]; + + const KINDS: readonly string[] = [ + 'ui', + 'UI', + 'ui-design', + 'ui-prototype', + 'image', + 'scene', + 'character', + 'character-animation', + 'art-spritesheet', + 'icon-spec', + 'background-music', + 'spec', + 'game-entry', + 'game-background', + 'game-code', + 'code', + 'source', + 'video', + 'font', + 'binary', + ]; + + const EXTENSIONS: readonly string[] = [ + 'png', + 'jpg', + 'webp', + 'gif', + 'svg', + 'mp4', + 'mp3', + 'json', + 'yaml', + 'html', + 'md', + 'ts', + 'bundle', + 'zip', + ]; + + const MEDIA_TYPE_EXTENSION_PAIRS: ReadonlyArray = [ + ['image/png', 'png'], + ['image/jpeg', 'jpg'], + ['image/webp', 'webp'], + ['audio/mpeg', 'mp3'], + ['video/mp4', 'mp4'], + ['application/json', 'json'], + ['text/markdown', 'md'], + ]; + + /** 由 mediaType 单独推出的合法预览分支集合;空 mediaType 表示"没有内容证据"。 */ + const allowedPreviewKindsForMediaType = ( + mediaType: string, + ): readonly string[] | null => { + if (mediaType === '') return null; + if (mediaType.startsWith('audio/')) return ['audio']; + if (mediaType.startsWith('image/')) { + return mediaType === 'image/png' || + mediaType === 'image/jpeg' || + mediaType === 'image/webp' + ? ['raster-image'] + : ['media-image']; + } + if (mediaType.startsWith('video/')) return ['video']; + if ( + mediaType === 'text/html' || + mediaType === 'text/css' || + mediaType.includes('javascript') || + mediaType.includes('typescript') + ) { + return ['code']; + } + if ( + mediaType.includes('json') || + mediaType.includes('yaml') || + mediaType.startsWith('text/') + ) { + return ['document']; + } + // 明确但非视觉的二进制类型:既不能进画布也不能有预览。 + return ['placeholder']; + }; + + const probeResource = (kind: string, path: string, mediaType: string) => ({ + id: 'asset:probe', + category: 'unclassified' as const, + subtype: kind, + label: 'probe', + path, + mediaType, + sourceLabel: '', + taskTitle: null, + manifestAssetId: 'probe', + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + }); + + test('只要 mediaType 与路径扩展名同属一个通道,kind 不能改写预览分支', () => { + const violations: string[] = []; + let combinations = 0; + + // 路径扩展名是**独立**的判定证据,与 mediaType 冲突时按既有设计由扩展名压过 + // (见文件末尾那条显式用例)。要隔离出「kind 是否抢在内容证据前面」, + // 就必须让扩展名与 mediaType 同通道,另外再用无扩展名路径跑一遍。 + for (const [mediaType, matchingExtension] of MEDIA_TYPE_EXTENSION_PAIRS) { + const allowed = allowedPreviewKindsForMediaType(mediaType)!; + for (const kind of KINDS) { + for (const extension of [matchingExtension, '']) { + combinations += 1; + const path = extension ? `assets/probe.${extension}` : 'assets/probe'; + const previewKind = projectResourceCardPreviewKind( + probeResource(kind, path, mediaType), + ); + if (!allowed.includes(previewKind)) { + violations.push( + `kind=${kind} mediaType=${mediaType} ext=${extension || '(无)'} → 预览分支「${previewKind}」,但内容证据只允许 ${allowed.join('/')}`, + ); + } + } + } + } + + // 组合数必须是全量笛卡尔积,避免将来有人收窄枚举让这条断言悄悄变弱。 + expect(combinations).toBe( + MEDIA_TYPE_EXTENSION_PAIRS.length * KINDS.length * 2, + ); + expect(violations).toEqual([]); + }); + + test('扩展名与 mediaType 冲突时按既有设计由扩展名决定,不是缺陷', () => { + // 显示类型本来就先看扩展名:登记元数据与扩展名不一致时以路径为准。 + // 这里把该行为钉成显式合同,避免后人误当成本轮那条 `kind` 抢权的缺陷。 + expect( + projectResourceCardPreviewKind( + probeResource('ui', 'assets/probe.mp3', 'image/png'), + ), + ).toBe('audio'); + expect( + projectResourceCardPreviewKind( + probeResource('ui', 'assets/probe.mp4', 'image/png'), + ), + ).toBe('video'); + }); + + test('本轮缺陷形状:JSON 规格无论 kind 写成什么都不得落占位或走图片分支', () => { + for (const kind of [ + 'UI', + 'ui', + 'ui-design', + 'ui-prototype', + 'image', + 'icon', + ]) { + const previewKind = projectResourceCardPreviewKind( + probeResource(kind, 'ui/UI 设计 1.json', 'application/json'), + ); + expect(previewKind, `kind=${kind}`).toBe('document'); + expect( + projectResourceTypeLabel({ + subtype: kind, + path: 'ui/UI 设计 1.json', + mediaType: 'application/json', + }), + `kind=${kind}`, + ).toBe('文档'); + } + }); + + test('mediaType 缺失时才由扩展名与 kind 决定分支', () => { + expect( + projectResourceCardPreviewKind(probeResource('ui', 'assets/a.png', '')), + ).toBe('raster-image'); + expect( + projectResourceCardPreviewKind(probeResource('UI', 'assets/a.json', '')), + ).toBe('document'); + expect( + projectResourceCardPreviewKind( + probeResource('spec', 'assets/a.json', ''), + ), + ).toBe('document'); + expect( + projectResourceCardPreviewKind( + probeResource('game-entry', 'game/index.html', ''), + ), + ).toBe('code'); + // `kind` 只能给出粗类型(art),无法确定具体渲染族;扩展名也判不出时落占位, + // 这是"不进画布的未知二进制"而非本轮那条待读永不发起的缺陷。 + expect( + projectResourceCardPreviewKind( + probeResource('ui', 'assets/a.bundle', ''), + ), + ).toBe('placeholder'); + expect( + projectResourceCardPreviewKind( + probeResource('binary', 'assets/a.bundle', ''), + ), + ).toBe('placeholder'); + }); +});