diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 04349b47a..4e2658c9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1988,9 +1988,11 @@ fn non_canonical_manifest_asset_kinds(payload: &str) -> Vec<(String, String, Str let Some(raw_kind) = asset.get("kind").and_then(serde_json::Value::as_str) else { continue; }; - if raw_kind.trim() == GameCreationAppAssetKind::Unknown.as_str() - || GameCreationAppAssetKind::from_str_or_unknown(raw_kind) - != GameCreationAppAssetKind::Unknown + // 判据直接来自唯一词汇表(`ALL` 含字面 `unknown`),不再另开一个"纯解析"入口: + // 严格解析入口只有 `parse_with_context`,这里只需要判真值、不需要再留痕。 + if GameCreationAppAssetKind::ALL + .iter() + .any(|kind| kind.as_str() == raw_kind) { continue; } diff --git a/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs b/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs index 3340293ae..7e41d7872 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs @@ -79,10 +79,9 @@ macro_rules! game_creation_app_asset_kinds { /// 严格解析:只认 canonical 值,其余(含大小写、空白变体)一律 `Unknown`。 /// - /// 反序列化必须保持不可失败(一份 legacy manifest 不该让整个项目读不出来), - /// 所以这里是"认不出就 `Unknown`",而不是 `Err`;名字里的 `or_unknown` 只描述 - /// 这个收口,不代表任何归一化或兼容。 - pub fn from_str_or_unknown(value: &str) -> Self { + /// 私有实现细节,**不是**解析入口:外部一律走 [`Self::parse_with_context`], + /// 保证"认不出"这件事只在一个地方被收口、被留痕。 + fn match_canonical(value: &str) -> Self { let mut index = 0; while index < Self::ALL.len() { let candidate = Self::ALL[index]; @@ -94,12 +93,14 @@ macro_rules! game_creation_app_asset_kinds { Self::Unknown } - /// 解析外部 manifest / API 字符串:严格解析,并在认不出 canonical 值时把原始串与 - /// 上下文交给已注册的 reporter(AGC 壳注册成 `app_log!`)。 + /// **唯一**的 kind 解析入口:严格等值匹配,认不出 canonical 值时把原始串与上下文 + /// 交给已注册的 reporter(AGC 壳注册成 `app_log!`)。 /// + /// 不做 trim、不做大小写归一、不查别名表、不做迁移——认不出就收口成 `Unknown`, + /// 而不是 `Err`:反序列化必须不可失败(一份 legacy manifest 不该让整个项目读不出来)。 /// 字面 `"unknown"` 本身是合法输入,不算异常,不报。 pub fn parse_with_context(value: &str, context: &'static str) -> Self { - let parsed = Self::from_str_or_unknown(value); + let parsed = Self::match_canonical(value); if parsed == Self::Unknown && value != Self::Unknown.as_str() { report_non_canonical_asset_kind(value, context); } @@ -328,6 +329,7 @@ mod tests { /// 严格口径下大小写与空白变体都不命中。 #[test] fn declared_vocabulary_is_unique_and_strictly_parsed() { + let _guard = record_reports(); assert_eq!(GameCreationAppAssetKind::ALL.len(), 19); let mut wire_values = std::collections::BTreeSet::new(); for kind in GameCreationAppAssetKind::ALL { @@ -337,20 +339,22 @@ mod tests { kind.as_str() ); assert_eq!( - GameCreationAppAssetKind::from_str_or_unknown(kind.as_str()), + GameCreationAppAssetKind::parse_with_context(kind.as_str(), "unit-test.vocabulary"), *kind ); } assert_eq!( - GameCreationAppAssetKind::from_str_or_unknown("unknown"), + GameCreationAppAssetKind::parse_with_context("unknown", "unit-test.vocabulary"), GameCreationAppAssetKind::Unknown ); for raw in ["UI", "ui", " image ", "Image", "art-spritesheet-slice"] { assert_eq!( - GameCreationAppAssetKind::from_str_or_unknown(raw), + GameCreationAppAssetKind::parse_with_context(raw, "unit-test.vocabulary"), GameCreationAppAssetKind::Unknown, "{raw} 不许命中 canonical 值" ); } + // 每个非 canonical 变体都要留痕(5 条大小写/空白/legacy 变体都算异常)。 + assert_eq!(recorded_reports().len(), 5); } }