修正 AGC 图片快速编辑的 400 判据测试:补 art-spritesheet / ui 正向用例并钉住请求级链路

- 服务端白名单用例改为按静态图集合断言,新增 `art-spritesheet` / `ui` / `ui-prototype` / `game-art` / `game-background` / `art-spritesheet-slice` 正向用例
- 保留 `future-kind` / `video` / `audio` / `image-sequence` / `character-animation` / `document` / `code` 负向用例,并补 mediaType AND 门用例
- 新增请求级回归用例 `game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds`,按 AGC 客户端真实请求体(source 已改写为 ai-game-creator-client)覆盖放行与拒绝两侧
- 新增 `game_creator_client_source_is_recognized_as_game_creator_resource_editor`,把 source 别名与队列 consumer 判定钉在同口径
- 修正 icon 相关旧断言:`icon` 已是共享契约静态图类型,改断言 non-static 媒体仍被拒
This commit is contained in:
2026-09-10 19:01:04 +08:00
parent 9e59abde53
commit 8c74e5f97c
2 changed files with 264 additions and 6 deletions
@@ -5475,6 +5475,102 @@ mod tests {
);
}
/// 平台把 4xx 兜底成 `请求参数不合法`,具体原因写在 `details.message`。
/// 只读 `error.message` 会让用户永远看不到真实原因,因此这里把取值顺序钉在测试里。
#[test]
fn editor_api_rejection_reason_prefers_details_message_and_keeps_context() {
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({
"error": {
"code": "BAD_REQUEST",
"message": "请求参数不合法",
"details": {
"provider": "editor-image-edit",
"message": "当前素材类型不支持图片快速编辑",
"assetKind": "art-spritesheet",
"mediaType": "image",
},
}
})
.to_string()
.as_str(),
),
Some(
"当前素材类型不支持图片快速编辑|provider editor-image-edit|素材类型 art-spritesheet|媒体类型 image"
.to_string()
),
"details.message 必须优先于通用 error.message,并附上 provider 与类型上下文"
);
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({
"error": {
"code": "UPSTREAM_FAILED",
"message": "上游服务请求失败",
"details": { "message": "Provider 返回 429", "provider": "editor-image-edit" },
}
})
.to_string()
.as_str(),
),
Some("Provider 返回 429provider editor-image-edit|错误码 UPSTREAM_FAILED".to_string()),
"非 BAD_REQUEST 的稳定码照旧透出"
);
// 顶层 details(没有 error.details 的错误体)同样优先。
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({ "details": { "message": "具体原因" }, "message": "通用文案" })
.to_string()
.as_str(),
),
Some("具体原因".to_string())
);
// 只有 error.message 时退回它;error 是字符串时按文本兜底。
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({ "error": { "code": "BAD_REQUEST", "message": "请求参数不合法" } })
.to_string()
.as_str(),
),
Some("请求参数不合法".to_string()),
"BAD_REQUEST 不重复透出错误码"
);
assert_eq!(
editor_api_rejection_reason_from_body(serde_json::json!({ "error": "直白文本" }).to_string().as_str()),
Some("直白文本".to_string())
);
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({ "message": "顶层文案" }).to_string().as_str()
),
Some("顶层文案".to_string())
);
// 空值与非 JSON 正文不给用户编造原因。
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({ "error": { "message": " " } }).to_string().as_str()
),
None
);
assert_eq!(editor_api_rejection_reason_from_body("<html>502</html>"), None);
assert_eq!(editor_api_rejection_reason_from_body(""), None);
// 超长正文仍按 200 字符截断,避免把整段响应体塞进用户可见文案。
let long = "".repeat(400);
let reason = editor_api_rejection_reason_from_body(
serde_json::json!({ "error": { "details": { "message": long.clone() } } })
.to_string()
.as_str(),
)
.expect("超长正文仍应给出原因");
assert_eq!(reason, "".repeat(200));
}
async fn with_test_external_editor_credentials<T>(
api_base_url: &str,
api_key: &str,
@@ -20602,24 +20602,44 @@ mod tests {
}
#[test]
fn editor_image_edit_source_kind_whitelist_covers_current_and_unknown_types() {
fn editor_image_edit_source_kind_whitelist_covers_static_image_kinds_only() {
let cases = [
(None, None, true),
(None, Some("image"), true),
(Some("image"), Some("image"), true),
(Some("spec"), Some("image"), true),
(Some("character"), Some("image"), true),
(Some("icon"), Some("image"), true),
(Some("icon-spritesheet"), Some("image"), true),
(Some("icon-spec"), Some("image"), true),
(Some("publication-material"), Some("image"), true),
(Some("ui-design"), Some("image"), true),
(Some("scene"), Some("image"), true),
(Some("icon"), Some("image"), false),
// `canonicalGameCreationAppAssetKind()` 的合法输入:平台按本地 manifest 原始类型
// 登记来源资源时会出现这些 legacy 值,实测 `art-spritesheet` / `ui` 曾整片 400。
(Some("art-spritesheet"), Some("image"), true),
(Some("art-spritesheet-slice"), Some("image"), true),
(Some("ui"), Some("image"), true),
(Some("ui-prototype"), Some("image"), true),
(Some("game-art"), Some("image"), true),
(Some("game-background"), Some("image"), true),
(Some("character-art"), Some("image"), true),
(Some("illustration"), Some("image"), true),
// 非静态媒体与非静态类型必须继续拒绝。
(Some("character-animation"), Some("image-sequence"), false),
(Some("sound-effect"), Some("audio"), false),
(Some("background-music"), Some("audio"), false),
(Some("video"), Some("video"), false),
(Some("audio"), Some("audio"), false),
(Some("document"), Some("application/pdf"), false),
(Some("code"), Some("text/plain"), false),
(Some("future-asset-kind"), Some("image"), false),
(None, Some("future-media-type"), false),
// mediaType 是 AND 门:图片类 assetKind 挂非静态媒体照旧拒绝。
(Some("art-spritesheet"), Some("video"), false),
(Some("ui"), Some("audio"), false),
(Some("icon"), Some("image-sequence"), false),
(Some("scene"), Some("image-sequence"), false),
];
for (asset_kind, media_type, expected_allowed) in cases {
let result = ensure_editor_image_edit_source_kind_allowed(asset_kind, media_type);
@@ -20630,8 +20650,8 @@ mod tests {
);
}
let error = ensure_editor_image_edit_source_kind_allowed(Some("icon"), Some("image"))
.expect_err("individual icon assets should not support quick edit");
let error = ensure_editor_image_edit_source_kind_allowed(Some("art-spritesheet"), Some("video"))
.expect_err("非静态媒体即使挂着图片类 assetKind 也不得支持快速编辑");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("message")),
@@ -20677,7 +20697,15 @@ mod tests {
icon_source.effective_asset_kind.as_deref(),
Some(icon_source.media_type.as_str()),
)
.is_err()
.is_ok()
);
assert!(
ensure_editor_image_edit_source_kind_allowed(
icon_source.effective_asset_kind.as_deref(),
Some("image-sequence"),
)
.is_err(),
"单张 icon 可快速编辑,但同一类型挂非静态媒体仍必须拒绝"
);
let icon_spec_source = resolve_editor_image_edit_target_layer_source(
"bucket",
@@ -20747,7 +20775,8 @@ mod tests {
Some("image"),
Some(&json!({ "source": "editor-agent" })),
)
.is_err()
.is_ok(),
"icon 已是共享契约里的静态图 canonical 类型,与 generationInputs.source 无关"
);
assert!(
ensure_editor_image_edit_source_kind_allowed_for_request(
@@ -20767,6 +20796,139 @@ mod tests {
);
}
/// AGC 客户端发送前把 `generationInputs.source` 改写成
/// `ai-game-creator-client``resource_editor.rs` 的 `submit_resource_edit_remote`),
/// 服务端必须按队列 consumer 的同口径把它认成同一个 AGC consumer,否则 AGC 分支是死代码。
#[test]
fn game_creator_client_source_is_recognized_as_game_creator_resource_editor() {
assert!(is_game_creator_resource_editor_generation(Some(&json!({
"source": GAME_CREATOR_CLIENT_GENERATION_SOURCE
}))));
assert!(is_game_creator_resource_editor_generation(Some(&json!({
"source": GAME_CREATOR_RESOURCE_EDITOR_SOURCE
}))));
assert!(
is_game_creator_resource_editor_generation(Some(&json!({
"source": " game-creator-resource-editor "
}))),
"别名判定沿用 trim 口径"
);
assert!(!is_game_creator_resource_editor_generation(Some(&json!({
"source": "editor-agent"
}))));
assert!(!is_game_creator_resource_editor_generation(Some(&json!({}))));
assert!(!is_game_creator_resource_editor_generation(None));
// 端到端同口径:AGC 客户端实际发出的 source 必须与 `from_job` 的 consumer 判定一致。
// 快速编辑端点把 `generationInputs` 对象本身当判定输入,所以这里按 `/generationInputs` 取。
let mut queued_job = atomic_editor_generation_job_fixture();
queued_job.dedupe_key =
"game-creator-client-generation:editor_image_generation:fingerprint".to_string();
queued_job.request_payload_json = json!({
"generationInputs": { "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE }
})
.to_string();
assert_eq!(
EditorGenerationQueueResultContext::from_job(&queued_job).consumer,
EditorGenerationQueueConsumer::GameCreatorResourceEditor
);
let queued_payload: Value =
serde_json::from_str(queued_job.request_payload_json.as_str())
.expect("fixture payload should be JSON");
assert!(is_game_creator_resource_editor_generation(
queued_payload.pointer("/generationInputs")
));
}
/// 实测 400×5 的回归用例:AGC「图片快速编辑」发出的请求,source 已被改写成
/// `ai-game-creator-client`,来源资源的 assetKind 是本地 manifest 原始类型
/// `art-spritesheet` / `ui`mediaType=image)。这条路径必须放行;
/// 非静态媒体(video/audio/image-sequence)必须继续拒绝。
#[test]
fn game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds() {
// 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形:请求体的
// `generationInputs` 就是这里传入的判定输入。
let agc_payload: Value = serde_json::from_str(
r#"{
"prompt": "把这张图改成夜间配色",
"sourceReferenceId": "resource-art-spritesheet",
"generationInputs": {
"source": "ai-game-creator-client",
"assetKind": "art-spritesheet"
}
}"#,
)
.expect("AGC quick edit payload should be JSON");
let generation_inputs = agc_payload
.pointer("/generationInputs")
.expect("AGC payload should carry generationInputs");
for (asset_kind, media_type) in [
("art-spritesheet", "image"),
("ui", "image"),
("ui-prototype", "image"),
("game-art", "image"),
("game-background", "image"),
("icon", "image"),
] {
assert!(
ensure_editor_image_edit_source_kind_allowed_for_request(
Some(asset_kind),
Some(media_type),
Some(generation_inputs),
)
.is_ok(),
"AGC 快速编辑必须放行 {asset_kind}/{media_type}"
);
}
for (asset_kind, media_type) in [
("art-spritesheet", "video"),
("ui", "audio"),
("video", "video"),
("sound-effect", "audio"),
("background-music", "audio"),
("character-animation", "image-sequence"),
("future-kind", "image"),
] {
assert!(
ensure_editor_image_edit_source_kind_allowed_for_request(
Some(asset_kind),
Some(media_type),
Some(generation_inputs),
)
.is_err(),
"非静态来源必须继续拒绝:{asset_kind}/{media_type}"
);
}
// 400 错误体必须带上真实原因,客户端才能诊断(对应 AGC 的
// `editor_api_rejection_reason` 取值顺序修正)。
let error = ensure_editor_image_edit_source_kind_allowed_for_request(
Some("future-kind"),
Some("image"),
Some(generation_inputs),
)
.expect_err("未知类型必须拒绝");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("provider")),
Some(&json!("editor-image-edit"))
);
assert_eq!(
error.details().and_then(|details| details.get("message")),
Some(&json!("当前素材类型不支持图片快速编辑"))
);
assert_eq!(
error.details().and_then(|details| details.get("assetKind")),
Some(&json!("future-kind"))
);
assert_eq!(
error.details().and_then(|details| details.get("mediaType")),
Some(&json!("image"))
);
}
#[test]
fn editor_image_edit_target_binding_prefers_object_id_and_fails_closed() {
let target = EditorImageEditTargetLayerSource {