同路径重登记:kind 变化时重派生 category,同 kind 时保留落盘分类

- `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 的派生行为,因此与上面的更新分支断言放在同一个提交
This commit is contained in:
2026-09-11 18:48:03 +08:00
parent 3082c3e854
commit f476eb89e5
2 changed files with 147 additions and 1 deletions
@@ -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"))
@@ -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,
&registered.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::<Vec<_>>();
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();