修复资源登记把任意图片写成 ui 类型
停用本地导入图片的 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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user