Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts
T
suzmii 083df0aa2c 类型判定改为内容证据优先:消除 kind 与内容证据同权的脆弱点
**口径更正(后加的)**:本条提交**不是**"修复真机那 8 张卡"。经用真源码复算真机 manifest 确认,当前 HEAD 上这 8 张 `.json` 本就正确落 `document`、角标「文档」,该现象不复现——用户看到的是更早构建的行为。本条的真实价值是**消除 `kind` 与内容证据同权的脆弱点,并把「派发决策由内容证据决定」钉成不变量**,见下。

- 背景:真机「待归类」栏目曾出现 8 张卡——角标写「图片」、卡面却只有占位图标。该现象在当前 HEAD 上不复现(见上),但它对应一个**真实存在且此前只是碰巧没被踩中的脆弱点**:`projectedResourceKind` 里 `kind` 与内容证据**同权**,而 `artKind` 含 `ui`;一旦界面规格类资产丢了 `.json` 扩展名,预览派发就会翻车。原先能通过**完全依赖 `.json` 恰好在那张扩展名表里**,属巧合而非设计。
- 机制(旧行为):`kind:"UI"` 被 `artKind` 命中 → 判定为 `art`;预览调度再按 `art` 走图像分支,又因 `mediaType:"application/json"` 不是图像而兜底成 `placeholder`,而 `requestPreview` 对 `placeholder` 直接返回——**一次 IPC 都不发**,卡片既不报错也没有图。
- `resourceProjectionModel.ts` 的 `projectedResourceKind` 改为两趟判定:第一趟只看 `mediaType` 与路径扩展名(文件内容的可验证证据),第二趟才看 `kind`(登记标签,可能过时或宽泛)。`kind` 不再与内容证据同权,因此 `kind:"ui"` 无法再把 `application/json` 改写成 `art`。
- 不改 `artKind` 的取值(`ui` 仍在表内),只调整判定顺序:最小改动面,`kind:"ui"` + `image/png` 这类正常资产行为不变(仍由 `mediaType` 命中)。
- 界面规格 JSON 现在落 `document` 分支,走 `read_local_project_text_preview`(Rust 侧 `.json` 与 `application/json` 本就在支持范围内且这些条目已登记),卡面渲染 JSON 文本摘要,角标随 `projectResourceTypeLabel` 变为「文档」。预览链条数、缓存、并发与门禁一律未动。
- `tests/resourceCardPreviewRealManifest.test.ts` 中记录旧行为的用例同步跟上新合同:`placeholder: 8` 改为 `document: 8`,用例名与注释改写为「52 张 PNG 走图片分支,8 条 UI 规格走文档分支,无一落占位」,并新增「角标与预览分支必须说同一件事」的逐条断言。旧断言记录的是缺陷本身,按「四不写」直接更新为当前权威行为,不做兼容保留;其余既有断言一条未放宽。
- 验证:`npm run test -- apps/ai-game-creator-shell/tests` 1202 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 通过;`git diff --check` 干净。
2026-09-11 13:30:14 +08:00

