kind 别名表大小写不敏感并收口 font:堵住 8 条 UI 资产永远落「待归类」

- `canonical_game_creation_app_asset_kind`(Rust)与 `canonicalGameCreationAppAssetKind`(TS)改为对 trim 后的小写值查别名表与 canonical 目录,修正「UI 设计资产的现役写入侧写的是大写 `"UI"`」这条真机事实
- 别名表补 `font → document`:字体上传(`ttf / otf / woff / woff2`)登记的 manifest `kind` 就是 `font`
- 为什么必须在别名表收口、读时自愈救不回来:`"UI"` 与 `"font"` 的派生结果本身就是 `unclassified`,自愈规则「派生值不是 unclassified 才触发」永不成立;真机 8 条 `kind:"UI"` + `mediaType:"application/json"` + `localPath:"ui/UI 设计 N.json"` 与写入路径逐字段吻合
- 改写 Rust 断言 `game_creation_app_asset_category_for_kind("UI") == Unclassified` → `UiInteraction`,并补 `canonical_...("UI") == "ui-design"` 与 `font == Document`:旧断言把这条 bug 钉成了「期望行为」,新形态才是唯一真源——写入侧真实写出的 kind 必须能落进明确栏目,否则真机资产永远归不了类
- 改写 TS 断言 `gameCreationAppAssetCategoryForKind('UI') == 'unclassified'` → `'ui-interaction'`,理由同上
- `game_creation_app.rs` 里「现役写入侧仍会写出这些非 canonical 值,必须在这里收口」的注释此前只收口了小写 `ui`,与大写写入侧矛盾,现按注释本意收口
- 新增写侧→分类的端到端契约用例:`resource_bridge.rs` 的 `bridge_is_idempotent_and_installs_source_image` 走真实生产函数 → 真实 `register_local_asset_at(..., "UI", ...)` → 断言落盘 `category` 为 `ui-interaction`
- 新增 `assetKindCanonicalMapping.test.ts` 的「写侧 kind 字面量 → 分类」用例组:直接解析 UI 编辑器写侧源码第 3 个实参,写点换个新字面量就会红,防下次再漏
This commit is contained in:
2026-09-11 18:46:58 +08:00
parent 576786324f
commit 3082c3e854
5 changed files with 120 additions and 7 deletions
@@ -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()]
@@ -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');
});
});
@@ -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',
);
@@ -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 (
@@ -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"),