From f476eb89e5dbdd5bbe58099cac85d0fadf3f772e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 18:48:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E8=B7=AF=E5=BE=84=E9=87=8D=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=EF=BC=9Akind=20=E5=8F=98=E5=8C=96=E6=97=B6=E9=87=8D?= =?UTF-8?q?=E6=B4=BE=E7=94=9F=20category=EF=BC=8C=E5=90=8C=20kind=20?= =?UTF-8?q?=E6=97=B6=E4=BF=9D=E7=95=99=E8=90=BD=E7=9B=98=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `register_local_asset_entry`(`assets.rs`)命中同 `localPath` 的既有资产时,旧实现只覆盖 `kind` / `media_type` / `source`,**从不重算 `category`**:`kind` 变了而 `category` 停在旧值,且陈旧的非 `unclassified` 值会被读侧无条件信任(自愈只在落盘值是 `unclassified` 时才触发),该资产就永远停在错误栏目 - 修法:只在 `existing.kind != kind` 时重派生 `category`;`kind` 未变时刻意不动 `category`——落盘分类是权威值,同 kind 重登记不得抹掉它 - `register_local_asset_records_existing_asset_with_canvas_source` 补上 `category` 断言:首次登记 `kind:"character"` 落 `character`,重登记改成 `kind:"ui"` 后必须变 `ui-interaction`(旧用例只断言 `kind` 与 `source.kind`,正好走更新分支却漏掉 `category`,所以这条错位一直没被抓住) - 新增用例 `register_local_asset_keeps_explicit_category_when_kind_is_unchanged`:显式设成 `audio` 后同 kind 重登记,`category` 与 `tags` 必须原样保留,把「不能无条件重派生」这条不变量钉死 - 新增用例 `register_local_asset_derives_category_from_real_write_side_kinds`:用与现役写入侧逐字一致的字面量(`"UI"` / `"font"`)走真实 `register_local_asset_at`,断言落到 `ui-interaction` / `document`;它属于同一套 `register_local_asset_*` 写 API 的派生行为,因此与上面的更新分支断言放在同一个提交 --- .../src-tauri/src/assets.rs | 9 +- .../src-tauri/src/tests/project.rs | 139 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index b4f225918..400b47221 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1759,7 +1759,14 @@ pub(crate) fn register_local_asset_entry( .iter_mut() .find(|asset| asset.local_path == normalized_path) { - existing.kind = kind.to_string(); + // kind 变了必须重派生 category:否则同路径重登记会把新 kind 和旧分类拼在一起, + // 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified + // 时才触发),于是这个资产永远停在错误栏目。 + // kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。 + if existing.kind != kind { + existing.kind = kind.to_string(); + existing.category = game_creation_app_asset_category_for_kind(kind); + } existing.media_type = media_type.to_string(); existing.source = source; Ok((existing.id.clone(), "asset.update")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 721710027..3c916ad80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1909,6 +1909,7 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { .expect("manifest json"); assert_eq!(manifest["assets"][0]["id"], result.id); assert_eq!(manifest["assets"][0]["kind"], "character"); + assert_eq!(manifest["assets"][0]["category"], "character"); assert_eq!(manifest["assets"][0]["localPath"], "assets/hero.png"); assert_eq!(manifest["assets"][0]["source"]["kind"], "canvas"); assert_eq!( @@ -1951,11 +1952,149 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { .expect("manifest json"); assert_eq!(manifest["assets"].as_array().unwrap().len(), 1); assert_eq!(manifest["assets"][0]["kind"], "ui"); + // kind 变了必须重派生 category:旧分类 `character` 是非 unclassified 值,会被读侧 + // 无条件信任、自愈也不会触发,资产就永远停在「角色与对象」。 + assert_eq!(manifest["assets"][0]["category"], "ui-interaction"); assert_eq!(manifest["assets"][0]["source"]["kind"], "generated"); fs::remove_dir_all(root).ok(); } +/// kind 未变时,同路径重登记必须保留落盘分类。 +/// +/// 分类是权威值(`update_local_project_resource_classification` 由 Agent / 客户端写入), +/// 无条件按 kind 重派生会把显式设置静默改回派生值;而只在 kind 变化时重派生,才既能修掉 +/// 「新 kind 配旧分类」的错位,又不丢显式分类。 +#[test] +fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "同 kind 重登记保留分类").expect("project init"); + write_local_project_file_at(&root, "ui/UI 设计 1.json", "{}").expect("ui asset file"); + let source = || GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }; + let registered = register_local_asset_at( + &root, + "ui/UI 设计 1.json", + "UI", + "application/json", + "ui-workflow", + source(), + ) + .expect("register UI asset"); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision") + .revision; + update_manifest_asset_classification_at( + &root, + "project-1", + revision, + ®istered.id, + "audio", + vec!["界面".to_string()], + ) + .expect("set explicit category"); + + register_local_asset_at( + &root, + "ui/UI 设计 1.json", + "UI", + "application/json", + "ui-workflow", + source(), + ) + .expect("re-register UI asset"); + + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + assert_eq!(manifest["assets"][0]["kind"], "UI"); + assert_eq!(manifest["assets"][0]["category"], "audio"); + assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"])); + + fs::remove_dir_all(root).ok(); +} + +/// 写侧 → 分类的端到端口径:现役写入侧直接写出的非 canonical kind 字面量必须落进明确栏目。 +/// +/// 这里的字面量与写入侧逐字一致:UI 设计资产是 +/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的 +/// `register_local_asset_at(root, path, "UI", "application/json", ...)`, +/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。 +/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。 +#[test] +fn register_local_asset_derives_category_from_real_write_side_kinds() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "写侧 kind 分类口径").expect("project init"); + let source = || GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }; + write_local_project_file_at(&root, "ui/UI 设计 1.json", "{}").expect("ui asset file"); + write_local_project_file_at(&root, "assets/ui-font.ttf", "fake-font").expect("font file"); + + register_local_asset_at( + &root, + "ui/UI 设计 1.json", + "UI", + "application/json", + "ui-workflow", + source(), + ) + .expect("register UI asset"); + register_local_asset_at( + &root, + "assets/ui-font.ttf", + "font", + "font/ttf", + "font", + source(), + ) + .expect("register font asset"); + + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + let mut categories = manifest["assets"] + .as_array() + .unwrap() + .iter() + .map(|asset| { + ( + asset["kind"].as_str().unwrap().to_string(), + asset["category"].as_str().unwrap().to_string(), + ) + }) + .collect::>(); + categories.sort(); + assert_eq!( + categories, + vec![ + ("UI".to_string(), "ui-interaction".to_string()), + ("font".to_string(), "document".to_string()), + ] + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn register_local_asset_rejects_missing_or_unsafe_path() { let root = unique_project_path();