262 lines
9.2 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.
// @vitest-environment jsdom
// 真机取证:用真机 `What do u wanna do kitten` manifest 的逐条真实形状,
// 可执行地数出「每张卡走哪个预览分支」「最终落哪个画布栏目」「实际发出哪些 IPC」。
//
// 真机数据(只读读出,未修改):
// assets 61 条 = ui/image/png ×49、ui/application/json ×8、art-spritesheet/image/png ×1、
// game-background/image/png ×1、game-entry/text/html ×1、icon-spec/image/png ×1
// 每条都带显式 category:scene ×1、ui-interaction ×2、unclassified ×58
// versions 5 条
import { describe, expect, test } from 'vitest';
import {
gameCreationAppAssetCategory,
type GameCreationAppAssetManifestEntry,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import { projectResourceCardPreviewKind } from '../src/view/project-development/resourceCardPreviewModel';
import {
projectResourceCanvasCategory,
projectResourcesFromReadModels,
projectResourceTypeLabel,
} from '../src/view/project-development/resourceProjectionModel';
type Shape = {
kind: string;
mediaType: string;
ext: string;
count: number;
category: 'scene' | 'ui-interaction' | 'unclassified';
};
/** 真机 manifest 的 assets 逐条形状(category 为磁盘上的持久化值)。 */
const REAL_ASSET_SHAPES: readonly Shape[] = [
{
kind: 'ui',
mediaType: 'image/png',
ext: 'png',
count: 49,
category: 'unclassified',
},
{
kind: 'ui',
mediaType: 'application/json',
ext: 'json',
count: 8,
category: 'unclassified',
},
{
kind: 'art-spritesheet',
mediaType: 'image/png',
ext: 'png',
count: 1,
category: 'ui-interaction',
},
{
kind: 'game-background',
mediaType: 'image/png',
ext: 'png',
count: 1,
category: 'scene',
},
{
kind: 'game-entry',
mediaType: 'text/html',
ext: 'html',
count: 1,
category: 'unclassified',
},
{
kind: 'icon-spec',
mediaType: 'image/png',
ext: 'png',
count: 1,
category: 'ui-interaction',
},
];
function buildRealAssets(): GameCreationAppAssetManifestEntry[] {
const assets: GameCreationAppAssetManifestEntry[] = [];
let index = 0;
for (const shape of REAL_ASSET_SHAPES) {
for (let i = 0; i < shape.count; i += 1) {
index += 1;
assets.push({
id: `asset-${index}`,
kind: shape.kind,
mediaType: shape.mediaType,
localPath: `assets/probe-${index}.${shape.ext}`,
source: { kind: 'generated' },
category: shape.category,
});
}
}
return assets;
}
function realManifest() {
return {
schemaVersion: 'game-creation-app.v1',
projectId: 'agc-real-probe',
name: '真机取证项目',
tasks: [],
assets: buildRealAssets(),
};
}
function countBy<T>(items: readonly T[], key: (item: T) => string) {
const counts = new Map<string, number>();
for (const item of items) {
const value = key(item);
counts.set(value, (counts.get(value) ?? 0) + 1);
}
return Object.fromEntries(counts);
}
describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
test('61 条真机资产全部进入资源投影', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
expect(resources).toHaveLength(61);
});
test('存量 57 条 ui 的落盘 unclassified 被读时重派生自愈', () => {
const assets = realManifest().assets;
const uiAssets = assets.filter((asset) => asset.kind === 'ui');
// 前置事实:这 57 条落盘值确实是 unclassified(历史误判现场)。
expect(uiAssets).toHaveLength(57);
expect(uiAssets.every((asset) => asset.category === 'unclassified')).toBe(
true,
);
// 读取侧已经把它重派生成 ui-interaction:读时自愈,不回写 manifest。
expect(
uiAssets.every(
(asset) => gameCreationAppAssetCategory(asset) === 'ui-interaction',
),
).toBe(true);
// 直接钉住「读时重派生」这一行为本身:落盘 unclassified + kind 能派生明确分类
// → 用派生值;这正是修复前那 57 条留在「待归类」的原因。
expect(
gameCreationAppAssetCategory({ kind: 'ui', category: 'unclassified' }),
).toBe('ui-interaction');
// 落盘值本身就是明确分类时仍然信任落盘值(用户手动设置的权威性)。
expect(
gameCreationAppAssetCategory({ kind: 'ui', category: 'scene' }),
).toBe('scene');
// 落盘值缺失时按 kind 派生(历史 manifest 兼容)。
expect(
gameCreationAppAssetCategory({ kind: 'ui', category: undefined }),
).toBe('ui-interaction');
});
test('落盘 unclassified 且 kind 派生也是 unclassified 时不受影响', () => {
// image / video / code 的 canonical 分类本来就是 unclassified,
// 这类资产必须继续信任落盘值,不被重派生规则误伤。
for (const kind of ['image', 'video', 'code', 'publication-material']) {
expect(
gameCreationAppAssetCategory({ kind, category: 'unclassified' }),
).toBe('unclassified');
}
});
test('读时重派生后:57 条 ui 归位 UI 交互,待归类只剩 code 类 game-entry', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
const byCategory = countBy(resources, (resource) =>
projectResourceCanvasCategory(resource),
);
// 存量自愈后的真实分布:57 条 ui 归位到 UI 交互,只剩 game-entry(code)在待归类。
expect(byCategory).toEqual({
unclassified: 1,
'ui-interaction': 59,
scene: 1,
});
const uiResources = resources.filter(
(resource) => resource.subtype === 'ui',
);
expect(uiResources).toHaveLength(57);
expect(
uiResources.every(
(resource) =>
projectResourceCanvasCategory(resource) === 'ui-interaction',
),
).toBe(true);
// 唯一留在待归类的就是 game-entry:code → unclassified 是分类表的设计口径。
expect(
resources
.filter(
(resource) =>
projectResourceCanvasCategory(resource) === 'unclassified',
)
.map((resource) => resource.subtype),
).toEqual(['game-entry']);
});
test('按预览分支计数:52 张 PNG 走图片分支,8 条 UI 规格走文档分支,无一落占位', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
const previewKindByResource = resources.map((resource) => ({
resource,
previewKind: projectResourceCardPreviewKind(resource),
typeLabel: projectResourceTypeLabel(resource),
canvasCategory: projectResourceCanvasCategory(resource),
}));
const byPreviewKind = countBy(previewKindByResource, (e) => e.previewKind);
// 这里曾经是 `placeholder: 8`:8 条 `ui/application/json` 因为 `kind:"UI"` 命中
// `artKind`(其中含 `ui`)而被判成 art,于是角标显示「图片」,而预览调度按 art 走
// 图像分支、又因 mediaType 不是图像而兜底成 placeholder —— 卡片永不发起读取,
// 表现为「标着图片却只有占位图标」。类型判定改为 mediaType/扩展名优先、kind 只做
// 兜底后,它们正确落文档分支,卡面渲染 JSON 文本摘要。
expect(byPreviewKind).toEqual({
'raster-image': 52,
document: 8,
code: 1,
});
// 占位分支在真机上必须为空 —— 上面那条 `toEqual` 已经钉住了唯一取值集合,
// 这里不再另加"不存在"式断言,避免写成恒真守卫。
const documentAssets = previewKindByResource.filter(
(e) => e.previewKind === 'document',
);
expect(countBy(documentAssets, (e) => e.resource.mediaType)).toEqual({
'application/json': 8,
});
expect(countBy(documentAssets, (e) => e.resource.subtype)).toEqual({
ui: 8,
});
// 角标与预览分支必须是同一套口径:标「图片」的必须真的走图片分支。
for (const entry of previewKindByResource) {
if (entry.typeLabel === '图片') {
expect(entry.previewKind).toBe('raster-image');
}
if (entry.typeLabel === '文档') {
expect(entry.previewKind).toBe('document');
}
}
});
test('按画布栏目计数:待归类从 58 降到 1,UI 交互升到 59', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
const byCategory = countBy(resources, (resource) =>
projectResourceCanvasCategory(resource),
);
expect(byCategory).toEqual({
unclassified: 1,
'ui-interaction': 59,
scene: 1,
});
});
test('预览分支与画布栏目是两个独立轴:落 UI 交互的 PNG 仍走图片分支', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
const iconSpec = resources.find(
(resource) => resource.subtype === 'icon-spec',
);
expect(iconSpec).toBeDefined();
// 真机 icon-spec 落在 UI 交互栏目。
expect(projectResourceCanvasCategory(iconSpec!)).toBe('ui-interaction');
// 但它仍必须走 PNG 的图片预览分支,而不是媒体分支。
expect(projectResourceCardPreviewKind(iconSpec!)).toBe('raster-image');
});
});