修复主分支融合后的画布恢复边界
修复 Direct 不确定结果与普通失败的补偿分流 补齐角色动画源图片账号绑定复用 升级 External Editor v3 恢复测试身份 同步账号作用域画布生成测试契约
This commit is contained in:
@@ -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, &[], ¤t_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()
|
||||
},
|
||||
¤t_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, &[], ¤t_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(),
|
||||
¤t_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"]
|
||||
|
||||
Reference in New Issue
Block a user