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 4b5ab0d47..f1ed549ff 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 @@ -2897,6 +2897,60 @@ fn create_ui_design_doc_from_images_skips_a_taken_ui_design_path() { fs::remove_dir_all(root).ok(); } +/// 批次里后面某张设计图不合法时,前面已通过校验的图片不能留在 manifest 里。 +/// +/// 登记动作本身是 manifest 副作用:如果边校验边登记,一旦后续引用失败就没有回滚路径, +/// 会在 manifest 里留下没有任何文档引用的图片条目。整批先校验再登记才不会漏。 +#[test] +fn create_ui_design_doc_from_images_leaves_no_orphan_assets_when_a_later_image_fails() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "UI 设计批次校验").expect("project init"); + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 32, + 24, + image::Rgba([1, 2, 3, 255]), + )) + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("encode design image"); + fs::create_dir_all(root.join("assets")).expect("assets dir"); + fs::write(root.join("assets/page.png"), bytes).expect("write design image"); + + let error = crate::ui_editor::agent_tools::create_ui_design_doc_from_images( + crate::ui_editor::agent_tools::CreateUiDesignDocFromImagesInput { + project_path: root.to_string_lossy().to_string(), + expected_project_id: "project-1".to_string(), + images: vec![ + crate::ui_editor::agent_tools::UiDesignImageReference { + asset_id: None, + path: Some("assets/page.png".to_string()), + }, + crate::ui_editor::agent_tools::UiDesignImageReference { + asset_id: None, + path: Some("assets/missing.png".to_string()), + }, + ], + }, + ) + .expect_err("批次中存在缺失的设计图时必须整批失败"); + assert!(error.contains("missing.png"), "{error}"); + + let manifest = crate::read_existing_manifest_for_project(&root).expect("read manifest"); + assert!( + manifest + .assets + .iter() + .all(|asset| asset.local_path != "assets/page.png"), + "整批校验失败时不该把已校验通过的图片登记进 manifest" + ); + assert!(!root.join("ui/UI 设计 1.json").is_file()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn register_local_asset_rejects_missing_or_unsafe_path() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/creation.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/creation.rs index b4095a084..7ba5f227f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/creation.rs @@ -131,34 +131,102 @@ pub(crate) fn create_ui_design_doc_from_images( }) } -/// 解析并登记设计图,返回要装进文档的图片与本次调用新登记的图片资源 id。 +/// 只读校验出来的设计图引用:资源身份已确定,但还没写 manifest。 +enum ResolvedDesignImage { + /// manifest 里已有这张图片,本次直接复用它的资源身份。 + Registered { + asset_id: String, + relative_path: String, + pixel_size: (u32, u32), + }, + /// 本次调用需要新登记的设计图,登记后才有 assetId。 + Pending { + relative_path: String, + pixel_size: (u32, u32), + media_type: &'static str, + }, +} + +impl ResolvedDesignImage { + /// 同批次查重键:已登记图片按资源身份,待登记图片按归一化路径。 + fn identity(&self) -> String { + match self { + ResolvedDesignImage::Registered { asset_id, .. } => asset_id.clone(), + ResolvedDesignImage::Pending { relative_path, .. } => { + format!("path:{relative_path}") + } + } + } +} + +/// 先整批校验、再登记,任何一张不合法都不会在 manifest 里留下无人引用的图片条目。 fn prepare_design_images( root: &Path, manifest: &GameCreationAppManifest, references: &[UiDesignImageReference], ) -> Result<(Vec, Vec), String> { - let mut images = Vec::with_capacity(references.len()); - let mut registered_ids = Vec::new(); - let mut seen_ids = BTreeSet::new(); + let mut resolved = Vec::with_capacity(references.len()); + let mut seen = BTreeSet::new(); for reference in references { - let (image, registered_id) = prepare_design_image(root, manifest, reference)?; - if let Some(id) = registered_id { - registered_ids.push(id); - } - if !seen_ids.insert(image.image_id.clone()) { + let image = resolve_design_image(root, manifest, reference)?; + if !seen.insert(image.identity()) { return Err("同一次调用不能重复登记同一张设计图".to_string()); } - images.push(image); + resolved.push(image); + } + let mut images = Vec::with_capacity(resolved.len()); + let mut registered_ids = Vec::new(); + for image in resolved { + let (asset_id, relative_path, pixel_size) = match image { + ResolvedDesignImage::Registered { + asset_id, + relative_path, + pixel_size, + } => (asset_id, relative_path, pixel_size), + ResolvedDesignImage::Pending { + relative_path, + pixel_size, + media_type, + } => { + let registered = register_local_asset_at( + root, + &relative_path, + GameCreationAppAssetKind::UiDesign, + media_type, + "ui-design", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + )?; + registered_ids.push(registered.id.clone()); + (registered.id, relative_path, pixel_size) + } + }; + images.push(UiDesignDocumentImage { + image_id: asset_id, + path: relative_path, + pixel_size, + }); } Ok((images, registered_ids)) } -fn prepare_design_image( +/// 解析一张设计图引用并读取它的真实尺寸:只读,不写 manifest。 +fn resolve_design_image( root: &Path, manifest: &GameCreationAppManifest, reference: &UiDesignImageReference, -) -> Result<(UiDesignDocumentImage, Option), String> { - let (asset_id, relative_path, registered_id) = +) -> Result { + let (asset_id, relative_path, media_type): (Option, String, Option<&'static str>) = match (reference.asset_id.as_deref(), reference.path.as_deref()) { (Some(asset_id), None) => { let asset_id = asset_id.trim(); @@ -168,7 +236,7 @@ fn prepare_design_image( .find(|asset| asset.id == asset_id) .ok_or_else(|| format!("设计图资源不存在:{asset_id}"))?; require_image_media_type(&asset.media_type)?; - (asset.id.clone(), asset.local_path.clone(), None) + (Some(asset.id.clone()), asset.local_path.clone(), None) } (None, Some(path)) => { let normalized_path = normalize_relative_path(path.trim())?; @@ -180,51 +248,38 @@ fn prepare_design_image( // 已登记图片复用原资源身份,不因为本次调用改写它的 kind 与分类。 Some(existing) => { require_image_media_type(&existing.media_type)?; - (existing.id.clone(), existing.local_path.clone(), None) + (Some(existing.id.clone()), existing.local_path.clone(), None) } None => { let absolute_path = resolve_local_project_path(root, &normalized_path)?; let media_type = design_image_media_type(&absolute_path)?; - let registered = register_local_asset_at( - root, - &normalized_path, - GameCreationAppAssetKind::UiDesign, - media_type, - "ui-design", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - )?; - (registered.id.clone(), normalized_path, Some(registered.id)) + (None, normalized_path, Some(media_type)) } } } _ => return Err("每张设计图必须且只能给 assetId 或 path".to_string()), }; let absolute_path = resolve_local_project_path(root, &relative_path)?; - let dimensions = image::open(&absolute_path) + let pixel_size = image::open(&absolute_path) .map_err(|error| format!("读取设计图失败:{relative_path}:{error}"))? .dimensions(); - if dimensions.0 == 0 || dimensions.1 == 0 { + if pixel_size.0 == 0 || pixel_size.1 == 0 { return Err(format!("设计图尺寸无效:{relative_path}")); } - Ok(( - UiDesignDocumentImage { - image_id: asset_id, - path: relative_path, - pixel_size: dimensions, - }, - registered_id, - )) + match (asset_id, media_type) { + (Some(asset_id), _) => Ok(ResolvedDesignImage::Registered { + asset_id, + relative_path, + pixel_size, + }), + (None, Some(media_type)) => Ok(ResolvedDesignImage::Pending { + relative_path, + pixel_size, + media_type, + }), + // 上面两条已覆盖 (assetId) 与 (path 未登记) 两种解析结果。 + (None, None) => Err("每张设计图必须且只能给 assetId 或 path".to_string()), + } } fn require_image_media_type(media_type: &str) -> Result<(), String> {