修复素材删除幂等、重命名 CAS 与 manifest 预览缓存一致性

修复素材删除幂等、重命名 CAS 与 manifest 预览缓存一致性(PR #316 review)

- assets.rs:2048-2049 [bug · medium] 已被修复:删除登记在「身份 + revision 两项 CAS 都已通过」后遇到素材已不在 manifest 时按 no-op 成功收敛并照常推进 revision,不再报「项目资源不存在」;新增 `asset_delete_retry_converges_when_only_the_revision_advance_failed` 复现「manifest 已提交、revision 未推进」并验证重试收敛(原「不存在的素材必须报错」断言相应改为幂等语义,前端只消费 assetId/committedProjectRevision)。
- assets.rs:2035-2042 [performance · low] 已被修复:引用集合只算一次并用 `HashSet<String>` 做成员判定,`retain` 不再是 O(V²);版本守卫闭包按约定在写入前另行求值、拿不到该可变借用,故两次扫描保留并已在注释里说明。
- commands.rs:1032 [maintainability · medium] 已被修复:`RenameLocalProjectAssetInput` 补 `expectedProjectId` / `expectedProjectRevision`,`rename_local_project_asset_at` 与删除/分类更新同口径做两段身份复核 + revision CAS,失败时磁盘、manifest、revision 全不动;新增 CAS 用例(陈旧 revision / 跨项目身份 / 空身份)。注意:前端 `confirmResourceRename` 必须同时补传这两个字段(TS 侧由前端 owner 落地)。
- asset_rename.rs:84 [bug · low] 已被修复:新增 `asset_frame_directory_matches`,目录段在 Windows/macOS 按大小写不敏感比较(Linux 保持敏感),与文件名比较同口径;新增 `Assets/hero.png` 帧对齐用例。
- asset_rename.rs:249-250 [bug · medium] 已被修复:revision 推进失败改为报 `reconciliation-required: 素材已改名…且 manifest 已落盘,但项目 revision 未能推进`,明确这是「已提交但对账未完成」而非普通失败;同时新增的 CAS 使后续命令必须按新 revision 重试,重试会命中同名 no-op 分支收敛。
- tests/asset_rename.rs:257-263 [maintainability · medium] 已被修复:删除 `RenameLocalProjectAssetFaultStage` 枚举与生产签名上的 `fault` 参数,故障注入改用 `#[cfg(test)]` 的 `.agent/runtime/test-fail-next-asset-rename-manifest-write` 标记文件(与 agent_db 既有约定一致),生产 API 面不再能被推上「只回滚、不写 manifest」的路径。
- manifest.rs:1386 [bug · medium] 已被修复:预览缓存失效点下沉到唯一的装盘公共体 `write_manifest_locked_with_version_guard`(install 成功后立即失效,含安装后回读不一致的失败路径),`mutate_manifest_at_allowing_version_removals` 与绑定改写通道一并覆盖;`write_manifest_with_lock_hook` 里的重复调用已移除。
- manifest.rs:1480-1482 [bug · medium] 已被修复:填充侧改为「先取身份快照 → 读取 → 复核快照一致才入缓存」,不再可能出现「旧内容 + 新文件身份」的缓存条目。
- manifest.rs:1135 [security · low] 已被修复:新增 `normalize_manifest_asset_tags`,在共享归一化之后按数量(16)与单标签长度(32 字符)失败关闭;超长标签不截断,避免与共享标签库静默分叉。
- manifest.rs:1180-1181 [bug · medium] 已被修复:审计改为幂等——前后分类与标签完全相同时不追加记录,重试不会再写出 `previousCategory == category` 的假变更审计(保留「manifest 落盘后、推进 revision 前追加」这一既有位置约定)。
- manifest.rs:1140 [maintainability · low] 已被修复:分类写入的项目写锁标签由 `asset.register` 改为 `asset.classification.update`,争用诊断不再误报成素材登记。
- 测试:新增 preview_cache_tests(命中/TOCTOU 不入缓存/身份漂移丢弃/主写入通道显式失效)、分类重复写入不留假审计、标签上下界拒绝;7 条变异验证全部 CAUGHT。
This commit is contained in:
2026-09-12 20:20:12 +08:00
parent 3a9e45c805
commit daef9f6cc9
8 changed files with 657 additions and 91 deletions
@@ -2043,6 +2043,9 @@ pub(crate) struct DeleteLocalProjectAssetResult {
/// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记;
/// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS
/// 失败时 manifest 与 revision 都不变。
/// - **幂等**CAS 通过后登记已不在 manifest 里(上一次调用删掉了登记、只在推进 revision
/// 时失败)时按 no-op 成功收敛,并照常推进 revision —— 这是「manifest 已提交但 revision
/// 未推进」那条中断路径的自愈方式,重试不再报「项目资源不存在」。
pub(crate) fn delete_manifest_asset_at(
root: &Path,
expected_project_id: &str,
@@ -2091,12 +2094,21 @@ pub(crate) fn delete_manifest_asset_at(
.iter()
.position(|asset| asset.id == asset_id)
else {
return Err(format!("项目资源不存在:{asset_id}"));
// 幂等收敛:上一次调用可能已经把登记删掉、只在最后推进 revision 时失败
// manifest 已落盘、revision 仍停在旧值)。此时 `expectedProjectId` 与
// `expectedProjectRevision` 两项 CAS 都已通过,唯一正确的处置是把它当
// no-op 成功 —— 重试再报「项目资源不存在」会让这条命令永远无法自愈。
// 收敛路径上 `localPath` 回报空串(前端只用 assetId 与 revision)。
return Ok(());
};
local_path = manifest.assets[index].local_path.clone();
manifest.assets.remove(index);
if delete_referenced_versions {
let referenced_version_ids: Vec<String> =
// 引用集合在这里算一次即可;成员判定用 `HashSet`:逐版本 `Vec::contains`
// 在引用该素材的版本很多时是 O(V²)。
// (版本守卫侧的 `allowed_version_removals` 闭包按约定在写入前另算一次,
// 它拿不到这里的可变借用,所以两次扫描无法合并。)
let referenced_version_ids: std::collections::HashSet<String> =
asset_referencing_versions(manifest, asset_id)
.into_iter()
.map(|version| version.version_id)
@@ -1066,14 +1066,21 @@ pub(crate) fn replace_local_project_version_resource(
/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。
///
/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败
/// 时把文件改回原名,不留半成品。
/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId`
/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。
#[tauri::command]
pub(crate) fn rename_local_project_asset(
input: RenameLocalProjectAssetInput,
) -> Result<RenameLocalProjectAssetResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
rename_local_project_asset_at(root, &input.asset_id, &input.new_file_name, None)
rename_local_project_asset_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.asset_id,
&input.new_file_name,
)
}
#[tauri::command]
@@ -6,10 +6,15 @@ use super::filesystem::validate_portable_project_path_component;
///
/// 只收"新文件名"而不是新旧两个全路径:改名被限制在资产当前所在目录内,目录由 manifest
/// 里的 `localPath` 决定,调用方无法指定目标目录。
///
/// `expectedProjectId` / `expectedProjectRevision` 与删除、分类更新同口径:陈旧客户端
/// 不允许凭旧 revision 改写素材文件与 manifest。
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct RenameLocalProjectAssetInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) expected_project_revision: u64,
pub(crate) asset_id: String,
pub(crate) new_file_name: String,
}
@@ -22,12 +27,24 @@ pub(crate) struct RenameLocalProjectAssetResult {
pub(crate) committed_project_revision: u64,
}
/// 故障注入点:让测试在不依赖只读路径的前提下验证"manifest 写失败必须回滚文件改名"。
/// 生产路径始终传 `None`
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RenameLocalProjectAssetFaultStage {
/// 文件已改名、manifest 尚未写入时失败。
ManifestWrite,
/// 测试注入:`.agent/runtime/test-fail-next-asset-rename-manifest-write` 存在时,下一步
/// manifest 写入按失败返回,用来验证"文件已改名必须改回原名"的回滚
///
/// 与 `agent_db` 的 `test-fail-next-*` 同一套约定,且整段是 `#[cfg(test)]`:生产签名
/// [`rename_local_project_asset_at`])不接受任何故障注入参数,也没有第二个入口能把函数
/// 推上"只回滚、绝不写 manifest"的那条路。
#[cfg(test)]
fn take_rename_manifest_write_failure_injection(root: &Path) -> Result<(), String> {
let failure_path = root.join(".agent/runtime/test-fail-next-asset-rename-manifest-write");
match std::fs::read_to_string(&failure_path) {
Ok(_) => {
std::fs::remove_file(&failure_path)
.map_err(|error| format!("清理素材改名 manifest 写失败注入标记失败:{error}"))?;
Err("fault-injected:rename-asset-manifest-write".to_string())
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!("读取素材改名 manifest 写失败注入标记失败:{error}")),
}
}
/// 校验调用方给出的新文件名,返回 trim 后的名字。
@@ -64,9 +81,9 @@ fn asset_file_extension(file_name: &str) -> Option<String> {
/// 一个帧的 `imageSrc` 是否就是被改名的那份本地文件。
///
/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后文件名与旧
/// 一致(大小写不敏感,和扩展名比较同口径)。只认真正指向该文件的帧,同目录但指向其它文件的帧
/// 保持原样,避免改名连带改坏别的引用。
/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后目录与旧
/// 目录等价、文件名与旧名一致(都按所在文件系统的大小写语义比较)。只认真正指向该文件的帧,
/// 同目录但指向其它文件的帧保持原样,避免改名连带改坏别的引用。
fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_name: &str) -> bool {
let image_src = image_src.trim();
if image_src.is_empty()
@@ -81,7 +98,21 @@ fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_nam
Some((directory, file_name)) => (directory, file_name),
None => ("", image_src),
};
frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name)
asset_frame_directory_matches(frame_directory, directory)
&& frame_file_name.eq_ignore_ascii_case(previous_file_name)
}
/// 目录段的大小写语义必须与所在文件系统一致。
///
/// 文件名一直按大小写不敏感比较(`Assets/Hero.png` 与 `assets/hero.png` 在 Windows / macOS 上
/// 是同一份文件);目录段如果按大小写敏感比较,同一份文件在 `imageSequenceFrames` 里写成
/// 不同目录大小写时就对齐不上,改名后留下悬空帧引用。Linux 保持大小写敏感。
fn asset_frame_directory_matches(frame_directory: &str, directory: &str) -> bool {
if cfg!(any(windows, target_os = "macos")) {
frame_directory.eq_ignore_ascii_case(directory)
} else {
frame_directory == directory
}
}
/// 把 manifest 条目里指向被改名文件的序列帧对齐到新 `localPath`。
@@ -125,26 +156,53 @@ fn rollback_asset_file_rename(
///
/// 事务顺序(全程持既有项目写锁,期间不会出现第二个项目写者;manifest 写入仍走既有边界:
/// 版本数组不可变校验 + 安装后回读一致性校验):
/// 0. 与删除 / 分类更新同口径的 CAS`expectedProjectId` 两段复核 + 当前 revision 比对,
/// 失败时磁盘、manifest、revision 全都不动;
/// 1. 读 manifest 定位资产,取旧 `localPath`
/// 2. 校验新文件名、扩展名一致、目标不冲突,然后 `rename` 磁盘文件;
/// 3. 更新 manifest 的 `localPath``id` / `kind` / `mediaType` / `source` / `category` / `tags`
/// 全部不变),并把 `imageSequenceFrames` 里指向该文件的帧对齐到新路径;
/// 4. 写 manifest
/// 5. 第 4 步失败时把文件改回原名,不留"文件已改名但 manifest 还是旧路径"的半成品;
/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required
/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required
/// 6. 第 4 步成功、第 6 步(推进 revision)失败时**不回滚**:文件与 manifest 都已提交,
/// 这是已提交但对账未完成的状态,按 `reconciliation-required:` 前缀上报,不按普通失败
/// 处理(普通失败会诱导调用方"重试一次",把已提交的改名再走一遍)。
/// 此时磁盘与 manifest 都在**新**名字上,同名重试会命中上面的 no-op 分支直接成功,
/// 所以调用方按"已经改名成功"刷新即可收敛。
pub(crate) fn rename_local_project_asset_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
asset_id: &str,
new_file_name: &str,
fault: Option<RenameLocalProjectAssetFaultStage>,
) -> Result<RenameLocalProjectAssetResult, String> {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("素材重命名 assetId 不能为空".to_string());
}
let new_file_name = normalize_asset_file_name(new_file_name)?;
if expected_project_revision
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
{
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
}
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
return Err("素材重命名 expectedProjectId 不能为空".to_string());
}
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
let _lock = acquire_project_write_lock(root, "asset.register")?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
{
return Err("project-revision-conflict".to_string());
}
let manifest_path = root.join(".agent/manifest.json");
let mut manifest = read_existing_manifest_for_project(root)?;
let index = manifest
@@ -232,12 +290,13 @@ pub(crate) fn rename_local_project_asset_at(
let asset = manifest.assets[index].clone();
// 文件已改名:从这里开始的任何失败都必须把文件改回原名。
let write_error = match fault {
Some(RenameLocalProjectAssetFaultStage::ManifestWrite) => {
Some("fault-injected:rename-asset-manifest-write".to_string())
}
None => write_manifest(&manifest_path, &manifest).err(),
};
// 注入结果必须并进 `write_error`、不能就地 `?` 返回,否则会绕过下面的回滚。
#[cfg(test)]
let injected_write_error = take_rename_manifest_write_failure_injection(root).err();
#[cfg(not(test))]
let injected_write_error: Option<String> = None;
let write_error =
injected_write_error.or_else(|| write_manifest(&manifest_path, &manifest).err());
if let Some(error) = write_error {
return Err(rollback_asset_file_rename(
&current_absolute,
@@ -246,8 +305,13 @@ pub(crate) fn rename_local_project_asset_at(
));
}
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("素材已改名,但项目 revision 未能推进:{error}"))?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root).map_err(
|error| {
format!(
"reconciliation-required: 素材已改名为 {next_local_path} 且 manifest 已落盘,但项目 revision 未能推进:{error}"
)
},
)?;
Ok(RenameLocalProjectAssetResult {
asset,
previous_local_path,
@@ -1108,7 +1108,8 @@ pub(crate) struct UpdateLocalProjectResourceClassificationResult {
/// 只更新单个 manifest 资产条目的功能分类与自定义标签,保持条目字段构成与顺序不变。
/// `category` 必须是 6 个合法值之一,非法值直接失败、不回退到 `kind` 派生;
/// `tags` 按既有归一化语义 trim、去空、去重
/// `tags` 按既有归一化语义 trim、去空、去重,并在此处再按数量与单标签长度收口:
/// manifest 会被整份读、整份解析、整份重写,无界标签等于让后续每一次读写都为它付费。
pub(crate) fn update_manifest_asset_classification_at(
root: &Path,
expected_project_id: &str,
@@ -1132,12 +1133,12 @@ pub(crate) fn update_manifest_asset_classification_at(
}
let category = game_creation_app_asset_category_from_str(category)
.ok_or_else(|| format!("非法资源分类:{}", category.trim()))?;
let tags = normalize_game_creation_app_asset_tags(&tags);
let tags = normalize_manifest_asset_tags(&tags)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
let _lock = acquire_project_write_lock(root, "asset.register")?;
let _lock = acquire_project_write_lock(root, "asset.classification.update")?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
@@ -1164,19 +1165,26 @@ pub(crate) fn update_manifest_asset_classification_at(
// 与 `assets.rs` 的 `asset.register` / `asset.update` 同一位置约定(manifest 写成功后追加)。
// 放在推进 revision 之前是刻意的——分类已经真实落盘,审计不能因为紧随其后的 revision 推进
// 失败而缺失,否则「改过但查不到」正是这条缺陷要修的东西;推进失败仍然照旧报错(见下方 map_err)。
append_agent_db_record(
root,
serde_json::json!({
"recordType": "asset.classification.update",
"assetId": asset.id,
"expectedProjectRevision": expected_project_revision,
"previousCategory": previous_category,
"previousTags": previous_tags,
"category": asset.category,
"tags": asset.tags,
}),
)
.map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?;
//
// 这条位置约定的代价是"重试会走到同一个值":上次调用可能已经写进同一份分类、只在推进
// revision 时失败(revision 未变,所以重试会再次通过 CAS 并重新落到同一个值)。前后值完全
// 相同的记录不是"从 X 改成 Y",重复追加只会污染这份审计要保住的语义,因此跳过。
let classification_changed = previous_category != asset.category || previous_tags != asset.tags;
if classification_changed {
append_agent_db_record(
root,
serde_json::json!({
"recordType": "asset.classification.update",
"assetId": asset.id,
"expectedProjectRevision": expected_project_revision,
"previousCategory": previous_category,
"previousTags": previous_tags,
"category": asset.category,
"tags": asset.tags,
}),
)
.map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?;
}
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("资源分类已写入,但项目 revision 未能推进:{error}"))?;
Ok(UpdateLocalProjectResourceClassificationResult {
@@ -1333,6 +1341,32 @@ fn normalize_manifest_task_id_list(
Ok(output)
}
/// 资源标签的持久化上界:manifest 每次写入都被整份序列化重写、每次读取都被整份解析,
/// 标签数量与单标签长度若无界,客户端就能让这份文件无限膨胀,并把成本摊到之后每一次读写上。
const ASSET_CLASSIFICATION_MAX_TAGS: usize = 16;
const ASSET_CLASSIFICATION_MAX_TAG_CHARS: usize = 32;
/// 复用共享契约的归一化(trim / 去空 / 去重),再按 manifest 自己的持久化上界失败关闭。
///
/// 这里刻意不做"超长就截断":截断后的标签与共享标签库里的同一条不再相等,
/// 用户看到的标签和实际存下来的标签会静默分叉,问题只是从"文件变大"换成"标签对不上"。
fn normalize_manifest_asset_tags(tags: &[String]) -> Result<Vec<String>, String> {
let normalized = normalize_game_creation_app_asset_tags(tags);
for tag in &normalized {
if tag.chars().count() > ASSET_CLASSIFICATION_MAX_TAG_CHARS {
return Err(format!(
"单个资源标签不能超过 {ASSET_CLASSIFICATION_MAX_TAG_CHARS} 个字符"
));
}
}
if normalized.len() > ASSET_CLASSIFICATION_MAX_TAGS {
return Err(format!(
"资源标签最多支持 {ASSET_CLASSIFICATION_MAX_TAGS}"
));
}
Ok(normalized)
}
pub(crate) fn read_or_create_manifest(
root: &Path,
) -> Result<(PathBuf, GameCreationAppManifest), String> {
@@ -1556,14 +1590,20 @@ fn remove_manifest_backup(path: &Path) -> Result<(), String> {
/// 这条缓存只服务「这个资源是否已登记」这一层判断;它不放松 `file.read` 权限、项目边界、
/// 敏感路径、普通文件或读取漂移门禁 —— 那些门禁分别在 [`crate::commands::read_local_project_image_preview_at`]
/// 与真实字节读取路径上执行,且真实读取从不经过本缓存。
///
/// 填充侧同样是"快照 → 读取 → 复核快照":读取前后文件身份一致才入缓存。
pub(crate) fn read_manifest_cached_for_preview(
path: &Path,
) -> Result<GameCreationAppManifest, String> {
if let Some(cached) = cached_preview_manifest_if_unchanged(path) {
return Ok(cached);
}
// **先取身份快照,再读内容**:反过来(先读内容、后取身份)时,读取与取身份之间的一次
// 并发替换会让缓存里出现"旧内容 + 新文件身份",此后每个读取都会通过身份复核并一直返回
// 旧 manifest,直到下一次显式失效。读完复核一次身份,只有两次快照一致才允许入缓存。
let before = preview_manifest_snapshot(path);
let manifest = read_manifest(path)?;
remember_preview_manifest(path, &manifest);
remember_preview_manifest_if_stable(path, before, &manifest);
Ok(manifest)
}
@@ -1575,6 +1615,16 @@ struct CachedPreviewManifest {
manifest: GameCreationAppManifest,
}
/// 缓存命中判据里"这一份文件此刻是什么"的快照:长度 + 修改时间 + 句柄身份。
///
/// 阅读侧与写入侧共用同一个快照函数,保证"缓存的内容"与"快照的身份"永远是同一时刻取的。
#[derive(Clone, Debug, Eq, PartialEq)]
struct PreviewManifestSnapshot {
len: u64,
modified: Option<SystemTime>,
identity: Option<(u64, u64)>,
}
/// 缓存容量只需覆盖「同时打开的项目数」,取 8 已远大于实际并发。
const PREVIEW_MANIFEST_CACHE_LIMIT: usize = 8;
@@ -1596,8 +1646,9 @@ fn forget_preview_manifest(path: &Path) {
}
}
fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppManifest> {
let key = path.to_string_lossy().into_owned();
/// 取当前文件身份快照;路径不是普通文件(缺失、符号链接、目录、reparse point)时返回 `None`
/// 表示"这份文件此刻不可缓存"。
fn preview_manifest_snapshot(path: &Path) -> Option<PreviewManifestSnapshot> {
let metadata = fs::symlink_metadata(path).ok()?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return None;
@@ -1605,17 +1656,27 @@ fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppMa
if metadata_is_windows_reparse_point(&metadata) {
return None;
}
let len = metadata.len();
let modified = metadata.modified().ok();
let identity = fs::File::open(path)
.ok()
.and_then(|file| open_file_identity_key(&file).ok());
Some(PreviewManifestSnapshot {
len: metadata.len(),
modified: metadata.modified().ok(),
identity: fs::File::open(path)
.ok()
.and_then(|file| open_file_identity_key(&file).ok()),
})
}
fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppManifest> {
let key = path.to_string_lossy().into_owned();
let snapshot = preview_manifest_snapshot(path)?;
let mut cache = preview_manifest_cache().lock().ok()?;
let index = cache
.iter()
.position(|(cached_key, _)| cached_key == &key)?;
let cached = &cache[index].1;
if cached.len != len || cached.modified != modified || cached.identity != identity {
if cached.len != snapshot.len
|| cached.modified != snapshot.modified
|| cached.identity != snapshot.identity
{
cache.remove(index);
return None;
}
@@ -1626,17 +1687,27 @@ fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppMa
Some(manifest)
}
fn remember_preview_manifest(path: &Path, manifest: &GameCreationAppManifest) {
let key = path.to_string_lossy().into_owned();
let Ok(metadata) = fs::symlink_metadata(path) else {
/// 读取前后两次快照一致才入缓存:不一致说明读取期间文件被替换过,`manifest` 未必对应当前文件。
fn remember_preview_manifest_if_stable(
path: &Path,
before: Option<PreviewManifestSnapshot>,
manifest: &GameCreationAppManifest,
) {
let Some(before) = before else {
return;
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
if preview_manifest_snapshot(path).as_ref() != Some(&before) {
return;
}
let identity = fs::File::open(path)
.ok()
.and_then(|file| open_file_identity_key(&file).ok());
remember_preview_manifest(path, before, manifest);
}
fn remember_preview_manifest(
path: &Path,
snapshot: PreviewManifestSnapshot,
manifest: &GameCreationAppManifest,
) {
let key = path.to_string_lossy().into_owned();
let Ok(mut cache) = preview_manifest_cache().lock() else {
return;
};
@@ -1646,9 +1717,9 @@ fn remember_preview_manifest(path: &Path, manifest: &GameCreationAppManifest) {
cache.push_back((
key,
CachedPreviewManifest {
len: metadata.len(),
modified: metadata.modified().ok(),
identity,
len: snapshot.len,
modified: snapshot.modified,
identity: snapshot.identity,
manifest: manifest.clone(),
},
));
@@ -1841,11 +1912,9 @@ where
}
let _write_lock = acquire_manifest_write_lock(path)?;
after_lock();
let result = write_manifest_locked(path, manifest, allowed_version_removals);
if result.is_ok() {
forget_preview_manifest(path);
}
result
// 预览缓存的失效放在装盘公共体里(见 `write_manifest_locked_with_version_guard`),
// 这里不再单独调用一次:所有写入通道必须共用同一个失效点。
write_manifest_locked(path, manifest, allowed_version_removals)
}
fn write_manifest_locked(
@@ -1915,6 +1984,14 @@ fn write_manifest_locked_with_version_guard(
"manifest 临时文件",
)?;
install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?;
// 文件已经被替换:从这一刻起缓存里的内容与磁盘不再对应,必须失效。
//
// 失效点放在这个唯一的装盘公共体里,所有写入通道(`write_manifest`、`mutate_manifest_at`、
// `mutate_manifest_at_allowing_version_removals`、`mutate_manifest_allowing_version_binding_rewrites`
// 都覆盖到;命中判据只有长度 + mtime + 文件身份,兜不住同一 mtime 粒度内的同长度原地改写,
// 而 Windows 上某些卷的文件身份又是恒等常量。失败路径(安装后回读不一致)同样要失效 ——
// 那时文件也已经换过了,留着旧缓存才是错的。
forget_preview_manifest(path);
prepare_game_creator_private_path_for_read(path, false, "manifest")?;
let installed = read_manifest(path)?;
if installed != *manifest {
@@ -1965,6 +2042,8 @@ mod classification_tests;
#[cfg(test)]
mod import_tests;
#[cfg(test)]
mod preview_cache_tests;
#[cfg(test)]
mod recovery_tests;
#[cfg(test)]
mod version_binding_rewrite_tests;
@@ -420,6 +420,106 @@ fn asset_classification_rejections_do_not_append_agent_db_audit_records() {
fs::remove_dir_all(root).ok();
}
/// 审计只记「真的改了什么」:把同一个分类与同一组标签再写一次不留记录。
///
/// 这条同时是「上一次已写入、只在推进 revision 时失败」那种中断态的重试语义:
/// 那次重试会重新落到同一个值,如果照旧追加,审计里就会出现一条
/// `previousCategory == category` 的假变更记录。
#[test]
fn asset_classification_repeat_write_does_not_append_a_noop_audit_record() {
let (root, asset_id) = project_with_two_assets("audit-idempotent");
let project_id = read_existing_manifest_for_project(&root)
.expect("read manifest")
.project_id;
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision before")
.revision;
let first = update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"scene",
vec!["主舞台".to_string()],
)
.expect("first classification update");
assert_eq!(first.committed_project_revision, revision_before + 1);
assert_eq!(classification_audit_records(&root).len(), 1);
let repeat = update_manifest_asset_classification_at(
&root,
&project_id,
first.committed_project_revision,
&asset_id,
"scene",
vec!["主舞台".to_string()],
)
.expect("repeating the same classification must succeed");
assert_eq!(repeat.committed_project_revision, revision_before + 2);
assert_eq!(repeat.asset.category, GameCreationAppAssetCategory::Scene);
assert_eq!(repeat.asset.tags, vec!["主舞台"]);
assert_eq!(
classification_audit_records(&root).len(),
1,
"同一个值重复写入不得追加假变更审计"
);
fs::remove_dir_all(root).ok();
}
/// 标签必须在写入前有界:数量与单标签长度都要拒绝,manifest 与 revision 都不动。
#[test]
fn asset_classification_rejects_unbounded_tags() {
let (root, asset_id) = project_with_two_assets("tag-bounds");
let project_id = read_existing_manifest_for_project(&root)
.expect("read manifest")
.project_id;
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision before")
.revision;
let manifest_before = read_existing_manifest_for_project(&root).expect("read manifest before");
let too_many = (0..17)
.map(|index| format!("标签{index}"))
.collect::<Vec<_>>();
let many_error = update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"scene",
too_many,
)
.expect_err("too many tags must be rejected");
assert!(many_error.contains("最多支持"), "unexpected: {many_error}");
let too_long = vec!["".repeat(33)];
let long_error = update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"scene",
too_long,
)
.expect_err("an over-long tag must be rejected");
assert!(long_error.contains("不能超过"), "unexpected: {long_error}");
assert_eq!(
read_existing_manifest_for_project(&root).expect("read manifest after"),
manifest_before
);
assert_eq!(
read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision after")
.revision,
revision_before
);
fs::remove_dir_all(root).ok();
}
/// 审计写失败必须可见:分类已经落盘的既定行为不变(不静默回滚),但命令要报错,
/// 而且不能留下一条假的审计。
#[test]
@@ -0,0 +1,165 @@
use super::*;
/// 独立的临时项目根:缓存是进程内全局状态,按路径做键,所以每个用例必须用自己的路径。
fn preview_cache_test_root(test_name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-manifest-preview-cache-{test_name}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn preview_cache_manifest_path(root: &Path) -> PathBuf {
let path = root.join(".agent/manifest.json");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create preview cache manifest directory");
}
path
}
/// 直接落盘一份 manifest 内容(不走 `write_manifest`,避免它自己带来的缓存失效干扰断言)。
fn install_preview_cache_manifest(path: &Path, manifest: &GameCreationAppManifest) {
let payload = serde_json::to_string_pretty(manifest).expect("serialize preview cache manifest");
fs::write(path, format!("{payload}\n")).expect("install preview cache manifest");
}
fn preview_cache_entry_count(path: &Path) -> usize {
let key = path.to_string_lossy().into_owned();
preview_manifest_cache()
.lock()
.expect("lock preview manifest cache")
.iter()
.filter(|(cached_key, _)| cached_key == &key)
.count()
}
/// 读取前后身份一致时正常入缓存并命中。
#[test]
fn preview_cache_serves_a_repeated_read_from_the_snapshot() {
let root = preview_cache_test_root("hit");
let path = preview_cache_manifest_path(&root);
let manifest = new_game_creation_app_manifest("preview-cache-project", "预览缓存项目");
install_preview_cache_manifest(&path, &manifest);
let first = read_manifest_cached_for_preview(&path).expect("first cached read");
assert_eq!(first.project_id, "preview-cache-project");
assert_eq!(preview_cache_entry_count(&path), 1);
let second = read_manifest_cached_for_preview(&path).expect("second cached read");
assert_eq!(second, first);
assert_eq!(preview_cache_entry_count(&path), 1);
fs::remove_dir_all(root).ok();
}
/// 读取期间文件被并发替换(快照对不上)时不得入缓存:
/// 否则缓存里会出现"旧内容 + 新文件身份",之后每个读取都会通过身份复核并一直返回旧内容。
#[test]
fn preview_cache_skips_insertion_when_the_file_changed_during_the_read() {
let root = preview_cache_test_root("toc-tou");
let path = preview_cache_manifest_path(&root);
let stale = new_game_creation_app_manifest("preview-cache-stale", "旧内容");
let fresh = new_game_creation_app_manifest("preview-cache-fresh", "新内容");
install_preview_cache_manifest(&path, &stale);
// 读之前取快照 ⇒ 这里模拟"读完之后、入缓存之前"文件被并发写者替换。
// 生产写入是"临时文件 + rename",所以这里也用 rename:原地 `fs::write` 改写不会换文件身份,
// 长度与 mtime 又可能落在同一粒度内,快照根本区分不出来(那正是写入侧必须显式失效的原因)。
let before = preview_manifest_snapshot(&path).expect("snapshot before read");
let replacement_path = path.with_file_name(".manifest.json.concurrent");
install_preview_cache_manifest(&replacement_path, &fresh);
fs::rename(&replacement_path, &path).expect("replace manifest concurrently");
remember_preview_manifest_if_stable(&path, Some(before), &stale);
assert_eq!(
preview_cache_entry_count(&path),
0,
"读取期间文件变化时不得写入缓存"
);
let read = read_manifest_cached_for_preview(&path).expect("read after replacement");
assert_eq!(read.project_id, "preview-cache-fresh");
fs::remove_dir_all(root).ok();
}
/// 缓存命中判据里"文件身份"这一维必须真的在起作用:
/// 用同长度内容 + 原封不动的 mtime 走 rename 替换文件(长度与修改时间都对得上,只有身份变了),
/// 旧条目必须被丢弃并重新读取,否则缓存会一直返回替换前的 manifest。
#[test]
fn preview_cache_drops_entries_whose_file_identity_changed() {
let root = preview_cache_test_root("identity-drift");
let path = preview_cache_manifest_path(&root);
let stale = new_game_creation_app_manifest("preview-cache-before", "替换前");
let fresh = new_game_creation_app_manifest("preview-cache-after-", "替换后");
install_preview_cache_manifest(&path, &stale);
let cached = read_manifest_cached_for_preview(&path).expect("prime the cache");
assert_eq!(cached.project_id, "preview-cache-before");
let original = fs::metadata(&path).expect("original manifest metadata");
let replacement_path = path.with_file_name(".manifest.json.replacement");
install_preview_cache_manifest(&replacement_path, &fresh);
assert_eq!(
fs::metadata(&replacement_path)
.expect("replacement metadata")
.len(),
original.len(),
"这条用例要求替换内容与原内容同长度"
);
let replacement = fs::OpenOptions::new()
.write(true)
.open(&replacement_path)
.expect("open replacement manifest");
replacement
.set_times(fs::FileTimes::new().set_modified(original.modified().expect("original mtime")))
.expect("restore replacement mtime");
drop(replacement);
fs::rename(&replacement_path, &path).expect("replace manifest by rename");
let read = read_manifest_cached_for_preview(&path).expect("read after external replacement");
assert_eq!(
read.project_id, "preview-cache-after-",
"长度与修改时间都一致时,只有文件身份能拦住外部替换后的旧缓存"
);
fs::remove_dir_all(root).ok();
}
/// 主写入通道必须让预览缓存失效:`mutate_manifest_at_allowing_version_removals`
/// (素材登记 / 删除 / 分类更新 / 版本增删)走的是 `write_manifest_locked`
/// 曾经是唯一不调用 `forget_preview_manifest` 的写入路径。
#[test]
fn mutate_manifest_at_allowing_version_removals_invalidates_the_preview_cache() {
let root = preview_cache_test_root("invalidate");
let path = preview_cache_manifest_path(&root);
let mut manifest = new_game_creation_app_manifest("preview-cache-mutate", "原始项目名");
manifest.goal = Some("原始目标".to_string());
install_preview_cache_manifest(&path, &manifest);
let cached = read_manifest_cached_for_preview(&path).expect("prime the cache");
assert_eq!(cached.goal.as_deref(), Some("原始目标"));
assert_eq!(preview_cache_entry_count(&path), 1);
// 同长度原地改写:命中判据里的长度不变,只有显式失效能保证不返回旧内容。
mutate_manifest_at_allowing_version_removals(&root, &no_version_removals, |manifest| {
manifest.goal = Some("新目标值".to_string());
Ok(())
})
.expect("mutate manifest");
assert_eq!(
preview_cache_entry_count(&path),
0,
"写入通道必须显式失效预览缓存"
);
let read = read_manifest_cached_for_preview(&path).expect("read after mutation");
assert_eq!(
read.goal.as_deref(),
Some("新目标值"),
"写入通道必须显式失效预览缓存"
);
fs::remove_dir_all(root).ok();
}
@@ -354,7 +354,8 @@ fn version_removal_stays_rejected_outside_the_confirmed_asset_delete() {
fs::remove_dir_all(root).ok();
}
/// 删除同样走 CAS:陈旧 revision、跨项目身份与不存在的素材都被拒绝,manifest 与 revision 不变
/// 删除同样走 CAS:陈旧 revision、跨项目身份都被拒绝,manifest 与 revision 不变
/// 登记已经不在 manifest 里时按幂等 no-op 成功收敛(见下一条用例)。
#[test]
fn asset_delete_enforces_revision_and_identity_cas() {
let root = asset_delete_project_fixture();
@@ -371,14 +372,6 @@ fn asset_delete_enforces_revision_and_identity_cas() {
.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),
@@ -392,3 +385,39 @@ fn asset_delete_enforces_revision_and_identity_cas() {
fs::remove_dir_all(root).ok();
}
/// 「manifest 已提交、revision 未推进」那条中断路径必须能自愈:
/// 用同一个 `expectedProjectRevision` 重试要按 no-op 成功收敛,并补推进 revision。
#[test]
fn asset_delete_retry_converges_when_only_the_revision_advance_failed() {
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);
delete_asset_at_current_revision(&root, &asset_id, false).expect("first delete commits");
let committed = project_revision(&root);
assert_eq!(committed, revision_before + 1);
// 复现中断态:登记已经删掉,但 revision 被写回旧值(等价于当年推进失败)。
let mut revision = read_game_creator_agent_runtime_project_revision(&root).expect("read");
revision.revision = revision_before;
write_game_creator_agent_runtime_project_revision(&root, &revision).expect("rewind revision");
let retry = delete_manifest_asset_at(&root, "project-1", revision_before, &asset_id, false)
.expect("retry must converge instead of reporting a missing asset");
assert_eq!(retry.asset_id, asset_id);
assert_eq!(retry.committed_project_revision, revision_before + 1);
assert!(retry.file_retained);
assert_eq!(
project_revision(&root),
revision_before + 1,
"收敛路径必须补推进 revision,否则工作区会停在旧 revision"
);
let manifest = read_manifest_for_project(&root).expect("read manifest after convergent retry");
assert!(
!manifest.assets.iter().any(|asset| asset.id == asset_id),
"收敛路径不得把登记写回去"
);
fs::remove_dir_all(root).ok();
}
@@ -48,6 +48,21 @@ fn asset_rename_revision(root: &Path) -> u64 {
.revision
}
/// 用当前 revision 发起重命名:模拟前端的正常调用(CAS 要求调用方带上它读到的 revision)。
fn rename_asset_at_current_revision(
root: &Path,
asset_id: &str,
new_file_name: &str,
) -> Result<RenameLocalProjectAssetResult, String> {
rename_local_project_asset_at(
root,
"project-1",
asset_rename_revision(root),
asset_id,
new_file_name,
)
}
/// 成功改名:磁盘文件真的换了名字,manifest 只改 `localPath`,资产 `id` 与其余字段不变,
/// 项目 revision 前进一格。
#[test]
@@ -56,8 +71,8 @@ fn rename_local_project_asset_renames_file_and_keeps_asset_identity() {
let before = asset_rename_entry(&root, &asset_id);
let revision_before = asset_rename_revision(&root);
let result = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None)
.expect("rename asset");
let result =
rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset");
assert_eq!(result.previous_local_path, "assets/hero.png");
assert_eq!(result.asset.local_path, "assets/hero-idle.png");
@@ -93,7 +108,7 @@ fn rename_local_project_asset_is_a_noop_for_the_same_file_name() {
let revision_before = asset_rename_revision(&root);
let result =
rename_local_project_asset_at(&root, &asset_id, "hero.png", None).expect("no-op rename");
rename_asset_at_current_revision(&root, &asset_id, "hero.png").expect("no-op rename");
assert_eq!(result.previous_local_path, "assets/hero.png");
assert_eq!(result.asset.local_path, "assets/hero.png");
@@ -125,7 +140,7 @@ fn rename_local_project_asset_rejects_unsafe_file_names() {
"..png",
"assets/../hero.png",
] {
let error = rename_local_project_asset_at(&root, &asset_id, name, None)
let error = rename_asset_at_current_revision(&root, &asset_id, name)
.expect_err("unsafe file name must be rejected");
assert!(
error.contains("不能为空") || error.contains("路径分隔符") || error.contains(".."),
@@ -148,7 +163,7 @@ fn rename_local_project_asset_rejects_cross_directory_targets() {
let (root, asset_id) = asset_rename_project_fixture();
fs::create_dir_all(root.join("assets/nested")).expect("nested dir");
let error = rename_local_project_asset_at(&root, &asset_id, "nested/hero.png", None)
let error = rename_asset_at_current_revision(&root, &asset_id, "nested/hero.png")
.expect_err("cross directory rename must be rejected");
assert!(error.contains("路径分隔符"), "意外错误:{error}");
@@ -168,7 +183,7 @@ fn rename_local_project_asset_rejects_extension_changes() {
let (root, asset_id) = asset_rename_project_fixture();
for name in ["hero.jpg", "hero", "hero.png.bak"] {
let error = rename_local_project_asset_at(&root, &asset_id, name, None)
let error = rename_asset_at_current_revision(&root, &asset_id, name)
.expect_err("extension change must be rejected");
assert!(error.contains("扩展名"), "意外错误:{error}");
}
@@ -183,7 +198,7 @@ fn rename_local_project_asset_rejects_extension_changes() {
asset_rename_source(),
)
.expect("register extensionless asset");
let error = rename_local_project_asset_at(&root, &extensionless.id, "notes.txt", None)
let error = rename_asset_at_current_revision(&root, &extensionless.id, "notes.txt")
.expect_err("adding an extension must be rejected");
assert!(error.contains("扩展名"), "意外错误:{error}");
@@ -205,7 +220,7 @@ fn rename_local_project_asset_rejects_existing_target_name() {
.expect("other asset file");
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
let error = rename_local_project_asset_at(&root, &asset_id, "other.png", None)
let error = rename_asset_at_current_revision(&root, &asset_id, "other.png")
.expect_err("existing target must be rejected");
assert!(error.contains("同名文件"), "意外错误:{error}");
@@ -230,12 +245,12 @@ fn rename_local_project_asset_rejects_existing_target_name() {
fn rename_local_project_asset_requires_an_existing_asset_and_file() {
let (root, asset_id) = asset_rename_project_fixture();
let error = rename_local_project_asset_at(&root, "missing-asset", "hero-idle.png", None)
let error = rename_asset_at_current_revision(&root, "missing-asset", "hero-idle.png")
.expect_err("unknown asset must be rejected");
assert!(error.contains("项目资源不存在"), "意外错误:{error}");
fs::remove_file(root.join("assets/hero.png")).expect("remove asset file");
let error = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None)
let error = rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png")
.expect_err("missing file must be rejected");
assert!(error.contains("素材文件不存在"), "意外错误:{error}");
assert!(!root.join("assets/hero-idle.png").exists());
@@ -253,14 +268,14 @@ fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() {
let (root, asset_id) = asset_rename_project_fixture();
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
let revision_before = asset_rename_revision(&root);
let error = rename_local_project_asset_at(
&root,
&asset_id,
"hero-idle.png",
Some(RenameLocalProjectAssetFaultStage::ManifestWrite),
fs::write(
root.join(".agent/runtime/test-fail-next-asset-rename-manifest-write"),
"注入下一次 manifest 写失败",
)
.expect_err("injected manifest write failure must surface");
.expect("write rename manifest failure injection marker");
let error = rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png")
.expect_err("injected manifest write failure must surface");
assert!(error.contains("fault-injected"), "意外错误:{error}");
assert_eq!(
@@ -277,6 +292,101 @@ fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() {
fs::remove_dir_all(root).ok();
}
/// 重命名同样走 CAS:陈旧 revision 与跨项目身份都被拒绝,磁盘、manifest 与 revision 都不动。
#[test]
fn rename_local_project_asset_enforces_revision_and_identity_cas() {
let (root, asset_id) = asset_rename_project_fixture();
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
let revision_before = asset_rename_revision(&root);
let revision_error = rename_local_project_asset_at(
&root,
"project-1",
revision_before + 1,
&asset_id,
"hero-idle.png",
)
.expect_err("stale revision must be rejected");
assert_eq!(revision_error, "project-revision-conflict");
let identity_error = rename_local_project_asset_at(
&root,
"project-2",
revision_before,
&asset_id,
"hero-idle.png",
)
.expect_err("cross-project identity must be rejected");
assert_eq!(identity_error, "project-identity-conflict");
let blank_identity_error =
rename_local_project_asset_at(&root, " ", revision_before, &asset_id, "hero-idle.png")
.expect_err("blank expectedProjectId must be rejected");
assert!(
blank_identity_error.contains("不能为空"),
"{blank_identity_error}"
);
assert!(root.join("assets/hero.png").is_file());
assert!(!root.join("assets/hero-idle.png").exists());
assert_eq!(
fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"),
manifest_before
);
assert_eq!(asset_rename_revision(&root), revision_before);
fs::remove_dir_all(root).ok();
}
/// `imageSequenceFrames` 的目录段按文件系统大小写语义比较:同一份文件写成别的目录大小写
/// 也要被对齐(Windows / macOS 上 `Assets/hero.png` 与 `assets/hero.png` 是同一份文件)。
#[test]
fn rename_local_project_asset_aligns_frames_with_case_insensitive_directory() {
let (root, asset_id) = asset_rename_project_fixture();
mutate_manifest_at(&root, |manifest| {
let asset = manifest
.assets
.iter_mut()
.find(|asset| asset.id == asset_id)
.expect("asset entry");
asset.image_sequence_frames = Some(
[
("Assets/hero.png", 32u32, 32u32),
("other/hero.png", 64, 64),
]
.into_iter()
.map(
|(image_src, width, height)| GameCreationAppImageSequenceFrame {
image_src: image_src.to_string(),
object_key: None,
asset_object_id: None,
width,
height,
},
)
.collect(),
);
Ok(())
})
.expect("seed image sequence frames");
rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset");
let frames = asset_rename_entry(&root, &asset_id)
.image_sequence_frames
.expect("image sequence frames");
let expected = if cfg!(any(windows, target_os = "macos")) {
"assets/hero-idle.png"
} else {
"Assets/hero.png"
};
assert_eq!(frames[0].image_src, expected);
// 指向别的目录的帧在任何平台都不动。
assert_eq!(frames[1].image_src, "other/hero.png");
fs::remove_dir_all(root).ok();
}
/// `imageSequenceFrames` 里指向被改名文件的本地帧对齐到新路径,指向别的文件或远程地址的帧不动。
#[test]
fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file() {
@@ -309,7 +419,7 @@ fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file()
})
.expect("seed image sequence frames");
rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None).expect("rename asset");
rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset");
let frames = asset_rename_entry(&root, &asset_id)
.image_sequence_frames