// @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(items: readonly T[], key: (item: T) => string) { const counts = new Map(); 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 只做 // 兜底后,它们进入文本读取通道;卡面再消费原生识别结果显示 UI 设计或 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'); }); });