diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs index b68f5175b..a494fcbb2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs @@ -6,27 +6,35 @@ use shared_contracts::game_creation_app::{ GameCreationAppAssetManifestEntry, }; -/// 版本级资源替换(PRD §3.2 / §5.3)。 +/// 版本级资源替换(**直接替换**口径)。 /// -/// 落盘语义:**改 manifest 绑定,但落在追加的新版本上** —— 不原地修改既有版本、不动资源文件、 -/// 不建文件副本。一次 CAS 写入里完成三件事: -/// 1. 追加一条 `createdReason = resource-replacement` 的新版本,`parentVersionId` 指向被替换的源版本; -/// 2. 新版本的 `resourceBindings` = 源版本绑定集合**去掉源素材**,并保证**替换素材在集合里** -/// (替换素材是源版本创建之后才登记时,按源素材原来的位置插回,顺序稳定); -/// 3. 替换前后的资源身份由两份不可变记录 + `parentVersionId` + `createdReason` 确定: -/// 源素材的 `asset:{sourceResourceId}` 绑定在父版本里存在、在子版本里消失;替换素材的绑定在 -/// 子版本里存在。读侧差异:`父 − 子` 恰好是 `{sourceResourceId}`,`子 − 父` 是 -/// `{replacementResourceId}`(当替换素材在父版本创建时就已登记、因此已在父绑定里时, -/// `子 − 父` 为空集)。 -/// **已知限制**:后一种情况下版本记录无法单独反推"是哪次替换摘掉了源素材",要无歧义地 -/// 持久化配对就得给 `GameIterationVersion` 增字段(跨端契约变更);本切片按 PRD §5.4 -/// 的版本字段表口径不新增字段,因此不声称这一点是完整的前后身份记录。 +/// 落盘语义:**改 manifest 里该版本的绑定,指向另一个已登记资源** —— +/// 不追加新版本、不删除版本、不动资源文件、不建文件副本。 /// -/// 只追加语义不变:本模块走 `mutate_manifest_at`(默认空放行集合),从不调用 -/// `mutate_manifest_at_allowing_version_removals`;写入边界继续由 `validate_game_iteration_versions` -/// 与 `validate_version_records_are_append_only` 拦截修改 / 删除 / 重排。 +/// 写入通道是 `project/manifest.rs` 的 `mutate_manifest_allowing_version_binding_rewrites`: +/// 它只放行"显式列出的版本改写自己的 `resourceBindings`",不增删、不重排、不改其它字段。 +/// 因此本命令既表达了"这个版本现在用另一个素材",又没有把版本记录的只追加保证打穿。 +/// +/// 恒等绑定口径(`slotId` 恒为 `asset:{resourceId}`)下的绑定改写 = **源素材从这个版本的绑定 +/// 集合里消失 + 替换素材出现在这个集合里**: +/// - 替换素材是源版本创建之后才登记时,按源素材原来的位置插回,保持顺序稳定; +/// - 替换素材早就登记(因此早就在集合里)时只做摘除 —— 不能"把源素材那条槽位改写成替换素材", +/// 那会撞「资源槽位重复」。 +/// +/// 准入与提示: +/// - **硬门禁**只有 `categoryEqual` 与 `subtypeEqual`(拦"把角色图换成背景音乐"这类明显错误); +/// - `sizeSpecEqual` 只作为**提示**、不拒绝:它的完整判据今天不存在(manifest 资产表没有 +/// `width / height / durationMs`,实际只等于"媒体格式相等"),硬拦会误拒 `png ↔ webp` 这种 +/// 直接替换里最常见的正常需求。 +/// +/// 已知代价:直接替换形态下**没有可回溯的替换历史**。替换前的绑定身份只存在于 +/// ① `asset.version_binding.replace` 审计记录 ② manifest 的 `.previous` 恢复副本。 +/// 需要"某个版本历史上被换过几次、换成过什么"时要另立切片(例如 PRD §3.2 的版本级替换)。 const VERSION_REPLACEMENT_MAX_ID_CHARS: usize = 512; +/// 直接替换的审计记录类型,与 `asset.register` / `asset.update` 同族。 +const VERSION_BINDING_REPLACE_RECORD_TYPE: &str = "asset.version_binding.replace"; + #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ReadLocalProjectVersionReplacementCandidatesInput { @@ -35,7 +43,7 @@ pub(crate) struct ReadLocalProjectVersionReplacementCandidatesInput { pub(crate) source_resource_id: String, } -/// PRD §5.3 的三项兼容性。三项必须同时为 `true` 才允许创建下一版本。 +/// 三项兼容性。只有前两项是硬门禁;`size_spec_equal` 仅用于提示。 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ProjectVersionResourceCompatibility { @@ -45,18 +53,21 @@ pub(crate) struct ProjectVersionResourceCompatibility { } impl ProjectVersionResourceCompatibility { - fn all_compatible(self) -> bool { - self.category_equal && self.subtype_equal && self.size_spec_equal - } - - /// 不兼容原因:按 PRD §5.3 的字段顺序报告第一项不等的维度。 - fn blocked_reason(self) -> Option<&'static str> { + /// 硬门禁:分类与类型。任一不等即拒绝替换。 + fn blocking_reason(self) -> Option<&'static str> { if !self.category_equal { Some("分类不同") } else if !self.subtype_equal { Some("类型不同") - } else if !self.size_spec_equal { - Some("尺寸规格不同") + } else { + None + } + } + + /// 提示:通过硬门禁、但尺寸规格判据不等时给出(不拒绝)。 + fn warning(self) -> Option<&'static str> { + if self.blocking_reason().is_none() && !self.size_spec_equal { + Some("格式与源素材不同") } else { None } @@ -67,10 +78,13 @@ impl ProjectVersionResourceCompatibility { #[serde(rename_all = "camelCase")] pub(crate) struct LocalProjectVersionReplacementCandidate { pub(crate) resource_id: String, + /// 能否选中:等价于"没有硬门禁原因"。 pub(crate) compatible: bool, pub(crate) compatibility: ProjectVersionResourceCompatibility, - /// 不兼容原因;兼容时为 `null`。候选**渲染但禁用**,不用隐藏伪装成"素材不存在"。 + /// 硬门禁原因;通过时为 `null`。候选**渲染但禁用**,不用隐藏伪装成"素材不存在"。 pub(crate) blocked_reason: Option<&'static str>, + /// 非阻断提示(当前只有"格式与源素材不同")。 + pub(crate) warning: Option<&'static str>, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -92,26 +106,28 @@ pub(crate) struct ReplaceLocalProjectVersionResourceInput { pub(crate) replacement_resource_id: String, } -/// PRD §5.3 的 `ProjectVersionResourceReplacement`。 +/// 这一次替换的记录:哪个版本的哪个绑定指向改成了哪个素材。 +/// +/// 直接替换不产生新版本,所以没有 `parentVersionId`、也没有可推的前后版本对。 #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ProjectVersionResourceReplacement { - pub(crate) source_version_id: String, + pub(crate) version_id: String, pub(crate) source_resource_id: String, pub(crate) replacement_resource_id: String, pub(crate) compatibility: ProjectVersionResourceCompatibility, + pub(crate) warning: Option<&'static str>, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ReplaceLocalProjectVersionResourceResult { pub(crate) version_id: String, - pub(crate) parent_version_id: String, pub(crate) committed_project_revision: u64, pub(crate) replacement: ProjectVersionResourceReplacement, } -/// 槽位 ID 恒等于 `asset:{resourceId}`(v3 恒等绑定口径),替换时必须同步换掉。 +/// 槽位 ID 恒等于 `asset:{resourceId}`(v3 恒等绑定口径),绑定改写时同步维护。 fn version_binding_slot_id(resource_id: &str) -> String { format!("asset:{resource_id}") } @@ -120,8 +136,7 @@ fn version_binding_slot_id(resource_id: &str) -> String { /// /// 只做「声明值归一 + 缺声明时按扩展名回退」:去参数段(`; charset=...`)、去首尾空白、 /// 转小写,并把 `image/jpg` 归到 canonical 的 `image/jpeg`(与 `assets.rs` 的 -/// `CanvasImageFormat::from_media_type` 同口径)。这里**不**做全量 MIME 规范化表 —— 尺寸规格 -/// 判据比较的是"格式身份",不是完整 MIME 语义。 +/// `CanvasImageFormat::from_media_type` 同口径)。**不**做全量 MIME 规范化表。 fn canonical_asset_media_format(asset: &GameCreationAppAssetManifestEntry) -> String { let declared = asset .media_type @@ -159,24 +174,21 @@ fn asset_known_duration_ms(asset: &GameCreationAppAssetManifestEntry) -> Option< asset.image_sequence_duration_ms } -/// PRD §5.3 的 `sizeSpecEqual`。 +/// `sizeSpecEqual` 判据(**只提示、不拒绝**)。 /// -/// 判据 = 规范化媒体格式相等 **且**「任一方有事实的维度必须相等」(双方都无事实的维度不阻断)。 +/// 判据 = 规范化媒体格式相等 **且**「任一方有事实的维度必须相等」(双方都无事实的维度不参与)。 /// -/// **诚实标注(刻意接受的降级)**:当前 manifest 资产表**没有** `width / height / durationMs` -/// 字段(`GameCreationAppAssetManifestEntry` 只有 `imageSequenceFrames[].width|height` 与 -/// `imageSequenceDurationMs`),而现役写入侧(上传、派生、画板回传、生成回流)几乎全部写 -/// `imageSequenceFrames: None`,所以这条判据在实际数据上**退化为"媒体格式相等"**: -/// `png ↔ webp` 会被判为尺寸规格不同而拒绝。 -/// -/// 这不是完整实现。若产品要求「同分类同类型、跨图片格式也能替换」,必须改走"给 manifest asset -/// 增 width / height / durationMs 并在写入侧回填"的方案(跨端契约变更),届时本判据改为消费落盘尺寸 -/// 事实并把"缺失即未知"的口径写进契约;在尺寸事实落盘之前,不得声称本项是完整尺寸规格比较。 +/// **诚实标注(刻意接受的降级)**:manifest 资产表没有 `width / height / durationMs` 字段, +/// 且现役写入侧(上传、派生、画板回传、生成回流)几乎全部写 `imageSequenceFrames: None`, +/// 所以这条判据在真实数据上**退化为"媒体格式相等"**。正因为它不完整,本轮**不用它拒绝**替换, +/// 只在弹窗里提示"格式与源素材不同";要变成硬判据,必须先给 manifest asset 增尺寸字段并在写入侧 +/// 回填(跨端契约变更)。 fn version_resource_size_spec_equal( source: &GameCreationAppAssetManifestEntry, replacement: &GameCreationAppAssetManifestEntry, ) -> bool { - let format_equal = canonical_asset_media_format(source) == canonical_asset_media_format(replacement); + let format_equal = + canonical_asset_media_format(source) == canonical_asset_media_format(replacement); let size_equal = match ( asset_known_frame_size(source), asset_known_frame_size(replacement), @@ -197,8 +209,8 @@ fn version_resource_size_spec_equal( /// 三项兼容性判据(后端是权威判据,前端只做呈现)。 /// /// - `categoryEqual`:功能分类相等,用**读时自愈**口径(PRD §5.3「分类取值优先级」收口); -/// - `subtypeEqual`:canonical `kind` 相等(别名表在 `shared-contracts`); -/// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`] 的降级标注。 +/// - `subtypeEqual`:canonical `kind` 相等(别名表在 `shared-contracts`)——以上两项是硬门禁; +/// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`],只作提示。 fn version_resource_compatibility( source: &GameCreationAppAssetManifestEntry, replacement: &GameCreationAppAssetManifestEntry, @@ -280,7 +292,7 @@ fn manifest_asset<'a>( .ok_or_else(|| format!("项目资源不存在:{resource_id}")) } -/// 读取源素材在当前 manifest 里可用的替换候选,并给出后端权威兼容性结论。 +/// 读取源素材在当前 manifest 里可用的替换候选,并给出后端权威的兼容性结论与提示。 /// /// 只读:不改 manifest、不推进 revision。候选按 `manifest.assets` 顺序返回,源素材自身排除。 pub(crate) fn read_local_project_version_replacement_candidates_at( @@ -303,9 +315,10 @@ pub(crate) fn read_local_project_version_replacement_candidates_at( let compatibility = version_resource_compatibility(source_asset, asset); LocalProjectVersionReplacementCandidate { resource_id: asset.id.clone(), - compatible: compatibility.all_compatible(), + compatible: compatibility.blocking_reason().is_none(), compatibility, - blocked_reason: compatibility.blocked_reason(), + blocked_reason: compatibility.blocking_reason(), + warning: compatibility.warning(), } }) .collect(); @@ -317,16 +330,17 @@ pub(crate) fn read_local_project_version_replacement_candidates_at( }) } -/// 用 `replacement_resource_id` 替换源版本引用的 `source_resource_id`,追加下一迭代版本。 +/// 把源版本引用的 `source_resource_id` **直接替换**成 `replacement_resource_id`。 /// /// 语义与拒绝路径: -/// - 可运行版本不可变:既有版本记录一个字节都不改,新记录只能追加在末尾; -/// - 三项兼容性必须同时为 `true`,否则拒绝并给出不等维度,**不做假成功**; +/// - **不追加新版本**:只改该版本的绑定集合,其余版本整条不动、版本数量不变; +/// - 硬门禁:分类不同 / 类型不同 → 拒绝并说明哪一项不等,**不做假成功**; /// - 源版本不存在 / 源版本未绑定该素材 / 替换素材未登记 / 替换素材与源素材相同 → 拒绝; /// - CAS:`expectedProjectId` 与 `expectedProjectRevision` 必须与锁内读到的事实一致, /// 任何拒绝都保证 manifest 与 revision 不变; -/// - 成功后推进一次项目 revision,且新版本的 `projectRevision` 必须等于推进后的值 -/// (`validate_game_iteration_versions` 要求子版本修订严格大于父版本)。 +/// - 成功后推进一次项目 revision(改绑定属于 `versions` 变化,跨面快照门禁要求 revision 前进), +/// 并追加一条 `asset.version_binding.replace` 审计。审计写失败会把错误报出,但 manifest 已落盘 +/// (与 `asset.register` 同口径,不做回滚)。 pub(crate) fn replace_local_project_version_resource_at( root: &Path, expected_project_id: &str, @@ -345,7 +359,8 @@ pub(crate) fn replace_local_project_version_resource_at( return Err("替换素材 expectedProjectId 不能为空".to_string()); } let source_version_id = normalized_version_id(source_version_id)?.to_string(); - let source_resource_id = normalized_resource_id(source_resource_id, "sourceResourceId")?.to_string(); + let source_resource_id = + normalized_resource_id(source_resource_id, "sourceResourceId")?.to_string(); let replacement_resource_id = normalized_resource_id(replacement_resource_id, "replacementResourceId")?.to_string(); if source_resource_id == replacement_resource_id { @@ -368,115 +383,103 @@ pub(crate) fn replace_local_project_version_resource_at( .checked_add(1) .ok_or_else(|| "项目 revision 已达到上限".to_string())?; - let mut appended: Option<(String, ProjectVersionResourceCompatibility)> = None; - mutate_manifest_at(root, |manifest| { - // 锁内复核 CAS:项目写锁已持有,此处再读一次 durable revision,把"读 revision → 写 manifest" - // 之间的窗口收干,任何漂移都在写入前失败关闭。 - if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision - { - return Err("project-revision-conflict".to_string()); - } - if manifest.project_id != expected_project_id { - return Err("project-identity-conflict".to_string()); - } - let source_version = replacement_source_version(manifest, &source_version_id)?; - let source_version_id_for_child = source_version.version_id.clone(); - let source_version_revision = source_version.project_revision; - if target_revision <= source_version_revision { - return Err(format!( - "替换版本 revision {target_revision} 必须大于源版本 revision {source_version_revision}" - )); - } - require_source_binding(source_version, &source_resource_id)?; - let source_asset = manifest_asset(manifest, &source_resource_id)?.clone(); - let replacement_asset = manifest_asset(manifest, &replacement_resource_id)?.clone(); - let compatibility = version_resource_compatibility(&source_asset, &replacement_asset); - if !compatibility.all_compatible() { - return Err(format!( - "resource-replacement-incompatible:{}", - compatibility - .blocked_reason() - .unwrap_or("替换兼容性未通过") - )); - } + let mut applied: Option = None; + mutate_manifest_allowing_version_binding_rewrites( + root, + &|_manifest| vec![source_version_id.clone()], + |manifest| { + // 锁内复核 CAS:项目写锁已持有,此处再读一次 durable revision,把"读 revision → + // 写 manifest"之间的窗口收干,任何漂移都在写入前失败关闭。 + if read_game_creator_agent_runtime_project_revision(root)?.revision + != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + if manifest.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let version_index = manifest + .versions + .iter() + .position(|version| version.version_id == source_version_id) + .ok_or_else(|| format!("源项目版本不存在:{source_version_id}"))?; + require_source_binding(&manifest.versions[version_index], &source_resource_id)?; + let source_asset = manifest_asset(manifest, &source_resource_id)?.clone(); + let replacement_asset = manifest_asset(manifest, &replacement_resource_id)?.clone(); + let compatibility = version_resource_compatibility(&source_asset, &replacement_asset); + if let Some(reason) = compatibility.blocking_reason() { + return Err(format!("resource-replacement-incompatible:{reason}")); + } - let source_slot_id = version_binding_slot_id(&source_resource_id); - let Some(source_index) = source_version - .resource_bindings - .iter() - .position(|binding| { - binding.slot_id == source_slot_id && binding.resource_id == source_resource_id - }) - else { - return Err(format!( - "源版本未绑定该素材:{source_version_id_for_child} · {source_resource_id}" - )); - }; - // 子版本绑定 = 父版本绑定去掉源素材,并保证替换素材在集合里。 - // - // 恒等绑定口径下,一个版本的 `resourceBindings` 是该版本**使用的素材集合** - // (创建时随清单冻结),不是"哪个位置用了它"的槽位表。所以"替换"落盘为: - // 源素材从这个集合里消失 + 替换素材出现在这个集合里。不能把源素材那条槽位改写成 - // 替换素材 —— 替换素材若在该版本创建时就已登记,它本来就已在集合中,改槽位会撞 - // 「资源槽位重复」。 - // - // 替换素材是版本创建之后才登记时(最常见的真实路径:先生成/上传了更好的素材再替换), - // 按源素材原来的位置插回,保持槽位顺序稳定,子父差异恰好一增一减。 - let mut resource_bindings: Vec = source_version - .resource_bindings - .iter() - .filter(|binding| { - !(binding.slot_id == source_slot_id && binding.resource_id == source_resource_id) - }) - .cloned() - .collect(); - if !resource_bindings - .iter() - .any(|binding| binding.resource_id == replacement_resource_id) - { - resource_bindings.insert( - source_index.min(resource_bindings.len()), - GameIterationVersionResourceBinding { - slot_id: version_binding_slot_id(&replacement_resource_id), - resource_id: replacement_resource_id.clone(), - }, - ); - } - - let version_id = format!("replace-{target_revision}"); - manifest.versions.push(GameIterationVersion { - version_id: version_id.clone(), - parent_version_id: Some(source_version_id_for_child), - project_revision: target_revision, - resource_bindings, - created_reason: GameIterationVersionCreatedReason::ResourceReplacement, - created_at: unix_timestamp(), - edit_prompt: None, - }); - appended = Some((version_id, compatibility)); - Ok(()) - })?; + // 绑定改写 = 源素材从该版本的绑定集合里消失 + 保证替换素材在集合里。 + let slot_id = version_binding_slot_id(&source_resource_id); + let version = &mut manifest.versions[version_index]; + let source_index = version + .resource_bindings + .iter() + .position(|binding| { + binding.slot_id == slot_id && binding.resource_id == source_resource_id + }) + .ok_or_else(|| { + format!("源版本未绑定该素材:{source_version_id} · {source_resource_id}") + })?; + let mut resource_bindings: Vec = version + .resource_bindings + .iter() + .filter(|binding| { + !(binding.slot_id == slot_id && binding.resource_id == source_resource_id) + }) + .cloned() + .collect(); + if !resource_bindings + .iter() + .any(|binding| binding.resource_id == replacement_resource_id) + { + resource_bindings.insert( + source_index.min(resource_bindings.len()), + GameIterationVersionResourceBinding { + slot_id: version_binding_slot_id(&replacement_resource_id), + resource_id: replacement_resource_id.clone(), + }, + ); + } + version.resource_bindings = resource_bindings; + applied = Some(compatibility); + Ok(()) + }, + )?; let committed_project_revision = advance_agent_runtime_project_revision_locked(root) - .map_err(|error| format!("替换版本已追加,但项目 revision 未能推进:{error}"))?; - let Some((version_id, compatibility)) = appended else { - return Err("替换版本写入未产生结果".to_string()); + .map_err(|error| format!("绑定已改写,但项目 revision 未能推进:{error}"))?; + let Some(compatibility) = applied else { + return Err("替换写入未产生结果".to_string()); }; if committed_project_revision != target_revision { return Err(format!( - "替换版本已追加,但项目 revision 推进结果与预期不一致:期望 {target_revision},实际 {committed_project_revision}" + "绑定已改写,但项目 revision 推进结果与预期不一致:期望 {target_revision},实际 {committed_project_revision}" )); } + append_agent_db_record( + root, + serde_json::json!({ + "recordType": VERSION_BINDING_REPLACE_RECORD_TYPE, + "versionId": source_version_id, + "sourceResourceId": source_resource_id, + "replacementResourceId": replacement_resource_id, + "projectRevision": committed_project_revision, + }), + )?; + Ok(ReplaceLocalProjectVersionResourceResult { - version_id, - parent_version_id: source_version_id.clone(), + version_id: source_version_id.clone(), committed_project_revision, replacement: ProjectVersionResourceReplacement { - source_version_id, + version_id: source_version_id, source_resource_id, replacement_resource_id, compatibility, + warning: compatibility.warning(), }, }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs index 96ead3c7e..74a26b4d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs @@ -3,7 +3,12 @@ use shared_contracts::game_creation_app::{ GameCreationAppAssetCategory, GameCreationAppImageSequenceFrame, }; -/// 资源替换用例的项目夹具。 +/// 「直接替换」用例:改 manifest 里该版本的绑定,**不产生新版本**。 +/// +/// 覆盖:绑定改写的两条路径(1:1 交换 / 替换素材早已绑定)、除绑定外一切不动、版本数量不变、 +/// 硬门禁(分类 / 类型)拒绝且零副作用、尺寸规格只提示不拒绝、CAS、拒绝路径、候选读取、 +/// 读时自愈口径,以及审计记录。 + fn replacement_project_fixture() -> PathBuf { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "资源替换口径测试").expect("init project"); @@ -130,12 +135,13 @@ fn candidate_for<'a>( .unwrap_or_else(|| panic!("candidate missing: {resource_id}")) } -/// PRD §3.2 / §5.3 的主链路:替换 = 追加下一迭代版本 + 绑定集合按 1:1 交换,既有版本不可变。 -/// -/// 这条走最常见的真实路径:版本已经存在,用户之后才登记了更好的替换素材(因此替换素材不在父 -/// 版本绑定里)。子版本与父版本的差异恰好"一减一增"。 +fn replacement_audit_log(root: &Path) -> String { + fs::read_to_string(root.join(".agent/agent.db")).unwrap_or_default() +} + +/// 主链路:只改该版本的绑定,**不产生新版本**,其余版本与其余字段逐字不动。 #[test] -fn replacement_appends_next_iteration_version_and_keeps_existing_records_intact() { +fn replacement_rewrites_the_version_binding_without_adding_a_version() { let root = replacement_project_fixture(); let source_asset = register_replacement_fixture_asset( &root, @@ -152,7 +158,7 @@ fn replacement_appends_next_iteration_version_and_keeps_existing_records_intact( "art-scene", ); let source_version_id = register_replacement_initial_version(&root); - // 版本创建之后才登记替换素材:它不在父版本绑定里,替换在绑定集合上就是一次 1:1 交换。 + // 版本创建之后才登记替换素材:绑定改写就是一次 1:1 交换。 let replacement_asset = register_replacement_fixture_asset( &root, "assets/hero-b.png", @@ -162,18 +168,18 @@ fn replacement_appends_next_iteration_version_and_keeps_existing_records_intact( ); let before = read_manifest_for_project(&root).expect("read manifest before replacement"); - let parent_version = before + let source_version = before .versions .iter() .find(|version| version.version_id == source_version_id) .expect("source version exists") .clone(); assert!( - !parent_version + !source_version .resource_bindings .iter() .any(|binding| binding.resource_id == replacement_asset), - "夹具前提:替换素材不在父版本绑定里" + "夹具前提:替换素材不在该版本绑定里" ); let revision_before = replacement_project_revision(&root); @@ -185,109 +191,87 @@ fn replacement_appends_next_iteration_version_and_keeps_existing_records_intact( ) .expect("replace version resource"); - assert_eq!(result.parent_version_id, source_version_id); + assert_eq!(result.version_id, source_version_id, "被改的就是源版本本身"); assert_eq!(result.committed_project_revision, revision_before + 1); - assert_eq!(result.version_id, format!("replace-{}", revision_before + 1)); + assert_eq!(result.replacement.version_id, source_version_id); assert_eq!(result.replacement.source_resource_id, source_asset); assert_eq!(result.replacement.replacement_resource_id, replacement_asset); + assert_eq!(result.replacement.warning, None); assert!( result.replacement.compatibility.category_equal && result.replacement.compatibility.subtype_equal - && result.replacement.compatibility.size_spec_equal, - "同分类同类型同格式必须三项兼容" ); let after = read_manifest_for_project(&root).expect("read manifest after replacement"); - assert_eq!(after.versions.len(), before.versions.len() + 1); - // `after` 是重新从磁盘读回来的 manifest,所以这一条同时锁住「磁盘上的既有版本前缀逐项不变」。 + // 关键:版本数量不变(没有追加新版本),磁盘上的既有记录除绑定外逐字段不变。 assert_eq!( - after.versions[..before.versions.len()], - before.versions[..], - "既有版本记录必须原样保留" + after.versions.len(), + before.versions.len(), + "直接替换不得新增或删除版本" ); - - let child = after.versions.last().expect("child version appended"); - assert_eq!(child.version_id, result.version_id); - assert_eq!(child.parent_version_id.as_deref(), Some(source_version_id.as_str())); - assert_eq!( - child.created_reason, - GameIterationVersionCreatedReason::ResourceReplacement - ); - assert_eq!(child.project_revision, revision_before + 1); - assert!( - child.project_revision > parent_version.project_revision, - "子版本修订必须严格大于父版本" - ); - assert!(child.created_at >= parent_version.created_at); - assert!(child.edit_prompt.is_none()); + let rewritten = after + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .expect("source version still exists"); + assert_eq!(rewritten.version_id, source_version.version_id); + assert_eq!(rewritten.parent_version_id, source_version.parent_version_id); + assert_eq!(rewritten.project_revision, source_version.project_revision); + assert_eq!(rewritten.created_reason, source_version.created_reason); + assert_eq!(rewritten.created_at, source_version.created_at); + assert_eq!(rewritten.edit_prompt, source_version.edit_prompt); // 绑定按 1:1 交换:长度不变、顺序不变,只把源素材换成替换素材。 - assert_eq!(child.resource_bindings.len(), parent_version.resource_bindings.len()); - for (index, parent_binding) in parent_version.resource_bindings.iter().enumerate() { - let child_binding = &child.resource_bindings[index]; - if parent_binding.resource_id == source_asset { - assert_eq!(child_binding.resource_id, replacement_asset); - assert_eq!(child_binding.slot_id, format!("asset:{replacement_asset}")); + assert_eq!( + rewritten.resource_bindings.len(), + source_version.resource_bindings.len() + ); + for (index, original_binding) in source_version.resource_bindings.iter().enumerate() { + let rewritten_binding = &rewritten.resource_bindings[index]; + if original_binding.resource_id == source_asset { + assert_eq!(rewritten_binding.resource_id, replacement_asset); + assert_eq!( + rewritten_binding.slot_id, + format!("asset:{replacement_asset}") + ); } else { - assert_eq!(child_binding, parent_binding); + assert_eq!(rewritten_binding, original_binding); } } assert!( - child + rewritten .resource_bindings .iter() .all(|binding| binding.resource_id != source_asset), - "子版本不得继续引用源素材" + "源素材必须从该版本的绑定里消失" ); assert!( - child + rewritten .resource_bindings .iter() .any(|binding| binding.resource_id == untouched_asset), "未命中的绑定必须原样保留" ); - // 源版本仍然是替换前的绑定(可运行版本不可变)。 - assert!( - parent_version - .resource_bindings - .iter() - .any(|binding| binding.resource_id == source_asset) - ); - // 替换前后资源身份:父 − 子 = {source},子 − 父 = {replacement},各恰好一项(1:1 交换路径)。 - let child_ids: Vec<&str> = child - .resource_bindings - .iter() - .map(|binding| binding.resource_id.as_str()) - .collect(); - let parent_ids: Vec<&str> = parent_version - .resource_bindings - .iter() - .map(|binding| binding.resource_id.as_str()) - .collect(); - let added: Vec<&&str> = child_ids - .iter() - .filter(|resource_id| !parent_ids.contains(resource_id)) - .collect(); - let removed: Vec<&&str> = parent_ids - .iter() - .filter(|resource_id| !child_ids.contains(resource_id)) - .collect(); - assert_eq!(added, vec![&replacement_asset.as_str()]); - assert_eq!(removed, vec![&source_asset.as_str()]); - - // 素材表与磁盘文件都不因替换而变化。 + // 素材表与磁盘文件不因替换而变化。 assert_eq!(after.assets, before.assets, "替换不得改动素材登记"); assert!(root.join("assets/hero-a.png").is_file()); assert!(root.join("assets/hero-b.png").is_file()); + // 审计:直接替换没有可回溯的版本历史,这一条就是替换前身份的留痕。 + let audit = replacement_audit_log(&root); + assert!( + audit.contains("asset.version_binding.replace"), + "必须留下替换审计:{audit}" + ); + assert!(audit.contains(&source_version_id)); + assert!(audit.contains(&source_asset)); + assert!(audit.contains(&replacement_asset)); + fs::remove_dir_all(root).ok(); } -/// 替换素材在源版本创建时就已登记(因此已在父绑定里)时:只摘掉源素材,不重复添加替换素材。 -/// -/// 恒等绑定口径下每个版本绑定的是"使用的素材集合",所以这条路径的子版本比父版本少一条绑定, -/// 且**不得**出现重复槽位(`validate_game_iteration_versions` 会拒绝重复 `slotId`)。 +/// 替换素材在源版本创建时就已登记(因此已在绑定里)时:只摘掉源素材,不重复添加替换素材。 #[test] fn replacement_drops_source_binding_when_replacement_is_already_bound() { let root = replacement_project_fixture(); @@ -307,19 +291,12 @@ fn replacement_drops_source_binding_when_replacement_is_already_bound() { ); let source_version_id = register_replacement_initial_version(&root); let before = read_manifest_for_project(&root).expect("read manifest before replacement"); - let parent_version = before + let source_version = before .versions .iter() .find(|version| version.version_id == source_version_id) .expect("source version exists") .clone(); - assert!( - parent_version - .resource_bindings - .iter() - .any(|binding| binding.resource_id == already_bound_replacement), - "夹具前提:替换素材已在父版本绑定里" - ); replace_at_current_revision( &root, @@ -330,27 +307,32 @@ fn replacement_drops_source_binding_when_replacement_is_already_bound() { .expect("替换素材已绑定时仍必须允许替换"); let after = read_manifest_for_project(&root).expect("read manifest after replacement"); - let child = after.versions.last().expect("child version appended"); + assert_eq!(after.versions.len(), before.versions.len()); + let rewritten = after + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .expect("source version still exists"); assert_eq!( - child.resource_bindings.len() + 1, - parent_version.resource_bindings.len(), - "替换素材已绑定时,子版本只比父版本少掉源素材这一条" + rewritten.resource_bindings.len() + 1, + source_version.resource_bindings.len(), + "替换素材已绑定时,只比替换前少掉源素材这一条" ); assert!( - child + rewritten .resource_bindings .iter() .all(|binding| binding.resource_id != source_asset), "源素材必须从该版本的绑定里消失" ); assert!( - child + rewritten .resource_bindings .iter() .any(|binding| binding.resource_id == already_bound_replacement), "替换素材必须仍在绑定里" ); - let mut slot_ids: Vec<&str> = child + let mut slot_ids: Vec<&str> = rewritten .resource_bindings .iter() .map(|binding| binding.slot_id.as_str()) @@ -358,82 +340,63 @@ fn replacement_drops_source_binding_when_replacement_is_already_bound() { slot_ids.sort_unstable(); let unique = slot_ids.len(); slot_ids.dedup(); - assert_eq!(slot_ids.len(), unique, "子版本绑定槽位不得重复"); - assert_eq!( - after.versions[..before.versions.len()], - before.versions[..], - "既有版本记录必须原样保留" - ); + assert_eq!(slot_ids.len(), unique, "绑定槽位不得重复"); fs::remove_dir_all(root).ok(); } -/// 三项兼容性逐项拒绝:任何一项不相等都不得创建下一版本,且 manifest 与 revision 都不变。 +/// 硬门禁只有分类与类型;尺寸规格(这里退化为媒体格式)只提示、不拒绝。 #[test] -fn replacement_rejects_any_incompatible_dimension_and_writes_nothing() { - struct Case { +fn replacement_blocks_category_and_subtype_mismatch_but_only_hints_size_spec() { + struct BlockingCase { name: &'static str, source_kind: &'static str, - source_media_type: &'static str, target_kind: &'static str, - target_media_type: &'static str, expected_reason: &'static str, } - let cases = [ - Case { + let blocking_cases = [ + BlockingCase { name: "分类不同", source_kind: "character", - source_media_type: "image/png", target_kind: "scene", - target_media_type: "image/png", expected_reason: "分类不同", }, - Case { + BlockingCase { name: "类型不同", source_kind: "character", - source_media_type: "image/png", target_kind: "character-animation", - target_media_type: "image/png", expected_reason: "类型不同", }, - Case { - name: "尺寸规格不同", - source_kind: "character", - source_media_type: "image/png", - target_kind: "character", - target_media_type: "image/webp", - expected_reason: "尺寸规格不同", - }, ]; - for case in cases { + for case in blocking_cases { let root = replacement_project_fixture(); let source_asset = register_replacement_fixture_asset( &root, "assets/source.png", case.source_kind, - case.source_media_type, + "image/png", "art-source", ); let target_asset = register_replacement_fixture_asset( &root, "assets/target.png", case.target_kind, - case.target_media_type, + "image/png", "art-target", ); let source_version_id = register_replacement_initial_version(&root); let bytes_before = replacement_manifest_bytes(&root); let revision_before = replacement_project_revision(&root); - let error = replace_at_current_revision(&root, &source_version_id, &source_asset, &target_asset) - .expect_err(&format!("{} 必须被拒绝", case.name)); + let error = + replace_at_current_revision(&root, &source_version_id, &source_asset, &target_asset) + .expect_err(&format!("{} 必须被拒绝", case.name)); assert!( error.contains(case.expected_reason), "{} 的拒绝原因必须指出不等的维度,实际:{error}", case.name ); - assert_eq!( replacement_manifest_bytes(&root), bytes_before, @@ -449,33 +412,61 @@ fn replacement_rejects_any_incompatible_dimension_and_writes_nothing() { fs::remove_dir_all(root).ok(); } -} -/// 已知尺寸 / 时长事实存在时必须真正比较(不是只看格式)。 -#[test] -fn replacement_compares_known_frame_size_and_duration_facts_when_present() { + // 同分类同类型、只有媒体格式不同:**必须放行**,只给提示。 let root = replacement_project_fixture(); let source_asset = register_replacement_fixture_asset( &root, - "assets/seq-source.png", - "character-animation", + "assets/hero.png", + "character", "image/png", - "anim-source", + "art-hero", ); - let same_size_asset = register_replacement_fixture_asset( + let source_version_id = register_replacement_initial_version(&root); + let webp_asset = register_replacement_fixture_asset( &root, - "assets/seq-same.png", - "character-animation", - "image/png", - "anim-same", + "assets/hero.webp", + "character", + "image/webp", + "art-hero-webp", ); - let other_size_asset = register_replacement_fixture_asset( - &root, - "assets/seq-other.png", - "character-animation", - "image/png", - "anim-other", + + let candidates = + read_local_project_version_replacement_candidates_at(&root, &source_version_id, &source_asset) + .expect("read candidates"); + let webp_candidate = candidate_for(&candidates, &webp_asset); + assert!( + webp_candidate.compatible, + "格式不同不得阻断选中(硬门禁只有分类与类型)" ); + assert_eq!(webp_candidate.blocked_reason, None); + assert!(!webp_candidate.compatibility.size_spec_equal); + assert_eq!(webp_candidate.warning, Some("格式与源素材不同")); + + let result = replace_at_current_revision(&root, &source_version_id, &source_asset, &webp_asset) + .expect("同分类同类型的跨格式替换必须成功"); + assert_eq!(result.replacement.warning, Some("格式与源素材不同")); + let after = read_manifest_for_project(&root).expect("read manifest after format replacement"); + let rewritten = after + .versions + .iter() + .find(|version| version.version_id == source_version_id) + .expect("source version still exists"); + assert!( + rewritten + .resource_bindings + .iter() + .any(|binding| binding.resource_id == webp_asset) + ); + + fs::remove_dir_all(root).ok(); +} + +/// 已知尺寸 / 时长事实存在时照常比较,但结果只作为提示。 +/// +/// 每个场景用独立项目:直接替换会把源素材从绑定里摘掉,同一源不能连续替换两次。 +#[test] +fn replacement_hints_known_frame_size_and_duration_differences() { let frame = |image_src: &str, width: u32, height: u32| GameCreationAppImageSequenceFrame { image_src: image_src.to_string(), object_key: None, @@ -483,42 +474,73 @@ fn replacement_compares_known_frame_size_and_duration_facts_when_present() { width, height, }; - rewrite_replacement_manifest(&root, |manifest| { - for asset in manifest.assets.iter_mut() { - let (width, height, duration) = if asset.id == other_size_asset { - (1024, 768, 900) - } else { - (800, 600, 600) - }; - asset.image_sequence_frames = Some(vec![frame("frame-0001.png", width, height)]); - asset.image_sequence_duration_ms = Some(duration); + + for (label, target_size, expect_warning) in [ + ("帧尺寸与时长一致", (800_u32, 600_u32, 600_u64), false), + ("帧尺寸与时长不一致", (1024, 768, 900), true), + ] { + let root = replacement_project_fixture(); + let source_asset = register_replacement_fixture_asset( + &root, + "assets/seq-source.png", + "character-animation", + "image/png", + "anim-source", + ); + let target_asset = register_replacement_fixture_asset( + &root, + "assets/seq-target.png", + "character-animation", + "image/png", + "anim-target", + ); + let (target_width, target_height, target_duration) = target_size; + rewrite_replacement_manifest(&root, |manifest| { + for asset in manifest.assets.iter_mut() { + let (width, height, duration) = if asset.id == target_asset { + (target_width, target_height, target_duration) + } else { + (800, 600, 600) + }; + asset.image_sequence_frames = Some(vec![frame("frame-0001.png", width, height)]); + asset.image_sequence_duration_ms = Some(duration); + } + }); + let source_version_id = register_replacement_initial_version(&root); + + let candidates = read_local_project_version_replacement_candidates_at( + &root, + &source_version_id, + &source_asset, + ) + .expect("read candidates"); + let candidate = candidate_for(&candidates, &target_asset); + assert!( + candidate.compatible, + "{label} 时只提示、不阻断(尺寸判据不完整,不完整到不能用它拒绝)" + ); + + let result = replace_at_current_revision(&root, &source_version_id, &source_asset, &target_asset) + .expect("尺寸事实不同也必须允许替换"); + if expect_warning { + assert!(!candidate.compatibility.size_spec_equal, "{label}"); + assert_eq!(candidate.warning, Some("格式与源素材不同"), "{label}"); + assert_eq!( + result.replacement.warning, + Some("格式与源素材不同"), + "{label}" + ); + } else { + assert!(candidate.compatibility.size_spec_equal, "{label}"); + assert_eq!(candidate.warning, None, "{label}"); + assert_eq!(result.replacement.warning, None, "{label}"); } - }); - let source_version_id = register_replacement_initial_version(&root); - let candidates = - read_local_project_version_replacement_candidates_at(&root, &source_version_id, &source_asset) - .expect("read candidates"); - assert!(candidate_for(&candidates, &same_size_asset).compatible); - let other = candidate_for(&candidates, &other_size_asset); - assert!(!other.compatible); - assert!(!other.compatibility.size_spec_equal); - assert_eq!(other.blocked_reason, Some("尺寸规格不同")); - - replace_at_current_revision(&root, &source_version_id, &source_asset, &same_size_asset) - .expect("帧尺寸与时长一致时允许替换"); - let error = - replace_at_current_revision(&root, &source_version_id, &source_asset, &other_size_asset) - .expect_err("帧尺寸与时长不一致时必须拒绝"); - assert!(error.contains("尺寸规格不同"), "unexpected error: {error}"); - - fs::remove_dir_all(root).ok(); + fs::remove_dir_all(root).ok(); + } } /// 读时自愈口径与 `packages/shared` 对齐:落盘 `unclassified` 但 kind 能明确分类时按派生分类比较。 -/// -/// 这条同时是"两侧口径一致性"的锁:Rust 少掉 [`game_creation_app_asset_category_with_read_time_healing`] -/// 就会把历史误写的 UI 资产判成"分类不同",本用例立即转红。 #[test] fn replacement_heals_persisted_unclassified_category_like_the_shared_contract() { let root = replacement_project_fixture(); @@ -566,7 +588,6 @@ fn replacement_heals_persisted_unclassified_category_like_the_shared_contract() "落盘 unclassified + kind ui-design 必须按读时自愈口径判为同分类" ); assert!(healed.compatible); - assert_eq!(healed.blocked_reason, None); let result = replace_at_current_revision(&root, &source_version_id, &source_asset, &healed_asset) @@ -599,39 +620,33 @@ fn replacement_rejects_unresolvable_source_and_target() { let revision_before = replacement_project_revision(&root); let bytes_before = replacement_manifest_bytes(&root); - let missing_version = replace_at_current_revision(&root, "absent-version", &bound_asset, &unbound_asset) - .expect_err("未知源版本必须被拒绝"); + let missing_version = + replace_at_current_revision(&root, "absent-version", &bound_asset, &unbound_asset) + .expect_err("未知源版本必须被拒绝"); assert!( missing_version.contains("源项目版本不存在"), "unexpected error: {missing_version}" ); - let unbound_source = replace_at_current_revision(&root, &source_version_id, &unbound_asset, &bound_asset) - .expect_err("源版本未绑定的素材必须被拒绝"); + let unbound_source = + replace_at_current_revision(&root, &source_version_id, &unbound_asset, &bound_asset) + .expect_err("源版本未绑定的素材必须被拒绝"); assert!( unbound_source.contains("源版本未绑定该素材"), "unexpected error: {unbound_source}" ); - let unregistered_target = replace_at_current_revision( - &root, - &source_version_id, - &bound_asset, - "absent-asset", - ) - .expect_err("未登记的目标素材必须被拒绝"); + let unregistered_target = + replace_at_current_revision(&root, &source_version_id, &bound_asset, "absent-asset") + .expect_err("未登记的目标素材必须被拒绝"); assert!( unregistered_target.contains("项目资源不存在"), "unexpected error: {unregistered_target}" ); - let same_target = replace_at_current_revision( - &root, - &source_version_id, - &bound_asset, - &bound_asset, - ) - .expect_err("目标与源相同必须被拒绝"); + let same_target = + replace_at_current_revision(&root, &source_version_id, &bound_asset, &bound_asset) + .expect_err("目标与源相同必须被拒绝"); assert!( same_target.contains("替换素材与源素材相同"), "unexpected error: {same_target}" @@ -693,7 +708,7 @@ fn replacement_enforces_revision_and_identity_cas() { fs::remove_dir_all(root).ok(); } -/// 候选读取:源素材排除、按 manifest 顺序返回、逐项给出后端权威结论与不兼容原因;只读。 +/// 候选读取:源素材排除、按 manifest 顺序返回、逐项给出后端权威结论与原因;只读。 #[test] fn replacement_candidates_report_authoritative_compatibility() { let root = replacement_project_fixture(); @@ -762,9 +777,7 @@ fn replacement_candidates_report_authoritative_compatibility() { let compatible = candidate_for(&result, &compatible_asset); assert!(compatible.compatible); assert_eq!(compatible.blocked_reason, None); - assert!(compatible.compatibility.category_equal); - assert!(compatible.compatibility.subtype_equal); - assert!(compatible.compatibility.size_spec_equal); + assert_eq!(compatible.warning, None); let category = candidate_for(&result, &category_mismatch); assert!(!category.compatible); @@ -778,11 +791,10 @@ fn replacement_candidates_report_authoritative_compatibility() { assert_eq!(subtype.blocked_reason, Some("类型不同")); let format = candidate_for(&result, &format_mismatch); - assert!(!format.compatible); - assert!(format.compatibility.category_equal); - assert!(format.compatibility.subtype_equal); + assert!(format.compatible, "格式不同不阻断"); + assert_eq!(format.blocked_reason, None); assert!(!format.compatibility.size_spec_equal); - assert_eq!(format.blocked_reason, Some("尺寸规格不同")); + assert_eq!(format.warning, Some("格式与源素材不同")); // 只读:不改 manifest、不推进 revision。 assert_eq!(replacement_manifest_bytes(&root), bytes_before); @@ -804,9 +816,13 @@ fn replacement_candidates_report_authoritative_compatibility() { "unexpected error: {unbound}" ); - let blank_version = read_local_project_version_replacement_candidates_at(&root, " ", &source_asset) - .expect_err("空 sourceVersionId 必须被拒绝"); - assert!(blank_version.contains("sourceVersionId"), "unexpected error: {blank_version}"); + let blank_version = + read_local_project_version_replacement_candidates_at(&root, " ", &source_asset) + .expect_err("空 sourceVersionId 必须被拒绝"); + assert!( + blank_version.contains("sourceVersionId"), + "unexpected error: {blank_version}" + ); fs::remove_dir_all(root).ok(); }