diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 9501b79e6..3f569063f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1795,34 +1795,71 @@ pub(crate) fn register_local_asset_entry( }) } -/// 恒等绑定槽位前缀:`asset:{assetId}`。 -/// -/// 版本创建时会给当时 manifest 里的每个资源补一条恒等绑定,作为"未被运行槽位覆盖"的 -/// 兼容兜底(历史版本与既有校验语义依赖它)。因此恒等绑定**只表达登记关系**,不表达 -/// "这个素材真的被游戏用到了"。 -pub(crate) const GAME_ITERATION_IDENTITY_BINDING_PREFIX: &str = "asset:"; +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ReadLocalProjectAssetReferencesInput { + pub project_path: String, + pub asset_id: String, +} -/// 一个素材是否被任意版本的**真实运行槽位**绑定过。 +/// 引用了某个素材的项目版本摘要。删除弹窗据此列出「被哪些版本使用」。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectAssetReferenceVersion { + pub version_id: String, + pub project_revision: u64, + pub created_at: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReadLocalProjectAssetReferencesResult { + pub asset_id: String, + pub versions: Vec, +} + +/// 版本数组里 `resourceBindings` 指向该素材的版本,顺序与版本数组一致。 /// -/// C5 删除规则读作"从未被任何 slot 绑定过的素材才可删"。这个判断只看真实槽位绑定: -/// 恒等绑定是版本创建时的兼容兜底,若把它也算作"被引用",那么任何在某个版本创建之前 -/// 登记过的素材都永远删不掉,删除规则会完全失效。历史版本本身仍然不可变、不影响回放。 -fn asset_is_bound_to_runtime_slot( - versions: &[GameIterationVersion], +/// 版本绑定即资源记录:`resourceId` 指向 manifest 资产 ID,命中即表示该版本用了这个素材。 +fn asset_referencing_versions( + manifest: &GameCreationAppManifest, asset_id: &str, -) -> Option<(String, String)> { - for version in versions { - for binding in &version.resource_bindings { - if binding.resource_id != asset_id { - continue; - } - if binding.slot_id.starts_with(GAME_ITERATION_IDENTITY_BINDING_PREFIX) { - continue; - } - return Some((version.version_id.clone(), binding.slot_id.clone())); - } +) -> Vec { + manifest + .versions + .iter() + .filter(|version| { + version + .resource_bindings + .iter() + .any(|binding| binding.resource_id == asset_id) + }) + .map(|version| LocalProjectAssetReferenceVersion { + version_id: version.version_id.clone(), + project_revision: version.project_revision, + created_at: version.created_at, + }) + .collect() +} + +fn normalized_asset_id(asset_id: &str) -> Result<&str, String> { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + return Err("素材 assetId 不能为空".to_string()); } - None + Ok(asset_id) +} + +pub(crate) fn read_manifest_asset_references_at( + root: &Path, + asset_id: &str, +) -> Result { + let asset_id = normalized_asset_id(asset_id)?; + let manifest = read_existing_manifest_for_project(root)?; + Ok(ReadLocalProjectAssetReferencesResult { + asset_id: asset_id.to_string(), + versions: asset_referencing_versions(&manifest, asset_id), + }) } #[derive(Clone, Debug, Deserialize)] @@ -1832,6 +1869,8 @@ pub(crate) struct DeleteLocalProjectAssetInput { pub expected_project_id: String, pub expected_project_revision: u64, pub asset_id: String, + /// 用户是否勾选「把相关游戏版本一并删除」;默认不勾,只删素材登记。 + pub delete_referenced_versions: bool, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -1844,13 +1883,14 @@ pub(crate) struct DeleteLocalProjectAssetResult { pub file_retained: bool, } -/// 删除一个从未被真实运行槽位绑定过的素材登记。 +/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。 /// /// 语义: +/// - 未被任何版本引用 → 只摘掉登记; +/// - 被引用且 `delete_referenced_versions` 为 `false` → 只摘掉登记,引用它的版本原样保留为 +/// 悬空绑定,不做二次拦截;界面不为这些版本合成资源卡; +/// - 被引用且为 `true` → 素材与所有引用它的版本在**同一次 manifest 写入**里一起删除; /// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记; -/// - 被任意版本的真实运行槽位绑定过 → 拒绝,并在错误里给出版本与槽位; -/// - 删除同时清理运行期资源使用清单里指向它的引用,避免留下悬空归属; -/// - 已完成的版本记录本身不改动,历史回放不受影响; /// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS, /// 失败时 manifest 与 revision 都不变。 pub(crate) fn delete_manifest_asset_at( @@ -1858,6 +1898,7 @@ pub(crate) fn delete_manifest_asset_at( expected_project_id: &str, expected_project_revision: u64, asset_id: &str, + delete_referenced_versions: bool, ) -> Result { if expected_project_revision > shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION @@ -1868,10 +1909,7 @@ pub(crate) fn delete_manifest_asset_at( if expected_project_id.is_empty() { return Err("素材删除 expectedProjectId 不能为空".to_string()); } - let asset_id = asset_id.trim(); - if asset_id.is_empty() { - return Err("素材删除 assetId 不能为空".to_string()); - } + let asset_id = normalized_asset_id(asset_id)?; if read_existing_manifest_for_project(root)?.project_id != expected_project_id { return Err("project-identity-conflict".to_string()); @@ -1886,28 +1924,40 @@ pub(crate) fn delete_manifest_asset_at( } let mut local_path = String::new(); - mutate_manifest_at(root, |manifest| { - let Some(index) = manifest - .assets - .iter() - .position(|asset| asset.id == asset_id) - else { - return Err(format!("项目资源不存在:{asset_id}")); - }; - if let Some((version_id, slot_id)) = - asset_is_bound_to_runtime_slot(&manifest.versions, asset_id) - { - return Err(format!( - "素材 {asset_id} 已被版本 {version_id} 的运行槽位 {slot_id} 绑定,不能删除" - )); - } - local_path = manifest.assets[index].local_path.clone(); - manifest.assets.remove(index); - Ok(()) - })?; - - // 清理使用清单里指向该素材的引用;清单缺失或读取失败不阻塞删除结果。 - let _ = remove_asset_usage_references(root, asset_id); + mutate_manifest_at_allowing_version_removals( + root, + &|manifest| { + if !delete_referenced_versions { + return Vec::new(); + } + asset_referencing_versions(manifest, asset_id) + .into_iter() + .map(|version| version.version_id) + .collect() + }, + |manifest| { + let Some(index) = manifest + .assets + .iter() + .position(|asset| asset.id == asset_id) + else { + return Err(format!("项目资源不存在:{asset_id}")); + }; + local_path = manifest.assets[index].local_path.clone(); + manifest.assets.remove(index); + if delete_referenced_versions { + let referenced_version_ids: Vec = + asset_referencing_versions(manifest, asset_id) + .into_iter() + .map(|version| version.version_id) + .collect(); + manifest + .versions + .retain(|version| !referenced_version_ids.contains(&version.version_id)); + } + Ok(()) + }, + )?; let committed_project_revision = advance_agent_runtime_project_revision_locked(root) .map_err(|error| format!("素材登记已删除,但项目 revision 未能推进:{error}"))?; @@ -1920,32 +1970,6 @@ pub(crate) fn delete_manifest_asset_at( }) } -/// 从运行期资源使用清单里摘掉指向某素材的引用。 -/// -/// 这些引用记录的是"代码曾经引用了它"这一历史事实,素材登记被删除后它们不再有可解析的 -/// 资源身份,继续留着会产出悬空归属,因此一并清理。 -fn remove_asset_usage_references(root: &Path, asset_id: &str) -> Result<(), String> { - let Some(usage) = read_local_project_asset_usage(root, None)? else { - return Ok(()); - }; - if !usage - .references - .iter() - .any(|reference| reference.resource_id.as_deref() == Some(asset_id)) - { - return Ok(()); - } - let mut references = usage.references.clone(); - references.retain(|reference| reference.resource_id.as_deref() != Some(asset_id)); - let diagnostics = usage - .diagnostics - .iter() - .filter(|diagnostic| diagnostic.resource_id.as_deref() != Some(asset_id)) - .cloned() - .collect(); - write_local_project_asset_usage_at(root, &usage, references, diagnostics) -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 2831a0cd7..def2f7ddd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -848,10 +848,19 @@ pub(crate) fn update_local_project_resource_canvas_layout( ) } -/// 删除一个从未被真实运行槽位绑定过的素材登记。 +/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。 +#[tauri::command] +pub(crate) fn read_local_project_asset_references( + input: ReadLocalProjectAssetReferencesInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + read_manifest_asset_references_at(root, &input.asset_id) +} + +/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。 /// -/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。被任意版本的真实运行槽位绑定过时拒绝, -/// 错误里给出版本与槽位。 +/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。 #[tauri::command] pub(crate) fn delete_local_project_asset( input: DeleteLocalProjectAssetInput, @@ -863,6 +872,7 @@ pub(crate) fn delete_local_project_asset( &input.expected_project_id, input.expected_project_revision, &input.asset_id, + input.delete_referenced_versions, ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 7a8b93001..f6a13b90b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2686,6 +2686,7 @@ fn main() { read_local_project_resource_graph, update_local_project_resource_canvas_layout, delete_local_project_asset, + read_local_project_asset_references, get_local_game_project_revision, get_local_game_manifest, download_agc_update, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 7baff5dbe..7a3bc87d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -4,7 +4,6 @@ use similar::TextDiff; use std::io::{Seek, SeekFrom}; mod agent_db; -mod asset_usage; mod checkpoint; mod conversation; mod export; @@ -18,7 +17,6 @@ mod resource_layout; mod verification; pub(crate) use agent_db::*; -pub(crate) use asset_usage::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; pub(crate) use export::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_usage.rs deleted file mode 100644 index a78b96d92..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_usage.rs +++ /dev/null @@ -1,602 +0,0 @@ -//! 运行期资源使用清单(asset usage sidecar)。 -//! -//! 游戏代码注册「运行槽位 ↔ 资源身份」的归属关系,客户端据此知道运行中的游戏在 -//! 哪用了哪个资源。清单落在 `.agent/workbench/asset-usage/usage.json`,写入遵循 -//! `resource-layouts` 同款的 flock 独占锁 + projectId fencing + revision CAS 模式。 -//! -//! ## 与 `manifest.assets` 的分工(重要) -//! -//! 本文件**不是第二份资源清单**。`manifest.assets` 是资源身份的权威真相(登记表: -//! 有哪些资源、路径、类型、哈希),本文件只记录 manifest 没有的事实: -//! -//! - 稀疏的 `slotId → resourceId` 归属(纯引用,**不复制** path / sha256 / kind / mediaType); -//! - 注册来源 `loadSite`(代码位置,随 AI 改代码而变化); -//! - 引用未登记素材时的最小例外(这类引用没有 asset ID 可引用,只能落路径); -//! - 诊断。 -//! -//! 任何需要资源元数据(路径、哈希、类型)的消费方都应回查 manifest,不要把本文件 -//! 当第二真相。 -//! -//! ## 边界 -//! -//! 资源层面的问题(引用未登记素材、引用了不存在的资源 ID、素材没有槽位归属)一律 -//! 只产出诊断,**不阻断运行**。只有构建期的脚手架校验才可能阻断。 - -use std::fs::{self, File, OpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -use super::filesystem::normalize_relative_path; - -const ASSET_USAGE_LOCK_PATH: &str = ".agent/workbench/asset-usage/.usage.lock"; -const ASSET_USAGE_RELATIVE_PATH: &str = ".agent/workbench/asset-usage/usage.json"; -const ASSET_USAGE_LOCK_WAIT_ATTEMPTS: usize = 100; -const ASSET_USAGE_LOCK_WAIT_MILLIS: u64 = 10; - -pub(crate) const ASSET_USAGE_SCHEMA_VERSION: &str = "agc-asset-usage.v1"; - -/// 诊断严重级别。资源问题只产生 `warning`,不阻断运行。 -pub(crate) const ASSET_USAGE_SEVERITY_WARNING: &str = "warning"; - -/// 归属来源:由游戏代码显式注册到某个运行槽位。 -pub(crate) const ASSET_USAGE_ATTRIBUTION_SLOT: &str = "slot"; -/// 归属来源:被引用但没有槽位,不可单独替换。 -pub(crate) const ASSET_USAGE_ATTRIBUTION_UNATTRIBUTED: &str = "unattributed"; -/// 归属来源:引用了未登记素材,只有路径、没有资源身份。 -pub(crate) const ASSET_USAGE_ATTRIBUTION_UNREGISTERED: &str = "unregistered"; - -/// 一条运行期资源引用。 -/// -/// `resourceId` 存在时只保存身份引用,资源元数据一律回查 manifest; -/// `resourceId` 为空表示引用的是尚未登记的素材,此时才落 `sourcePath`。 -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AssetUsageReference { - /// 运行槽位;没有槽位时为 `None`(不可单独替换)。 - pub slot_id: Option, - pub slot_alias: Option, - /// manifest 资产 ID;未登记素材为 `None`。 - pub resource_id: Option, - /// 仅未登记素材使用:项目相对路径,作为待登记线索。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_path: Option, - /// 注册来源,例如 `game/game.js:preload`。 - #[serde(default, skip_serializing_if = "String::is_empty")] - pub load_site: String, - pub attribution: String, -} - -/// 一条资源诊断。 -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AssetUsageDiagnostic { - pub code: String, - pub severity: String, - pub message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slot_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resource_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_path: Option, -} - -pub(crate) fn asset_usage_unregistered_asset_code() -> &'static str { - "agc.asset-usage.unregistered-asset" -} - -pub(crate) fn asset_usage_unknown_resource_id_code() -> &'static str { - "agc.asset-usage.unknown-resource-id" -} - -pub(crate) fn asset_usage_duplicate_slot_code() -> &'static str { - "agc.asset-usage.duplicate-slot" -} - -pub(crate) fn asset_usage_missing_file_code() -> &'static str { - "agc.asset-usage.missing-file" -} - -/// 落盘的清单正文。 -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct LocalProjectAssetUsage { - pub schema_version: String, - pub project_id: String, - pub project_revision: u64, - /// 客户端记录事实的时间,单位毫秒。纯元数据,不参与业务判定。 - pub updated_at_ms: u64, - /// 运行期资源引用;`slotId` 升序、`resourceId` 次序稳定。 - pub references: Vec, - pub diagnostics: Vec, -} - -impl LocalProjectAssetUsage { - /// 运行槽位 → 资源身份的替换绑定。无槽位与未登记引用不产生绑定。 - pub(crate) fn slot_bindings(&self) -> Vec<(String, String)> { - let mut bindings = Vec::new(); - for reference in &self.references { - let (Some(slot_id), Some(resource_id)) = - (reference.slot_id.as_ref(), reference.resource_id.as_ref()) - else { - continue; - }; - if reference.attribution != ASSET_USAGE_ATTRIBUTION_SLOT { - continue; - } - bindings.push((slot_id.clone(), resource_id.clone())); - } - bindings - } - - /// 按资源身份统计被哪些槽位使用。 - pub(crate) fn slots_by_resource_id(&self) -> Vec<(String, Vec)> { - let mut grouped: Vec<(String, Vec)> = Vec::new(); - for reference in &self.references { - let (Some(resource_id), Some(slot_id)) = - (reference.resource_id.as_ref(), reference.slot_id.as_ref()) - else { - continue; - }; - match grouped.iter_mut().find(|(id, _)| id == resource_id) { - Some((_, slots)) => { - if !slots.iter().any(|slot| slot == slot_id) { - slots.push(slot_id.clone()); - } - } - None => grouped.push((resource_id.clone(), vec![slot_id.clone()])), - } - } - grouped.sort_by(|left, right| left.0.cmp(&right.0)); - grouped - } -} - -/// 游戏代码提交的一次资源使用状态。 -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct SubmitLocalProjectAssetUsageInput { - /// 当前项目 revision;用于 CAS,不允许回退。 - pub project_revision: u64, - #[serde(default)] - pub references: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct SubmitLocalProjectAssetUsageReference { - #[serde(default)] - pub slot_id: Option, - #[serde(default)] - pub slot_alias: Option, - /// 已登记资源给 ID;未登记素材留空并给 `sourcePath`。 - #[serde(default)] - pub resource_id: Option, - #[serde(default)] - pub source_path: Option, - #[serde(default)] - pub load_site: Option, -} - -/// 登记/读取后的清单快照。 -pub(crate) type SubmitLocalProjectAssetUsageResult = LocalProjectAssetUsage; - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct ReadLocalProjectAssetUsageInput { - #[serde(default)] - pub project_id: Option, -} - -/// 项目内的资源事实(来自 manifest,本模块只读不写)。 -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct LocalProjectAssetFacts { - pub asset_id: String, - pub local_path: String, -} - -struct AssetUsageWriteLock { - _file: File, -} - -static ASSET_USAGE_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); - -pub(crate) fn asset_usage_relative_path() -> &'static str { - ASSET_USAGE_RELATIVE_PATH -} - -fn asset_usage_path(root: &Path) -> Result { - Ok(root.join(normalize_relative_path(ASSET_USAGE_RELATIVE_PATH)?)) -} - -#[cfg(unix)] -fn try_open_asset_usage_write_lock_file(root: &Path) -> Result, String> { - use std::os::unix::io::AsRawFd; - - let _open_guard = ASSET_USAGE_LOCK_OPEN_GUARD - .get_or_init(|| Mutex::new(())) - .lock() - .map_err(|_| "资源使用清单锁打开状态已损坏".to_string())?; - - let relative_path = normalize_relative_path(ASSET_USAGE_LOCK_PATH)?; - let path = root.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建资源使用清单锁目录 {} 失败:{error}", parent.display()))?; - } - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&path) - .map_err(|error| format!("打开资源使用清单锁 {} 失败:{error}", path.display()))?; - - // SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock. - if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { - return Ok(Some(file)); - } - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::WouldBlock { - return Ok(None); - } - Err(format!("获取资源使用清单锁 {} 失败:{error}", path.display())) -} - -#[cfg(windows)] -fn try_open_asset_usage_write_lock_file(root: &Path) -> Result, String> { - use std::os::windows::fs::OpenOptionsExt; - - const ERROR_SHARING_VIOLATION: i32 = 32; - const ERROR_LOCK_VIOLATION: i32 = 33; - - let _open_guard = ASSET_USAGE_LOCK_OPEN_GUARD - .get_or_init(|| Mutex::new(())) - .lock() - .map_err(|_| "资源使用清单锁打开状态已损坏".to_string())?; - - let relative_path = normalize_relative_path(ASSET_USAGE_LOCK_PATH)?; - let path = root.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建资源使用清单锁目录 {} 失败:{error}", parent.display()))?; - } - match OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .share_mode(0) - .open(&path) - { - Ok(file) => Ok(Some(file)), - Err(error) - if matches!( - error.raw_os_error(), - Some(ERROR_SHARING_VIOLATION) | Some(ERROR_LOCK_VIOLATION) - ) => - { - Ok(None) - } - Err(error) => Err(format!("获取资源使用清单锁 {} 失败:{error}", path.display())), - } -} - -#[cfg(not(any(unix, windows)))] -fn try_open_asset_usage_write_lock_file(root: &Path) -> Result, String> { - let _ = root; - Err("当前平台不支持资源使用清单文件锁".to_string()) -} - -fn acquire_asset_usage_write_lock(root: &Path) -> Result { - for attempt in 0..ASSET_USAGE_LOCK_WAIT_ATTEMPTS { - if let Some(file) = try_open_asset_usage_write_lock_file(root)? { - return Ok(AssetUsageWriteLock { _file: file }); - } - if attempt + 1 < ASSET_USAGE_LOCK_WAIT_ATTEMPTS { - std::thread::sleep(Duration::from_millis(ASSET_USAGE_LOCK_WAIT_MILLIS)); - } - } - Err("资源使用清单正在被另一个写入方占用,请稍后重试".to_string()) -} - -/// 就地改写清单的引用与诊断,保持身份与 revision 不变。 -/// -/// 供素材删除等"登记被摘掉"的路径清理悬空归属使用;不改变 `projectRevision`,因此不会 -/// 干扰 revision CAS 语义。 -pub(crate) fn write_local_project_asset_usage_at( - root: &Path, - usage: &LocalProjectAssetUsage, - references: Vec, - diagnostics: Vec, -) -> Result<(), String> { - let _lock = acquire_asset_usage_write_lock(root)?; - let updated = LocalProjectAssetUsage { - schema_version: ASSET_USAGE_SCHEMA_VERSION.to_string(), - project_id: usage.project_id.clone(), - project_revision: usage.project_revision, - updated_at_ms: unix_timestamp_millis(), - references, - diagnostics, - }; - write_local_project_asset_usage(root, &updated) -} - -/// 读取清单。文件缺失、schema 不符或 projectId 不匹配时返回 `None`(等价于"尚未登记")。 -pub(crate) fn read_local_project_asset_usage( - root: &Path, - expected_project_id: Option<&str>, -) -> Result, String> { - let path = asset_usage_path(root)?; - let bytes = match fs::read(&path) { - Ok(bytes) => bytes, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!("读取资源使用清单 {} 失败:{error}", path.display())); - } - }; - let usage: LocalProjectAssetUsage = match serde_json::from_slice(&bytes) { - Ok(usage) => usage, - Err(_) => return Ok(None), - }; - if usage.schema_version != ASSET_USAGE_SCHEMA_VERSION { - return Ok(None); - } - if let Some(expected) = expected_project_id { - if usage.project_id != expected { - return Ok(None); - } - } - Ok(Some(usage)) -} - -fn write_local_project_asset_usage( - root: &Path, - usage: &LocalProjectAssetUsage, -) -> Result<(), String> { let path = asset_usage_path(root)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建资源使用清单目录 {} 失败:{error}", parent.display()))?; - } - let payload = serde_json::to_vec_pretty(usage) - .map_err(|error| format!("序列化资源使用清单失败:{error}"))?; - let temporary = path.with_extension("json.tmp"); - { - let mut file = File::create(&temporary) - .map_err(|error| format!("写入资源使用清单临时文件失败:{error}"))?; - file.write_all(&payload) - .map_err(|error| format!("写入资源使用清单临时文件失败:{error}"))?; - file.sync_all() - .map_err(|error| format!("刷新资源使用清单临时文件失败:{error}"))?; - } - fs::rename(&temporary, &path) - .map_err(|error| format!("安装资源使用清单 {} 失败:{error}", path.display()))?; - Ok(()) -} - -/// 项目相对路径对应文件是否存在且可读。用于"引用了不存在的素材"诊断。 -fn project_relative_file_exists(root: &Path, relative_path: &str) -> bool { - match normalize_relative_path(relative_path) { - Ok(normalized) => root.join(normalized).is_file(), - Err(_) => false, - } -} - -fn trimmed(value: Option<&str>) -> Option<&str> { - value.map(str::trim).filter(|item| !item.is_empty()) -} - -/// 登记一次游戏代码提交的资源使用状态。 -/// -/// 语义: -/// - `projectRevision` 只允许前进;回退时拒绝写入;同 revision 重放是幂等的(整体替换)。 -/// - 同一槽位注册多次 → 保留首条并产出 `duplicate-slot` 诊断。 -/// - 给了 `resourceId` 但 manifest 里不存在 → 产出 `unknown-resource-id` 诊断, -/// 并降级为未登记引用(保留身份字符串,便于 AI 对照修复)。 -/// - 没给 `resourceId` 但有 `sourcePath` → `unregistered` 引用 + `unregistered-asset` 诊断。 -/// - 槽位与资源都没有 → `unattributed` 引用(不可单独替换)。 -/// - 登记过程中引用的文件在磁盘上找不到 → `missing-file` 诊断。 -pub(crate) fn submit_local_project_asset_usage_at( - root: &Path, - project_id: &str, - assets: &[LocalProjectAssetFacts], - input: &SubmitLocalProjectAssetUsageInput, -) -> Result { - if project_id.trim().is_empty() { - return Err("资源使用登记缺少项目身份".to_string()); - } - let _lock = acquire_asset_usage_write_lock(root)?; - - if let Some(existing) = read_local_project_asset_usage(root, Some(project_id))? { - if input.project_revision < existing.project_revision { - return Err(format!( - "资源使用登记的 revision {} 小于已登记 revision {}", - input.project_revision, existing.project_revision - )); - } - } - - let mut references: Vec = Vec::new(); - let mut diagnostics: Vec = Vec::new(); - let mut seen_slots: Vec = Vec::new(); - - for submitted in &input.references { - let slot_id = trimmed(submitted.slot_id.as_deref()); - let resource_id = trimmed(submitted.resource_id.as_deref()); - let source_path = trimmed(submitted.source_path.as_deref()); - let slot_alias = trimmed(submitted.slot_alias.as_deref()).map(str::to_string); - let load_site = trimmed(submitted.load_site.as_deref()).unwrap_or_default(); - - if let Some(slot_id) = slot_id { - if seen_slots.iter().any(|value| value == slot_id) { - diagnostics.push(AssetUsageDiagnostic { - code: asset_usage_duplicate_slot_code().to_string(), - severity: ASSET_USAGE_SEVERITY_WARNING.to_string(), - message: format!("运行槽位 {slot_id} 在一个项目里注册了多次,只保留首次注册"), - slot_id: Some(slot_id.to_string()), - resource_id: resource_id.map(str::to_string), - source_path: source_path.map(str::to_string), - }); - continue; - } - seen_slots.push(slot_id.to_string()); - } - - let manifest_path = resource_id.and_then(|id| { - assets - .iter() - .find(|asset| asset.asset_id == id) - .map(|asset| asset.local_path.as_str()) - }); - - let (attribution, resolved_resource_id, resolved_source_path) = - match (resource_id, manifest_path) { - (Some(id), Some(path)) => { - // 已登记:只保留身份引用,路径与哈希回查 manifest。 - if !project_relative_file_exists(root, path) { - diagnostics.push(AssetUsageDiagnostic { - code: asset_usage_missing_file_code().to_string(), - severity: ASSET_USAGE_SEVERITY_WARNING.to_string(), - message: format!("已登记资源 {id} 的素材文件不存在:{path}"), - slot_id: slot_id.map(str::to_string), - resource_id: Some(id.to_string()), - source_path: Some(path.to_string()), - }); - } - ( - if slot_id.is_some() { - ASSET_USAGE_ATTRIBUTION_SLOT - } else { - ASSET_USAGE_ATTRIBUTION_UNATTRIBUTED - } - .to_string(), - Some(id.to_string()), - None, - ) - } - (Some(id), None) => { - diagnostics.push(AssetUsageDiagnostic { - code: asset_usage_unknown_resource_id_code().to_string(), - severity: ASSET_USAGE_SEVERITY_WARNING.to_string(), - message: format!("引用了 manifest 中不存在的资源 ID:{id}"), - slot_id: slot_id.map(str::to_string), - resource_id: Some(id.to_string()), - source_path: source_path.map(str::to_string), - }); - check_source_path_file(root, source_path, &mut diagnostics, slot_id, Some(id)); - ( - ASSET_USAGE_ATTRIBUTION_UNREGISTERED.to_string(), - Some(id.to_string()), - source_path.map(str::to_string), - ) - } - (None, _) => { - if let Some(path) = source_path { - diagnostics.push(AssetUsageDiagnostic { - code: asset_usage_unregistered_asset_code().to_string(), - severity: ASSET_USAGE_SEVERITY_WARNING.to_string(), - message: format!("引用了未登记素材:{path}"), - slot_id: slot_id.map(str::to_string), - resource_id: None, - source_path: Some(path.to_string()), - }); - } - check_source_path_file(root, source_path, &mut diagnostics, slot_id, None); - ( - if slot_id.is_some() { - ASSET_USAGE_ATTRIBUTION_UNREGISTERED - } else { - ASSET_USAGE_ATTRIBUTION_UNATTRIBUTED - } - .to_string(), - None, - source_path.map(str::to_string), - ) - } - }; - - // 无槽位、无资源、无路径的引用没有任何信息量,跳过以免污染清单。 - if slot_id.is_none() && resolved_resource_id.is_none() && resolved_source_path.is_none() { - continue; - } - - references.push(AssetUsageReference { - slot_id: slot_id.map(str::to_string), - slot_alias, - resource_id: resolved_resource_id, - source_path: resolved_source_path, - load_site: load_site.to_string(), - attribution, - }); - } - - references.sort_by(|left, right| { - left.slot_id - .as_deref() - .unwrap_or("") - .cmp(right.slot_id.as_deref().unwrap_or("")) - .then_with(|| { - left.resource_id - .as_deref() - .unwrap_or("") - .cmp(right.resource_id.as_deref().unwrap_or("")) - }) - .then_with(|| { - left.source_path - .as_deref() - .unwrap_or("") - .cmp(right.source_path.as_deref().unwrap_or("")) - }) - }); - diagnostics.sort_by(|left, right| { - left.code - .cmp(&right.code) - .then_with(|| left.slot_id.cmp(&right.slot_id)) - .then_with(|| left.source_path.cmp(&right.source_path)) - }); - - let usage = LocalProjectAssetUsage { - schema_version: ASSET_USAGE_SCHEMA_VERSION.to_string(), - project_id: project_id.to_string(), - project_revision: input.project_revision, - updated_at_ms: unix_timestamp_millis(), - references, - diagnostics, - }; - write_local_project_asset_usage(root, &usage)?; - Ok(usage) -} - -fn check_source_path_file( - root: &Path, - source_path: Option<&str>, - diagnostics: &mut Vec, - slot_id: Option<&str>, - resource_id: Option<&str>, -) { - let Some(path) = source_path else { - return; - }; - if project_relative_file_exists(root, path) { - return; - } - diagnostics.push(AssetUsageDiagnostic { - code: asset_usage_missing_file_code().to_string(), - severity: ASSET_USAGE_SEVERITY_WARNING.to_string(), - message: format!("引用的素材文件不存在:{path}"), - slot_id: slot_id.map(str::to_string), - resource_id: resource_id.map(str::to_string), - source_path: Some(path.to_string()), - }); -} - -fn unix_timestamp_millis() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default() -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index f982dd010..7e79b7066 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -720,47 +720,19 @@ pub(crate) fn read_existing_manifest_for_project( /// 构造正式版本的资源绑定。 /// -/// 绑定分两部分,顺序固定: -/// -/// 1. **运行槽位绑定**:来自运行期资源使用清单的真实 `slotId → resourceId`。这是替换与 -/// 点选需要的语义——同一个资源在不同运行位置可以被单独替换。 -/// 2. **恒等绑定**:`asset:{id} → id`,与历史版本保持一致的兜底,保证未被运行槽位覆盖的 -/// 资源仍然有绑定,历史版本与旧校验语义不受影响。 -/// -/// 槽位绑定优先:同一资源若已被某个槽位绑定,不再为它补恒等绑定,避免同一资源在版本里 -/// 出现两份互相冲突的语义。 +/// 绑定即资源记录:`slotId` 恒为恒等映射 `asset:{assetId}`,`resourceId` 指向 manifest 资产 ID, +/// 顺序与 `manifest.assets` 一致。要更换某个版本使用的资源,就改这里写进该版本的绑定。 pub(crate) fn game_iteration_resource_bindings( - root: &Path, manifest: &GameCreationAppManifest, ) -> Vec { - let slot_bindings = read_local_project_asset_usage(root, Some(&manifest.project_id)) - .ok() - .flatten() - .map(|usage| usage.slot_bindings()) - .unwrap_or_default(); - - let mut bound_resource_ids: Vec = Vec::new(); - let mut bindings: Vec = Vec::new(); - for (slot_id, resource_id) in slot_bindings { - if bound_resource_ids.iter().any(|id| id == &resource_id) { - continue; - } - bound_resource_ids.push(resource_id.clone()); - bindings.push(GameIterationVersionResourceBinding { - slot_id, - resource_id, - }); - } - for asset in &manifest.assets { - if bound_resource_ids.iter().any(|id| id == &asset.id) { - continue; - } - bindings.push(GameIterationVersionResourceBinding { + manifest + .assets + .iter() + .map(|asset| GameIterationVersionResourceBinding { slot_id: format!("asset:{}", asset.id), resource_id: asset.id.clone(), - }); - } - bindings + }) + .collect() } /// Registers the first formally playable project version after the current @@ -778,7 +750,7 @@ pub(crate) fn ensure_initial_game_iteration_version_at( if !manifest.versions.is_empty() { return Ok(false); } - let resource_bindings = game_iteration_resource_bindings(root, &manifest); + let resource_bindings = game_iteration_resource_bindings(&manifest); manifest.versions.push(GameIterationVersion { version_id: format!("initial-{project_revision}"), parent_version_id: None, @@ -817,7 +789,7 @@ pub(crate) fn append_agent_game_iteration_version_at( return Err("Agent 项目版本必须绑定大于 0 的项目 revision".to_string()); } mutate_manifest_at(root, |manifest| { - let resource_bindings = game_iteration_resource_bindings(root, manifest); + let resource_bindings = game_iteration_resource_bindings(manifest); if let Some(existing) = manifest .versions .iter() @@ -1362,6 +1334,19 @@ pub(crate) fn read_or_create_manifest( pub(crate) fn mutate_manifest_at( root: &Path, mutate: impl FnOnce(&mut GameCreationAppManifest) -> Result, +) -> Result { + mutate_manifest_at_allowing_version_removals(root, &no_version_removals, mutate) +} + +/// 同 [`mutate_manifest_at`],但允许显式放行的既有项目版本从版本数组里消失。 +/// +/// `allowed_version_removals` 按**写入前的 manifest** 求值,只有它返回的版本 ID 才允许被删除; +/// 其余既有版本仍必须原样、原顺序保留,新增版本只能追加在末尾。这是"项目版本记录写入后不可 +/// 修改、删除或重排"校验唯一被放开的入口。 +pub(crate) fn mutate_manifest_at_allowing_version_removals( + root: &Path, + allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec, + mutate: impl FnOnce(&mut GameCreationAppManifest) -> Result, ) -> Result { if root.as_os_str().is_empty() { return Err("项目目录不能为空".to_string()); @@ -1377,10 +1362,37 @@ pub(crate) fn mutate_manifest_at( let _write_lock = acquire_manifest_write_lock(&manifest_path)?; let (_, mut manifest) = read_or_create_manifest(root)?; let result = mutate(&mut manifest)?; - write_manifest_locked(&manifest_path, &manifest)?; + write_manifest_locked(&manifest_path, &manifest, allowed_version_removals)?; Ok(result) } +fn no_version_removals(_manifest: &GameCreationAppManifest) -> Vec { + Vec::new() +} + +/// 项目版本数组的写入边界:既有版本只允许按显式放行的 ID 删除,其余必须原样保留且只能追加。 +fn validate_version_records_are_append_only( + existing: &[GameIterationVersion], + candidate: &[GameIterationVersion], + allowed_version_removals: &[String], +) -> Result<(), String> { + let violation = || "项目版本记录写入后不可修改、删除或重排".to_string(); + let mut candidate_versions = candidate.iter(); + for existing_version in existing + .iter() + .filter(|version| !allowed_version_removals.contains(&version.version_id)) + { + if candidate_versions.next() != Some(existing_version) { + return Err(violation()); + } + } + // 放行删除的版本只能真的消失:不得出现在追加段里被改写成"删除后重排"。 + if candidate_versions.any(|version| allowed_version_removals.contains(&version.version_id)) { + return Err(violation()); + } + Ok(()) +} + fn manifest_backup_path(path: &Path) -> PathBuf { path.with_file_name(format!( ".{}.previous", @@ -1563,12 +1575,13 @@ pub(crate) fn write_manifest( path: &Path, manifest: &GameCreationAppManifest, ) -> Result<(), String> { - write_manifest_with_lock_hook(path, manifest, || {}) + write_manifest_with_lock_hook(path, manifest, &no_version_removals, || {}) } fn write_manifest_with_lock_hook( path: &Path, manifest: &GameCreationAppManifest, + allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec, after_lock: F, ) -> Result<(), String> where @@ -1582,25 +1595,25 @@ where } let _write_lock = acquire_manifest_write_lock(path)?; after_lock(); - write_manifest_locked(path, manifest) + write_manifest_locked(path, manifest, allowed_version_removals) } -fn write_manifest_locked(path: &Path, manifest: &GameCreationAppManifest) -> Result<(), String> { +fn write_manifest_locked( + path: &Path, + manifest: &GameCreationAppManifest, + allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec, +) -> Result<(), String> { validate_game_iteration_versions(&manifest.versions) .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; let payload = serde_json::to_string_pretty(manifest) .map_err(|error| format!("序列化 manifest 失败:{error}"))?; if manifest_storage_exists(path)? { let existing = read_manifest(path)?; - if existing.versions.len() > manifest.versions.len() - || existing - .versions - .iter() - .zip(&manifest.versions) - .any(|(existing, candidate)| existing != candidate) - { - return Err("项目版本记录写入后不可修改、删除或重排".to_string()); - } + validate_version_records_are_append_only( + &existing.versions, + &manifest.versions, + &allowed_version_removals(&existing), + )?; } match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index ec49b9c93..43f967d42 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -287,7 +287,7 @@ fn concurrent_manifest_write_cannot_overwrite_an_installed_version_with_a_stale_ let (release_newer_tx, release_newer_rx) = mpsc::channel(); let newer_path = manifest_path.clone(); let newer_writer = std::thread::spawn(move || { - write_manifest_with_lock_hook(&newer_path, &newer_manifest, || { + write_manifest_with_lock_hook(&newer_path, &newer_manifest, &no_version_removals, || { newer_locked_tx .send(()) .expect("signal newer lock acquired"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs new file mode 100644 index 000000000..c43a0f8d1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs @@ -0,0 +1,386 @@ +use super::*; + +fn asset_delete_project_fixture() -> PathBuf { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "素材删除口径测试").expect("init project"); + root +} + +fn write_asset_delete_file(root: &Path, relative_path: &str, contents: &[u8]) { + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create asset delete fixture parent"); + } + fs::write(&path, contents).expect("write asset delete fixture asset"); +} + +fn generated_asset_source(task_id: &str) -> GameCreationAppAssetSource { + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: Some(task_id.to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + } +} + +/// 登记一个素材并返回它的 manifest 资产 ID。 +fn register_asset_delete_fixture_asset(root: &Path, relative_path: &str, task_id: &str) -> String { + write_asset_delete_file(root, relative_path, relative_path.as_bytes()); + register_local_asset_at( + root, + relative_path, + "character", + "image/png", + "generated", + generated_asset_source(task_id), + ) + .expect("register asset delete fixture asset"); + read_manifest_for_project(root) + .expect("read manifest after registration") + .assets + .iter() + .find(|asset| asset.local_path == relative_path) + .expect("asset delete fixture asset registered") + .id + .clone() +} + +fn project_revision(root: &Path) -> u64 { + read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision before asset delete") + .revision +} + +/// 删除走 CAS:调用方拿当前 revision 发起,模拟前端的正常调用。 +fn delete_asset_at_current_revision( + root: &Path, + asset_id: &str, + delete_referenced_versions: bool, +) -> Result { + delete_manifest_asset_at( + root, + "project-1", + project_revision(root), + asset_id, + delete_referenced_versions, + ) +} + +/// 让当前 revision 成为一个正式版本,绑定当时 manifest 里的全部素材。 +fn register_initial_version(root: &Path) { + let revision = + advance_agent_runtime_project_revision_locked(root).expect("advance project revision"); + assert!( + ensure_initial_game_iteration_version_at(root, revision).expect("create initial version"), + "initial version should be appended" + ); +} + +/// 推进 durable revision 后追加一个 Agent 正式版本。 +fn register_agent_version(root: &Path) { + let revision = + advance_agent_runtime_project_revision_locked(root).expect("advance project revision"); + assert!( + append_agent_game_iteration_version_at(root, revision).expect("append agent version"), + "agent version should be appended" + ); +} + +fn version_references_asset(version: &GameIterationVersion, asset_id: &str) -> bool { + version + .resource_bindings + .iter() + .any(|binding| binding.resource_id == asset_id) +} + +/// 恒等绑定就是资源记录:每个已登记素材一条 `asset:{assetId} → assetId`,顺序与资产表一致。 +#[test] +fn game_iteration_bindings_are_identity_records_for_every_registered_asset() { + let root = asset_delete_project_fixture(); + let first = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan"); + let second = register_asset_delete_fixture_asset(&root, "assets/scene.png", "art-scene-plan"); + let manifest = read_manifest_for_project(&root).expect("read manifest with two assets"); + + let bindings = game_iteration_resource_bindings(&manifest); + let pairs: Vec<(String, String)> = bindings + .iter() + .map(|binding| (binding.slot_id.clone(), binding.resource_id.clone())) + .collect(); + assert_eq!( + pairs, + vec![ + (format!("asset:{first}"), first.clone()), + (format!("asset:{second}"), second.clone()), + ] + ); + + register_initial_version(&root); + let versioned = read_manifest_for_project(&root).expect("read manifest with initial version"); + assert_eq!(versioned.versions[0].resource_bindings, bindings); + + fs::remove_dir_all(root).ok(); +} + +/// 未被任何版本引用的素材:直接删除登记,磁盘文件保留,revision 推进一格。 +#[test] +fn asset_delete_removes_unreferenced_asset_registration() { + let root = asset_delete_project_fixture(); + let asset_id = register_asset_delete_fixture_asset(&root, "assets/unused.png", "art-unused"); + let revision_before = project_revision(&root); + + let result = delete_asset_at_current_revision(&root, &asset_id, false) + .expect("delete unreferenced asset"); + + assert_eq!(result.asset_id, asset_id); + assert_eq!(result.local_path, "assets/unused.png"); + assert!(result.file_retained, "素材不可变,磁盘文件必须保留"); + assert_eq!( + result.committed_project_revision, + revision_before + 1, + "删除成功后项目 revision 必须推进一格" + ); + assert!( + root.join("assets/unused.png").is_file(), + "删除登记不得删除磁盘文件" + ); + let manifest = read_manifest_for_project(&root).expect("read manifest after delete"); + assert!( + manifest.assets.iter().all(|asset| asset.id != asset_id), + "素材登记应已摘掉" + ); + assert!(manifest.versions.is_empty()); + + fs::remove_dir_all(root).ok(); +} + +/// 有引用但不勾选:只删素材,引用它的版本原样保留悬空绑定,不报错、不做二次拦截。 +#[test] +fn asset_delete_keeps_referencing_versions_when_not_confirmed() { + let root = asset_delete_project_fixture(); + let asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan"); + register_initial_version(&root); + register_agent_version(&root); + + let result = delete_asset_at_current_revision(&root, &asset_id, false) + .expect("delete referenced asset without deleting versions"); + + assert!(root.join(&result.local_path).is_file()); + let manifest = read_manifest_for_project(&root).expect("read manifest after delete"); + assert!( + manifest.assets.iter().all(|asset| asset.id != asset_id), + "素材登记应已摘掉" + ); + assert_eq!(manifest.versions.len(), 2, "未勾选时两个版本都必须原样保留"); + assert!( + manifest + .versions + .iter() + .all(|version| version_references_asset(version, &asset_id)), + "版本保留指向已删素材的悬空绑定" + ); + + // 悬空绑定不阻塞后续版本追加。 + register_agent_version(&root); + assert_eq!( + read_manifest_for_project(&root) + .expect("read manifest after later version") + .versions + .len(), + 3 + ); + + fs::remove_dir_all(root).ok(); +} + +/// 有引用且勾选:素材与所有引用它的版本在同一次 manifest 写入里一起删除。 +#[test] +fn asset_delete_removes_referencing_versions_when_confirmed() { + let root = asset_delete_project_fixture(); + let kept_asset_id = + register_asset_delete_fixture_asset(&root, "assets/base.png", "art-base-plan"); + register_initial_version(&root); + let removed_asset_id = + register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan"); + register_agent_version(&root); + register_agent_version(&root); + + let before = read_manifest_for_project(&root).expect("read manifest before delete"); + assert_eq!(before.versions.len(), 3); + assert!( + !version_references_asset(&before.versions[0], &removed_asset_id), + "首个版本早于该素材,不应引用它" + ); + assert_eq!( + before + .versions + .iter() + .filter(|version| version_references_asset(version, &removed_asset_id)) + .count(), + 2 + ); + + let result = delete_asset_at_current_revision(&root, &removed_asset_id, true) + .expect("delete referencing asset together with its versions"); + + assert!(root.join(&result.local_path).is_file()); + let manifest = read_manifest_for_project(&root).expect("read manifest after delete"); + assert!(manifest.assets.iter().all(|asset| asset.id != removed_asset_id)); + assert!( + manifest.assets.iter().any(|asset| asset.id == kept_asset_id), + "未被删除的素材登记必须保留" + ); + assert_eq!( + manifest.versions.len(), + 1, + "引用被删素材的版本必须一并删除:{:?}", + manifest + .versions + .iter() + .map(|version| version.version_id.clone()) + .collect::>() + ); + assert!( + manifest + .versions + .iter() + .all(|version| !version_references_asset(version, &removed_asset_id)), + "剩余版本不得再引用被删素材" + ); + assert_eq!(manifest.versions[0].version_id, before.versions[0].version_id); + + fs::remove_dir_all(root).ok(); +} + +/// 只读投影:列出引用了该素材的版本,顺序与版本数组一致。 +#[test] +fn asset_reference_projection_lists_only_versions_using_the_asset() { + let root = asset_delete_project_fixture(); + let early_asset_id = register_asset_delete_fixture_asset(&root, "assets/base.png", "art-base"); + register_initial_version(&root); + let late_asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-hero"); + register_agent_version(&root); + let manifest = read_manifest_for_project(&root).expect("read manifest"); + + let late_references = + read_manifest_asset_references_at(&root, &late_asset_id).expect("read late asset references"); + assert_eq!(late_references.asset_id, late_asset_id); + assert_eq!(late_references.versions.len(), 1); + assert_eq!( + late_references.versions[0].version_id, + manifest.versions[1].version_id + ); + assert_eq!( + late_references.versions[0].project_revision, + manifest.versions[1].project_revision + ); + assert_eq!( + late_references.versions[0].created_at, + manifest.versions[1].created_at + ); + + let early_references = read_manifest_asset_references_at(&root, &early_asset_id) + .expect("read early asset references"); + assert_eq!( + early_references + .versions + .iter() + .map(|version| version.version_id.clone()) + .collect::>(), + manifest + .versions + .iter() + .map(|version| version.version_id.clone()) + .collect::>() + ); + + let absent_references = + read_manifest_asset_references_at(&root, "absent-asset").expect("read absent references"); + assert!(absent_references.versions.is_empty()); + + let error = read_manifest_asset_references_at(&root, " ") + .expect_err("blank asset id must be rejected"); + assert!(error.contains("assetId"), "unexpected error: {error}"); + + fs::remove_dir_all(root).ok(); +} + +/// 版本数组"只允许追加"校验只为显式确认的连带删除放开,其它写入路径仍然必须拒绝。 +#[test] +fn version_removal_stays_rejected_outside_the_confirmed_asset_delete() { + let root = asset_delete_project_fixture(); + register_asset_delete_fixture_asset(&root, "assets/base.png", "art-base"); + register_initial_version(&root); + register_agent_version(&root); + let installed = fs::read(root.join(".agent/manifest.json")).expect("read installed manifest"); + + let error = mutate_manifest_at(&root, |manifest| { + manifest.versions.pop(); + Ok(()) + }) + .expect_err("a plain manifest mutation must not delete versions"); + assert!(error.contains("不可修改、删除或重排"), "unexpected error: {error}"); + + let reordered = mutate_manifest_at(&root, |manifest| { + manifest.versions.swap(0, 1); + Ok(()) + }) + .expect_err("a plain manifest mutation must not reorder versions"); + assert!(!reordered.is_empty(), "reorder must surface a rejection"); + + assert_eq!( + fs::read(root.join(".agent/manifest.json")).expect("read manifest bytes after rejection"), + installed, + "被拒绝的写入不得改动 manifest" + ); + + fs::remove_dir_all(root).ok(); +} + +/// 删除同样走 CAS:陈旧 revision、跨项目身份与不存在的素材都被拒绝,manifest 与 revision 不变。 +#[test] +fn asset_delete_enforces_revision_and_identity_cas() { + let root = asset_delete_project_fixture(); + let asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan"); + let revision_before = project_revision(&root); + + let revision_error = delete_manifest_asset_at( + &root, + "project-1", + revision_before + 1, + &asset_id, + true, + ) + .expect_err("stale revision must be rejected"); + assert_eq!(revision_error, "project-revision-conflict"); + + let identity_error = + delete_manifest_asset_at(&root, "project-2", revision_before, &asset_id, true) + .expect_err("cross-project identity must be rejected"); + assert_eq!(identity_error, "project-identity-conflict"); + + let missing_error = delete_manifest_asset_at(&root, "project-1", revision_before, "absent", true) + .expect_err("missing asset must be rejected"); + assert!( + missing_error.contains("项目资源不存在"), + "unexpected error: {missing_error}" + ); + + let manifest = read_manifest_for_project(&root).expect("read manifest after rejected deletes"); + assert!( + manifest.assets.iter().any(|asset| asset.id == asset_id), + "失败的删除不得改动 manifest" + ); + assert_eq!( + project_revision(&root), + revision_before, + "失败的删除不得推进 revision" + ); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs deleted file mode 100644 index 1982a5292..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs +++ /dev/null @@ -1,701 +0,0 @@ -use super::*; - -/// 建一个最小项目目录,并在 `assets/` 下写入一个真实素材文件。 -fn asset_usage_project_fixture() -> PathBuf { - let root = unique_project_path(); - fs::create_dir_all(root.join("assets")).expect("create asset usage fixture assets dir"); - root -} - -fn write_asset_usage_asset(root: &Path, relative_path: &str, contents: &[u8]) { - let path = root.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create asset usage fixture asset parent"); - } - fs::write(&path, contents).expect("write asset usage fixture asset"); -} - -fn asset_usage_reference( - slot_id: Option<&str>, - resource_id: Option<&str>, - source_path: Option<&str>, -) -> SubmitLocalProjectAssetUsageReference { - SubmitLocalProjectAssetUsageReference { - slot_id: slot_id.map(str::to_string), - slot_alias: None, - resource_id: resource_id.map(str::to_string), - source_path: source_path.map(str::to_string), - load_site: None, - } -} - -fn asset_usage_submit( - project_revision: u64, - references: Vec, -) -> SubmitLocalProjectAssetUsageInput { - SubmitLocalProjectAssetUsageInput { - project_revision, - references, - } -} - -fn asset_usage_diagnostic_codes(usage: &LocalProjectAssetUsage) -> Vec<&str> { - usage - .diagnostics - .iter() - .map(|diagnostic| diagnostic.code.as_str()) - .collect() -} - -/// 已登记资源:只保留身份引用,不复制路径,且能给出槽位绑定。 -#[test] -fn asset_usage_registers_registered_slot_as_sparse_reference() { - let root = asset_usage_project_fixture(); - write_asset_usage_asset(&root, "assets/hero.png", b"hero-bytes"); - let assets = vec![LocalProjectAssetFacts { - asset_id: "hero-image".to_string(), - local_path: "assets/hero.png".to_string(), - }]; - - let mut reference = asset_usage_reference(Some("hero"), Some("hero-image"), None); - reference.load_site = Some("game/game.js:preload".to_string()); - let usage = submit_local_project_asset_usage_at( - &root, - "project-1", - &assets, - &asset_usage_submit(7, vec![reference]), - ) - .expect("register asset usage"); - - assert_eq!(usage.schema_version, ASSET_USAGE_SCHEMA_VERSION); - assert_eq!(usage.project_id, "project-1"); - assert_eq!(usage.project_revision, 7); - assert_eq!(usage.references.len(), 1); - let entry = &usage.references[0]; - assert_eq!(entry.slot_id.as_deref(), Some("hero")); - assert_eq!(entry.resource_id.as_deref(), Some("hero-image")); - assert_eq!(entry.attribution, ASSET_USAGE_ATTRIBUTION_SLOT); - assert_eq!(entry.load_site, "game/game.js:preload"); - // 关键:已登记资源不复制 manifest 已有的路径/哈希,避免第二份资源清单。 - assert_eq!(entry.source_path, None); - assert!(usage.diagnostics.is_empty()); - assert_eq!( - usage.slot_bindings(), - vec![("hero".to_string(), "hero-image".to_string())] - ); - assert_eq!( - usage.slots_by_resource_id(), - vec![( - "hero-image".to_string(), - vec!["hero".to_string()] - )] - ); - - fs::remove_dir_all(root).ok(); -} - -/// 引用了 manifest 里不存在的资源 ID:产出诊断、保留身份、降级为非槽位绑定。 -#[test] -fn asset_usage_reports_unknown_resource_id_without_blocking() { - let root = asset_usage_project_fixture(); - let usage = submit_local_project_asset_usage_at( - &root, - "project-1", - &[], - &asset_usage_submit( - 1, - vec![asset_usage_reference(Some("hero"), Some("ghost"), None)], - ), - ) - .expect("register unknown resource reference"); - - assert_eq!( - asset_usage_diagnostic_codes(&usage), - vec![asset_usage_unknown_resource_id_code()] - ); - assert_eq!( - usage.references[0].attribution, - ASSET_USAGE_ATTRIBUTION_UNREGISTERED - ); - assert_eq!(usage.references[0].resource_id.as_deref(), Some("ghost")); - // 未登记引用不产生替换绑定。 - assert!(usage.slot_bindings().is_empty()); - - fs::remove_dir_all(root).ok(); -} - -/// 未登记素材:带路径、产出诊断、路径落进 sourcePath 作为待登记线索。 -#[test] -fn asset_usage_reports_unregistered_asset_with_source_path() { - let root = asset_usage_project_fixture(); - write_asset_usage_asset(&root, "assets/unregistered.png", b"raw-bytes"); - let usage = submit_local_project_asset_usage_at( - &root, - "project-1", - &[], - &asset_usage_submit( - 2, - vec![asset_usage_reference( - Some("banner"), - None, - Some("assets/unregistered.png"), - )], - ), - ) - .expect("register unregistered reference"); - - assert_eq!( - asset_usage_diagnostic_codes(&usage), - vec![asset_usage_unregistered_asset_code()] - ); - let entry = &usage.references[0]; - assert_eq!(entry.resource_id, None); - assert_eq!(entry.source_path.as_deref(), Some("assets/unregistered.png")); - assert_eq!(entry.attribution, ASSET_USAGE_ATTRIBUTION_UNREGISTERED); - - fs::remove_dir_all(root).ok(); -} - -/// 同一个槽位注册多次:保留首次,产出 duplicate-slot 诊断。 -#[test] -fn asset_usage_keeps_first_registration_for_duplicate_slot() { - let root = asset_usage_project_fixture(); - let assets = vec![ - LocalProjectAssetFacts { - asset_id: "first".to_string(), - local_path: "assets/first.png".to_string(), - }, - LocalProjectAssetFacts { - asset_id: "second".to_string(), - local_path: "assets/second.png".to_string(), - }, - ]; - write_asset_usage_asset(&root, "assets/first.png", b"first"); - write_asset_usage_asset(&root, "assets/second.png", b"second"); - - let usage = submit_local_project_asset_usage_at( - &root, - "project-1", - &assets, - &asset_usage_submit( - 1, - vec![ - asset_usage_reference(Some("hero"), Some("first"), None), - asset_usage_reference(Some("hero"), Some("second"), None), - ], - ), - ) - .expect("register duplicate slot"); - - assert_eq!(usage.references.len(), 1); - assert_eq!(usage.references[0].resource_id.as_deref(), Some("first")); - assert_eq!( - asset_usage_diagnostic_codes(&usage), - vec![asset_usage_duplicate_slot_code()] - ); - - fs::remove_dir_all(root).ok(); -} - -/// 引用的文件不存在:产出 missing-file 诊断,但仍然登记,不阻断。 -#[test] -fn asset_usage_reports_missing_file_reference() { - let root = asset_usage_project_fixture(); - let usage = submit_local_project_asset_usage_at( - &root, - "project-1", - &[], - &asset_usage_submit( - 3, - vec![asset_usage_reference( - Some("ghost-slot"), - None, - Some("assets/missing.png"), - )], - ), - ) - .expect("register missing file reference"); - - // 未登记素材 + 文件不存在是两条互补诊断,按 code 排序输出。 - assert_eq!( - asset_usage_diagnostic_codes(&usage), - vec![ - asset_usage_missing_file_code(), - asset_usage_unregistered_asset_code() - ] - ); - assert_eq!(usage.references.len(), 1); - - fs::remove_dir_all(root).ok(); -} - -/// 没有槽位的引用:记 unattributed,不产生替换绑定,不产出诊断。 -#[test] -fn asset_usage_records_unattributed_reference_without_diagnostic() { - let root = asset_usage_project_fixture(); - let assets = vec![LocalProjectAssetFacts { - asset_id: "style-doc".to_string(), - local_path: "memory/style.md".to_string(), - }]; - write_asset_usage_asset(&root, "memory/style.md", b"# style"); - - let usage = submit_local_project_asset_usage_at( - &root, - "project-1", - &assets, - &asset_usage_submit( - 4, - vec![asset_usage_reference(None, Some("style-doc"), None)], - ), - ) - .expect("register unattributed reference"); - - assert!(usage.diagnostics.is_empty()); - assert_eq!( - usage.references[0].attribution, - ASSET_USAGE_ATTRIBUTION_UNATTRIBUTED - ); - assert!(usage.slot_bindings().is_empty()); - - fs::remove_dir_all(root).ok(); -} - -/// 幂等重放:同一 revision 重复登记结果一致。 -#[test] -fn asset_usage_replay_at_same_revision_is_idempotent() { - let root = asset_usage_project_fixture(); - let assets = vec![LocalProjectAssetFacts { - asset_id: "hero-image".to_string(), - local_path: "assets/hero.png".to_string(), - }]; - write_asset_usage_asset(&root, "assets/hero.png", b"hero"); - - let input = asset_usage_submit( - 5, - vec![asset_usage_reference(Some("hero"), Some("hero-image"), None)], - ); - let first = submit_local_project_asset_usage_at(&root, "project-1", &assets, &input) - .expect("first asset usage submission"); - let second = submit_local_project_asset_usage_at(&root, "project-1", &assets, &input) - .expect("replayed asset usage submission"); - - assert_eq!(first.references, second.references); - assert_eq!(first.diagnostics, second.diagnostics); - assert_eq!(first.project_revision, second.project_revision); - - fs::remove_dir_all(root).ok(); -} - -/// revision 回退被拒绝:清单只允许随项目 revision 前进。 -#[test] -fn asset_usage_rejects_revision_regression() { - let root = asset_usage_project_fixture(); - submit_local_project_asset_usage_at( - &root, - "project-1", - &[], - &asset_usage_submit( - 9, - vec![asset_usage_reference( - Some("hero"), - None, - Some("assets/missing.png"), - )], - ), - ) - .expect("register forward revision"); - - let error = submit_local_project_asset_usage_at( - &root, - "project-1", - &[], - &asset_usage_submit(8, vec![]), - ) - .expect_err("regressed revision must be rejected"); - assert!(error.contains("小于已登记 revision"), "unexpected: {error}"); - - fs::remove_dir_all(root).ok(); -} - -/// projectId 不匹配的清单按"尚未登记"处理,不串项目。 -#[test] -fn asset_usage_read_ignores_other_project_identity() { - let root = asset_usage_project_fixture(); - submit_local_project_asset_usage_at( - &root, - "project-1", - &[], - &asset_usage_submit( - 1, - vec![asset_usage_reference( - Some("hero"), - None, - Some("assets/missing.png"), - )], - ), - ) - .expect("register project scoped usage"); - - assert!( - read_local_project_asset_usage(&root, Some("project-2")) - .expect("read other project usage") - .is_none() - ); - assert!( - read_local_project_asset_usage(&root, Some("project-1")) - .expect("read own project usage") - .is_some() - ); - - fs::remove_dir_all(root).ok(); -} - -/// 清单缺失时读取返回 None,而不是报错。 -#[test] -fn asset_usage_read_returns_none_when_absent() { - let root = asset_usage_project_fixture(); - assert!( - read_local_project_asset_usage(&root, Some("project-1")) - .expect("read absent asset usage") - .is_none() - ); - fs::remove_dir_all(root).ok(); -} - -/// 版本绑定升级:有运行槽位时用真实 `slotId → resourceId`, -/// 未覆盖的资源补恒等绑定,保证历史语义与旧校验不受影响。 -#[test] -fn game_iteration_bindings_prefer_runtime_slots_and_keep_identity_fallback() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "版本绑定升级测试").expect("init project"); - write_asset_usage_asset(&root, "assets/hero.png", b"hero"); - write_asset_usage_asset(&root, "memory/design.md", b"# design"); - register_local_asset_at( - &root, - "assets/hero.png", - "character", - "image/png", - "generated", - generated_asset_source("art-asset-plan"), - ) - .expect("register hero asset"); - register_local_asset_at( - &root, - "memory/design.md", - "document", - "text/markdown", - "generated", - generated_asset_source("design-foundation"), - ) - .expect("register design asset"); - let manifest = read_manifest_for_project(&root).expect("read manifest with registered assets"); - assert_eq!(manifest.assets.len(), 2, "fixture registers two assets"); - - // 无使用清单:绑定保持恒等,历史语义不变。 - let identity_bindings = game_iteration_resource_bindings(&root, &manifest); - let identity_pairs: Vec<(String, String)> = identity_bindings - .iter() - .map(|binding| (binding.slot_id.clone(), binding.resource_id.clone())) - .collect(); - assert_eq!(identity_pairs.len(), 2); - for pair in &identity_pairs { - assert_eq!( - pair.0, - format!("asset:{}", pair.1), - "缺少使用清单时必须保持恒等绑定:{pair:?}" - ); - } - - // 登记运行槽位后:槽位绑定优先,未覆盖资源仍补恒等绑定。 - let assets: Vec = manifest - .assets - .iter() - .map(|asset| LocalProjectAssetFacts { - asset_id: asset.id.clone(), - local_path: asset.local_path.clone(), - }) - .collect(); - let hero_asset_id = manifest - .assets - .iter() - .find(|asset| asset.local_path == "assets/hero.png") - .expect("hero asset registered") - .id - .clone(); - - submit_local_project_asset_usage_at( - &root, - &manifest.project_id, - &assets, - &asset_usage_submit( - 1, - vec![ - asset_usage_reference(Some("hero"), Some(&hero_asset_id), None), - asset_usage_reference(Some("title-hero"), Some(&hero_asset_id), None), - ], - ), - ) - .expect("register runtime slots"); - - let slot_bindings = game_iteration_resource_bindings(&root, &manifest); - let slot_pairs: Vec<(String, String)> = slot_bindings - .iter() - .map(|binding| (binding.slot_id.clone(), binding.resource_id.clone())) - .collect(); - assert_eq!( - slot_pairs.len(), - 2, - "同一资源的第二个槽位不重复绑定,且未覆盖资源保留恒等绑定:{slot_pairs:?}" - ); - assert_eq!(slot_pairs[0].0, "hero"); - assert_eq!(slot_pairs[0].1, hero_asset_id); - assert!( - slot_pairs[1].0.starts_with("asset:"), - "未被运行槽位覆盖的资源必须保留恒等绑定兜底:{slot_pairs:?}" - ); - - fs::remove_dir_all(root).ok(); -} - -fn generated_asset_source(task_id: &str) -> GameCreationAppAssetSource { - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Generated, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: Some(task_id.to_string()), - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - } -} - -/// 建一个项目并登记一个可删除的素材,返回(root, assetId)。 -fn asset_usage_deletable_fixture() -> (PathBuf, String) { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "素材删除规则测试").expect("init project"); - write_asset_usage_asset(&root, "assets/deletable.png", b"deletable"); - register_local_asset_at( - &root, - "assets/deletable.png", - "character", - "image/png", - "generated", - generated_asset_source("art-asset-plan"), - ) - .expect("register deletable asset"); - let manifest = read_manifest_for_project(&root).expect("read manifest"); - let asset_id = manifest - .assets - .iter() - .find(|asset| asset.local_path == "assets/deletable.png") - .expect("deletable asset registered") - .id - .clone(); - (root, asset_id) -} - -/// 用当前项目 revision 调一次删除,模拟前端拿最新 revision 发起的正常调用。 -fn delete_asset_at_current_revision( - root: &Path, - asset_id: &str, -) -> Result { - let revision = read_game_creator_agent_runtime_project_revision(root) - .expect("read project revision before delete") - .revision; - delete_manifest_asset_at(root, "project-1", revision, asset_id) -} - -/// 恒等绑定只是版本创建时的兼容兜底,不阻止删除;删除只摘掉登记、保留磁盘文件。 -#[test] -fn asset_delete_allows_asset_bound_only_by_identity_fallback() { - let (root, asset_id) = asset_usage_deletable_fixture(); - // 先造一个版本:它会给当时所有资源补恒等绑定,这正是要验证"不阻止删除"的场景。 - assert!( - ensure_initial_game_iteration_version_at(&root, 1).expect("create initial version"), - "initial version should be appended" - ); - let revision_before = read_game_creator_agent_runtime_project_revision(&root) - .expect("read revision before delete") - .revision; - - let result = delete_asset_at_current_revision(&root, &asset_id).expect("delete asset"); - assert_eq!(result.asset_id, asset_id); - assert_eq!(result.local_path, "assets/deletable.png"); - assert!(result.file_retained, "素材不可变,磁盘文件必须保留"); - assert_eq!( - result.committed_project_revision, - revision_before + 1, - "删除成功后项目 revision 必须推进一格" - ); - assert!( - root.join("assets/deletable.png").is_file(), - "删除登记不得删除磁盘文件" - ); - let manifest = read_manifest_for_project(&root).expect("read manifest after delete"); - assert!( - manifest.assets.iter().all(|asset| asset.id != asset_id), - "素材登记应已摘掉" - ); - // 历史版本记录本身不变,回放语义不受影响。 - assert_eq!(manifest.versions.len(), 1); - assert!(manifest.versions[0] - .resource_bindings - .iter() - .any(|binding| binding.resource_id == asset_id)); - - fs::remove_dir_all(root).ok(); -} - -/// 被真实运行槽位绑定过的素材禁止删除,错误里要能看出是哪个版本、哪个槽位。 -#[test] -fn asset_delete_rejects_asset_bound_to_runtime_slot() { - let (root, asset_id) = asset_usage_deletable_fixture(); - submit_local_project_asset_usage_at( - &root, - "project-1", - &[LocalProjectAssetFacts { - asset_id: asset_id.clone(), - local_path: "assets/deletable.png".to_string(), - }], - &asset_usage_submit( - 1, - vec![asset_usage_reference(Some("hero"), Some(&asset_id), None)], - ), - ) - .expect("register runtime slot"); - assert!( - ensure_initial_game_iteration_version_at(&root, 1).expect("create initial version"), - "initial version should be appended" - ); - - let error = delete_asset_at_current_revision(&root, &asset_id) - .expect_err("asset bound to a runtime slot must not be deleted"); - assert!(error.contains("运行槽位"), "unexpected error: {error}"); - assert!(error.contains("hero"), "error must name the slot: {error}"); - assert!( - read_manifest_for_project(&root) - .expect("read manifest after rejected delete") - .assets - .iter() - .any(|asset| asset.id == asset_id), - "被拒绝时素材登记必须原样保留" - ); - - fs::remove_dir_all(root).ok(); -} - -/// 删除素材时同步清理使用清单里指向它的悬空引用。 -#[test] -fn asset_delete_cleans_usage_references_to_removed_asset() { - let (root, asset_id) = asset_usage_deletable_fixture(); - // 无槽位引用(unattributed)不阻止删除,但删除后必须被清理。 - submit_local_project_asset_usage_at( - &root, - "project-1", - &[LocalProjectAssetFacts { - asset_id: asset_id.clone(), - local_path: "assets/deletable.png".to_string(), - }], - &asset_usage_submit( - 1, - vec![asset_usage_reference(None, Some(&asset_id), None)], - ), - ) - .expect("register unattributed reference"); - - delete_asset_at_current_revision(&root, &asset_id).expect("delete asset"); - let usage = read_local_project_asset_usage(&root, Some("project-1")) - .expect("read usage after delete") - .expect("usage file should remain"); - assert!( - usage - .references - .iter() - .all(|reference| reference.resource_id.as_deref() != Some(asset_id.as_str())), - "指向已删除素材的引用必须清理:{:?}", - usage.references - ); - - fs::remove_dir_all(root).ok(); -} - -/// 删除同样走 CAS:陈旧 revision 与跨项目身份都被拒绝,且 manifest 与 revision 原样不变。 -#[test] -fn asset_delete_enforces_revision_and_identity_cas() { - let (root, asset_id) = asset_usage_deletable_fixture(); - let revision_before = read_game_creator_agent_runtime_project_revision(&root) - .expect("read revision before failed delete") - .revision; - - let revision_error = - delete_manifest_asset_at(&root, "project-1", revision_before + 1, &asset_id) - .expect_err("stale revision must be rejected"); - assert_eq!(revision_error, "project-revision-conflict"); - - let identity_error = - delete_manifest_asset_at(&root, "project-2", revision_before, &asset_id) - .expect_err("cross-project identity must be rejected"); - assert_eq!(identity_error, "project-identity-conflict"); - - let missing_error = delete_manifest_asset_at(&root, "project-1", revision_before, "absent") - .expect_err("missing asset must be rejected"); - assert!( - missing_error.contains("项目资源不存在"), - "unexpected error: {missing_error}" - ); - - let manifest = read_manifest_for_project(&root).expect("read manifest after rejected deletes"); - assert!( - manifest.assets.iter().any(|asset| asset.id == asset_id), - "失败的删除不得改动 manifest" - ); - assert_eq!( - read_game_creator_agent_runtime_project_revision(&root) - .expect("read revision after rejected deletes") - .revision, - revision_before, - "失败的删除不得推进 revision" - ); - - fs::remove_dir_all(root).ok(); -} - -/// 落盘正文只包含引用与诊断,不含 manifest 已有的资源元数据。 -#[test] -fn asset_usage_persisted_payload_omits_manifest_owned_metadata() { - let root = asset_usage_project_fixture(); - let assets = vec![LocalProjectAssetFacts { - asset_id: "hero-image".to_string(), - local_path: "assets/hero.png".to_string(), - }]; - write_asset_usage_asset(&root, "assets/hero.png", b"hero"); - let mut reference = asset_usage_reference(Some("hero"), Some("hero-image"), None); - reference.load_site = Some("game/game.js:preload".to_string()); - submit_local_project_asset_usage_at( - &root, - "project-1", - &assets, - &asset_usage_submit(1, vec![reference]), - ) - .expect("register usage for payload inspection"); - - let payload = fs::read_to_string(root.join(asset_usage_relative_path())) - .expect("read persisted asset usage payload"); - for forbidden in ["sha256", "mediaType", "localPath", "kind"] { - assert!( - !payload.contains(forbidden), - "asset usage payload must not duplicate manifest field {forbidden}: {payload}" - ); - } - for required in ["slotId", "resourceId", "loadSite", "attribution"] { - assert!( - payload.contains(required), - "asset usage payload missing {required}: {payload}" - ); - } - - fs::remove_dir_all(root).ok(); -} 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 1a7e0b0a3..b0c205d00 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 @@ -6064,8 +6064,8 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { fs::remove_dir_all(root).ok(); } +mod asset_delete; mod collaboration; -mod asset_usage; mod command_runtime; pub(crate) mod configuration; mod goal;