feat(core,presenter,app-iced): 存档三个写操作(重命名 / 删除 / 备份)+ 行操作区(M247 SAVESOPS)
core::game::saves 新增 write 模块,三个入口的第一句都是 boundary::guard: - rename_world:saves/<old> → saves/<new>,撞名/同名/非法名字各自具名拒绝 - delete_world:递归删(不跟随符号链接、层级上限 64),先过边界校验 - backup_world:流式 zip(限额预检 → 临时文件 → 原子改名), <世界名>-YYYYMMDD-HHMMSS.zip,撞名顺延,绝不覆盖上一份 guard 三道判据(每次写前重做,不信任调用方):名字必须是单段裸名字; saves/ 必须是运行目录的真实直属子目录;世界目录必须是 saves/ 下的真目录 (解析后比父级,不比字符串前缀)。落点目录要求绝对路径(相对路径会落到进程 CWD, 实测过一次)。 presenter:SavesCommand 增 RenameWorld/DeleteWorld/BackupWorld,host 三个后台入口 写成功后重新列一遍目录并把「回执」挂在快照上(SavesOped:改名两端 / 被删的名字 / 备份落点+字节数+跳过的链接数)。SavesFailureView 增 file 与 op,写失败八类一一对应。 SavesState 增 draft / pending / confirm 三样交互态(草稿是行内输入缓冲, pending 与 ModsState::pending 同一条先例,confirm 是删除确认框在等谁)。 app-iced:世界行右侧重命名/备份/删除;行内输入框改名(现成模态队列没有「输入变了」 这一条消息,用输入模态等于重写一套);删除走现成 modal 的 Confirm,主按钮落点接在 app/update/dialog.rs。回执行画在卡片里(备份落点看不见就是静默失败)。 i18n:新增 25 条中英成对 key + 金表同步;删掉 M235 的「这一版只读」那条(写操作接上 之后它是假的)。出图夹具 ULL_SAVES_FIXTURE=rename|confirm|backed-up 三种形态。
This commit is contained in:
1 parent
3e852331f4
commit
c921b9c185
28 files changed
+3016
-216
No files matched your search
@@ -1,12 +1,24 @@
|
||||
//! 实例存档 / 截图的宿主实现(M235 · STOREMVP)。
|
||||
//! 实例存档 / 截图的宿主实现(M235 · STOREMVP 读路径 + M247 · SAVESOPS 写路径)。
|
||||
//!
|
||||
//! ## 三条纪律
|
||||
//! ## 四条纪律
|
||||
//!
|
||||
//! | 纪律 | 落点 |
|
||||
//! |---|---|
|
||||
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] 走 `job::spawn`,UI 只收快照 |
|
||||
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] / [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] 全走 `job::spawn`,UI 只收快照 |
|
||||
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
|
||||
//! | 只读 | 本模块一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
|
||||
//! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
|
||||
//! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) |
|
||||
//!
|
||||
//! ## 三个写操作的回程形状
|
||||
//!
|
||||
//! | 操作 | 成功 | 失败 |
|
||||
//! |---|---|---|
|
||||
//! | 重命名 | 新快照 + [`SavesOped::Renamed`] | 具名 [`SavesFailureKind`](撞名 / 名字不可用 / 越界 / 链接 / IO) |
|
||||
//! | 删除 | 新快照 + [`SavesOped::Deleted`] | 同上 |
|
||||
//! | 备份 | 新快照 + [`SavesOped::BackedUp`](落点路径 / 字节数 / 跳过的链接数) | 同上 + `TooLarge` |
|
||||
//!
|
||||
//! **写成功后一律重新列一遍**(`snapshot`):界面画的永远是磁盘上的真相,不是"我以为我改成了什么"。
|
||||
//! 回执只是"这次动的是哪一条",与快照一起回去,所以 UI 不会出现「列表变了但不知道谁变的」。
|
||||
//!
|
||||
//! ## 为什么不复用启动路径的 `run_dir()`
|
||||
//!
|
||||
@@ -22,21 +34,30 @@
|
||||
//! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6:
|
||||
//! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。
|
||||
//!
|
||||
//! ## 备份落点为什么是启动器自己的 `exports/`
|
||||
//!
|
||||
//! 本仓没有 `rfd` 这类原生文件选择框依赖(`core::launch::crash_bundle::default_target_dir` 的
|
||||
//! 文档里记着同一条理由),所以「用户选定目录」先退化成:`<data>/exports/saves/`。
|
||||
//! 命令带一个可选的 `dest`,将来接了选择框、或 CLI 想指定别处,都不用改 core。
|
||||
//! 成功的回执里带**完整路径 + 字节数**,用户不用猜落点。
|
||||
//!
|
||||
//! ## 失败口径
|
||||
//!
|
||||
//! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按
|
||||
//! i18n key 现求(M161 的纪律)。三类失败见 [`crate::dto::SavesFailureKind`]。
|
||||
//! i18n key 现求(M161 的纪律)。读路径三类失败见 [`crate::dto::SavesFailureKind`],
|
||||
//! 写路径八类见同一个枚举的 `Write*` 那一族——每一种在 UI 上都有一句话。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
use unlucky_core::game::isolation::derive_game_dir;
|
||||
use unlucky_core::game::saves::{self, SavesError, SavesScan};
|
||||
use unlucky_core::game::saves::{self, SavesError, SavesScan, SavesWriteError, SavesWriteKind};
|
||||
use unlucky_core::platform::opener;
|
||||
|
||||
use crate::dto::{
|
||||
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind,
|
||||
SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
|
||||
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp,
|
||||
SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView,
|
||||
WorldView,
|
||||
};
|
||||
|
||||
use super::env::HostEnv;
|
||||
@@ -81,14 +102,135 @@ pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand) {
|
||||
match command {
|
||||
SavesCommand::List(id) => spawn_list(env, tx, id),
|
||||
SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id),
|
||||
SavesCommand::RenameWorld { id, dir, to } => spawn_rename(env, tx, id, dir, to),
|
||||
SavesCommand::DeleteWorld { id, dir } => spawn_delete(env, tx, id, dir),
|
||||
SavesCommand::BackupWorld { id, dir, dest } => spawn_backup(env, tx, id, dir, dest),
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台重命名一个世界,**并把刷新后的快照与回执一起带回来**。
|
||||
pub fn spawn_rename(env: HostEnv, tx: JobSender, id: InstanceId, dir: String, to: String) {
|
||||
spawn("saves-rename", move || {
|
||||
let report = Report {
|
||||
instance: id.clone(),
|
||||
list: rename(&env, &id, &dir, &to),
|
||||
};
|
||||
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
|
||||
});
|
||||
}
|
||||
|
||||
/// 后台删除一个世界(**UI 已经二次确认过**;host 侧照样重新过边界校验)。
|
||||
pub fn spawn_delete(env: HostEnv, tx: JobSender, id: InstanceId, dir: String) {
|
||||
spawn("saves-delete", move || {
|
||||
let report = Report {
|
||||
instance: id.clone(),
|
||||
list: delete(&env, &id, &dir),
|
||||
};
|
||||
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
|
||||
});
|
||||
}
|
||||
|
||||
/// 后台把一个世界备份成 zip(落点见 [`export_dir`]),并带回新快照。
|
||||
pub fn spawn_backup(
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
id: InstanceId,
|
||||
dir: String,
|
||||
dest: Option<String>,
|
||||
) {
|
||||
spawn("saves-backup", move || {
|
||||
let report = Report {
|
||||
instance: id.clone(),
|
||||
list: backup(&env, &id, &dir, dest.as_deref()),
|
||||
};
|
||||
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
|
||||
});
|
||||
}
|
||||
|
||||
/// 列目录(后台线程里调)。
|
||||
fn list(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
|
||||
let (instance, run_dir) = target(env, id)?;
|
||||
let scan = saves::scan(&run_dir).map_err(|error| unusable(&instance.id, &error))?;
|
||||
Ok(view(&instance, &scan))
|
||||
snapshot(&instance, &run_dir, None)
|
||||
}
|
||||
|
||||
/// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。
|
||||
///
|
||||
/// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。
|
||||
fn snapshot(
|
||||
instance: &InstanceSummary,
|
||||
run_dir: &std::path::Path,
|
||||
last_op: Option<SavesOped>,
|
||||
) -> Result<SavesListView, SavesFailureView> {
|
||||
let scan = saves::scan(run_dir).map_err(|error| unusable(&instance.id, &error))?;
|
||||
Ok(view(&instance.id, &scan, last_op))
|
||||
}
|
||||
|
||||
/// 重命名 → 再列一遍(回执里记「谁改成了什么」)。
|
||||
fn rename(
|
||||
env: &HostEnv,
|
||||
id: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> Result<SavesListView, SavesFailureView> {
|
||||
let (instance, run_dir) = target(env, id)?;
|
||||
saves::rename_world(&run_dir, from, to)
|
||||
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Rename, from, &error))?;
|
||||
snapshot(
|
||||
&instance,
|
||||
&run_dir,
|
||||
Some(SavesOped::Renamed {
|
||||
from: from.to_string(),
|
||||
to: to.to_string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除 → 再列一遍(回执里记被删掉的那个名字)。
|
||||
fn delete(env: &HostEnv, id: &str, dir: &str) -> Result<SavesListView, SavesFailureView> {
|
||||
let (instance, run_dir) = target(env, id)?;
|
||||
saves::delete_world(&run_dir, dir)
|
||||
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Delete, dir, &error))?;
|
||||
snapshot(
|
||||
&instance,
|
||||
&run_dir,
|
||||
Some(SavesOped::Deleted {
|
||||
dir: dir.to_string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 备份 → 再列一遍(回执里记落点路径 / 字节数 / 被跳过的链接数)。
|
||||
fn backup(
|
||||
env: &HostEnv,
|
||||
id: &str,
|
||||
dir: &str,
|
||||
dest: Option<&str>,
|
||||
) -> Result<SavesListView, SavesFailureView> {
|
||||
let (instance, run_dir) = target(env, id)?;
|
||||
let dest = dest.map_or_else(|| export_dir(env), PathBuf::from);
|
||||
let done = saves::backup_world(&run_dir, dir, &dest)
|
||||
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Backup, dir, &error))?;
|
||||
let file = done
|
||||
.path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
snapshot(
|
||||
&instance,
|
||||
&run_dir,
|
||||
Some(SavesOped::BackedUp {
|
||||
dir: dir.to_string(),
|
||||
file,
|
||||
path: done.path.display().to_string(),
|
||||
bytes: done.bytes,
|
||||
skipped: done.skipped,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 备份的默认落点:`<data>/exports/saves/`(见模块文档「备份落点」一节)。
|
||||
fn export_dir(env: &HostEnv) -> PathBuf {
|
||||
env.layout.data().join("exports").join("saves")
|
||||
}
|
||||
|
||||
/// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。
|
||||
@@ -98,7 +240,7 @@ fn open_folder(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureVie
|
||||
let target = open_target(&scan, &run_dir);
|
||||
opener::open_dir(&target)
|
||||
.map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?;
|
||||
Ok(view(&instance, &scan))
|
||||
Ok(view(&instance.id, &scan, None))
|
||||
}
|
||||
|
||||
/// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。
|
||||
@@ -144,9 +286,9 @@ fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String>
|
||||
}
|
||||
|
||||
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
|
||||
fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
|
||||
fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.id.clone(),
|
||||
instance: instance.to_string(),
|
||||
dir: scan.dir.display().to_string(),
|
||||
exists: scan.exists,
|
||||
worlds: scan
|
||||
@@ -188,12 +330,15 @@ fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
last_op,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into<String>) -> SavesFailureView {
|
||||
SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind,
|
||||
detail: detail.into(),
|
||||
}
|
||||
@@ -208,5 +353,34 @@ fn unusable(instance: &str, error: &SavesError) -> SavesFailureView {
|
||||
)
|
||||
}
|
||||
|
||||
/// core 的写失败 → 具名类别 + 「路径:原文」(**不翻译**,翻译在 app 层)。
|
||||
///
|
||||
/// 八个类别一一对应,没有一个落到「未知」兜底里:兜底会变成静默(用户只看到"失败了")。
|
||||
fn write_failure(
|
||||
instance: &str,
|
||||
op: SavesFailureOp,
|
||||
file: &str,
|
||||
error: &SavesWriteError,
|
||||
) -> SavesFailureView {
|
||||
let kind = match error.kind {
|
||||
SavesWriteKind::UnsafeName => SavesFailureKind::WriteUnsafeName,
|
||||
SavesWriteKind::SymbolicLink => SavesFailureKind::WriteSymlink,
|
||||
SavesWriteKind::NotFound => SavesFailureKind::WriteMissing,
|
||||
SavesWriteKind::NotADirectory => SavesFailureKind::WriteNotADirectory,
|
||||
SavesWriteKind::Conflict => SavesFailureKind::WriteConflict,
|
||||
SavesWriteKind::Boundary => SavesFailureKind::WriteBoundary,
|
||||
SavesWriteKind::TooLarge => SavesFailureKind::WriteTooLarge,
|
||||
SavesWriteKind::Io => SavesFailureKind::WriteFailed,
|
||||
};
|
||||
let mut failure = failure(
|
||||
instance,
|
||||
kind,
|
||||
format!("{}:{}", error.path.display(), error.detail),
|
||||
);
|
||||
failure.file = file.to_string();
|
||||
failure.op = Some(op);
|
||||
failure
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -73,6 +73,12 @@ fn instance() -> InstanceSummary {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个真世界(`saves/<name>/level.dat`)。
|
||||
fn world(run: &std::path::Path, name: &str) {
|
||||
std::fs::create_dir_all(run.join("saves").join(name)).expect("建世界");
|
||||
std::fs::write(run.join("saves").join(name).join("level.dat"), b"nbt").expect("写 level.dat");
|
||||
}
|
||||
|
||||
/// **端到端**(真盘 + 真派生):世界 / 截图 / 跳过原因都过桥,且快照里的路径就是派生目录。
|
||||
///
|
||||
/// 抓得住:派生用错目录、`icon.png` 被当成"读到了"、跳过原因被折叠、字节数丢了。
|
||||
@@ -194,6 +200,179 @@ fn every_core_skip_reason_keeps_its_own_kind() {
|
||||
reason,
|
||||
}],
|
||||
};
|
||||
assert_eq!(view(&instance(), &scan).skipped[0].reason, expected);
|
||||
assert_eq!(view(ID, &scan, None).skipped[0].reason, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// **端到端**:重命名 → 真盘改名 + 新快照 + 回执。
|
||||
///
|
||||
/// 抓得住:host 用错运行目录、写完成却忘了重新列(界面还画旧名字)、回执与快照对不上。
|
||||
#[test]
|
||||
fn rename_round_trips_on_a_real_root() {
|
||||
let (_root, env, run) = fixture("saves-rename");
|
||||
world(&run, "Old World");
|
||||
world(&run, "Other");
|
||||
|
||||
let listed = rename(&env, ID, "Old World", "New World").expect("改名");
|
||||
assert!(!run.join("saves/Old World").exists(), "旧目录还在");
|
||||
assert!(
|
||||
run.join("saves/New World/level.dat").exists(),
|
||||
"新目录里没有内容"
|
||||
);
|
||||
let names: Vec<&str> = listed
|
||||
.worlds
|
||||
.iter()
|
||||
.map(|world| world.dir.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, ["New World", "Other"], "快照没跟着刷新");
|
||||
assert_eq!(
|
||||
listed.last_op,
|
||||
Some(SavesOped::Renamed {
|
||||
from: "Old World".to_string(),
|
||||
to: "New World".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// **端到端**:删除 → 真盘没了 + 新快照 + 回执。
|
||||
#[test]
|
||||
fn delete_round_trips_on_a_real_root() {
|
||||
let (_root, env, run) = fixture("saves-delete");
|
||||
world(&run, "Doomed");
|
||||
world(&run, "Kept");
|
||||
|
||||
let listed = delete(&env, ID, "Doomed").expect("删除");
|
||||
assert!(!run.join("saves/Doomed").exists(), "目录还在");
|
||||
let names: Vec<&str> = listed
|
||||
.worlds
|
||||
.iter()
|
||||
.map(|world| world.dir.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, ["Kept"], "别的世界被连带删了 / 快照没刷新");
|
||||
assert_eq!(
|
||||
listed.last_op,
|
||||
Some(SavesOped::Deleted {
|
||||
dir: "Doomed".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// **端到端**:备份 → zip 落在启动器自己的 `exports/saves/`,回执带路径与字节数。
|
||||
#[test]
|
||||
fn backup_round_trips_to_the_exports_dir() {
|
||||
let (_root, env, run) = fixture("saves-backup");
|
||||
world(&run, "My World");
|
||||
|
||||
let listed = backup(&env, ID, "My World", None).expect("备份");
|
||||
let exports = env.layout.data().join("exports").join("saves");
|
||||
let Some(SavesOped::BackedUp {
|
||||
dir,
|
||||
file,
|
||||
path,
|
||||
bytes,
|
||||
skipped,
|
||||
}) = listed.last_op.clone()
|
||||
else {
|
||||
panic!("备份成功却没有回执:{:?}", listed.last_op);
|
||||
};
|
||||
assert_eq!(dir, "My World");
|
||||
assert!(
|
||||
file.starts_with("My World-"),
|
||||
"文件名要带世界名与时戳:{file}"
|
||||
);
|
||||
assert!(file.ends_with(".zip"), "文件名要带 .zip:{file}");
|
||||
assert_eq!(std::path::PathBuf::from(&path), exports.join(&file));
|
||||
assert!(std::path::Path::new(&path).exists(), "zip 没真的落盘");
|
||||
assert!(bytes > 0);
|
||||
assert_eq!(skipped, 0);
|
||||
// 备份不改世界本身:列表还是一行。
|
||||
assert_eq!(listed.world_count(), 1);
|
||||
// 显式落点也要照用(将来接了目录选择框就走这条)。
|
||||
let custom = backup(
|
||||
&env,
|
||||
ID,
|
||||
"My World",
|
||||
Some(&run.join("elsewhere").display().to_string()),
|
||||
)
|
||||
.expect("备份到指定目录");
|
||||
assert!(matches!(custom.last_op, Some(SavesOped::BackedUp { .. })));
|
||||
assert!(run.join("elsewhere").is_dir(), "显式落点没被采用");
|
||||
}
|
||||
|
||||
/// 八种写失败各给**自己的**类别(没有一个落到「未知」兜底)。
|
||||
#[test]
|
||||
fn every_write_failure_keeps_its_own_kind() {
|
||||
let (_root, env, run) = fixture("saves-write-kinds");
|
||||
world(&run, "Taken");
|
||||
world(&run, "Other");
|
||||
|
||||
let cases = [
|
||||
("Other", "Taken", SavesFailureKind::WriteConflict),
|
||||
("Other", "..", SavesFailureKind::WriteUnsafeName),
|
||||
("Nope", "Fresh", SavesFailureKind::WriteMissing),
|
||||
];
|
||||
for (from, to, expected) in cases {
|
||||
let failure = rename(&env, ID, from, to).expect_err("改成功了");
|
||||
assert_eq!(failure.kind, expected, "{from} → {to}:{}", failure.detail);
|
||||
}
|
||||
|
||||
let failure = delete(&env, ID, "Nope").expect_err("删成功了");
|
||||
assert_eq!(failure.kind, SavesFailureKind::WriteMissing);
|
||||
// 空 / 相对的落点目录:会落到进程当前目录,所以是"越界"而不是"IO 失败"。
|
||||
let failure = backup(&env, ID, "Taken", Some("")).expect_err("备份到空目录居然成功了");
|
||||
assert_eq!(
|
||||
failure.kind,
|
||||
SavesFailureKind::WriteBoundary,
|
||||
"{}",
|
||||
failure.detail
|
||||
);
|
||||
|
||||
// core 的八个类别 → DTO 八个类别(一一对应,不折叠)。
|
||||
let pairs = [
|
||||
(
|
||||
SavesWriteKind::UnsafeName,
|
||||
SavesFailureKind::WriteUnsafeName,
|
||||
),
|
||||
(SavesWriteKind::SymbolicLink, SavesFailureKind::WriteSymlink),
|
||||
(SavesWriteKind::NotFound, SavesFailureKind::WriteMissing),
|
||||
(
|
||||
SavesWriteKind::NotADirectory,
|
||||
SavesFailureKind::WriteNotADirectory,
|
||||
),
|
||||
(SavesWriteKind::Conflict, SavesFailureKind::WriteConflict),
|
||||
(SavesWriteKind::Boundary, SavesFailureKind::WriteBoundary),
|
||||
(SavesWriteKind::TooLarge, SavesFailureKind::WriteTooLarge),
|
||||
(SavesWriteKind::Io, SavesFailureKind::WriteFailed),
|
||||
];
|
||||
for (core, expected) in pairs {
|
||||
let error = SavesWriteError {
|
||||
path: PathBuf::from("C:/mc/saves/x"),
|
||||
kind: core,
|
||||
detail: "原文".to_string(),
|
||||
};
|
||||
let failure = write_failure(ID, SavesFailureOp::Backup, "Taken", &error);
|
||||
assert_eq!(failure.kind, expected, "{core:?}");
|
||||
assert!(
|
||||
failure.detail.contains("saves"),
|
||||
"原文丢了:{}",
|
||||
failure.detail
|
||||
);
|
||||
assert_eq!(failure.instance, ID);
|
||||
assert_eq!(failure.file, "Taken", "失败那句话要指名是哪一行");
|
||||
assert_eq!(failure.op, Some(SavesFailureOp::Backup));
|
||||
}
|
||||
}
|
||||
|
||||
/// 找不到实例的写操作 ⇒ 同一个具名失败(不是"写成功了个空的")。
|
||||
#[test]
|
||||
fn write_ops_on_a_vanished_instance_are_named() {
|
||||
let (_root, env, _run) = fixture("saves-write-unknown");
|
||||
for failure in [
|
||||
rename(&env, "gone", "A", "B").expect_err("改名成功了"),
|
||||
delete(&env, "gone", "A").expect_err("删除成功了"),
|
||||
backup(&env, "gone", "A", None).expect_err("备份成功了"),
|
||||
] {
|
||||
assert_eq!(failure.kind, SavesFailureKind::InstanceUnavailable);
|
||||
assert_eq!(failure.instance, "gone");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user