diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs index 0200500d5..410746488 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs @@ -220,6 +220,7 @@ mod tests { use crate::ui_editor::persistence::{load_ui_design_state_at, LoadUiDesignStateInput}; use crate::ui_editor::utils::UIDesignImageId; use crate::{init_local_game_project_at, register_local_asset_entry}; + use shared_contracts::game_creation_app::GameCreationAppAssetCategory; use std::io::Cursor; fn fixture() -> tempfile::TempDir { @@ -281,6 +282,14 @@ mod tests { let first = ensure_ui_design_resource_for_prototype(input.clone()).expect("bridge"); assert!(first.created); assert_eq!(first.asset.kind, "UI"); + // 写侧 → 分类的端到端断言:这条路径走的是与 + // `workflow.rs` / `persistence.rs` 完全相同的 `register_local_asset_at(..., "UI", ...)`。 + // 别名表漏掉大写 `UI` 时,这里会落 unclassified(真机 8 条 UI 资产的表现), + // 且派生值本身就是 unclassified,读时自愈也救不回来。 + assert_eq!( + first.asset.category, + GameCreationAppAssetCategory::UiInteraction + ); assert_eq!( first.asset.source.reference_resource_ids, vec!["prototype-resource".to_string()] diff --git a/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts b/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts index 61e8d89ee..b0739ec5f 100644 --- a/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts +++ b/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts @@ -119,9 +119,10 @@ const OBSERVED_ALIAS_INPUTS = [ describe('资源 kind 别名表跨语言一致性', () => { test('两侧 canonical kind 目录一致', () => { - expect(rustCanonicalKinds()).toEqual([ - ...GAME_CREATION_APP_CANONICAL_ASSET_KINDS, - ]); + 'font', + expect(rustCanonicalKinds()).toEqual([ + ...GAME_CREATION_APP_CANONICAL_ASSET_KINDS, + ]); }); test('两侧别名映射逐条一致', () => { @@ -222,6 +223,9 @@ describe('真机出现的 kind 归类口径', () => { test.each(decided)( '$kind → $canonical → $category', + // 现役写入侧的大写字面量与字体 kind,两者都必须落进明确栏目。 + { kind: 'UI', canonical: 'ui-design', category: 'ui-interaction' }, + { kind: 'font', canonical: 'document', category: 'document' }, ({ kind, canonical, category }) => { expect(canonicalGameCreationAppAssetKind(kind)).toBe(canonical); expect(gameCreationAppAssetCategoryForKind(kind)).toBe(category); @@ -244,3 +248,61 @@ describe('真机出现的 kind 归类口径', () => { ).toEqual([...GAME_CREATION_APP_CANONICAL_ASSET_KINDS].sort()); }); }); + +describe('写侧 kind 字面量 → 分类的端到端口径', () => { + const UI_EDITOR_WRITE_SITES = [ + 'apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs', + 'apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs', + 'apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs', + ]; + + /** + * 取每个 `register_local_asset_(at|entry)(` 调用的第 3 个实参——写侧固定把 kind 放在 + * 第 3 位、单独占一行(`root` / 路径 / `"UI"`)。只认独占一行的字符串字面量, + * 传变量的写点直接跳过,避免把第 4 位的 mediaType 误当成 kind。 + */ + function registeredKindLiterals(file: string): string[] { + const source = readFileSync(file, 'utf8'); + const kinds: string[] = []; + for (const call of source.matchAll( + /register_local_asset_(?:at|entry)\(\r?\n(?:[^\n]*\r?\n){2}([^\n]*)/g, + )) { + const literal = /^\s*"([^"]*)",\s*$/.exec(call[1]!); + if (literal) kinds.push(literal[1]!); + } + return kinds; + } + + test('UI 设计写点仍直接写 `"UI"`,且它落 UI 交互而不是待归类', () => { + // 8 条真机 UI 资产是 `kind:"UI"` + `mediaType:"application/json"`: + // 该 kind 不在别名表里时派生结果也是 unclassified,读时自愈同样救不回来。 + for (const file of UI_EDITOR_WRITE_SITES) { + expect(registeredKindLiterals(file)).toContain('UI'); + } + expect(gameCreationAppAssetCategoryForKind('UI')).toBe('ui-interaction'); + }); + + test('UI 编辑器写点写出的每个 kind 都落明确栏目', () => { + // 画板导出这条链上的写点只写 UI 设计资产,不允许再写出落「待归类」的 kind。 + for (const file of UI_EDITOR_WRITE_SITES) { + for (const kind of registeredKindLiterals(file)) { + expect( + gameCreationAppAssetCategoryForKind(kind), + `${file} 写出的 kind ${kind} 落到了待归类`, + ).not.toBe('unclassified'); + } + } + }); + + test('字体写点写 `"font"`,落文档栏目', () => { + const commandsSource = readFileSync( + 'apps/ai-game-creator-shell/src-tauri/src/commands.rs', + 'utf8', + ); + // 字体上传的写入侧:`register_local_asset_entry(root, path, "font", …)`。 + expect(commandsSource).toMatch( + /register_local_asset_entry\(\s*\n\s*root,\s*\n\s*&relative_path,\s*\n\s*"font",/, + ); + expect(gameCreationAppAssetCategoryForKind('font')).toBe('document'); + }); +}); diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index e9164d80e..b6ab26444 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -747,6 +747,12 @@ describe('AI 游戏创作 App 共享契约', () => { ); expect(canonicalGameCreationAppAssetKind('illustration')).toBe('image'); expect(canonicalGameCreationAppAssetKind('character')).toBe('character'); + // 别名表大小写不敏感:UI 设计资产的现役写入侧写的是大写 `"UI"`。 + expect(canonicalGameCreationAppAssetKind('UI')).toBe('ui-design'); + expect(canonicalGameCreationAppAssetKind('UI-Prototype')).toBe('ui-design'); + // 字体资产的现役写入侧写的是 `font`。 + expect(canonicalGameCreationAppAssetKind('font')).toBe('document'); + expect(canonicalGameCreationAppAssetKind('FONT')).toBe('document'); expect(canonicalGameCreationAppAssetKind('unknown-kind')).toBe('image'); }); @@ -777,7 +783,10 @@ describe('AI 游戏创作 App 共享契约', () => { expect(gameCreationAppAssetCategoryForKind('game-background')).toBe( 'scene', ); - expect(gameCreationAppAssetCategoryForKind('UI')).toBe('unclassified'); + // 现役写入侧写的是大写 `"UI"`(画板导出的 UI 设计 JSON 资产)与 `font`(字体上传): + // 两者都必须落进明确栏目,否则 8 条真机 UI 资产会永远停在「待归类」。 + expect(gameCreationAppAssetCategoryForKind('UI')).toBe('ui-interaction'); + expect(gameCreationAppAssetCategoryForKind('font')).toBe('document'); expect(gameCreationAppAssetCategoryForKind('unknown-kind')).toBe( 'unclassified', ); diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index af93c67a7..70e82533b 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -508,6 +508,9 @@ export type GameCreationAppCanonicalAssetKind = * 否则会落到 `canonicalGameCreationAppAssetKind` 的 `image` 兜底,再经 * `image → unclassified` 被误分到「待归类」。 * + * `font` 同样属于现役写入侧:字体上传(`ttf / otf / woff / woff2`)登记的 manifest + * 资产 `kind` 就是 `font`,不在此收口就只能落「待归类」。 + * * 本表与 Rust 侧 `server-rs/crates/shared-contracts/src/game_creation_app.rs` 的 * `canonical_game_creation_app_asset_kind` 必须成对维护,由 * `tests/assetKindCanonicalMapping.test.ts` 交叉钉住。 @@ -531,12 +534,19 @@ const GAME_CREATION_APP_LEGACY_ASSET_KINDS: Record< 'game-style': 'code', animation: 'character-animation', asset: 'image', + font: 'document', }; export function canonicalGameCreationAppAssetKind( value: string, ): GameCreationAppCanonicalAssetKind { - const normalized = value.trim(); + /** + * kind 词汇大小写不敏感:UI 设计资产的现役写入侧写的是**大写** `"UI"` + * (`src-tauri/src/ui_editor/resource_bridge.rs`、`workflow.rs`、`persistence.rs`)。 + * 只按小写收口会让它落 `image` 兜底、再经 `image → unclassified` 永远停在「待归类」, + * 且派生值本身就是 `unclassified`,读时自愈也救不回来。 + */ + const normalized = value.trim().toLowerCase(); const legacyKind = GAME_CREATION_APP_LEGACY_ASSET_KINDS[normalized]; if (legacyKind) return legacyKind; if ( 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 797b8c44c..073eae8d9 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -570,7 +570,15 @@ pub const GAME_CREATION_APP_CANONICAL_ASSET_KINDS: [&str; 16] = [ ]; pub fn canonical_game_creation_app_asset_kind(value: &str) -> &'static str { - match value.trim() { + // kind 词汇大小写不敏感:UI 设计资产的现役写入侧写的是**大写** `"UI"` + // (`apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs` 的 + // `register_local_asset_at(..., "UI", ...)`,`workflow.rs` / `persistence.rs` 同), + // 只按小写收口会让它落到 `image` 兜底、再经 `image -> unclassified` 永远停在 + // 「待归类」;且派生值本身就是 unclassified,读时自愈也救不回来。 + // TS 侧 `canonicalGameCreationAppAssetKind` 用同一口径,由 + // `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 交叉钉住。 + let normalized = value.trim().to_lowercase(); + match normalized.as_str() { "game-background" => "scene", "character-art" => "character", "ui-prototype" => "ui-design", @@ -583,6 +591,9 @@ pub fn canonical_game_creation_app_asset_kind(value: &str) -> &'static str { "game-entry" | "game-script" | "game-style" => "code", "animation" => "character-animation", "asset" => "image", + // 字体上传(`ttf / otf / woff / woff2`)登记的 manifest 资产 kind 就是 `font`, + // 不收口同样只能落「待归类」。 + "font" => "document", canonical => match GAME_CREATION_APP_CANONICAL_ASSET_KINDS .iter() .find(|candidate| **candidate == canonical) @@ -2150,9 +2161,21 @@ mod tests { game_creation_app_asset_category_for_kind("game-background"), GameCreationAppAssetCategory::Scene ); + // 现役写入侧写的是大写 `"UI"`(UI 设计 JSON 资产)与 `font`(字体上传)。 + // 旧断言钉的是「`"UI"` 落待归类」,那正是真机 8 条 UI 资产永远归不了类的成因: + // 派生值本身就是 unclassified,读时自愈也救不回来,只能在别名表收口。 assert_eq!( game_creation_app_asset_category_for_kind("UI"), - GameCreationAppAssetCategory::Unclassified + GameCreationAppAssetCategory::UiInteraction + ); + assert_eq!( + canonical_game_creation_app_asset_kind("UI"), + "ui-design", + "kind 别名表必须大小写不敏感" + ); + assert_eq!( + game_creation_app_asset_category_for_kind("font"), + GameCreationAppAssetCategory::Document ); assert_eq!( game_creation_app_asset_category_for_kind("unknown-kind"),