Files
Genarrative/apps/ai-game-creator-shell/tests/resourcePreviewPredicateContract.test.ts
T
k88936 48caa23997 收紧AGC资源kind枚举边界
canvas生成与运行时统一使用共享GameCreationAppAssetKind

删除平台kind兼容映射与退役kind拒绝测试

更新资源kind审计里程碑与绑定文档
2026-09-19 01:28:27 +08:00

240 lines
7.4 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import { projectResourceCardPreviewKind } from '../src/view/project-development/resourceCardPreviewModel';
import { projectResourceTypeLabel } from '../src/view/project-development/resourceProjectionModel';
/**
* 派发决策必须由**可验证的内容证据**(mediaType,缺失时退路径扩展名)决定,
* 登记标签 `kind` 只能在两者都给不出结论时兜底。
*
* 这条不变量是本轮「标着图片却只有占位图标」缺陷的直接护栏:登记标签与
* mediaType 同权,界面规格的 `application/json` 会被宽泛登记标签顶成 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[] = [
'unknown',
'UNKNOWN',
'ui-design',
'future-ui',
'image',
'scene',
'character',
'character-animation',
'icon-spritesheet',
'icon-spec',
'background-music',
'spec',
'future-game-entry',
'scene',
'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-design', 'assets/probe.mp3', 'image/png'),
),
).toBe('audio');
expect(
projectResourceCardPreviewKind(
probeResource('ui-design', 'assets/probe.mp4', 'image/png'),
),
).toBe('video');
});
test('本轮缺陷形状:JSON 规格无论 kind 写成什么都不得落占位或走图片分支', () => {
for (const kind of [
'UNKNOWN',
'unknown',
'ui-design',
'future-ui',
'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-design', 'assets/a.png', ''),
),
).toBe('raster-image');
expect(
projectResourceCardPreviewKind(
probeResource('unknown', 'assets/a.json', ''),
),
).toBe('document');
expect(
projectResourceCardPreviewKind(
probeResource('spec', 'assets/a.json', ''),
),
).toBe('document');
expect(
projectResourceCardPreviewKind(
probeResource('code', 'game/index.html', ''),
),
).toBe('code');
// `kind` 只能给出粗类型(art),无法确定具体渲染族;扩展名也判不出时落占位,
// 这是"不进画布的未知二进制"而非本轮那条待读永不发起的缺陷。
expect(
projectResourceCardPreviewKind(
probeResource('ui-design', 'assets/a.bundle', ''),
),
).toBe('placeholder');
expect(
projectResourceCardPreviewKind(
probeResource('binary', 'assets/a.bundle', ''),
),
).toBe('placeholder');
});
});