资源 kind 只保留一个公开解析入口

- server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs:删掉公开的 `from_str_or_unknown()`(`from_str_lossy` 的残留命名),严格匹配降级为私有 `match_canonical()`;`parse_with_context()` 成为唯一公开解析入口,`FromStr`、serde 与所有边界一律走它,认不出 canonical 值只有这一处收口并留痕。
- server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs:词汇表用例改走唯一入口,并断言每个非 canonical 变体都留痕。
- apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:读侧回捞 legacy kind 时不再调用第二个解析函数,直接用唯一词汇表 `ALL` 判真值(`unknown` 本身是成员,自然跳过)。
This commit is contained in:
2026-09-17 16:27:48 +08:00
parent 20b34918d3
commit 796ad7bb51
2 changed files with 19 additions and 13 deletions
@@ -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;
}
@@ -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 值时把原始串与
/// 上下文交给已注册的 reporterAGC 壳注册成 `app_log!`)。
/// **唯一**的 kind 解析入口:严格等值匹配,认不出 canonical 值时把原始串与上下文
/// 交给已注册的 reporterAGC 壳注册成 `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);
}
}