8b11dc4e7a
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m50s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m10s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m5s
Project CI / Backend tests (pull_request) Successful in 6m30s
Project CI / Frontend tests (pull_request) Successful in 3m12s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m44s
Project CI / Native shell tests (pull_request) Successful in 6m33s
Project CI / Repository checks (pull_request) Successful in 5m5s
- names.len() == MCP_OPERATIONS.len() + TOOLS.len() 在 BTreeSet 去重构造下恒真、没有判别力; 改回固定 47(32 条 legacy 工具 + 15 条语义工具),并注明上面的插入断言已保证两组名字互不覆盖 - 本地实跑:cargo test --locked -p api-server --manifest-path server-rs/Cargo.toml --bin api-server semantic → 18 passed
716 lines
24 KiB
Rust
716 lines
24 KiB
Rust
use super::*;
|
|
use std::collections::BTreeSet;
|
|
|
|
fn object(value: Value) -> Map<String, Value> {
|
|
value
|
|
.as_object()
|
|
.expect("test input must be an object")
|
|
.clone()
|
|
}
|
|
|
|
fn prepare(
|
|
tool_name: &str,
|
|
action: Option<&str>,
|
|
input: Value,
|
|
key: Option<&str>,
|
|
) -> Result<PreparedCall, Value> {
|
|
let mut arguments = if let Some(action) = action {
|
|
object(json!({"action": action, "input": input}))
|
|
} else {
|
|
object(input)
|
|
};
|
|
if let Some(key) = key {
|
|
arguments.insert("idempotencyKey".into(), json!(key));
|
|
}
|
|
find(tool_name)
|
|
.expect("semantic tool must exist")
|
|
.prepare(arguments)
|
|
}
|
|
|
|
#[test]
|
|
fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() {
|
|
assert_eq!(TOOLS.len(), 15);
|
|
// legacy 工具集合直接来自内置 OpenAPI(排除 5 条 x-mcp-excluded 元数据/入口操作)。
|
|
// 支付收银台新增 createExternalPaymentOrder / getExternalPaymentOrder 后,
|
|
// 可调用操作由 30 增至 32:语义工具只做增量,不得替换 legacy 工具。
|
|
assert_eq!(MCP_OPERATIONS.len(), 32);
|
|
let legacy = MCP_OPERATIONS
|
|
.iter()
|
|
.map(mcp_operation_tool)
|
|
.collect::<Vec<_>>();
|
|
let mut names = BTreeSet::new();
|
|
for tool in &legacy {
|
|
assert!(names.insert(tool.name.to_string()), "duplicate legacy tool");
|
|
}
|
|
for entry in TOOLS.iter() {
|
|
assert!(
|
|
names.insert(entry.tool.name.to_string()),
|
|
"semantic tool shadows a legacy tool"
|
|
);
|
|
assert!(
|
|
entry
|
|
.tool
|
|
.description
|
|
.as_deref()
|
|
.is_some_and(|text| !text.is_empty())
|
|
);
|
|
}
|
|
// 37 条 OpenAPI 操作里 5 条元数据/入口操作被 x-mcp-excluded 排除,剩下 32 条 legacy 工具
|
|
// 加 15 条语义工具共 47 条;上面的插入断言已保证两组名字互不覆盖,这里固定总数防止漏注册。
|
|
assert_eq!(names.len(), 47);
|
|
}
|
|
|
|
#[test]
|
|
fn semantic_annotations_cover_existing_state_changes_in_any_action() {
|
|
for (name, destructive) in [
|
|
("find_canvas_projects", false),
|
|
("find_assets", false),
|
|
("check_generation", false),
|
|
("manage_canvas_projects", true),
|
|
("prepare_asset_upload", true),
|
|
("generate_image", true),
|
|
("modify_image", true),
|
|
("generate_icon_spritesheet", true),
|
|
("extract_ui_assets", true),
|
|
("generate_character_animation", true),
|
|
("generate_video", true),
|
|
("generate_audio", true),
|
|
("edit_canvas", true),
|
|
("organize_asset_library", true),
|
|
("delete_resources", true),
|
|
] {
|
|
let tool = &find(name).expect("semantic tool must exist").tool;
|
|
let serialized = serde_json::to_value(tool).unwrap();
|
|
assert_eq!(
|
|
serialized["annotations"]["destructiveHint"],
|
|
json!(destructive),
|
|
"incorrect published risk for {name}"
|
|
);
|
|
}
|
|
|
|
// 申请票据只新增;确认对象会 upsert,整个工具必须涵盖该分支的风险。
|
|
let upload = find("prepare_asset_upload").unwrap();
|
|
let ticket = upload
|
|
.actions
|
|
.iter()
|
|
.find(|a| a.name == Some("create_upload_ticket"))
|
|
.unwrap();
|
|
let confirm = upload
|
|
.actions
|
|
.iter()
|
|
.find(|a| a.name == Some("confirm_upload"))
|
|
.unwrap();
|
|
assert!(!ticket.destructive);
|
|
assert!(confirm.destructive);
|
|
}
|
|
|
|
#[test]
|
|
fn every_semantic_action_maps_to_one_existing_operation_and_correct_parameter_location() {
|
|
// (tool, action, operationId, input, pathParameters, queryParameters, body)
|
|
let cases = [
|
|
(
|
|
"find_canvas_projects",
|
|
Some("list"),
|
|
"listEditorProjects",
|
|
json!({}),
|
|
Value::Null,
|
|
Value::Null,
|
|
Value::Null,
|
|
),
|
|
(
|
|
"find_canvas_projects",
|
|
Some("recent"),
|
|
"loadRecentEditorProject",
|
|
json!({}),
|
|
Value::Null,
|
|
Value::Null,
|
|
Value::Null,
|
|
),
|
|
(
|
|
"find_canvas_projects",
|
|
Some("get"),
|
|
"getEditorProject",
|
|
json!({"projectId":"project-1"}),
|
|
json!({"projectId":"project-1"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
),
|
|
(
|
|
"manage_canvas_projects",
|
|
Some("create"),
|
|
"createEditorProject",
|
|
json!({"title":"项目"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"title":"项目"}),
|
|
),
|
|
(
|
|
"manage_canvas_projects",
|
|
Some("rename"),
|
|
"renameEditorProject",
|
|
json!({"projectId":"project-1","title":"新名称"}),
|
|
json!({"projectId":"project-1"}),
|
|
Value::Null,
|
|
json!({"title":"新名称"}),
|
|
),
|
|
(
|
|
"find_assets",
|
|
Some("list_library"),
|
|
"getEditorAssetLibrary",
|
|
json!({}),
|
|
Value::Null,
|
|
Value::Null,
|
|
Value::Null,
|
|
),
|
|
(
|
|
"find_assets",
|
|
Some("get_project_resources"),
|
|
"getEditorProject",
|
|
json!({"projectId":"project-1"}),
|
|
json!({"projectId":"project-1"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
),
|
|
(
|
|
"find_assets",
|
|
Some("get_download_url"),
|
|
"getExternalAssetReadUrl",
|
|
json!({"objectKey":"assets/a.png","expireSeconds":60}),
|
|
Value::Null,
|
|
json!({"objectKey":"assets/a.png","expireSeconds":60}),
|
|
Value::Null,
|
|
),
|
|
(
|
|
"prepare_asset_upload",
|
|
Some("create_upload_ticket"),
|
|
"createExternalDirectUploadTicket",
|
|
json!({"legacyPrefix":"images","fileName":"a.png"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"legacyPrefix":"images","fileName":"a.png"}),
|
|
),
|
|
(
|
|
"prepare_asset_upload",
|
|
Some("confirm_upload"),
|
|
"confirmExternalAssetObject",
|
|
json!({"objectKey":"assets/a.png","assetKind":"image"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"objectKey":"assets/a.png","assetKind":"image"}),
|
|
),
|
|
(
|
|
"generate_image",
|
|
None,
|
|
"generateExternalEditorImage",
|
|
json!({"prompt":"城堡","kind":"spec"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"prompt":"城堡","kind":"spec"}),
|
|
),
|
|
(
|
|
"modify_image",
|
|
Some("edit"),
|
|
"editExternalEditorImage",
|
|
json!({"sourceReferenceId":"resource-1","prompt":"红色衣服"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"sourceReferenceId":"resource-1","prompt":"红色衣服"}),
|
|
),
|
|
(
|
|
"modify_image",
|
|
Some("variation"),
|
|
"generateExternalEditorImage",
|
|
json!({"prompt":"另一种构图","referenceImageSrcs":["assets/a.png"]}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"prompt":"另一种构图","referenceImageSrcs":["assets/a.png"],"kind":"quick-edit"}),
|
|
),
|
|
(
|
|
"modify_image",
|
|
Some("remove_background"),
|
|
"removeExternalEditorImageBackground",
|
|
json!({"sourceImageSrc":"assets/a.png","backgroundMode":"flat","screenColor":"auto"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"sourceImageSrc":"assets/a.png","backgroundMode":"flat","screenColor":"auto"}),
|
|
),
|
|
(
|
|
"generate_icon_spritesheet",
|
|
None,
|
|
"generateExternalEditorIconSpritesheet",
|
|
json!({"referenceId":"resource-1","iconDescriptions":["剑"],"sliceMode":"grid","gridX":1,"gridY":1}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"referenceId":"resource-1","iconDescriptions":["剑"],"sliceMode":"grid","gridX":1,"gridY":1}),
|
|
),
|
|
(
|
|
"extract_ui_assets",
|
|
None,
|
|
"extractExternalEditorUiDesignAssets",
|
|
json!({"sourceImageSrc":"assets/ui.png","aspectRatio":"1:1","imageSize":"1K","spritesheetLabel":"组件"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"sourceImageSrc":"assets/ui.png","aspectRatio":"1:1","imageSize":"1K","spritesheetLabel":"组件"}),
|
|
),
|
|
(
|
|
"generate_character_animation",
|
|
None,
|
|
"generateExternalEditorCharacterAnimation",
|
|
json!({"sourceLayerId":"layer-1","sourceImageSrc":"assets/a.png","sourceWidth":512,"sourceHeight":512,"promptText":"行走","resolution":"480p","ratio":"same","frameCount":32,"durationSeconds":4,"model":"seedance2.0-fast"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"sourceLayerId":"layer-1","sourceImageSrc":"assets/a.png","sourceWidth":512,"sourceHeight":512,"promptText":"行走","resolution":"480p","ratio":"same","frameCount":32,"durationSeconds":4,"model":"seedance2.0-fast"}),
|
|
),
|
|
(
|
|
"generate_video",
|
|
None,
|
|
"generateExternalEditorVideo",
|
|
json!({"prompt":"海浪","model":"seedance2.0-fast","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"prompt":"海浪","model":"seedance2.0-fast","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}),
|
|
),
|
|
(
|
|
"generate_audio",
|
|
Some("sound_effect"),
|
|
"generateExternalEditorSoundEffect",
|
|
json!({"prompt":"鸟鸣","duration":2.0}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"prompt":"鸟鸣","duration":2.0}),
|
|
),
|
|
(
|
|
"generate_audio",
|
|
Some("background_music"),
|
|
"generateExternalEditorBackgroundMusic",
|
|
json!({"gptDescriptionPrompt":"轻快音乐","makeInstrumental":true}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"gptDescriptionPrompt":"轻快音乐","makeInstrumental":true}),
|
|
),
|
|
(
|
|
"edit_canvas",
|
|
Some("get"),
|
|
"getEditorProject",
|
|
json!({"projectId":"project-1"}),
|
|
json!({"projectId":"project-1"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
),
|
|
(
|
|
"edit_canvas",
|
|
Some("save_layout"),
|
|
"saveEditorProjectCanvas",
|
|
json!({"projectId":"project-1","viewport":{},"layers":[],"expectedRevision":7}),
|
|
json!({"projectId":"project-1"}),
|
|
Value::Null,
|
|
json!({"viewport":{},"layers":[],"expectedRevision":7}),
|
|
),
|
|
(
|
|
"edit_canvas",
|
|
Some("register_resource"),
|
|
"createEditorProjectResource",
|
|
json!({"projectId":"project-1","imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}),
|
|
json!({"projectId":"project-1"}),
|
|
Value::Null,
|
|
json!({"imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}),
|
|
),
|
|
(
|
|
"organize_asset_library",
|
|
Some("create_folder"),
|
|
"createEditorAssetFolder",
|
|
json!({"label":"角色","sortOrder":2}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"label":"角色","sortOrder":2}),
|
|
),
|
|
(
|
|
"organize_asset_library",
|
|
Some("update_folder"),
|
|
"updateEditorAssetFolder",
|
|
json!({"folderId":"folder-1","collapsed":true}),
|
|
json!({"folderId":"folder-1"}),
|
|
Value::Null,
|
|
json!({"collapsed":true}),
|
|
),
|
|
(
|
|
"organize_asset_library",
|
|
Some("create_asset"),
|
|
"createEditorAsset",
|
|
json!({"folderId":"folder-1","label":"图","imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
json!({"folderId":"folder-1","label":"图","imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}),
|
|
),
|
|
(
|
|
"organize_asset_library",
|
|
Some("update_asset"),
|
|
"updateEditorAsset",
|
|
json!({"assetId":"asset-1","folderId":"folder-2"}),
|
|
json!({"assetId":"asset-1"}),
|
|
Value::Null,
|
|
json!({"folderId":"folder-2"}),
|
|
),
|
|
(
|
|
"check_generation",
|
|
None,
|
|
"getExternalEditorGenerationJob",
|
|
json!({"operationId":"task-1"}),
|
|
json!({"operationId":"task-1"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
),
|
|
(
|
|
"delete_resources",
|
|
Some("delete_project"),
|
|
"deleteEditorProject",
|
|
json!({"projectId":"project-1"}),
|
|
json!({"projectId":"project-1"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
),
|
|
(
|
|
"delete_resources",
|
|
Some("delete_folder"),
|
|
"deleteEditorAssetFolder",
|
|
json!({"folderId":"folder-1"}),
|
|
json!({"folderId":"folder-1"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
),
|
|
(
|
|
"delete_resources",
|
|
Some("delete_asset"),
|
|
"deleteEditorAsset",
|
|
json!({"assetId":"asset-1"}),
|
|
json!({"assetId":"asset-1"}),
|
|
Value::Null,
|
|
Value::Null,
|
|
),
|
|
];
|
|
assert_eq!(cases.len(), 31);
|
|
for (tool, action, operation, input, path, query, body) in cases {
|
|
let requires_key = MCP_OPERATIONS
|
|
.iter()
|
|
.find(|op| op.operation_id == operation)
|
|
.unwrap()
|
|
.requires_idempotency_key;
|
|
let prepared = prepare(
|
|
tool,
|
|
action,
|
|
input,
|
|
requires_key.then_some("stable-request-1"),
|
|
)
|
|
.unwrap_or_else(|error| panic!("{tool}/{action:?}: {error}"));
|
|
assert_eq!(
|
|
prepared.operation.operation_id, operation,
|
|
"{tool}/{action:?}"
|
|
);
|
|
for (location, expected) in [
|
|
("pathParameters", path),
|
|
("queryParameters", query),
|
|
("body", body),
|
|
] {
|
|
assert_eq!(
|
|
prepared
|
|
.arguments
|
|
.get(location)
|
|
.cloned()
|
|
.unwrap_or(Value::Null),
|
|
expected,
|
|
"{tool}/{action:?} {location}"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
prepared.arguments.get("idempotencyKey"),
|
|
requires_key.then_some(&json!("stable-request-1")),
|
|
"{tool}/{action:?}"
|
|
);
|
|
assert!(
|
|
prepared.optional_idempotency_key.is_none(),
|
|
"{tool}/{action:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn semantic_schemas_keep_existing_business_field_details() {
|
|
let image = find("generate_image").unwrap();
|
|
let old_image = MCP_OPERATIONS
|
|
.iter()
|
|
.find(|op| op.operation_id == "generateExternalEditorImage")
|
|
.unwrap();
|
|
for field in [
|
|
"prompt",
|
|
"kind",
|
|
"screenColor",
|
|
"referenceImageSrcs",
|
|
"canvasCompletion",
|
|
] {
|
|
assert_eq!(
|
|
image.tool.input_schema["properties"][field],
|
|
old_image.input_schema["properties"]["body"]["properties"][field],
|
|
"image {field}"
|
|
);
|
|
}
|
|
let variation = find("modify_image")
|
|
.unwrap()
|
|
.actions
|
|
.iter()
|
|
.find(|action| action.name == Some("variation"))
|
|
.unwrap();
|
|
assert!(variation.input_schema["properties"].get("kind").is_none());
|
|
assert_eq!(
|
|
variation.input_schema["properties"]["referenceImageSrcs"],
|
|
old_image.input_schema["properties"]["body"]["properties"]["referenceImageSrcs"]
|
|
);
|
|
let background = find("modify_image")
|
|
.unwrap()
|
|
.actions
|
|
.iter()
|
|
.find(|action| action.name == Some("remove_background"))
|
|
.unwrap();
|
|
let old_background = MCP_OPERATIONS
|
|
.iter()
|
|
.find(|op| op.operation_id == "removeExternalEditorImageBackground")
|
|
.unwrap();
|
|
assert_eq!(
|
|
background.input_schema["if"],
|
|
old_background.input_schema["properties"]["body"]["if"]
|
|
);
|
|
assert_eq!(
|
|
background.input_schema["then"],
|
|
old_background.input_schema["properties"]["body"]["then"]
|
|
);
|
|
let icon = find("generate_icon_spritesheet").unwrap();
|
|
assert_eq!(
|
|
icon.tool.input_schema["properties"]["sliceMode"]["enum"],
|
|
json!(["connected-components", "grid"])
|
|
);
|
|
let animation = find("generate_character_animation").unwrap();
|
|
assert_eq!(
|
|
animation.tool.input_schema["properties"]["model"]["const"],
|
|
json!("seedance2.0-fast")
|
|
);
|
|
let upload = find("prepare_asset_upload")
|
|
.unwrap()
|
|
.actions
|
|
.iter()
|
|
.find(|action| action.name == Some("confirm_upload"))
|
|
.unwrap();
|
|
assert!(
|
|
upload.input_schema["properties"]
|
|
.get("ownerUserId")
|
|
.is_none()
|
|
);
|
|
let read_url = find("find_assets")
|
|
.unwrap()
|
|
.actions
|
|
.iter()
|
|
.find(|action| action.name == Some("get_download_url"))
|
|
.unwrap();
|
|
assert!(
|
|
read_url.input_schema["properties"]
|
|
.get("legacyPublicPath")
|
|
.is_some()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_actions_and_fields_are_rejected_before_rest_dispatch() {
|
|
let invalid = [
|
|
("find_canvas_projects", Some("missing"), json!({}), None),
|
|
("find_canvas_projects", Some("get"), json!({}), None),
|
|
(
|
|
"find_canvas_projects",
|
|
Some("list"),
|
|
json!({"projectId":"project-1"}),
|
|
None,
|
|
),
|
|
(
|
|
"prepare_asset_upload",
|
|
Some("confirm_upload"),
|
|
json!({"objectKey":"a","assetKind":"image","ownerUserId":"other-user"}),
|
|
None,
|
|
),
|
|
(
|
|
"modify_image",
|
|
Some("variation"),
|
|
json!({"prompt":"变体","kind":"spec"}),
|
|
Some("stable-key"),
|
|
),
|
|
(
|
|
"modify_image",
|
|
Some("edit"),
|
|
json!({"prompt":"修改","sourceReferenceId":"resource-1","sourceImageSrc":"a"}),
|
|
Some("stable-key"),
|
|
),
|
|
(
|
|
"generate_audio",
|
|
Some("background_music"),
|
|
json!({"gptDescriptionPrompt":"音乐","makeInstrumental":true,"prompt":"错分支"}),
|
|
Some("stable-key"),
|
|
),
|
|
(
|
|
"delete_resources",
|
|
Some("delete_asset"),
|
|
json!({"folderId":"folder-1"}),
|
|
None,
|
|
),
|
|
(
|
|
"find_assets",
|
|
Some("get_download_url"),
|
|
json!({"objectKey":[]}),
|
|
None,
|
|
),
|
|
(
|
|
"generate_video",
|
|
None,
|
|
json!({"prompt":"片段","model":"not-a-model","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}),
|
|
Some("stable-key"),
|
|
),
|
|
];
|
|
for (tool, action, input, key) in invalid {
|
|
assert!(
|
|
prepare(tool, action, input, key).is_err(),
|
|
"{tool}/{action:?} should reject invalid input"
|
|
);
|
|
}
|
|
assert!(
|
|
find("find_canvas_projects")
|
|
.unwrap()
|
|
.prepare(object(json!({"action":"list","input":{},"unexpected":1})))
|
|
.is_err()
|
|
);
|
|
assert!(find("manage_canvas_projects").unwrap().prepare(object(json!({"action":"rename","input":{"projectId":"project-1","title":"名称"},"idempotencyKey":"unexpected"}))).is_err());
|
|
assert!(
|
|
find("generate_image")
|
|
.unwrap()
|
|
.prepare(object(
|
|
json!({"prompt":"图","idempotencyKey":"stable-key","unexpected":1})
|
|
))
|
|
.is_err()
|
|
);
|
|
let wrong_model = prepare(
|
|
"generate_video",
|
|
None,
|
|
json!({"prompt":"片段","model":"not-a-model","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}),
|
|
Some("stable-key"),
|
|
)
|
|
.err()
|
|
.unwrap();
|
|
assert_eq!(wrong_model["field"], json!("model"));
|
|
}
|
|
|
|
#[test]
|
|
fn only_three_create_actions_accept_optional_idempotency_keys() {
|
|
let cases = [
|
|
("manage_canvas_projects", "create", json!({"title":"项目"})),
|
|
(
|
|
"edit_canvas",
|
|
"register_resource",
|
|
json!({"projectId":"project-1","imageSrc":"assets/a.png","width":1,"height":1,"sourceType":"image"}),
|
|
),
|
|
(
|
|
"organize_asset_library",
|
|
"create_folder",
|
|
json!({"label":"素材"}),
|
|
),
|
|
];
|
|
for (tool, action, input) in cases {
|
|
let without = prepare(tool, Some(action), input.clone(), None).unwrap();
|
|
assert!(without.optional_idempotency_key.is_none());
|
|
let with = prepare(tool, Some(action), input, Some("create-1")).unwrap();
|
|
assert_eq!(
|
|
with.optional_idempotency_key
|
|
.as_ref()
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"create-1"
|
|
);
|
|
assert!(!with.arguments.contains_key("idempotencyKey"));
|
|
}
|
|
assert!(prepare("organize_asset_library", Some("create_asset"), json!({"folderId":"folder-1","label":"图","imageSrc":"a","width":1,"height":1,"sourceType":"image"}), Some("create-1")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn all_nine_generation_operations_require_a_valid_idempotency_key() {
|
|
let actions = TOOLS
|
|
.iter()
|
|
.flat_map(|tool| tool.actions.iter())
|
|
.filter(|action| action.operation.requires_idempotency_key)
|
|
.collect::<Vec<_>>();
|
|
let operation_ids = actions
|
|
.iter()
|
|
.map(|action| action.operation.operation_id.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
assert_eq!(operation_ids.len(), 9);
|
|
for action in actions {
|
|
assert!(
|
|
action.key_schema.is_some(),
|
|
"{}",
|
|
action.operation.operation_id
|
|
);
|
|
assert!(
|
|
action.call_schema()["required"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("idempotencyKey")),
|
|
"{}",
|
|
action.operation.operation_id
|
|
);
|
|
}
|
|
assert!(prepare("generate_image", None, json!({"prompt":"图"}), None).is_err());
|
|
for invalid_key in ["", "has space", "line\nbreak"] {
|
|
assert!(
|
|
prepare(
|
|
"generate_image",
|
|
None,
|
|
json!({"prompt":"图"}),
|
|
Some(invalid_key)
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
let valid = prepare(
|
|
"generate_image",
|
|
None,
|
|
json!({"prompt":"图"}),
|
|
Some("generate-1"),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(valid.arguments["idempotencyKey"], json!("generate-1"));
|
|
assert!(valid.optional_idempotency_key.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn image_variation_uses_the_same_canonical_request_as_quick_edit_generation() {
|
|
let input = json!({"prompt":"另一种构图","referenceImageSrcs":["assets/a.png"],"projectId":"project-1","assetLabel":"变体"});
|
|
let variation = prepare(
|
|
"modify_image",
|
|
Some("variation"),
|
|
input.clone(),
|
|
Some("same-request"),
|
|
)
|
|
.unwrap();
|
|
let mut direct_input = object(input);
|
|
direct_input.insert("kind".into(), json!("quick-edit"));
|
|
let direct = prepare(
|
|
"generate_image",
|
|
None,
|
|
Value::Object(direct_input),
|
|
Some("same-request"),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
variation.operation.operation_id,
|
|
direct.operation.operation_id
|
|
);
|
|
assert_eq!(variation.arguments, direct.arguments);
|
|
assert_eq!(
|
|
variation.optional_idempotency_key,
|
|
direct.optional_idempotency_key
|
|
);
|
|
}
|