修复主分支融合后的画布恢复边界
Project CI / Repository checks (pull_request) Failing after 2m10s
Project CI / Frontend tests (pull_request) Successful in 4m39s
Project CI / Backend tests (pull_request) Successful in 5m38s
Project CI / Native shell tests (pull_request) Successful in 15m7s

修复 Direct 不确定结果与普通失败的补偿分流
补齐角色动画源图片账号绑定复用
升级 External Editor v3 恢复测试身份
同步账号作用域画布生成测试契约
This commit is contained in:
2026-08-24 02:29:37 +08:00
parent 6ba663958a
commit d6a3d635de
7 changed files with 561 additions and 50 deletions
@@ -1625,6 +1625,12 @@ fn begin_direct_taonier_regeneration_compensation_at(
}
}
fn direct_taonier_generation_failure_needs_reconciliation(error: &str) -> bool {
platform_art_generation_error_needs_reconciliation(error)
|| error.starts_with(PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX)
|| error.starts_with("reconciliation-required:")
}
fn direct_taonier_regeneration_stage_failure(
root: &Path,
workflow: Option<&mut DirectTaonierRegenerationWorkflow>,
@@ -1632,10 +1638,7 @@ fn direct_taonier_regeneration_stage_failure(
local_path: &str,
error: String,
) -> String {
if platform_art_generation_error_needs_reconciliation(&error)
|| error.starts_with(PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX)
|| error.starts_with("reconciliation-required:")
{
if direct_taonier_generation_failure_needs_reconciliation(&error) {
// A remote side effect or local asset.register may already be durable. Keep the new
// file, manifest, workflow, and stage ledger together for exact recovery; restoring the
// old package here would erase the only local proof and allow a duplicate paid request.
@@ -1661,6 +1664,32 @@ fn direct_taonier_regeneration_stage_failure(
direct_taonier_regeneration_failure(root, Some(rollback), error)
}
fn direct_taonier_art_generation_failure(asset_label: &str, error: String) -> String {
if direct_taonier_generation_failure_needs_reconciliation(&error) {
// Keep reconciliation markers at byte zero. The regeneration stage owns the
// compensation decision and must be able to distinguish an uncertain durable
// side effect from an ordinary pre-commit failure.
return error;
}
format!("陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}")
}
fn direct_taonier_strict_spritesheet_stage_failure(
root: &Path,
rollback: Option<&DirectTaonierRegenerationRollback>,
error: String,
) -> String {
if direct_taonier_generation_failure_needs_reconciliation(&error) {
// The strict transaction or its remote request may already be durable. Preserve the
// transaction marker, files, manifest, workflow, and retained stage ledger together.
return error;
}
// The v4 rollback contract intentionally snapshots only art-spec/background. A strict
// spritesheet failure must restore those frozen replacements without trying to register the
// spritesheet itself as a rollback asset.
direct_taonier_regeneration_failure(root, rollback, error)
}
fn direct_taonier_art_generation_outcome(
warning: Option<String>,
slice_warning: Option<String>,
@@ -2713,11 +2742,7 @@ async fn generate_direct_taonier_art_asset_at(
],
});
}
Err(error) => {
return Err(format!(
"陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}"
));
}
Err(error) => return Err(direct_taonier_art_generation_failure(asset_label, error)),
};
if generated.asset.local_path != output_path {
return Err(format!(
@@ -3059,7 +3084,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
"art.spritesheet",
"正在生成核心图集并切分四类运行时素材",
);
let outcome = generate_direct_taonier_art_asset_at(
let outcome = match generate_direct_taonier_art_asset_at(
root,
prompt,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
@@ -3070,9 +3095,16 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
mode.regenerates_existing(),
)
.await
.map_err(|error| {
direct_taonier_regeneration_failure(root, regeneration_rollback.as_ref(), error)
})?;
{
Ok(outcome) => outcome,
Err(error) => {
return Err(direct_taonier_strict_spritesheet_stage_failure(
root,
regeneration_rollback.as_ref(),
error,
));
}
};
warnings.extend(outcome.warnings);
slice_warnings.extend(outcome.slice_warnings);
if !direct_taonier_strict_art_package_is_valid(root) {
@@ -5728,7 +5760,14 @@ mod tests {
"source": context.source,
"actionId": context.action_id,
"actionFingerprint": context.action_fingerprint,
"externalConfigurationFingerprint": "retained-service-fingerprint",
"externalConfigurationFingerprint":
platform_art_generation_external_service_fingerprint(
"http://127.0.0.1:38201",
None,
),
"accessScheme": "external-developer-v1",
"externalServiceOrigin": "http://127.0.0.1:38201",
"platformOwnerUserId": null,
"endpoint": "/api/external/v1/editor/images/generations",
"canvasName": "retained-direct-canvas",
"generationPrompt": "retained direct art spec",
@@ -6805,7 +6844,14 @@ mod tests {
"source": context.source,
"actionId": context.action_id,
"actionFingerprint": context.action_fingerprint,
"externalConfigurationFingerprint": "test-service-fingerprint",
"externalConfigurationFingerprint":
platform_art_generation_external_service_fingerprint(
"http://127.0.0.1:38201",
None,
),
"accessScheme": "external-developer-v1",
"externalServiceOrigin": "http://127.0.0.1:38201",
"platformOwnerUserId": null,
"endpoint": "/api/external/v1/editor/icon-spritesheets/generations",
"canvasName": "taonier-test-canvas",
"generationPrompt": "测试严格图集",
@@ -7456,8 +7502,10 @@ mod tests {
.expect("record background replacement");
persist_direct_taonier_regeneration_rollback_at(root.path(), &mut workflow, &rollback)
.expect("persist rollback anchors");
begin_direct_taonier_strict_spritesheet_commit_at(root.path(), &mut workflow)
.expect("enter strict spritesheet stage");
let error = direct_taonier_regeneration_failure(
let error = direct_taonier_strict_spritesheet_stage_failure(
root.path(),
Some(&rollback),
"模拟 strict spritesheet 失败".to_string(),
@@ -7485,6 +7533,21 @@ mod tests {
.expect("previous entry")
);
}
let resumed = read_direct_taonier_regeneration_workflow_at(root.path())
.expect("read compensated workflow")
.expect("same invocation remains resumable");
assert_eq!(
resumed.state,
DirectTaonierRegenerationWorkflowState::InProgress
);
assert!(!resumed.strict_spritesheet_pending);
assert!(!direct_taonier_regeneration_has_replacement_anchor(
&resumed
));
assert!(
!direct_taonier_regeneration_workflow_requires_recovery_at(root.path())
.expect("ordinary strict failure is fully compensated")
);
}
#[test]
@@ -7646,6 +7709,67 @@ mod tests {
);
}
#[test]
fn generation_reconciliation_markers_reach_strict_stage_without_compensation() {
for marker in [
format!("{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} operation result unknown"),
format!("{PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX} source preserved"),
format!("{DIRECT_TAONIER_LOCAL_RECONCILIATION_PREFIX} asset.register unknown"),
"reconciliation-required: binding install unknown".to_string(),
] {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(
root.path(),
"direct-strict-stage-reconciliation",
"严格图集结果待核对",
)
.expect("init project");
register_direct_taonier_art_package_fixture(root.path());
let mut workflow = prepare_direct_taonier_regeneration_workflow_at(
root.path(),
"严格图集结果待核对",
"client-turn-strict-reconciliation",
)
.expect("prepare workflow");
let rollback = workflow.rollback.clone();
begin_direct_taonier_strict_spritesheet_commit_at(root.path(), &mut workflow)
.expect("enter strict spritesheet stage");
let replacement = tiny_replacement_png(32, 64, 192);
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
"art-spritesheet",
"uncertain-committed-spritesheet",
vec!["taonier-resource-icon-spec".to_string()],
&replacement,
);
let propagated =
direct_taonier_art_generation_failure("陶泥儿首版核心游戏美术图集", marker.clone());
assert_eq!(propagated, marker);
let error = direct_taonier_strict_spritesheet_stage_failure(
root.path(),
rollback.as_ref(),
propagated,
);
assert_eq!(error, marker);
assert_eq!(
std::fs::read(root.path().join(DIRECT_CODEX_SPRITESHEET_ASSET_PATH))
.expect("preserved uncertain spritesheet"),
replacement
);
let retained = read_direct_taonier_regeneration_workflow_at(root.path())
.expect("read workflow")
.expect("workflow remains");
assert_eq!(
retained.state,
DirectTaonierRegenerationWorkflowState::InProgress
);
assert!(retained.strict_spritesheet_pending);
}
}
#[test]
fn failed_art_spec_stage_after_local_commit_is_discovered_and_restored() {
let root = tempfile::tempdir().expect("temp dir");
@@ -9725,8 +9725,16 @@ mod canvas_generation_tests {
action_id: "post-202-action".to_string(),
action_fingerprint: "post-202-fingerprint".to_string(),
};
let current_prompt = "账号 A 已提交的生成请求";
let current_options = PlatformArtAssetGenerationOptions {
asset_kind: "game-art".to_string(),
asset_label: "账号隔离测试".to_string(),
..PlatformArtAssetGenerationOptions::default()
};
let generation_prompt =
build_platform_art_asset_prompt(current_prompt, &[], &current_options);
let request_body = serde_json::json!({
"prompt": "账号 A 已提交的生成请求",
"prompt": generation_prompt,
"kind": "spec",
"projectId": "post-202-project",
"assetFolderId": "post-202-folder",
@@ -9743,7 +9751,7 @@ mod canvas_generation_tests {
&runtime_context,
"/api/external/v1/editor/images/generations",
"202 后切换账号",
"账号 A 已提交的生成请求",
&generation_prompt,
&request_body,
&ledger_access,
)
@@ -9752,13 +9760,9 @@ mod canvas_generation_tests {
let error = generate_platform_art_asset_with_runtime_options_at(
root,
"调用输入不得覆盖持久化请求",
current_prompt,
&[],
&PlatformArtAssetGenerationOptions {
asset_kind: "game-art".to_string(),
asset_label: "账号隔离测试".to_string(),
..PlatformArtAssetGenerationOptions::default()
},
&current_options,
false,
&runtime_context,
)
@@ -9852,6 +9856,7 @@ mod canvas_generation_tests {
"resource": {
"resourceId": "persisted-resource-1",
"projectId": "persisted-canvas-project",
"objectKey": "generated/accepted-recovery.png",
"imageSrc": server_download_url
},
"warning": {
@@ -9874,6 +9879,24 @@ mod canvas_generation_tests {
stream
.write_all(response.as_bytes())
.expect("write accepted operation response");
} else if request.starts_with(
"GET /api/assets/read-url?objectKey=generated%2Faccepted-recovery.png ",
) {
let body = serde_json::json!({
"read": {
"objectKey": "generated/accepted-recovery.png",
"signedUrl": server_download_url
}
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
);
stream
.write_all(response.as_bytes())
.expect("write accepted recovery read URL");
} else if request.starts_with("GET /download.png ") {
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
@@ -9986,12 +10009,14 @@ mod canvas_generation_tests {
.collect::<Vec<_>>();
assert_eq!(
requests.len(),
2,
3,
"accepted recovery must only poll and download"
);
assert!(requests[0]
.starts_with("GET /api/runtime/external-generation/jobs/accepted-operation-1 "));
assert!(requests[1].starts_with("GET /download.png "));
assert!(requests[1]
.starts_with("GET /api/assets/read-url?objectKey=generated%2Faccepted-recovery.png "));
assert!(requests[2].starts_with("GET /download.png "));
assert!(requests.iter().all(|request| !request.starts_with("POST ")));
assert!(requests
.iter()
@@ -10166,6 +10191,38 @@ mod canvas_generation_tests {
let binding_access =
ExternalEditorBindingAccess::new(&base_url, "changed-reference-key", None)
.expect("prepare changed-reference binding access");
let manifest = read_manifest_for_project(root).expect("read changed-reference manifest");
let source = manifest
.assets
.iter()
.find(|asset| asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH)
.expect("registered changed-reference art spec");
let source_bytes =
fs::read(root.join(&source.local_path)).expect("read changed-reference art spec bytes");
let principal = external_editor_binding_principal(&binding_access)
.expect("derive changed-reference principal");
let source_identity = new_external_editor_source_identity(
&source.id,
&format!("{:x}", Sha256::digest(&source_bytes)),
&source.media_type,
&source.kind,
)
.expect("derive changed-reference source identity");
let binding = new_external_editor_resource_binding(
&manifest.project_id,
&principal,
"same-canvas-project",
&source_identity,
Some("current-art-spec"),
"generated/current-art-spec.png",
"current-art-spec-object",
Some(1),
Some(1),
unix_timestamp(),
)
.expect("create changed-reference current account binding");
write_external_editor_resource_binding_at(root, &binding)
.expect("persist changed-reference current account binding");
let (state, created) = prepare_platform_art_generation_runtime_state(
root,
&runtime_context,
@@ -10382,14 +10439,18 @@ mod canvas_generation_tests {
let ledger_access =
ExternalEditorBindingAccess::for_developer(&base_url, "accepted-failure-key")
.expect("create accepted failure ledger access");
let current_prompt = "失败恢复提示词";
let current_options = PlatformArtAssetGenerationOptions::default();
let generation_prompt =
build_platform_art_asset_prompt(current_prompt, &[], &current_options);
let (state, _) = prepare_platform_art_generation_runtime_state(
root,
&runtime_context,
"/api/external/v1/editor/images/generations",
"失败恢复画布",
"失败恢复提示词",
&generation_prompt,
&serde_json::json!({
"prompt": "失败恢复提示词",
"prompt": generation_prompt,
"kind": "spec",
"projectId": "failed-canvas-project",
"assetFolderId": "failed-asset-folder",
@@ -10409,9 +10470,9 @@ mod canvas_generation_tests {
async {
match request_platform_art_asset_with_runtime_options_at(
root,
"不得重新提交",
current_prompt,
&[],
&PlatformArtAssetGenerationOptions::default(),
&current_options,
Some(&runtime_context),
)
.await
@@ -10486,6 +10547,7 @@ mod canvas_generation_tests {
"resource": {
"resourceId": "recovered-resource",
"projectId": "test-canvas-project",
"objectKey": "generated/recovered-art-spec.png",
"imageSrc": image_url
},
"taskId": "recovered-task",
@@ -10502,6 +10564,24 @@ mod canvas_generation_tests {
stream
.write_all(response.as_bytes())
.expect("write accepted scan operation response");
} else if request.starts_with(
"GET /api/assets/read-url?objectKey=generated%2Frecovered-art-spec.png ",
) {
let body = serde_json::json!({
"read": {
"objectKey": "generated/recovered-art-spec.png",
"signedUrl": image_url
}
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
);
stream
.write_all(response.as_bytes())
.expect("write accepted scan read URL response");
} else if request.starts_with("GET /artifact.png ") {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
@@ -10539,8 +10619,52 @@ mod canvas_generation_tests {
&base_url,
);
let run_id = "accepted-recovery-scan-run";
setup_platform_art_generation_runtime_accepted_for_recovery_test(root, run_id)
.expect("setup accepted generation recovery state");
let pending =
setup_platform_art_generation_runtime_accepted_for_recovery_test(root, run_id)
.expect("setup accepted generation recovery state");
let runtime_context = platform_art_generation_runtime_context_from_pending(&pending);
remove_platform_art_generation_runtime_state_at(
root,
&runtime_context.agent_id,
&runtime_context.run_id,
)
.expect("replace stale accepted generation fixture");
let options = PlatformArtAssetGenerationOptions {
output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()),
aspect_ratio: "1:1".to_string(),
image_size: "1K".to_string(),
asset_kind: "icon-spec".to_string(),
asset_label: "游戏统一视觉规范图".to_string(),
replace_existing: false,
};
let prompt = "恢复已受理视觉规范图";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
let (_, _, frozen_platform_session) =
resolve_canvas_sync_api_credentials(None, None).expect("freeze recovery account");
let frozen_platform_session =
frozen_platform_session.expect("accepted recovery scan must use a platform account");
let ledger_access = ExternalEditorBindingAccess::for_platform(&frozen_platform_session)
.expect("create accepted recovery scan access");
let (state, created) = prepare_platform_art_generation_runtime_state(
root,
&runtime_context,
"/api/external/v1/editor/images/generations",
"durable-test-canvas",
&generation_prompt,
&serde_json::json!({
"prompt": generation_prompt,
"kind": "spec",
"assetKind": "icon-spec",
"projectId": "test-canvas-project",
"assetFolderId": "test-asset-folder",
"referenceImageSrcs": []
}),
&ledger_access,
)
.expect("write current accepted generation fixture");
assert!(created);
mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 0)
.expect("accept current generation fixture");
resume_game_creator_agent_background_tasks_at(root)
.expect("resume accepted generation through recovery scan");
@@ -10548,6 +10672,9 @@ mod canvas_generation_tests {
.recv_timeout(Duration::from_secs(3))
.expect("operation GET after recovery scan");
let second = request_receiver
.recv_timeout(Duration::from_secs(3))
.expect("read URL GET after recovery scan");
let third = request_receiver
.recv_timeout(Duration::from_secs(3))
.expect("download GET after recovery scan");
for _ in 0..100 {
@@ -10566,7 +10693,9 @@ mod canvas_generation_tests {
server.join().expect("join accepted scan fixture");
assert!(first.starts_with("GET /api/runtime/external-generation/jobs/test-operation-id "));
assert!(second.starts_with("GET /artifact.png "));
assert!(second
.starts_with("GET /api/assets/read-url?objectKey=generated%2Frecovered-art-spec.png "));
assert!(third.starts_with("GET /artifact.png "));
assert!(request_receiver
.recv_timeout(Duration::from_millis(100))
.is_err());
@@ -1619,7 +1619,10 @@ mod orphaned_external_generation_recovery_tests {
"source": "direct-codex",
"actionId": "direct-taonier-art-spec",
"actionFingerprint": "direct-taonier-art-v1:icon-spec:art-spec",
"externalConfigurationFingerprint": "test-service",
"externalConfigurationFingerprint": "c".repeat(64),
"accessScheme": "external-developer-v1",
"externalServiceOrigin": "https://editor.example.test",
"platformOwnerUserId": null,
"endpoint": "/api/external/v1/editor/images/generations",
"canvasName": "Direct TaoNier",
"generationPrompt": "Direct art spec",
@@ -2031,7 +2031,8 @@ async fn ensure_resource_edit_source_reference(
LocalProjectResourceEditKind::ImageReference => binding
.remote_resource_id
.filter(|reference| is_registered_editor_reference_id(reference)),
LocalProjectResourceEditKind::Video => Some(binding.object_key),
LocalProjectResourceEditKind::Video
| LocalProjectResourceEditKind::CharacterAnimation => Some(binding.object_key),
_ => return Err("当前资源类型不需要远端源 binding".to_string()),
};
if let Some(stable_reference) = stable_reference {
@@ -6269,6 +6270,130 @@ mod tests {
assert!(!register_request.contains(&second_request.operation_id));
}
#[tokio::test]
async fn character_animation_reuses_exact_source_binding_object_key() {
let directory = tempfile::tempdir().expect("create character animation binding fixture");
let root = directory.path();
init_local_game_project_at(root, PROJECT_ID, "角色动画源 binding 复用测试")
.expect("initialize project");
let uploaded = upload_local_asset_at(
root,
"character-source.png",
"image/png",
&resource_editor_test_png(),
)
.expect("upload character source");
let manifest = read_existing_manifest_for_project(root).expect("read character manifest");
let source_asset = manifest
.assets
.iter()
.find(|asset| asset.id == uploaded.id)
.cloned()
.expect("character source asset");
let mut request = input(
root,
Uuid::new_v4().to_string(),
LocalProjectResourceEditKind::CharacterAnimation,
format!("asset:{}", source_asset.id),
);
request.source_asset_id = Some(source_asset.id.clone());
request.source_path = Some(source_asset.local_path.clone());
request.source_media_type = Some(source_asset.media_type.clone());
request.source_subtype = Some(source_asset.kind.clone());
let source = resolve_resource_edit_source(root, &manifest, &request)
.expect("resolve character source");
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared);
write_resource_edit_ledger(root, &ledger).expect("write character animation ledger");
let listener =
TcpListener::bind("127.0.0.1:0").expect("bind character animation binding fixture");
let base_url = format!(
"http://{}",
listener.local_addr().expect("character binding address")
);
let upload_url = format!("{base_url}/unused-upload");
let access =
ExternalEditorBindingAccess::new(&base_url, "character-animation-binding-key", None)
.expect("character binding access");
let principal =
external_editor_binding_principal(&access).expect("character binding principal");
write_external_editor_project_binding_at(
root,
&new_external_editor_project_binding(
PROJECT_ID,
&principal,
"source-binding-project",
"source-binding-folder",
1,
)
.expect("character project binding"),
)
.expect("write character project binding");
let source_identity = new_external_editor_source_identity(
&source_asset.id,
&source.source_sha256,
&source.media_type,
&source.asset_kind,
)
.expect("character source identity");
write_external_editor_resource_binding_at(
root,
&new_external_editor_resource_binding(
PROJECT_ID,
&principal,
"source-binding-project",
&source_identity,
None,
"generated/character-source.png",
"character-source-object",
None,
None,
1,
)
.expect("character resource binding"),
)
.expect("write character resource binding");
let (done_sender, done_receiver) = mpsc::channel();
let server = spawn_source_binding_fixture(listener, upload_url, false, None, done_receiver);
let prepared = ensure_resource_edit_source_reference(
root,
&reqwest::Client::new(),
&access,
&principal,
&request,
&source,
&mut ledger,
)
.await
.expect("reuse character source binding without remote mutation");
done_sender
.send(())
.expect("stop character animation binding fixture");
let requests = server
.join()
.expect("join character animation binding fixture");
assert_eq!(prepared.stable_reference, "generated/character-source.png");
assert_eq!(
ledger.source_stable_reference.as_deref(),
Some("generated/character-source.png")
);
assert_eq!(
ledger.remote_project_id.as_deref(),
Some("source-binding-project")
);
assert_eq!(
requests.len(),
2,
"cache hit must only revalidate project and folder"
);
assert!(requests.iter().all(|request| {
let line = request.lines().next().unwrap_or_default();
line.starts_with("GET /api/external/v1/editor/projects ")
|| line.starts_with("GET /api/external/v1/editor/assets/library ")
}));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn source_registration_identity_persists_before_account_switch_is_reported() {
let directory = tempfile::tempdir().expect("create source registration switch fixture");
@@ -1621,6 +1621,11 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r
init_local_game_project_at(&root, "project-canvas-repair-atomic", "月光厨房")
.expect("project init");
register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec");
bind_canvas_visual_asset_fixture_to_current_editor(
&root,
"assets/art-spec.png",
"canvas-project-1",
);
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
@@ -1806,7 +1811,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r
});
let mut saw_generation_request = false;
for _ in 0..3 {
for _ in 0..5 {
let captured = request_receiver
.recv_timeout(Duration::from_secs(15))
.expect("canvas request before generation response");
@@ -225,7 +225,7 @@ fn spritesheet_slice_test_png_bytes(index: usize) -> Vec<u8> {
[64, 128, 240, 255],
[224, 96, 224, 255],
];
rgba_test_png_bytes(&[colors[index]])
rgba_test_png_bytes(&[[0, 0, 0, 0], colors[index]])
}
fn rgba_test_png_bytes(pixels: &[[u8; 4]]) -> Vec<u8> {
@@ -3006,7 +3006,7 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
serde_json::json!({
"name": name,
"imageSrc": format!("/generated/canvas/spritesheet-slice-{index}.png"),
"width": 1,
"width": 2,
"height": 1,
"resource": {
"resourceId": format!("slice-resource-{index}"),
@@ -3014,7 +3014,7 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
"imageSrc": format!("/generated/canvas/spritesheet-slice-{index}.png"),
"objectKey": format!("generated/canvas/spritesheet-slice-{index}.png"),
"assetObjectId": format!("slice-asset-object-{index}"),
"width": 1,
"width": 2,
"height": 1,
"sourceType": "generated",
"taskId": "task-1",
@@ -3136,11 +3136,39 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
{
assert!(normalized_request.contains("authorization: bearer "));
("200 OK", "application/json", projects_body.as_bytes().to_vec())
} else if request.starts_with("POST /api/external/v1/editor/projects ")
|| request.starts_with("POST /api/editor/projects ")
{
assert!(normalized_request.contains("authorization: bearer "));
assert!(normalized_request.contains("idempotency-key: game-creator-project-"));
(
"200 OK",
"application/json",
serde_json::json!({
"data": { "project": { "projectId": "canvas-project-1" } }
})
.to_string()
.into_bytes(),
)
} else if request.starts_with("GET /api/external/v1/editor/assets/library ")
|| request.starts_with("GET /api/editor/assets/library ")
{
assert!(normalized_request.contains("authorization: bearer "));
("200 OK", "application/json", library_body.as_bytes().to_vec())
} else if request.starts_with("POST /api/external/v1/editor/assets/folders ")
|| request.starts_with("POST /api/editor/assets/folders ")
{
assert!(normalized_request.contains("authorization: bearer "));
assert!(normalized_request.contains("idempotency-key: game-creator-folder-"));
(
"200 OK",
"application/json",
serde_json::json!({
"data": { "folder": { "folderId": "folder-1" } }
})
.to_string()
.into_bytes(),
)
} else if request.starts_with("GET /api/external/v1/editor/projects/canvas-project-1 ")
|| request.starts_with("GET /api/editor/projects/canvas-project-1 ")
{
@@ -3298,7 +3326,7 @@ fn spawn_mock_external_canvas_api_server() -> String {
fn spawn_mock_external_canvas_generation_api_server(
request_sender: Option<mpsc::Sender<String>>,
) -> String {
spawn_mock_external_canvas_api_server_with_capture(16, request_sender)
spawn_mock_external_canvas_api_server_with_capture(20, request_sender)
}
fn spawn_mock_external_canvas_generation_api_server_with_gate(
@@ -3306,7 +3334,7 @@ fn spawn_mock_external_canvas_generation_api_server_with_gate(
generation_response_gate: mpsc::Receiver<()>,
) -> String {
spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
16,
20,
Some(request_sender),
Some(generation_response_gate),
)
@@ -3319,7 +3347,7 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String {
listener.local_addr().expect("mock canvas api addr")
);
std::thread::spawn(move || {
for index in 0..3 {
for index in 0..5 {
let (mut stream, _) = listener.accept().expect("mock canvas api accept");
let mut request_buffer = [0_u8; 8192];
let read_len = stream.read(&mut request_buffer).unwrap_or(0);
@@ -3345,6 +3373,19 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String {
)
}
1 => {
assert!(
request.starts_with("POST /api/external/v1/editor/projects ")
|| request.starts_with("POST /api/editor/projects ")
);
(
"200 OK",
serde_json::json!({
"data": { "project": { "projectId": "canvas-project-1" } }
})
.to_string(),
)
}
2 => {
assert!(
request.starts_with("GET /api/external/v1/editor/assets/library ")
|| request.starts_with("GET /api/editor/assets/library ")
@@ -3360,6 +3401,19 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String {
.to_string(),
)
}
3 => {
assert!(
request.starts_with("POST /api/external/v1/editor/assets/folders ")
|| request.starts_with("POST /api/editor/assets/folders ")
);
(
"200 OK",
serde_json::json!({
"data": { "folder": { "folderId": "folder-1" } }
})
.to_string(),
)
}
_ => {
assert!(
request.starts_with("POST /api/external/v1/editor/images/generations ")
@@ -3434,6 +3488,56 @@ fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &st
.expect("register canvas visual asset fixture");
}
fn bind_canvas_visual_asset_fixture_to_current_editor(
root: &Path,
local_path: &str,
remote_project_id: &str,
) {
let manifest = read_manifest_for_project(root).expect("read visual binding fixture manifest");
let source = manifest
.assets
.iter()
.find(|asset| asset.local_path == local_path)
.expect("registered visual binding fixture");
let bytes = fs::read(root.join(&source.local_path)).expect("read visual binding fixture bytes");
let (api_base_url, api_key, frozen_platform_session) =
resolve_canvas_sync_api_credentials(None, None).expect("resolve visual fixture account");
let frozen_platform_session = frozen_platform_session
.as_ref()
.expect("visual binding fixture requires a platform account");
let access =
ExternalEditorBindingAccess::new(&api_base_url, &api_key, Some(frozen_platform_session))
.expect("create visual binding fixture access");
let principal = external_editor_binding_principal(&access)
.expect("derive visual binding fixture principal");
let source_identity = new_external_editor_source_identity(
&source.id,
&format!("{:x}", Sha256::digest(&bytes)),
&source.media_type,
&source.kind,
)
.expect("derive visual binding fixture source identity");
let binding = new_external_editor_resource_binding(
&manifest.project_id,
&principal,
remote_project_id,
&source_identity,
source.source.resource_id.as_deref(),
&format!("fixtures/{}.png", source.id),
source
.source
.asset_object_id
.as_deref()
.expect("visual fixture asset object id"),
Some(1),
Some(1),
unix_timestamp(),
)
.expect("create visual resource binding fixture");
write_external_editor_resource_binding_at(root, &binding)
.expect("persist visual resource binding fixture");
}
fn ui_prototype_checks_fixture(passed: bool) -> serde_json::Value {
serde_json::json!({
"informationHud": passed,
@@ -351,7 +351,7 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re
assert!(
validate_manifest_required_visual_asset(&root, &manifest, "design-foundation")
.expect_err("stale UI reference must fail")
.contains("精确引用当前统一视觉规范图")
.contains("绑定当前统一视觉规范图的本地内容身份")
);
let ui = manifest
@@ -402,7 +402,7 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re
assert!(
validate_manifest_required_visual_asset(&root, &manifest, task_id)
.unwrap_err()
.contains("精确引用当前统一视觉规范图"),
.contains("绑定当前统一视觉规范图的本地内容身份"),
"{task_id} must become stale after art-spec resource changes"
);
}
@@ -658,6 +658,11 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
);
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec");
bind_canvas_visual_asset_fixture_to_current_editor(
&root,
"assets/art-spec.png",
"canvas-project-1",
);
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
@@ -846,7 +851,9 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
assert_eq!(
canvas_requests
.iter()
.filter(|request| request.starts_with("POST /api/editor/"))
.filter(|request| {
request.starts_with("POST /api/editor/icon-spritesheets/generations ")
})
.count(),
1,
"queued/running polling must not submit generation again"
@@ -911,6 +918,13 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
)
.expect("write runtime config");
let _config_guard = use_test_runtime_config_dir(config_dir.to_path_buf());
if root.join("assets/art-spec.png").is_file() {
bind_canvas_visual_asset_fixture_to_current_editor(
root,
"assets/art-spec.png",
"canvas-project-1",
);
}
request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options)
.await
.expect("prepare canonical visual request");
@@ -920,7 +934,10 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
.recv_timeout(Duration::from_secs(2))
.expect("canvas api request")
})
.find(|request| request.starts_with("POST "))
.find(|request| {
request.starts_with("POST /api/editor/images/generations ")
|| request.starts_with("POST /api/editor/icon-spritesheets/generations ")
})
.expect("generation request")
}
@@ -994,6 +1011,11 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m
init_local_game_project_at(&root, "project-canvas-request-lock", "月光厨房")
.expect("project init");
register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec");
bind_canvas_visual_asset_fixture_to_current_editor(
&root,
"assets/art-spec.png",
"canvas-project-1",
);
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
@@ -1035,7 +1057,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m
});
let mut saw_generation_request = false;
for _ in 0..3 {
for _ in 0..5 {
let captured = request_receiver
.recv_timeout(Duration::from_secs(15))
.expect("canvas request before generation response");
@@ -1155,9 +1177,8 @@ async fn generate_local_game_draft_fails_after_max_passes_without_final_artifact
.map(|path| path.replace('\\', "/"))
.collect::<Vec<_>>();
assert!(
artifact_paths
.iter()
.any(|path| path == ".agent/passes/pass-3/game.html"),
artifact_paths.iter().any(|path| path.as_str()
== format!(".agent/passes/pass-{GAME_CREATOR_AGENT_LOOP_MAX_PASSES}/game.html")),
"unexpected max-pass artifact paths: {artifact_paths:?}"
);
assert!(!trace["steps"]