Files
Genarrative/apps/ai-game-creator-shell/tests/resourcePreviewPredicateContract.test.ts
T
suzmii 5a37ccfd2b 资源卡暴露预览状态,并补「派发决策由内容证据决定」的契约用例
占位分支对「还没读 / 读失败 / 压根不适用」给的是同一个图标,卡面也没有任何文字提示,现场只能看到「没有图」,无法区分是调度没发请求、原生拒绝了读取,还是这类资源本来就没有预览。本轮排查「图片加载不出来」时,这个不可观测性正是反复误判的来源。

- `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` 干净。
2026-09-11 13:26:17 +08:00

236 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});