Files
Genarrative/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs
T
suzmii 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
2026-10-04 03:25:54 +08:00

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
);
}