修复资源登记把任意图片写成 ui 类型
Project CI / Repository checks (pull_request) Successful in 3m17s
Project CI / Frontend tests (pull_request) Successful in 3m55s
Project CI / Backend tests (pull_request) Successful in 6m29s
Project CI / Native shell tests (pull_request) Successful in 17m16s

停用本地导入图片的 ui 默认 kind:agent_local_project_file_type 的图片扩展名分支改写 canonical image(派生 unclassified,落「待归类」),并注明不许把 ui 当图片默认值及其不可恢复原因——读时自愈只在落盘 category 为 unclassified 时触发,kind 本身写错时自愈只会把错值放大成 ui-interaction
账户素材导入改用平台真实类型:新增 imported_platform_asset_kind,账户素材库记录自带的 assetKind 不再被常量 ui 顶掉,缺失才退回中性 image
平台素材导入改用响应里的 assetKind:网页项目画布与平台素材导入同样走 imported_platform_asset_kind,不再写死 ui
上传素材按内容证据推导 kind:upload_local_asset_at 不再把来源词 uploaded 当类型,改由 uploaded_asset_kind 按 mediaType 与扩展名推导 audio/video/image/document/code,判不出才用中性 asset;同一命令也收 .wav 与 .md,因此不能一刀切成 image
补 4 处回归与反查用例:本地导入与上传断言落盘 kind 与 category 且不得出现 ui、ui-interaction、uploaded;两处平台导入的 kind 解析加纯函数单测与调用点反查门禁;变异把错值改回来必须变红(M1/M2/M3 实测 4 个用例全红,还原后 diff 与基线逐字节一致)
覆盖边界:账户素材库与平台素材导入是异步 HTTP 路径,仓库内没有可复用的端到端夹具,这两条覆盖是纯函数单测加调用点反查门禁,不是端到端断言;本地导入与上传是读落盘 manifest 的行为级断言
后续事项:ImportedAsset.asset_kind 仍返回平台原始值(缺失为 none),与已落盘 kind 可能不一致,本次未改动
This commit is contained in:
2026-09-12 02:30:31 +08:00
parent 90177308e0
commit 5b62d1e4ef
3 changed files with 247 additions and 5 deletions
@@ -467,6 +467,77 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool {
active_external_editor_api_credentials().is_some()
}
/// 上传素材落盘的 manifest `kind`:只由**内容证据**推导(`mediaType` 优先,扩展名兜底)。
///
/// 不用 `uploaded` 这类**来源词**当类型:它不在 canonical 目录里,会经别名表落到
/// `image → unclassified`,把本该归「音频」「文档」的上传素材一起说成图片。
///
/// 更不能把 `ui` 当"图片的默认类型"`ui → ui-design → ui-interaction` 会把任意上传图片
/// 钉死在「UI 交互」栏,而"是不是 UI 素材"跟"扩展名是不是 .png"毫无关系;这类错值还不可恢复
/// ——读时自愈只在落盘 `category` 是 `unclassified` 且该 `kind` 能派生出非 `unclassified`
/// 分类时才生效,`kind` 本身错时自愈只会把错值放大。
///
/// 判不出内容类型时返回中性的 `asset`(派生 `unclassified` → 「待归类」),**不猜具体类型**。
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
let media_type = media_type.trim().to_ascii_lowercase();
let extension = Path::new(file_name)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let extension = extension.as_str();
if media_type.starts_with("audio/")
|| matches!(
extension,
"mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus"
)
{
"audio"
} else if media_type.starts_with("video/") || matches!(extension, "mp4" | "webm" | "mov") {
"video"
} else if media_type.starts_with("font/")
|| matches!(extension, "ttf" | "otf" | "woff" | "woff2")
{
"document"
} else if media_type.starts_with("image/")
|| matches!(
extension,
"png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp"
)
{
"image"
} else if matches!(media_type.as_str(), "text/html" | "text/css")
|| media_type.contains("javascript")
|| media_type.contains("typescript")
|| matches!(
extension,
"html" | "htm" | "css" | "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx"
)
{
"code"
} else if media_type.starts_with("text/")
|| matches!(media_type.as_str(), "application/json" | "application/xml")
|| matches!(
extension,
"md" | "markdown"
| "mdx"
| "txt"
| "json"
| "yaml"
| "yml"
| "toml"
| "csv"
| "ini"
| "conf"
| "xml"
)
{
"document"
} else {
"asset"
}
}
pub(crate) fn upload_local_asset_at(
root: &Path,
file_name: &str,
@@ -492,7 +563,7 @@ pub(crate) fn upload_local_asset_at(
register_local_asset_entry(
root,
&relative_path,
"uploaded",
uploaded_asset_kind(file_name, media_type),
media_type,
"upload",
GameCreationAppAssetSource {
@@ -2051,6 +2122,45 @@ mod tests {
}
}
/// 上传素材的 `kind` 只由内容证据推导:既不能写 `uploaded`(来源词当类型),
/// 更不能把 `ui` 当图片默认值(`ui → ui-design → ui-interaction` 会把任意上传图片
/// 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来)。
///
/// 变异验证:把 `uploaded_asset_kind` 改回返回常量(`"uploaded"` 或 `"ui"`)必须让本用例变红。
#[test]
fn uploaded_asset_kind_uses_content_evidence_and_never_ui() {
assert_eq!(uploaded_asset_kind("hero.png", "image/png"), "image");
assert_eq!(uploaded_asset_kind("../角色.png", "image/png"), "image");
assert_eq!(uploaded_asset_kind("bg.webp", ""), "image");
assert_eq!(uploaded_asset_kind("theme.mp3", "audio/mpeg"), "audio");
assert_eq!(uploaded_asset_kind("intro.mp4", ""), "video");
assert_eq!(uploaded_asset_kind("rules.md", "text/markdown"), "document");
assert_eq!(uploaded_asset_kind("ui-font.ttf", "font/ttf"), "document");
assert_eq!(uploaded_asset_kind("game.js", "text/javascript"), "code");
// 判不出内容类型 → 中性 `asset`(派生 `unclassified` → 「待归类」),不猜具体类型。
assert_eq!(
uploaded_asset_kind("unknown.bin", "application/octet-stream"),
"asset"
);
assert_eq!(uploaded_asset_kind("no-extension", ""), "asset");
// 反查:正文里的"来源词"和"UI 默认值"都不得出现在返回值里。
for (file_name, media_type) in [
("hero.png", "image/png"),
("theme.mp3", "audio/mpeg"),
("unknown.bin", ""),
] {
let kind = uploaded_asset_kind(file_name, media_type);
assert_ne!(kind, "uploaded", "{file_name} 写回了来源词");
assert_ne!(kind, "ui", "{file_name} 写回了非 canonical 的 `ui`");
assert!(
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
.contains(&kind)
|| kind == "asset",
"非 canonical kind: {kind}{file_name}"
);
}
}
#[test]
fn infer_file_extension_preserves_supported_local_resource_extensions() {
assert_eq!(
@@ -2944,13 +2944,37 @@ mod agent_asset_import_tests {
.local_path
.starts_with("assets/uploads/local-"));
assert!(first.assets[1].local_path.ends_with(".png"));
assert_eq!(first.assets[0].asset_kind.as_deref(), Some("ui"));
assert_eq!(first.assets[0].asset_kind.as_deref(), Some("image"));
assert_eq!(first.assets[2].asset_kind.as_deref(), Some("audio"));
assert_eq!(first.assets[3].asset_kind.as_deref(), Some("video"));
assert_eq!(first.assets[4].asset_kind.as_deref(), Some("code"));
assert_eq!(first.assets[5].asset_kind.as_deref(), Some("document"));
assert!(first.assets[4].local_path.ends_with(".html"));
assert!(root.join(&first.assets[1].local_path).is_file());
// P0 回归:本地导入的图片只能落中性的 `image`(→ `unclassified` → 「待归类」)。
// 曾经写成 `ui`,经 `ui → ui-design → ui-interaction` 把任意图片钉死在「UI 交互」栏,
// 且落盘 `category` 成了非 `unclassified` 值后读时自愈永远救不回来。
// 把 `agent_local_project_file_type` 的图片分支改回 `"ui"` 必须让本用例变红。
let manifest: serde_json::Value = serde_json::from_str(
&fs::read_to_string(root.join(".agent/manifest.json")).expect("read imported manifest"),
)
.expect("parse imported manifest");
let imported_assets = manifest["assets"].as_array().expect("assets array");
let png_rows = imported_assets
.iter()
.filter(|asset| asset["mediaType"] == "image/png")
.collect::<Vec<_>>();
assert_eq!(png_rows.len(), 2);
for asset in &png_rows {
assert_eq!(asset["kind"], "image", "{asset}");
assert_eq!(asset["category"], "unclassified", "{asset}");
}
assert!(
!imported_assets
.iter()
.any(|asset| asset["kind"] == "ui" || asset["category"] == "ui-interaction"),
"本地导入不得产出 `ui` / `ui-interaction`{manifest}"
);
let revision_after_first = read_game_creator_agent_runtime_project_revision(root)
.expect("read imported revision")
.revision;
@@ -3041,6 +3065,61 @@ mod agent_asset_import_tests {
import_local_project_assets_for_agent(root, &["assets/broken.js".to_string()]).is_err()
);
}
/// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**,
/// 判不出才退回中性 `image`(→ 「待归类」),**永远不许回退成常量 `ui`**。
///
/// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回 `"ui"` 的实现,
/// 本用例必须变红(`character` / `scene` / 空值 / 未知值四组断言都会失败)。
#[test]
fn imported_platform_asset_kind_prefers_payload_type_over_ui_default() {
assert_eq!(imported_platform_asset_kind(Some("character")), "character");
assert_eq!(imported_platform_asset_kind(Some("scene")), "scene");
assert_eq!(
imported_platform_asset_kind(Some("character-animation")),
"character-animation"
);
assert_eq!(imported_platform_asset_kind(Some("icon-spec")), "icon-spec");
// 平台确实说它是 UI 设计稿时,才允许落 `ui-design`(→ 「UI 交互」);
// 大写 `UI` 与旧值 `ui` 都要归一到同一口径,但**不允许由我们替它默认**。
assert_eq!(imported_platform_asset_kind(Some("UI")), "ui-design");
assert_eq!(imported_platform_asset_kind(Some("ui")), "ui-design");
// 缺失 / 空白 / 未知值一律落 canonical `image` 兜底(→ 「待归类」)。
assert_eq!(imported_platform_asset_kind(None), "image");
assert_eq!(imported_platform_asset_kind(Some(" ")), "image");
assert_eq!(imported_platform_asset_kind(Some("mystery")), "image");
// `kind` 是外部输入,原型链键必须走 `image` 兜底,不能取到原型上的值。
assert_eq!(imported_platform_asset_kind(Some("__proto__")), "image");
assert_eq!(imported_platform_asset_kind(Some("constructor")), "image");
}
/// 反查门禁:两处平台素材导入必须继续用 `imported_platform_asset_kind` 解析 `kind`
/// 不允许回退成写死的字面量。
///
/// 断言的是**调用点原文**而不是函数被提及的次数——本测试自身也在同一文件里,
/// 按名字计数会把测试代码算进去,门禁就假绿了。
#[test]
fn platform_asset_import_sites_resolve_kind_instead_of_hardcoding_it() {
let source = include_str!("commands.rs");
for call in [
"imported_platform_asset_kind(record.asset_kind",
"imported_platform_asset_kind(json_string_field(",
] {
assert!(
source.contains(call),
"平台导入必须用平台给的 `assetKind` 解析 manifest `kind`,不得回退成常量:{call}"
);
}
for hardcoded in [
"register_local_asset_entry(\n root,\n &local_path,\n \"ui\",",
"register_local_asset_entry(\n root,\n &local_path,\n \"uploaded\",",
] {
assert!(
!source.contains(hardcoded),
"平台导入不得再把 manifest `kind` 写成常量字面量:{hardcoded}"
);
}
}
}
fn remote_asset_local_path(asset_id: &str, extension: &str) -> String {
@@ -3585,9 +3664,17 @@ fn agent_local_project_file_type(
"bmp" => bytes.starts_with(b"BM").then_some("image/bmp"),
_ => None,
};
// 图片的登记 `kind` 只能是内容证据能支撑的中性值:图片就是 `image`
// canonical `image` 派生 `unclassified`,落「待归类」,由用户/后续流程再定).
//
// 绝不许把 `ui` 当"图片的默认类型"`ui → ui-design → ui-interaction` 会让任意
// PNG 钉死在「UI 交互」栏,而"是不是 UI 素材"跟"扩展名是不是 .png"毫无关系。
// 这种错值还不可恢复——读时自愈只在落盘 `category` 是 `unclassified` 且该 `kind`
// 能派生出非 `unclassified` 分类时才生效,这里 `kind` 本身就是错的,自愈只会把
// 错值放大成显式的 `ui-interaction`。
media_type.map(|media_type| AgentLocalProjectFileType {
category: "image",
asset_kind: "ui",
asset_kind: "image",
media_type,
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
})
@@ -3887,6 +3974,26 @@ pub(crate) fn import_local_project_assets_for_agent(
Ok(RemoteImportResult { assets: imported })
}
/// 平台素材导入落盘的 manifest `kind`:**有真实类型就用真实类型**,判不出才退回中性的
/// `image`(派生 `unclassified` → 「待归类」)。
///
/// 这里绝不允许回退成 `ui`:账户素材库记录与网页项目画布响应本来就带 `assetKind`
/// `AgentEditorAssetRecord::asset_kind` / 响应字段 `assetKind`),把它换成常量等于丢掉
/// 唯一一条"这东西是什么"的事实,并把角色立绘、怪物、道具、场景图一并钉进「UI 交互」栏
/// `ui → ui-design → ui-interaction`)。落盘 `category` 一旦是非 `unclassified` 值,
/// 读时自愈永远救不回来,所以宁可写 `image`(待归类)也不能写假 `ui`。
fn imported_platform_asset_kind(platform_kind: Option<&str>) -> String {
match platform_kind.map(str::trim).filter(|kind| !kind.is_empty()) {
// 平台侧词汇可能与 canonical 目录不完全一致(例如大写 `UI`),统一过 canonical 归一,
// 保证写入侧只产出 canonical `kind`。
Some(kind) => {
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind)
.to_string()
}
None => "image".to_string(),
}
}
/// 按账户素材 `assetId` 查询权威素材、换签下载并登记到当前项目。模型只提交
/// `assetId`objectKey/URL/Token 始终由本函数在客户端内部解析。
pub(crate) async fn import_account_editor_assets_for_agent(
@@ -4042,10 +4149,13 @@ pub(crate) async fn import_account_editor_assets_for_agent(
"editor.project-canvas.agent-import",
),
};
// 账户素材记录自带 `assetKind`:它就是"这东西是什么"的权威来源,必须落进 manifest,
// 不许再用常量 `ui` 顶掉它(见 `imported_platform_asset_kind`)。
let asset_kind = imported_platform_asset_kind(record.asset_kind.as_deref());
let registered = register_local_asset_entry(
root,
&local_path,
"ui",
&asset_kind,
&media_type,
"canvas",
GameCreationAppAssetSource {
@@ -4156,10 +4266,14 @@ pub(crate) async fn import_ui_editor_remote_assets(
.and_then(|_| file.sync_all())
.map_err(|error| format!("写入平台素材失败:{error}"))?;
drop(file);
// 平台响应里的 `assetKind` 同样是"这东西是什么"的权威来源,必须落进 manifest;
// 缺失才退回 `image`(待归类),绝不用常量 `ui`(见 `imported_platform_asset_kind`)。
let asset_kind =
imported_platform_asset_kind(json_string_field(&asset, "assetKind").as_deref());
let registered = register_local_asset_entry(
root,
&local_path,
"ui",
&asset_kind,
&media_type,
"canvas",
GameCreationAppAssetSource {
@@ -1849,6 +1849,11 @@ fn upload_local_asset_writes_file_and_manifest_entry() {
assert_eq!(manifest["assets"][0]["id"], result.id);
assert_eq!(manifest["assets"][0]["mediaType"], "image/png");
assert_eq!(manifest["assets"][0]["source"]["kind"], "uploaded");
// 落盘 `kind` 只能由内容证据推导:图片是 `image`(→ `unclassified` → 「待归类」)。
// 不许写 `uploaded`(来源词当类型),更不许把 `ui` 当图片默认值——那会把任意上传图片
// 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来。
assert_eq!(manifest["assets"][0]["kind"], "image");
assert_eq!(manifest["assets"][0]["category"], "unclassified");
assert_eq!(manifest["assets"][0]["localPath"], result.local_path);
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.lines().any(|line| {
@@ -1864,6 +1869,19 @@ fn upload_local_asset_writes_file_and_manifest_entry() {
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["assets"].as_array().unwrap().len(), 2);
// 同一上传命令的音频素材必须归「音频」,不能被图片口径顶掉。
assert_eq!(manifest["assets"][1]["kind"], "audio");
assert_eq!(manifest["assets"][1]["category"], "audio");
assert!(
!manifest["assets"]
.as_array()
.unwrap()
.iter()
.any(|asset| asset["kind"] == "uploaded"
|| asset["kind"] == "ui"
|| asset["category"] == "ui-interaction"),
"上传登记不得产出 `uploaded` / `ui` / `ui-interaction`{manifest}"
);
fs::remove_dir_all(root).ok();
}