feat(saves): 实例存档 / 截图的只读列表与打开文件夹(M235 STOREMVP)
用户诉求是 PCL 差距四项里的「存档截图 / 导入」,M230 审计确认三层全缺。塔裁决 U3
把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙(缩略图要引图片解码依赖,而
AGENTS §2 只允许 palette-extract 引 image 类依赖;截图/导入产物形态也没定型)。
core
- `game::saves`(新):只读列 `<run_dir>/saves`(世界目录)与 `<run_dir>/screenshots`
(图片文件)。只调 symlink_metadata / read_dir / metadata,不建目录、不写文件、不读
文件内容(`icon.png` 只判存在)。目录不存在 ⇒ 空列表不是错误;存在但不可用(符号
链接 / 不是目录 / 读不了)⇒ 具名错误。条目级异常逐条登记原因,不静默丢。
- `platform::opener`(新):`open_dir` 是**目录**打开器的唯一收敛点(explorer / open /
xdg-open),内部复用 `child::quiet`,cfg 分支只此一处。塔明确要求按 AGENTS §6 放
platform 而不是业务模块,并要一条可执行判据防退化
(`the_launcher_literals_live_here_only` 扫 `crates/core/src`:除本文件外不许出现
`"explorer"` / `"xdg-open"` 字面量)。
presenter
- `dto::saves`:SavesCommand(List / OpenFolder)、SavesListView、SavesFailureView
(结构 + OS 原文,不拼中文)、`SavesListView` 的计数助手。
- `host::saves`:走 `job::spawn`,UI 只收快照;运行目录仍是
`isolation::derive_game_dir` 的同一个答案(与启动 / mod 列表同源,**不写盘**)。
「打开文件夹」打开哪个目录由 host 定:`saves/` → `screenshots/` → 运行目录。
失败分三类(InstanceUnavailable / DirUnusable / OpenFailed),可重试。
- `state::saves`:快照整体替换、迟到结果丢弃、失败保留绑定(重试才有意义)。
- 接线:CoreEvent::Saves / AppCommand::Saves / HostTask::Saves。
app-iced
- `pages::instances::saves`:详情页 SAVES 卡片(MODS 之后、启动参数之前;**不套第二层
滚动条**,整列那一条是唯一入口)。世界行带 UTC 时间与「有图标」,截图行带大小与时间;
第一行固定画「这一版只读」(U3 边界让用户看得见)。读盘失败画一条红字 + 「重试」;
打开文件夹失败**不画**重试(那个动作的重试就是再点一次按钮)。
- 接线:Message::{ReloadSaves, OpenSavesFolder}、`sync_saves`(与 `sync_mods` 同一判据)、
Model::saves、`saves_shot` 出图夹具(ULL_SAVES_FIXTURE=1)。
i18n
- `InstancesSaves*` 20 条中英成对,catalog 与金表在**同一条锚点**后追加、逐字节一致。
证据
- core 只读判据:`game::saves::tests` 在真盘建世界/截图,调用前后各拍一次
(相对路径, 是否目录, 字节数, mtime)指纹并逐条比对;另有用例钉「目录缺失不建目录」。
- presenter:`host::saves::tests` 真盘端到端(custom 档位派生运行目录 → 快照)。
- GUI:`--software-renderer` 1180×760 浅/深各一张 + 1180×1280 两张「整块卡片可见」
(1180×760 下 SAVES 卡片位于折叠线附近,只能看到卡头;第二对用 ULL_WINDOW_SIZE
把窗口拉高,口径与 M98 的矩阵一致),落 `DevDocs/artifacts/M235/`。
没做的(U3 边界内,见 handoff):缩略图墙、导入、删除/重命名、`level.dat` 的
LevelName 解析(世界名暂时就是目录名)。
This commit is contained in:
1 parent
3789cb7e61
commit
f217d83680
36 files changed
+2702
-17
No files matched your search
@@ -9,7 +9,8 @@ use crate::toast::ToastKind;
|
||||
|
||||
use super::accounts::{self, LoginJob};
|
||||
use super::{
|
||||
addon, game, install, java, launch, loader_versions, logs, mods, settings, versions, CoreHost,
|
||||
addon, game, install, java, launch, loader_versions, logs, mods, saves, settings, versions,
|
||||
CoreHost,
|
||||
};
|
||||
|
||||
impl CoreHost {
|
||||
@@ -289,6 +290,11 @@ impl CoreHost {
|
||||
let running = self.in_flight(&id);
|
||||
mods::dispatch(self.env.clone(), self.tx.clone(), *command, running);
|
||||
}
|
||||
// M235:实例存档 / 截图的只读列表 + 打开文件夹
|
||||
// (只读,所以不必问"游戏在不在跑";拉起文件管理器在 core 的 platform opener 里)
|
||||
AppCommand::Saves(command) => {
|
||||
saves::dispatch(self.env.clone(), self.tx.clone(), *command);
|
||||
}
|
||||
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
|
||||
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
|
||||
self.chat.dispatch(other);
|
||||
|
||||
@@ -233,6 +233,12 @@ impl CoreHost {
|
||||
}
|
||||
self.send(CoreEvent::Mods(Box::new(list)));
|
||||
}
|
||||
// M235:实例存档 / 截图的只读列表。成功 ⇒ 整份快照;失败 ⇒ 具名原因给 app 求文案。
|
||||
// 「打开文件夹」也是这条:拉起文件管理器之后再回一份新鲜快照(用户在那边删了世界,
|
||||
// 切回来这一拍就跟上)。
|
||||
HostTask::Saves(report) => {
|
||||
self.send(CoreEvent::Saves(Box::new(report.list)));
|
||||
}
|
||||
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,8 @@ pub enum HostTask {
|
||||
Update(Box<crate::host::update::Report>),
|
||||
/// 本地 Mod 列表 / 启停(M224):列表快照或具名失败(见 `host::mods::Report`)
|
||||
Mods(Box<super::mods::Report>),
|
||||
/// 实例存档 / 截图的只读列表(M235):快照或具名失败(见 `host::saves::Report`)
|
||||
Saves(Box<super::saves::Report>),
|
||||
}
|
||||
|
||||
/// 后台线程用的发送端(`Sender` 克隆即可)。
|
||||
|
||||
@@ -34,6 +34,7 @@ mod logs;
|
||||
mod meter;
|
||||
mod mods;
|
||||
mod opener;
|
||||
mod saves;
|
||||
mod settings;
|
||||
mod update;
|
||||
mod update_state;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! 实例存档 / 截图的宿主实现(M235 · STOREMVP)。
|
||||
//!
|
||||
//! ## 三条纪律
|
||||
//!
|
||||
//! | 纪律 | 落点 |
|
||||
//! |---|---|
|
||||
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] 走 `job::spawn`,UI 只收快照 |
|
||||
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
|
||||
//! | 只读 | 本模块一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
|
||||
//!
|
||||
//! ## 为什么不复用启动路径的 `run_dir()`
|
||||
//!
|
||||
//! 与 [`super::mods`] 同一条理由:`host::launch` 的 `run_dir()` 顺带把「这次用哪个档位」写进实例
|
||||
//! 设置(`observe_mode`)。**列存档不该因为用户点开详情页就写盘**,所以这里只取派生结果。
|
||||
//!
|
||||
//! ## 「打开文件夹」为什么由 host 决定打开哪个目录
|
||||
//!
|
||||
//! `saves/` 可能还不存在(新实例),而用户点「打开文件夹」想看到的是**能放存档的地方**。
|
||||
//! 于是退化成:`saves/` → `screenshots/` → 运行目录,取第一个存在的。这个判断是"磁盘上到底
|
||||
//! 有什么"的事实,放在 host 这一侧;UI 只发一条 [`crate::dto::SavesCommand::OpenFolder`]。
|
||||
//!
|
||||
//! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6:
|
||||
//! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。
|
||||
//!
|
||||
//! ## 失败口径
|
||||
//!
|
||||
//! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按
|
||||
//! i18n key 现求(M161 的纪律)。三类失败见 [`crate::dto::SavesFailureKind`]。
|
||||
|
||||
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::platform::opener;
|
||||
|
||||
use crate::dto::{
|
||||
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind,
|
||||
SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
|
||||
};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{spawn, JobSender};
|
||||
|
||||
/// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
/// 实例 id(迟到结果靠它判「这条还是不是当前实例的」)。
|
||||
pub instance: InstanceId,
|
||||
/// 列表结果:成功 = 整份快照;失败 = 具名原因。
|
||||
pub list: Result<SavesListView, SavesFailureView>,
|
||||
}
|
||||
|
||||
/// 后台列一个实例的 `saves/` 与 `screenshots/`。
|
||||
pub fn spawn_list(env: HostEnv, tx: JobSender, id: InstanceId) {
|
||||
spawn("saves-list", move || {
|
||||
let report = Report {
|
||||
instance: id.clone(),
|
||||
list: list(&env, &id),
|
||||
};
|
||||
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
|
||||
});
|
||||
}
|
||||
|
||||
/// 后台用系统文件管理器打开存档目录,**并把刷新后的快照一起带回来**。
|
||||
///
|
||||
/// 带快照回来不是顺手:用户常在文件管理器里删掉旧世界再切回来,页面这一拍就能跟着更新,
|
||||
/// 不用等下一次进详情页。
|
||||
pub fn spawn_open_folder(env: HostEnv, tx: JobSender, id: InstanceId) {
|
||||
spawn("saves-open", move || {
|
||||
let report = Report {
|
||||
instance: id.clone(),
|
||||
list: open_folder(&env, &id),
|
||||
};
|
||||
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
|
||||
});
|
||||
}
|
||||
|
||||
/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列目录(后台线程里调)。
|
||||
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))
|
||||
}
|
||||
|
||||
/// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。
|
||||
fn open_folder(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))?;
|
||||
let target = open_target(&scan, &run_dir);
|
||||
opener::open_dir(&target)
|
||||
.map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?;
|
||||
Ok(view(&instance, &scan))
|
||||
}
|
||||
|
||||
/// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。
|
||||
fn open_target(scan: &SavesScan, run_dir: &std::path::Path) -> PathBuf {
|
||||
if scan.exists {
|
||||
return scan.dir.clone();
|
||||
}
|
||||
if scan.screenshots_exists {
|
||||
return scan.screenshots_dir.clone();
|
||||
}
|
||||
run_dir.to_path_buf()
|
||||
}
|
||||
|
||||
/// 实例 + 它的运行目录(找不到实例 / 派生不出目录都给具名失败)。
|
||||
fn target(env: &HostEnv, id: &str) -> Result<(InstanceSummary, PathBuf), SavesFailureView> {
|
||||
let instances = super::game::instances(env);
|
||||
let Some(instance) = locate(&instances, id) else {
|
||||
return Err(failure(
|
||||
id,
|
||||
SavesFailureKind::InstanceUnavailable,
|
||||
format!("实例 {id} 不在快照里(可能刚被删除)"),
|
||||
));
|
||||
};
|
||||
let dir = run_dir(env, instance)
|
||||
.map_err(|detail| failure(&instance.id, SavesFailureKind::InstanceUnavailable, detail))?;
|
||||
Ok((instance.clone(), dir))
|
||||
}
|
||||
|
||||
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。
|
||||
fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
||||
let global = super::game::global_mode(env);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
||||
let derivation = derive_game_dir(
|
||||
&env.game,
|
||||
&instance.version_id,
|
||||
global,
|
||||
config.as_ref(),
|
||||
None,
|
||||
)
|
||||
.map_err(|error| format!("派生运行目录失败:{error}"))?;
|
||||
Ok(derivation.game_dir)
|
||||
}
|
||||
|
||||
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
|
||||
fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.id.clone(),
|
||||
dir: scan.dir.display().to_string(),
|
||||
exists: scan.exists,
|
||||
worlds: scan
|
||||
.worlds
|
||||
.iter()
|
||||
.map(|world| WorldView {
|
||||
dir: world.dir.clone(),
|
||||
icon: world.icon,
|
||||
modified_ms: world.modified_ms,
|
||||
})
|
||||
.collect(),
|
||||
screenshots_dir: scan.screenshots_dir.display().to_string(),
|
||||
screenshots_exists: scan.screenshots_exists,
|
||||
screenshots: scan
|
||||
.screenshots
|
||||
.iter()
|
||||
.map(|shot| ScreenshotView {
|
||||
file: shot.file.clone(),
|
||||
bytes: shot.bytes,
|
||||
modified_ms: shot.modified_ms,
|
||||
})
|
||||
.collect(),
|
||||
skipped: scan
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|skip| SavesSkipView {
|
||||
area: match skip.area {
|
||||
saves::Area::Saves => SavesArea::Saves,
|
||||
saves::Area::Screenshots => SavesArea::Screenshots,
|
||||
},
|
||||
file: skip.file.clone(),
|
||||
reason: match skip.reason {
|
||||
saves::SkipKind::SymbolicLink => SavesSkipKind::SymbolicLink,
|
||||
saves::SkipKind::NotADirectoryExpected => SavesSkipKind::NotADirectory,
|
||||
saves::SkipKind::NotAFile => SavesSkipKind::NotAFile,
|
||||
saves::SkipKind::UnsafeName => SavesSkipKind::UnsafeName,
|
||||
saves::SkipKind::Unreadable => SavesSkipKind::Unreadable,
|
||||
saves::SkipKind::NotAnImage => SavesSkipKind::NotAnImage,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into<String>) -> SavesFailureView {
|
||||
SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
kind,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// core 的错误 → 「目录不可用」+ 原文(**不翻译**,翻译在 app 层)。
|
||||
fn unusable(instance: &str, error: &SavesError) -> SavesFailureView {
|
||||
failure(
|
||||
instance,
|
||||
SavesFailureKind::DirUnusable,
|
||||
format!("{}:{}", error.dir.display(), error.detail),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,199 @@
|
||||
//! `host::saves` 的用例:**真盘端到端**(派生运行目录 → core 只读列目录 → 快照回来)。
|
||||
//!
|
||||
//! 端到端那条是这个模块存在的主要理由:只有"真的建一个实例根、真的放几个世界和截图再读回来"
|
||||
//! 才能验「派生出来的目录就是玩家看到的那个目录」。
|
||||
//!
|
||||
//! 抓不住什么:`OpenFolder` **不在这里真跑**(它会拉起用户的文件管理器);它唯一的判断
|
||||
//! (打开哪个目录)由 [`open_target_degrades_from_saves_to_run_dir`] 这条纯函数用例钉住,
|
||||
//! 真正 spawn 的那一步在 core 的 `platform::opener` 用例里。
|
||||
|
||||
use super::*;
|
||||
use crate::dto::{InstanceRuntime, LaunchArgsView, LoaderInfo, LoaderKind};
|
||||
use unlucky_core::game::saves::{SavesScan, SavesSkip, SkipKind};
|
||||
|
||||
const ID: &str = "survival";
|
||||
const VERSION: &str = "1.20.4-fabric-0.16.5";
|
||||
|
||||
/// 一次性临时根(与本 crate 其它 host 测试同款:不引第三方 tempfile)。
|
||||
struct TempRoot(PathBuf);
|
||||
|
||||
impl Drop for TempRoot {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个可用的便携根 + 一个 `custom` 档位的实例(运行目录由实例自己指,D20 里唯一这样表达的档位)。
|
||||
///
|
||||
/// 走 `custom` 是**故意**的:用例不必造 `versions/<id>/` 就能验「派生出来的目录就是读的那个」。
|
||||
fn fixture(tag: &str) -> (TempRoot, HostEnv, PathBuf) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
let root = TempRoot(path);
|
||||
std::fs::create_dir_all(&root.0).expect("建临时目录");
|
||||
let env = HostEnv::at_root(&root.0);
|
||||
let run = root.0.join("run");
|
||||
std::fs::create_dir_all(&run).expect("建运行目录");
|
||||
std::fs::create_dir_all(env.layout.config()).expect("建 config");
|
||||
let profile = format!(r#"{{"id":"{ID}","name":"生存","versionId":"{VERSION}"}}"#);
|
||||
std::fs::write(
|
||||
env.layout.profiles_config(),
|
||||
format!(r#"{{"schemaVersion":1,"profiles":[{profile}]}}"#),
|
||||
)
|
||||
.expect("写 profiles");
|
||||
let config = format!(
|
||||
r#"{{"schemaVersion":1,"id":"{ID}","versionId":"{VERSION}","isolation":"custom","gameDir":{}}}"#,
|
||||
serde_json::to_string(&run).expect("路径转 JSON")
|
||||
);
|
||||
let path = env.layout.instance_config(ID).expect("实例 id");
|
||||
std::fs::create_dir_all(path.parent().expect("instances 目录")).expect("建 instances");
|
||||
std::fs::write(path, config).expect("写实例设置");
|
||||
(root, env, run)
|
||||
}
|
||||
|
||||
fn instance() -> InstanceSummary {
|
||||
InstanceSummary {
|
||||
id: ID.to_string(),
|
||||
name: "生存".to_string(),
|
||||
mc_version: "1.20.4".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
},
|
||||
last_played: None,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "custom".to_string(),
|
||||
version_id: VERSION.to_string(),
|
||||
version_present: true,
|
||||
launch: LaunchArgsView::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// **端到端**(真盘 + 真派生):世界 / 截图 / 跳过原因都过桥,且快照里的路径就是派生目录。
|
||||
///
|
||||
/// 抓得住:派生用错目录、`icon.png` 被当成"读到了"、跳过原因被折叠、字节数丢了。
|
||||
#[test]
|
||||
fn list_round_trips_on_a_real_root() {
|
||||
let (_root, env, run) = fixture("saves-e2e");
|
||||
std::fs::create_dir_all(run.join("saves/My World")).expect("建世界");
|
||||
std::fs::write(run.join("saves/My World/icon.png"), b"png").expect("写图标");
|
||||
std::fs::create_dir_all(run.join("saves/Bare World")).expect("建世界 2");
|
||||
std::fs::create_dir_all(run.join("screenshots")).expect("建截图目录");
|
||||
std::fs::write(run.join("screenshots/2024-05-01_10.00.00.png"), b"abc").expect("写截图");
|
||||
std::fs::write(run.join("screenshots/notes.txt"), b"x").expect("写非图片");
|
||||
|
||||
let listed = list(&env, ID).expect("列存档");
|
||||
assert!(listed.exists && listed.screenshots_exists);
|
||||
assert_eq!(std::path::PathBuf::from(&listed.dir), run.join("saves"));
|
||||
assert_eq!(
|
||||
std::path::PathBuf::from(&listed.screenshots_dir),
|
||||
run.join("screenshots")
|
||||
);
|
||||
|
||||
let worlds: Vec<(&str, bool)> = listed
|
||||
.worlds
|
||||
.iter()
|
||||
.map(|world| (world.dir.as_str(), world.icon))
|
||||
.collect();
|
||||
assert_eq!(worlds, [("Bare World", false), ("My World", true)]);
|
||||
assert_eq!(listed.world_count(), 2);
|
||||
|
||||
assert_eq!(listed.screenshot_count(), 1);
|
||||
assert_eq!(listed.screenshots[0].bytes, 3);
|
||||
assert_eq!(
|
||||
listed.skipped_count(),
|
||||
1,
|
||||
"非图片文件要登记原因,不能静默丢"
|
||||
);
|
||||
assert_eq!(listed.skipped[0].reason, SavesSkipKind::NotAnImage);
|
||||
assert_eq!(listed.skipped[0].area, SavesArea::Screenshots);
|
||||
}
|
||||
|
||||
/// 「打开文件夹」打开哪个目录:`saves/` → `screenshots/` → 运行目录。
|
||||
///
|
||||
/// 纯函数,**不 spawn**(真拉起文件管理器那一步在 core 的 opener 用例里)。
|
||||
#[test]
|
||||
fn open_target_degrades_from_saves_to_run_dir() {
|
||||
let run = PathBuf::from("D:/ULL/.ULL/data/instances/x");
|
||||
let mut scan = SavesScan {
|
||||
dir: run.join("saves"),
|
||||
exists: true,
|
||||
worlds: Vec::new(),
|
||||
screenshots_dir: run.join("screenshots"),
|
||||
screenshots_exists: true,
|
||||
screenshots: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
};
|
||||
assert_eq!(open_target(&scan, &run), run.join("saves"));
|
||||
|
||||
scan.exists = false;
|
||||
assert_eq!(
|
||||
open_target(&scan, &run),
|
||||
run.join("screenshots"),
|
||||
"saves 不在时退到 screenshots"
|
||||
);
|
||||
|
||||
scan.screenshots_exists = false;
|
||||
assert_eq!(open_target(&scan, &run), run, "两个都没有时打开运行目录");
|
||||
}
|
||||
|
||||
/// 找不到实例 ⇒ 具名失败(不是"空列表":那会让页面以为这实例没存档)。
|
||||
#[test]
|
||||
fn missing_instance_is_a_named_failure() {
|
||||
let (_root, env, _run) = fixture("saves-unknown");
|
||||
let failure = list(&env, "no-such-instance").expect_err("不存在的实例居然列成功了");
|
||||
assert_eq!(failure.kind, SavesFailureKind::InstanceUnavailable);
|
||||
assert_eq!(failure.instance, "no-such-instance");
|
||||
assert!(!failure.detail.is_empty(), "原因被吃掉了");
|
||||
}
|
||||
|
||||
/// `saves/` 存在但不可用 ⇒ `DirUnusable`,且原文里带着那个路径(用户能自己去看)。
|
||||
#[test]
|
||||
fn unusable_dir_maps_to_dir_unusable() {
|
||||
let (_root, env, run) = fixture("saves-unusable");
|
||||
std::fs::write(run.join("saves"), b"not a dir").expect("用文件占住 saves");
|
||||
|
||||
let failure = list(&env, ID).expect_err("saves 是个文件,居然列成功了");
|
||||
assert_eq!(failure.kind, SavesFailureKind::DirUnusable);
|
||||
assert!(
|
||||
failure.detail.contains("saves"),
|
||||
"原文里没有路径:{}",
|
||||
failure.detail
|
||||
);
|
||||
}
|
||||
|
||||
/// 跳过原因一一对应(`NotADirectoryExpected` → `NotADirectory` 这条尤其容易抄错)。
|
||||
#[test]
|
||||
fn every_core_skip_reason_keeps_its_own_kind() {
|
||||
let cases = [
|
||||
(SkipKind::SymbolicLink, SavesSkipKind::SymbolicLink),
|
||||
(
|
||||
SkipKind::NotADirectoryExpected,
|
||||
SavesSkipKind::NotADirectory,
|
||||
),
|
||||
(SkipKind::NotAFile, SavesSkipKind::NotAFile),
|
||||
(SkipKind::UnsafeName, SavesSkipKind::UnsafeName),
|
||||
(SkipKind::Unreadable, SavesSkipKind::Unreadable),
|
||||
(SkipKind::NotAnImage, SavesSkipKind::NotAnImage),
|
||||
];
|
||||
for (reason, expected) in cases {
|
||||
let scan = SavesScan {
|
||||
dir: PathBuf::from("s"),
|
||||
exists: true,
|
||||
worlds: Vec::new(),
|
||||
screenshots_dir: PathBuf::from("sh"),
|
||||
screenshots_exists: true,
|
||||
screenshots: Vec::new(),
|
||||
skipped: vec![SavesSkip {
|
||||
area: saves::Area::Saves,
|
||||
file: "x".to_string(),
|
||||
reason,
|
||||
}],
|
||||
};
|
||||
assert_eq!(view(&instance(), &scan).skipped[0].reason, expected);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user