资源卡暴露预览状态,并补「派发决策由内容证据决定」的契约用例
占位分支对「还没读 / 读失败 / 压根不适用」给的是同一个图标,卡面也没有任何文字提示,现场只能看到「没有图」,无法区分是调度没发请求、原生拒绝了读取,还是这类资源本来就没有预览。本轮排查「图片加载不出来」时,这个不可观测性正是反复误判的来源。 - `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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user