资源卡暴露预览状态,并补「派发决策由内容证据决定」的契约用例

占位分支对「还没读 / 读失败 / 压根不适用」给的是同一个图标,卡面也没有任何文字提示,现场只能看到「没有图」,无法区分是调度没发请求、原生拒绝了读取,还是这类资源本来就没有预览。本轮排查「图片加载不出来」时,这个不可观测性正是反复误判的来源。

- `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` 干净。
This commit is contained in:
2026-09-11 13:26:17 +08:00
parent cd1aceb3d9
commit 5a37ccfd2b
2 changed files with 242 additions and 0 deletions
@@ -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={
{
@@ -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<readonly [string, string]> = [
['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');
});
});