From c5df56d8559aa907b0a75893e8402d089f7b945d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 21:18:55 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=B5=84=E6=BA=90=20kind=20?= =?UTF-8?q?=E5=88=AB=E5=90=8D=E8=A1=A8=EF=BC=9Aui=20=E5=BD=92=20UI=20?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=EF=BC=8Cgame-*=20=E5=BD=92=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=EF=BC=8Canimation=20=E5=BD=92=E8=A7=92=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TS 侧 GAME_CREATION_APP_LEGACY_ASSET_KINDS 补 5 条别名:ui → ui-design、game-entry/game-script/game-style → code、animation → character-animation、asset → image - Rust 侧 canonical_game_creation_app_asset_kind 同步补齐同一批别名,两侧必须成对维护 - 按拍板口径不往 GAME_CREATION_APP_CANONICAL_ASSET_KINDS 加新 kind,避免出现 ui 与 ui-design 两个同义 kind - 保持 art-spritesheet-slice → icon → ui-interaction 不动 - 补注释写明别名表的职责是「线上值 → canonical」而非仅收历史遗留,画板导出等现役写入侧同样要在这里收口 - 新增 assetKindCanonicalMapping.test.ts:解析 Rust 源码交叉钉住两侧 canonical 目录与别名映射逐条一致,并逐条断言已拍板的 kind → canonical → 栏目口径 - 新增 resourceCardPreviewRealManifest.test.ts:按真机 manifest 逐条形状可执行地数出预览分支与栏目分布(raster-image 52 / placeholder 8 / code 1;unclassified 58 / ui-interaction 2 / scene 1) - 真机取证用例同时钉住「别名只救新登记、不救存量」:57 条 ui 的落盘 category 仍是 unclassified,需读时重派生或一次性回填才能归位 - 修正两条按旧错误栏目断言的既有用例:kind=ui 的上传图归「UI 交互」、animation 视频归「角色与对象」 --- .../appSurface/project-development.suite.ts | 11 +- .../tests/assetKindCanonicalMapping.test.ts | 153 +++++++++++ .../resourceCardPreviewRealManifest.test.ts | 240 ++++++++++++++++++ .../shared/src/contracts/gameCreationApp.ts | 20 ++ .../shared-contracts/src/game_creation_app.rs | 6 + 5 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 1631f2068..f9dfe1770 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -1835,10 +1835,10 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - fireEvent.click(within(outline).getByRole('button', { name: /待归类/ })); + fireEvent.click(within(outline).getByRole('button', { name: /UI 交互/ })); expect( await screen.findByRole('button', { - name: '选中资源:待归类 local-imported-image.png', + name: '选中资源:UI 交互 local-imported-image.png', }), ).not.toBeNull(); }); @@ -2705,11 +2705,12 @@ export function registerProjectWorkbenchFoundationTests() { ), ).toBe(true); - // 视频(animation)与音频(bgm)都不在 canonical kind 目录里,落在「待归类」栏目。 - await openResourceBookCategory('待归类'); + // 视频(animation → character-animation)落在「角色与对象」, + // 音频(bgm 不在 canonical 目录里)落在「待归类」。 + await openResourceBookCategory('角色与对象'); fireEvent.click( screen.getByRole('button', { - name: '选中资源:待归类 intro.mp4', + name: '选中资源:角色与对象 intro.mp4', }), ); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts b/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts new file mode 100644 index 000000000..45ee79150 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts @@ -0,0 +1,153 @@ +// kind 别名表必须跨语言一致:TS 侧 `GAME_CREATION_APP_LEGACY_ASSET_KINDS`(前端读投影用) +// 与 Rust 侧 `canonical_game_creation_app_asset_kind`(写入侧按 kind 派生 category 用) +// 是同一套口径的两份实现。任何一边漏加别名,就会让「落盘 category」与「读侧栏目」 +// 互相矛盾——`ui` 漏加就正好是「57 条 UI 资产显示成待归类」的成因。 +import { readFileSync } from 'node:fs'; + +import { describe, expect, test } from 'vitest'; + +import { + canonicalGameCreationAppAssetKind, + GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND, + GAME_CREATION_APP_CANONICAL_ASSET_KINDS, + gameCreationAppAssetCategoryForKind, + type GameCreationAppCanonicalAssetKind, +} from '../../../packages/shared/src/contracts/gameCreationApp'; + +const RUST_CONTRACT_PATH = + 'server-rs/crates/shared-contracts/src/game_creation_app.rs'; + +/** 从 Rust 侧 `canonical_game_creation_app_asset_kind` 里解析出别名 → canonical 表。 */ +function rustAssetKindAliases(): Map { + const source = readFileSync(RUST_CONTRACT_PATH, 'utf8'); + const start = source.indexOf('pub fn canonical_game_creation_app_asset_kind'); + expect(start).toBeGreaterThan(-1); + // 取到函数体结束(下一个顶层 `pub fn` / 常量前的空行大括号收口)。 + const body = source.slice(start, source.indexOf('\n}\n', start)); + + const aliases = new Map(); + for (const line of body.split('\n')) { + const arm = /^\s*((?:"[^"]+"\s*\|?\s*)+)=>\s*"([^"]+)",\s*$/.exec(line); + if (!arm) continue; + const sources = Array.from(arm[1]!.matchAll(/"([^"]+)"/g)).map( + (match) => match[1]!, + ); + for (const one of sources) { + aliases.set(one, arm[2]!); + } + } + return aliases; +} + +/** 从 Rust 侧解析 canonical kind 常量目录。 */ +function rustCanonicalKinds(): string[] { + const source = readFileSync(RUST_CONTRACT_PATH, 'utf8'); + const start = source.indexOf( + 'pub const GAME_CREATION_APP_CANONICAL_ASSET_KINDS', + ); + expect(start).toBeGreaterThan(-1); + const body = source.slice(start, source.indexOf('];', start)); + return Array.from(body.matchAll(/"([^"]+)"/g)).map((match) => match[1]!); +} + +/** TS 侧的别名表(未导出,用 canonical 目录探测「非 canonical 输入被改写成什么」)。 */ +const OBSERVED_ALIAS_INPUTS = [ + 'game-background', + 'character-art', + 'ui-prototype', + 'ui', + 'art-spritesheet', + 'art-spritesheet-slice', + 'illustration', + 'game-art', + 'game-entry', + 'game-script', + 'game-style', + 'animation', + 'asset', +] as const; + +describe('资源 kind 别名表跨语言一致性', () => { + test('两侧 canonical kind 目录一致', () => { + expect(rustCanonicalKinds()).toEqual([ + ...GAME_CREATION_APP_CANONICAL_ASSET_KINDS, + ]); + }); + + test('两侧别名映射逐条一致', () => { + const rustAliases = rustAssetKindAliases(); + const mismatches: Array<{ kind: string; ts: string; rust: string }> = []; + for (const [kind, rustCanonical] of rustAliases) { + const tsCanonical = canonicalGameCreationAppAssetKind(kind); + if (tsCanonical !== rustCanonical) { + mismatches.push({ kind, ts: tsCanonical, rust: rustCanonical }); + } + } + expect(mismatches).toEqual([]); + // 反向:TS 侧认得的别名,Rust 侧也必须认得(否则写入侧会写出错 category)。 + const rustOnly = new Set(rustAliases.keys()); + const missingInRust = OBSERVED_ALIAS_INPUTS.filter( + (kind) => + canonicalGameCreationAppAssetKind(kind) !== kind && !rustOnly.has(kind), + ); + expect(missingInRust).toEqual([]); + }); + + test('每个别名都指向 canonical 目录内的值', () => { + const canonical = + GAME_CREATION_APP_CANONICAL_ASSET_KINDS as readonly string[]; + for (const kind of OBSERVED_ALIAS_INPUTS) { + expect(canonical).toContain(canonicalGameCreationAppAssetKind(kind)); + } + }); +}); + +describe('真机出现的 kind 归类口径', () => { + /** 已拍板的口径:现役 kind → canonical → 栏目。 */ + const decided: Array<{ + kind: string; + canonical: GameCreationAppCanonicalAssetKind; + category: string; + }> = [ + { kind: 'ui', canonical: 'ui-design', category: 'ui-interaction' }, + { kind: 'game-entry', canonical: 'code', category: 'unclassified' }, + { kind: 'game-script', canonical: 'code', category: 'unclassified' }, + { kind: 'game-style', canonical: 'code', category: 'unclassified' }, + { + kind: 'animation', + canonical: 'character-animation', + category: 'character', + }, + { kind: 'asset', canonical: 'image', category: 'unclassified' }, + // 明确保持不动:它落到 ui-interaction 是正确口径。 + { + kind: 'art-spritesheet-slice', + canonical: 'icon', + category: 'ui-interaction', + }, + ]; + + test.each(decided)( + '$kind → $canonical → $category', + ({ kind, canonical, category }) => { + expect(canonicalGameCreationAppAssetKind(kind)).toBe(canonical); + expect(gameCreationAppAssetCategoryForKind(kind)).toBe(category); + }, + ); + + test('canonical 目录里没有 ui / animation / asset,避免出现同义 kind', () => { + const canonical = + GAME_CREATION_APP_CANONICAL_ASSET_KINDS as readonly string[]; + for (const forbidden of ['ui', 'animation', 'asset', 'game-entry']) { + expect(canonical).not.toContain(forbidden); + } + // ui 与 ui-design 不能同时存在,否则就是两套词汇。 + expect(canonical).toContain('ui-design'); + }); + + test('分类表穷举 canonical 目录,别名不会落空', () => { + expect( + Object.keys(GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND).sort(), + ).toEqual([...GAME_CREATION_APP_CANONICAL_ASSET_KINDS].sort()); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts new file mode 100644 index 000000000..cd7b33b06 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts @@ -0,0 +1,240 @@ +// @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('存量 category 与新 kind 口径的差异必须显式记录(别名救不了存量)', () => { + const assets = realManifest().assets; + const drifted = assets + .map((asset) => ({ + kind: asset.kind, + onDisk: asset.category, + // 只按 kind 重新派生(丢弃存量 category),这就是加别名之后新登记资产会拿到的值。 + derivedFromKind: gameCreationAppAssetCategory({ + kind: asset.kind, + category: undefined, + }), + })) + .filter((entry) => entry.onDisk !== entry.derivedFromKind); + + const driftedByKind = new Map< + string, + { onDisk: string; derivedFromKind: string; count: number } + >(); + for (const entry of drifted) { + const key = `${entry.kind}|${entry.onDisk}|${entry.derivedFromKind}`; + const existing = driftedByKind.get(key); + if (existing) { + existing.count += 1; + } else { + driftedByKind.set(key, { ...entry, count: 1 }); + } + } + + // 真机现状:57 条 ui 的落盘 category 是 unclassified,而按新 kind 口径应是 + // ui-interaction。这条差异是「别名只救新登记、不救存量」的直接证据:读取侧 + // `gameCreationAppAssetCategory` 优先信任落盘 category,所以别名加完, + // 这 57 条仍然留在「待归类」。 + expect(drifted).toHaveLength(57); + expect(Object.fromEntries(driftedByKind)).toEqual({ + 'ui|unclassified|ui-interaction': { + kind: 'ui', + onDisk: 'unclassified', + derivedFromKind: 'ui-interaction', + count: 57, + }, + }); + }); + + test('别名落地后存量仍落待归类:58 不变(根治需要读时重派生或一次性回填)', () => { + const resources = projectResourcesFromReadModels(realManifest(), [], []); + const byCategory = countBy(resources, (resource) => + projectResourceCanvasCategory(resource), + ); + + // 期望分布是加别名**之后**的实际结果,不是目标态。 + expect(byCategory).toEqual({ + unclassified: 58, + 'ui-interaction': 2, + scene: 1, + }); + // 若哪天真做了存量回填,这两个数字应当变成 unclassified:1 / ui-interaction:59, + // 届时这条断言会失败并提醒更新口径。 + const uiResources = resources.filter( + (resource) => resource.subtype === 'ui', + ); + expect(uiResources).toHaveLength(57); + expect( + uiResources.every( + (resource) => + projectResourceCanvasCategory(resource) === 'unclassified', + ), + ).toBe(true); + }); + + test('按预览分支计数:只有 JSON 规格会落占位,52 张 PNG 走图片分支', () => { + 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); + expect(byPreviewKind).toEqual({ + 'raster-image': 52, + placeholder: 8, + code: 1, + }); + // 占位卡片的构成:8 条 ui/application/json。它们不是"图片读不出来", + // 而是被归成文档以外的未知分支后又没有可预览内容(game-entry 走 code 分支渲染文本)。 + const placeholders = previewKindByResource.filter( + (e) => e.previewKind === 'placeholder', + ); + expect(countBy(placeholders, (e) => e.resource.mediaType)).toEqual({ + 'application/json': 8, + }); + expect(countBy(placeholders, (e) => e.resource.subtype)).toEqual({ ui: 8 }); + }); + + test('按画布栏目计数:58 条落待归类,与真机截图一致', () => { + const resources = projectResourcesFromReadModels(realManifest(), [], []); + const byCategory = countBy(resources, (resource) => + projectResourceCanvasCategory(resource), + ); + + expect(byCategory).toEqual({ + unclassified: 58, + 'ui-interaction': 2, + 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'); + }); +}); diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 8dcc3bfdd..66ef93e39 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -500,6 +500,20 @@ export const GAME_CREATION_APP_CANONICAL_ASSET_KINDS = [ export type GameCreationAppCanonicalAssetKind = (typeof GAME_CREATION_APP_CANONICAL_ASSET_KINDS)[number]; +/** + * 线上出现的非 canonical kind → canonical kind 的别名表。 + * + * 职责是「线上值 → canonical」,与该值是否属于历史遗留无关:画板导出等现役写入侧 + * 仍会写出 `ui` / `animation` / `asset` 这类非 canonical 值,同样必须在这里收口, + * 否则会落到 `canonicalGameCreationAppAssetKind` 的 `image` 兜底,再经 + * `image → unclassified` 被误分到「待归类」。 + * + * 本表与 Rust 侧 `server-rs/crates/shared-contracts/src/game_creation_app.rs` 的 + * `canonical_game_creation_app_asset_kind` 必须成对维护,由 + * `tests/assetKindCanonicalMapping.test.ts` 交叉钉住。 + * + * 注意不要动 `art-spritesheet-slice → icon`:它落到 ui-interaction 是正确口径。 + */ const GAME_CREATION_APP_LEGACY_ASSET_KINDS: Record< string, GameCreationAppCanonicalAssetKind @@ -507,10 +521,16 @@ const GAME_CREATION_APP_LEGACY_ASSET_KINDS: Record< 'game-background': 'scene', 'character-art': 'character', 'ui-prototype': 'ui-design', + ui: 'ui-design', 'art-spritesheet': 'icon-spritesheet', 'art-spritesheet-slice': 'icon', illustration: 'image', 'game-art': 'image', + 'game-entry': 'code', + 'game-script': 'code', + 'game-style': 'code', + animation: 'character-animation', + asset: 'image', }; export function canonicalGameCreationAppAssetKind( diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 2f98ce7d1..ce5906c9d 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -570,9 +570,15 @@ pub fn canonical_game_creation_app_asset_kind(value: &str) -> &'static str { "game-background" => "scene", "character-art" => "character", "ui-prototype" => "ui-design", + // 画板导出等现役写入侧仍会写出这些非 canonical 值,必须在这里收口, + // 否则会落到 image 兜底并被 image -> unclassified 误分到「待归类」。 + "ui" => "ui-design", "art-spritesheet" => "icon-spritesheet", "art-spritesheet-slice" => "icon", "illustration" | "game-art" => "image", + "game-entry" | "game-script" | "game-style" => "code", + "animation" => "character-animation", + "asset" => "image", canonical => match GAME_CREATION_APP_CANONICAL_ASSET_KINDS .iter() .find(|candidate| **candidate == canonical)