diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index b76042096..368886a8e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -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, slice_warning: Option, @@ -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"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 1ade732cb..53fd53274 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -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::>(); 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()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index d775df5ae..045ed4484 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -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", diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index c64edf659..ec018e5d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -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"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index a4e05512f..4695fd7ab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -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"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index e936da8ad..628dec413 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -225,7 +225,7 @@ fn spritesheet_slice_test_png_bytes(index: usize) -> Vec { [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 { @@ -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>, ) -> 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 1167a8440..06b7ecbcf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -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::>(); 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"]