补齐 External v1 场景路由的客户端请求构造用例并勾选该验收项

- 新增 background_stage_posts_the_dedicated_scene_route_and_its_ledger_matches:用规范图夹具驱动真实生成链路,断言背景阶段只发一次 scenes 路由 POST、正文是结构化场景意图(sceneContent/stylePreset=custom/customStyle/referenceImageSrcs,且没有 assetKind/prompt),成功后不留在途账本
- 变异验证:把场景分支的路由改回通用图片路由后该用例变红,改回后恢复绿
- External v1 场景路由里程碑勾选「客户端定向测试」一项,逐条记录账本、恢复与 reuse/regenerate 的实跑证据;剩余仅本地真实栈受理 smoke
This commit is contained in:
kdletters
2026-09-28 17:25:55 +08:00
parent cc01e304c0
commit 69d798e484
3 changed files with 334 additions and 7 deletions
@@ -15894,4 +15894,325 @@ mod canvas_generation_tests {
b"old-image"
);
}
/// 场景(背景)阶段的请求夹具:只接受专用场景路由,记录原始请求,并按队列 → 换签 → 下载链路服务完。
fn serve_scene_route_fixture(
stream: &mut std::net::TcpStream,
base_url: &str,
posts: &std::sync::Mutex<Vec<String>>,
others: &std::sync::Mutex<Vec<String>>,
) {
let request = read_test_http_request(stream);
if request.starts_with("POST /api/external/v1/editor/scenes/generations ") {
posts
.lock()
.expect("record scene generation posts")
.push(request);
write_test_json_response(
stream,
"202 Accepted",
&serde_json::json!({
"data": {
"operationId": "scene-route-operation",
"status": "queued",
"pollAfterMs": 0,
}
}),
);
return;
}
others
.lock()
.expect("record scene fixture requests")
.push(request.clone());
if request.starts_with("GET /api/external/v1/generations/scene-route-operation ") {
write_test_json_response(
stream,
"200 OK",
&serde_json::json!({
"data": {
"operationId": "scene-route-operation",
"status": "completed",
"pollAfterMs": 0,
"result": {
"resource": {
"resourceId": "scene-route-resource",
"objectKey": "scene-route.png",
}
}
}
}),
);
return;
}
if request.starts_with("GET /api/external/v1/assets/read-url?") {
write_test_json_response(
stream,
"200 OK",
&serde_json::json!({
"read": {"signedUrl": format!("{base_url}/scene-route.png")}
}),
);
return;
}
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
// 项目绑定已由 `install_test_external_project_binding` 预置,远端只需回认同一组身份。
write_test_json_response(
stream,
"200 OK",
&serde_json::json!({
"data": {"projects": [{
"projectId": "manual-test-canvas",
"title": "场景生成画布",
}]}
}),
);
return;
}
if request.starts_with("GET /api/external/v1/editor/assets/library ") {
write_test_json_response(
stream,
"200 OK",
&serde_json::json!({
"data": {"library": {"folders": [{
"folderId": "manual-test-assets",
"label": "场景生成素材",
}]}}
}),
);
return;
}
if request.starts_with("GET /scene-route.png ") {
let png = rgba_test_png(u8::MAX).bytes;
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
png.len()
)
.expect("write scene fixture png headers");
stream.write_all(&png).expect("write scene fixture png");
return;
}
panic!("unexpected scene route fixture request: {request}");
}
/// 背景阶段(Scene)必须命中 external v1 的专用场景路由并以结构化场景字段提交,而且这一轮
/// 真正写下的账本必须被「保留账本」判据按 Scene 认下来。
///
/// 这条配对正是 2026-09-23 之前出过错的地方:客户端向通用图片路由提交 `assetKind = scene`
/// 被平台参数校验拒绝(HTTP 400),而保留账本匹配器又期望 `game-background`。两侧任一侧回流,
/// 本条用例都会红。
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn background_stage_posts_the_dedicated_scene_route_and_its_ledger_matches() {
let temporary = tempfile::tempdir().expect("create scene route project");
let root = temporary.path();
init_local_game_project_at(root, "manual-scene-route", "场景路由测试")
.expect("init scene route project");
write_project_permission_policy_at(
root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow standalone scene generation");
// `Scene` 属于「必须带规范图引用」的 kind:先种一张 assets/art-spec.png 并登记成 Canvas 来源。
fs::write(
root.join(AGENT_RUNTIME_ART_SPEC_PATH),
rgba_test_png(u8::MAX).bytes,
)
.expect("write canonical art spec");
register_local_asset_at(
root,
AGENT_RUNTIME_ART_SPEC_PATH,
GameCreationAppAssetKind::IconSpec,
"image/png",
"scene-route-test",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("manual-test-canvas".to_string()),
resource_id: Some("scene-route-art-spec".to_string()),
asset_object_id: Some("scene-route-art-spec-object".to_string()),
task_id: Some("scene-route-art-spec-task".to_string()),
prompt: None,
model: None,
generation_route: Some("/api/external/v1/editor/images/generations".to_string()),
generation_kind: Some("spec".to_string()),
reference_resource_ids: Vec::new(),
},
)
.expect("register canonical art spec");
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind scene route fixture");
listener
.set_nonblocking(true)
.expect("set scene route fixture nonblocking");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let api_key = "scene-route-key";
install_test_external_project_binding(root, &base_url, api_key);
// 规范图的远端引用走 binding 缓存命中:预置绑定后不需要额外的上传夹具端点。
{
let access = ExternalEditorBindingAccess::new(&base_url, api_key, None)
.expect("prepare scene route binding access");
let manifest = read_manifest_for_project(root).expect("read scene route manifest");
let source = manifest
.assets
.iter()
.find(|asset| asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH)
.expect("registered canonical art spec");
let source_bytes =
fs::read(root.join(&source.local_path)).expect("read art spec bytes");
let principal =
external_editor_binding_principal(&access).expect("derive scene route principal");
let source_identity = new_external_editor_source_identity(
&source.id,
&format!("{:x}", Sha256::digest(&source_bytes)),
&source.media_type,
&source.kind,
)
.expect("derive scene route source identity");
let binding = new_external_editor_resource_binding(
&manifest.project_id,
&principal,
"manual-test-canvas",
&source_identity,
Some("scene-route-art-spec-resource"),
"generated/scene-route-art-spec.png",
"scene-route-art-spec-object",
Some(1),
Some(1),
unix_timestamp(),
)
.expect("create canonical resource binding");
write_external_editor_resource_binding_at(root, &binding)
.expect("persist canonical resource binding");
}
let posts = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let others = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let (stop_sender, stop_receiver) = std::sync::mpsc::channel();
let server_posts = posts.clone();
let server_others = others.clone();
let server_base_url = base_url.clone();
let server = std::thread::spawn(move || {
let mut handlers = Vec::new();
loop {
if stop_receiver.try_recv().is_ok() {
break;
}
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
continue;
}
Err(error) => panic!("accept scene route request: {error}"),
};
let posts = server_posts.clone();
let others = server_others.clone();
let base_url = server_base_url.clone();
handlers.push(std::thread::spawn(move || {
serve_scene_route_fixture(&mut stream, &base_url, &posts, &others);
}));
}
for handler in handlers {
handler.join().expect("join scene route fixture handler");
}
});
let options = PlatformArtAssetGenerationOptions {
output_path: Some("assets/scene-route-background.png".to_string()),
aspect_ratio: "16:9".to_string(),
image_size: "1K".to_string(),
asset_kind: GameCreationAppAssetKind::Scene,
asset_label: "雨夜天台".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
};
let prompt = "雨夜天台眺望霓虹城市的横屏背景";
let credentials = crate::assets::external_editor_api_credentials_for_test(
base_url.clone(),
api_key.to_string(),
);
let generated = crate::assets::with_external_editor_api_credentials(credentials, async {
match generate_platform_art_asset_with_options_at(root, prompt, &[], &options).await {
Ok(generated) => generated,
Err(error) => panic!("scene generation must succeed: {error}"),
}
})
.await;
stop_sender.send(()).expect("stop scene route fixture");
server.join().expect("join scene route fixture");
assert_eq!(
generated.resource_id.as_deref(),
Some("scene-route-resource"),
"场景生成必须复用夹具返回的远端资源身份"
);
let captured = posts.lock().expect("read captured scene posts").clone();
assert_eq!(
captured.len(),
1,
"背景阶段只能有一次生成 POST:{captured:#?}"
);
let post = &captured[0];
assert!(
post.starts_with("POST /api/external/v1/editor/scenes/generations "),
"背景阶段必须走专用场景路由:{post}"
);
let body_text = post
.split("\r\n\r\n")
.nth(1)
.expect("scene generation POST must carry a body");
let body: serde_json::Value =
serde_json::from_str(body_text).expect("scene generation body must be JSON");
assert!(
body["sceneContent"]
.as_str()
.is_some_and(|value| value.contains("雨夜天台眺望霓虹城市")),
"结构化场景意图必须放在 sceneContent:{body}"
);
assert_eq!(body["stylePreset"], "custom", "{body}");
assert!(
body["customStyle"]
.as_str()
.is_some_and(|value| !value.trim().is_empty()),
"自定义风格描述不能为空:{body}"
);
assert!(
body.get("assetKind").is_none(),
"结构化场景请求不得再带通用 assetKind:{body}"
);
assert!(
body.get("prompt").is_none(),
"结构化场景请求不得再带通用 prompt:{body}"
);
assert!(body["referenceImageSrcs"].is_array(), "{body}");
assert_eq!(body["assetLabel"], "雨夜天台", "{body}");
// standalone 成功安装后不留「在途账本」:同一条精确动作的恢复账本必须已被清理,
// 否则下一次同一动作会被当成恢复而不是新提交(场景侧的账本判据本身由
// `retained_stage_recovery_accepts_the_same_reference_contract_as_submission` 覆盖)。
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
let runtime_context =
standalone_platform_art_generation_runtime_context(&generation_prompt, &options, false)
.expect("scene runtime context");
assert!(
super::external_generation_state::read_platform_art_generation_runtime_state(
root,
&runtime_context,
)
.expect("read scene generation ledger")
.is_none(),
"成功安装后不得保留在途生成账本"
);
}
}
@@ -51,6 +51,9 @@
- 客户端:`canvas_generation.rs` 的 Scene 专属分支(新路由 + `sceneContent`)、保留账本匹配器(`canvas_generation.rs` 的 endpoint/`sceneContent` 判定)、`external_generation_state.rs`、`runtime_driver/recovery_scan.rs`、`direct_runtime/mod.rs` 的背景身份常量都与线上口径一致。
- 本轮实测:`cargo test -p api-server --manifest-path server-rs/Cargo.toml -- scene --test-threads=1` → **18 passed / 0 failed**(覆盖站内拒绝 scene 绕过、外部场景鉴权与非法入参、非法幂等键、共享场景契约与可下载队列结果、OpenAPI 契约)。
- 客户端账本与身份契约:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --offline -- external_generation_state --test-threads=1` → **10 passed**(覆盖生成账本身份、复用与并发认领口径;场景路由的账本匹配器就在这个模块里)。
- 仍未取得(勾选状态保持未完成):客户端「背景阶段请求命中新场景路由且为结构化字段」这一条**没有现成用例**。`canvas_generation.rs` 的场景分支是内联在生成链路里的 `(endpoint, body)` 字面量(`/api/external/v1/editor/scenes/generations` + `sceneContent` / `stylePreset=custom`),要单独把它钉住,测试里得先种一张规范图——`Scene` 属于「必须带规范图引用」的 kind(`platform_art_asset_kind_requires_canonical_spec_reference`),所以还需要补规范图上传的夹具端点;本轮没有做,也不打算用「把同一段字面量抄进断言」的镜像用例凑数。
- `reuse-or-create` / `regenerate` 的端到端回归与「本地真实栈 + 测试 Key 受理」smoke 同样未执行。
- 本机限制:客户端 `-- background` 这个过滤词会匹配到一大片 `background_agent_runtime` 家族(本轮实测 138 条:47 passed / 91 failed,失败几乎都是等待 mock LLM 请求的 `Timeout` 或状态竞争),**不能用它作为本里程碑的判据**。已在 `pitfalls` 记录该家族的抖动特征与 A/B 方法:同一条用例在合并前的分支尖(`74b83aa1a`)上也会失败,安静状态下又能 3/3 通过。
- 客户端定向测试(本轮补齐):新增 `background_stage_posts_the_dedicated_scene_route_and_its_ledger_matches`。它用项目内夹具驱动**真实生成链路**,先种一张 `assets/art-spec.png` 并预置其账号绑定(`Scene` 属于必须带规范图引用的 kind),然后断言背景阶段只发一次 `POST /api/external/v1/editor/scenes/generations`,正文是结构化场景意图(`sceneContent` 带原始意图、`stylePreset=custom`、非空 `customStyle`、`referenceImageSrcs` 数组、没有 `assetKind` / `prompt`),并在成功安装后确认在途账本已被清理。
- 变异验证:把场景分支的路由改回 `/api/external/v1/editor/images/generations` 后该用例立刻变红(夹具拒绝通用路由、生成失败),改回后恢复绿(1.59s)。因此这一条勾选不是镜像断言。
- 账本与恢复口径:`retained_stage_recovery_accepts_the_same_reference_contract_as_submission`(Scene 账本必须按新路由 + `sceneContent` + `stylePreset=custom` 被认下)与 `legacy_route_background_registration_still_counts_as_valid_base`(旧路由登记仍可读)单跑通过。
- `reuse-or-create` / `regenerate` 回归:`direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet`(复用已登记背景)、`failed_background_stage_after_local_commit_restores_recorded_spec_and_background` 与 `failed_complete_package_regeneration_restores_replaced_spec_and_background`(失败后按记录恢复 spec / 背景)单跑通过。
- 仍未取得:**本地真实栈 + 测试 Key 的受理 smoke**(`npm run dev:api-server` 后用真实 API Key POST 新路由校验受理与重放),需要本地 dev 栈与可用 Key。
- 本机限制:客户端 `-- background` 过滤会匹配一大片 `background_agent_runtime` 家族并大量 `Timeout`(本轮实测 138 条:47 passed / 91 failed),不能当判据;该家族抖动特征与 A/B 方法记在 `pitfalls`。
@@ -36,7 +36,7 @@
- [x] 新路由契约测试通过:路由矩阵与鉴权、参数 400、同键重放返回原任务、同键不同请求 409。
- [x] 外部场景路由与站内路由对相同输入产出相同的后端 Prompt 与入队 payload(共享实现单测对照)。
- [x] `docs/openapi/genarrative-external-v1.openapi.json` 与实现一致,相关契约检查通过。
- [ ] 客户端定向测试:背景阶段请求命中新路由且为结构化字段;保留账本匹配器与实际请求口径一致;`reuse-or-create` 与 `regenerate` 路径回归通过。
- [x] 客户端定向测试:背景阶段请求命中新路由且为结构化字段;保留账本匹配器与实际请求口径一致;`reuse-or-create` 与 `regenerate` 路径回归通过。
- [ ] 本地真实栈 smoke:`npm run dev:api-server` + 测试 Key 下美术包背景阶段受理成功(Provider 出图按环境可用性记录为已验证或未验证)。
## 证据要求
@@ -54,6 +54,9 @@
- 客户端:`canvas_generation.rs` 的 Scene 专属分支(新路由 + `sceneContent`)、保留账本匹配器(`canvas_generation.rs` 的 endpoint/`sceneContent` 判定)、`external_generation_state.rs`、`runtime_driver/recovery_scan.rs`、`direct_runtime/mod.rs` 的背景身份常量都与线上口径一致。
- 本轮实测:`cargo test -p api-server --manifest-path server-rs/Cargo.toml -- scene --test-threads=1` → **18 passed / 0 failed**(覆盖站内拒绝 scene 绕过、外部场景鉴权与非法入参、非法幂等键、共享场景契约与可下载队列结果、OpenAPI 契约)。
- 客户端账本与身份契约:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --offline -- external_generation_state --test-threads=1` → **10 passed**(覆盖生成账本身份、复用与并发认领口径;场景路由的账本匹配器就在这个模块里)。
- 仍未取得(勾选状态保持未完成):客户端「背景阶段请求命中新场景路由且为结构化字段」这一条**没有现成用例**。`canvas_generation.rs` 的场景分支是内联在生成链路里的 `(endpoint, body)` 字面量(`/api/external/v1/editor/scenes/generations` + `sceneContent` / `stylePreset=custom`),要单独把它钉住,测试里得先种一张规范图——`Scene` 属于「必须带规范图引用」的 kind(`platform_art_asset_kind_requires_canonical_spec_reference`),所以还需要补规范图上传的夹具端点;本轮没有做,也不打算用「把同一段字面量抄进断言」的镜像用例凑数。
- `reuse-or-create` / `regenerate` 的端到端回归与「本地真实栈 + 测试 Key 受理」smoke 同样未执行。
- 本机限制:客户端 `-- background` 这个过滤词会匹配到一大片 `background_agent_runtime` 家族(本轮实测 138 条:47 passed / 91 failed,失败几乎都是等待 mock LLM 请求的 `Timeout` 或状态竞争),**不能用它作为本里程碑的判据**。已在 `pitfalls` 记录该家族的抖动特征与 A/B 方法:同一条用例在合并前的分支尖(`74b83aa1a`)上也会失败,安静状态下又能 3/3 通过。
- 客户端定向测试(本轮补齐):新增 `background_stage_posts_the_dedicated_scene_route_and_its_ledger_matches`。它用项目内夹具驱动**真实生成链路**,先种一张 `assets/art-spec.png` 并预置其账号绑定(`Scene` 属于必须带规范图引用的 kind),然后断言背景阶段只发一次 `POST /api/external/v1/editor/scenes/generations`,正文是结构化场景意图(`sceneContent` 带原始意图、`stylePreset=custom`、非空 `customStyle`、`referenceImageSrcs` 数组、没有 `assetKind` / `prompt`),并在成功安装后确认在途账本已被清理。
- 变异验证:把场景分支的路由改回 `/api/external/v1/editor/images/generations` 后该用例立刻变红(夹具拒绝通用路由、生成失败),改回后恢复绿(1.59s)。因此这一条勾选不是镜像断言。
- 账本与恢复口径:`retained_stage_recovery_accepts_the_same_reference_contract_as_submission`(Scene 账本必须按新路由 + `sceneContent` + `stylePreset=custom` 被认下)与 `legacy_route_background_registration_still_counts_as_valid_base`(旧路由登记仍可读)单跑通过。
- `reuse-or-create` / `regenerate` 回归:`direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet`(复用已登记背景)、`failed_background_stage_after_local_commit_restores_recorded_spec_and_background` 与 `failed_complete_package_regeneration_restores_replaced_spec_and_background`(失败后按记录恢复 spec / 背景)单跑通过。
- 仍未取得:**本地真实栈 + 测试 Key 的受理 smoke**(`npm run dev:api-server` 后用真实 API Key POST 新路由校验受理与重放),需要本地 dev 栈与可用 Key。
- 本机限制:客户端 `-- background` 过滤会匹配一大片 `background_agent_runtime` 家族并大量 `Timeout`(本轮实测 138 条:47 passed / 91 failed),不能当判据;该家族抖动特征与 A/B 方法记在 `pitfalls`。