From 5a37ccfd2b5f287c7ccbecc769362217a732fda3 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 13:26:17 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8D=A1=E6=9A=B4=E9=9C=B2?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E7=8A=B6=E6=80=81=EF=BC=8C=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E3=80=8C=E6=B4=BE=E5=8F=91=E5=86=B3=E7=AD=96=E7=94=B1=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E8=AF=81=E6=8D=AE=E5=86=B3=E5=AE=9A=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 占位分支对「还没读 / 读失败 / 压根不适用」给的是同一个图标,卡面也没有任何文字提示,现场只能看到「没有图」,无法区分是调度没发请求、原生拒绝了读取,还是这类资源本来就没有预览。本轮排查「图片加载不出来」时,这个不可观测性正是反复误判的来源。 - `index.tsx` 的 `ResourceCard` 根节点新增 `data-preview-status`(`idle` / `loading` / `loaded` / `failed`)与 `data-preview-error`(仅失败态给出原因)。排障从此只需读一个 DOM 属性,而不必去猜调度层。纯新增属性,不改变任何渲染分支与调度行为。 - 新增 `tests/resourcePreviewPredicateContract.test.ts`:把「派发决策必须由可验证的内容证据(mediaType,缺失时退路径扩展名)决定,登记标签 `kind` 只能在两者都给不出结论时兜底」钉成不变量。用 7 组「mediaType + 同通道扩展名」× 20 个 `kind` 的全量笛卡尔积断言 `kind` 不能改写预览分支,并断言组合总数等于笛卡尔积(防止将来收窄枚举让断言悄悄变弱)。另附本轮缺陷形状的最小复现(`application/json` + 任意图片类 `kind` 必须落 `document` 而不是 `placeholder`)、mediaType 缺失时由扩展名决定、以及扩展名与 mediaType 冲突时按既有设计由扩展名决定这三组显式合同。 - 该用例在**变异验证**下确实会红:把 `kind` 判定移回第一趟(还原本轮的分叉)后,本文件 4 条用例失败(不变量报出 56 处违规),恢复后全绿。断言不是恒真假守卫。 - 验证:`npm run test -- apps/ai-game-creator-shell/tests` 84 files passed / 1202 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;prettier 与 eslint 干净;check:encoding 4378 文件;`git diff --check` 干净。 --- .../src/view/project-development/index.tsx | 7 + .../resourcePreviewPredicateContract.test.ts | 235 ++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 apps/ai-game-creator-shell/tests/resourcePreviewPredicateContract.test.ts 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'); + }); +});