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
@@ -29,14 +29,24 @@ use super::super::Texts;
|
||||
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
|
||||
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
|
||||
const ALLOWED: &[&str] = &[
|
||||
"Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB",
|
||||
"Java",
|
||||
"pid",
|
||||
"JVM",
|
||||
"OpenGL",
|
||||
"Vulkan",
|
||||
"MiB",
|
||||
// M224:`mods` 是**目录名**(文件系统事实)。中文界面上写成「模组目录」会让用户找不到那个
|
||||
// 文件夹——`mods/` 就是他要打开的那个东西,所以两套语言都原样写它。
|
||||
"mods",
|
||||
// M235:`saves` / `screenshots` 同样是**目录名**(文件系统事实,玩家要照着去找)。
|
||||
// 「正在读取 saves/…」「还没有 saves/ 或 screenshots/ 目录」里的这两个词不许翻——
|
||||
// 翻成「存档目录」用户就对不上那两层文件夹了。
|
||||
"saves",
|
||||
"screenshots",
|
||||
];
|
||||
|
||||
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
||||
const PAGE_SOURCES: [(&str, &str); 7] = [
|
||||
const PAGE_SOURCES: [(&str, &str); 8] = [
|
||||
("instances.rs", include_str!("../../instances.rs")),
|
||||
("instances/detail.rs", include_str!("../detail.rs")),
|
||||
("instances/demo.rs", include_str!("../demo.rs")),
|
||||
@@ -51,6 +61,8 @@ const PAGE_SOURCES: [(&str, &str); 7] = [
|
||||
),
|
||||
// M224:MODS 区(本地 mod 列表 + 启停开关)
|
||||
("instances/mods.rs", include_str!("../mods.rs")),
|
||||
// M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
|
||||
("instances/saves.rs", include_str!("../saves.rs")),
|
||||
];
|
||||
|
||||
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
|
||||
@@ -68,7 +80,7 @@ const PAGE_SOURCES: [(&str, &str); 7] = [
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 28] = [
|
||||
const ALLOWED_LITERALS: [&str; 30] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
@@ -110,6 +122,9 @@ const ALLOWED_LITERALS: [&str; 28] = [
|
||||
"disabled",
|
||||
"count",
|
||||
"file",
|
||||
// M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。
|
||||
"worlds",
|
||||
"shots",
|
||||
];
|
||||
|
||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! 「存档区」(M235)的**模型**判据:快照 → 页面要画的东西。
|
||||
//!
|
||||
//! 为什么只测一个纯函数:iced 0.14 的 `Element` 没有内省接口(与 `tests/launch_args.rs`
|
||||
//! 同一取舍),画出来的行在测试里拿不到。所以把"快照 → 行/计数/失败线"全部压在
|
||||
//! [`saves::from_state`] 里,在这里逐条钉住——页面那一支只负责把这些字段画出来。
|
||||
//!
|
||||
//! 守门形状:`detail.rs` 必须调 `saves::section(ui, saves, …)`(`instances/tests.rs` 的源码
|
||||
//! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
|
||||
ScreenshotView, WorldView,
|
||||
};
|
||||
use unlucky_presenter::state::SavesState;
|
||||
|
||||
use super::super::saves::{failure_line, from_state};
|
||||
use super::super::Texts;
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
/// 2024-05-01 10:00 UTC(UTC 毫秒)。
|
||||
const STAMP: u64 = 1_714_557_600_000;
|
||||
|
||||
fn view(instance: &str) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.to_string(),
|
||||
dir: "C:/mc/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "Better World".to_string(),
|
||||
icon: true,
|
||||
modified_ms: Some(STAMP),
|
||||
}],
|
||||
screenshots_dir: "C:/mc/screenshots".to_string(),
|
||||
screenshots_exists: true,
|
||||
screenshots: vec![ScreenshotView {
|
||||
file: "2024-05-01_10.00.00.png".to_string(),
|
||||
bytes: 2_048,
|
||||
modified_ms: None,
|
||||
}],
|
||||
skipped: vec![SavesSkipView {
|
||||
area: SavesArea::Screenshots,
|
||||
file: "notes.txt".to_string(),
|
||||
reason: SavesSkipKind::NotAnImage,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// 已绑定实例的视图态(`bind` 是 presenter 内部的 `pub(super)`,这里直接写字段——
|
||||
/// 它本来就是公开状态的一部分,与 `AppState::bind_saves` 走的是同一条路)。
|
||||
fn bound_state(instance: &str) -> SavesState {
|
||||
SavesState {
|
||||
bound: Some(instance.to_string()),
|
||||
..SavesState::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 快照 → 行:世界带「有图标」,截图带大小;时间**按 UTC 切**,取不到就说"时间未知"。
|
||||
#[test]
|
||||
fn the_snapshot_becomes_rows_with_stamps_and_badges() {
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
|
||||
assert!(!saves.loading);
|
||||
assert!(!saves.retryable, "成功的快照不该带重试");
|
||||
assert_eq!(saves.worlds.len(), 1);
|
||||
assert_eq!(saves.worlds[0].name, "Better World");
|
||||
let zh_icon = Texts::of(Lang::ZhCn.catalog()).saves_world_icon();
|
||||
assert!(
|
||||
saves.worlds[0].detail.contains("2024-05-01 10:00 UTC"),
|
||||
"世界的副文案要带 UTC 时间:{:?}",
|
||||
saves.worlds[0].detail
|
||||
);
|
||||
assert!(
|
||||
saves.worlds[0].detail.contains(zh_icon),
|
||||
"有 icon.png 的世界要带标记:{:?}",
|
||||
saves.worlds[0].detail
|
||||
);
|
||||
|
||||
assert_eq!(saves.screenshots.len(), 1);
|
||||
assert!(
|
||||
saves.screenshots[0].detail.contains("2.0 KB"),
|
||||
"截图的副文案要带大小:{:?}",
|
||||
saves.screenshots[0].detail
|
||||
);
|
||||
assert!(
|
||||
saves.screenshots[0]
|
||||
.detail
|
||||
.contains(Texts::of(Lang::ZhCn.catalog()).saves_time_unknown()),
|
||||
"mtime 缺失时要说『时间未知』,不许拿 0 或当前时间糊:{:?}",
|
||||
saves.screenshots[0].detail
|
||||
);
|
||||
|
||||
// 计数与跳过:3 个世界 / 12 张截图这种句子在这一层拼好。
|
||||
assert_eq!(saves.count, "1 个世界 · 1 张截图");
|
||||
assert_eq!(saves.skipped, 1);
|
||||
assert_eq!(saves.skipped_note, "跳过 1 项");
|
||||
}
|
||||
|
||||
/// 首帧(已绑定、还没有快照)是 `loading`;没有绑定则什么都不算"已加载"。
|
||||
#[test]
|
||||
fn a_bound_instance_without_a_snapshot_is_loading() {
|
||||
let state = bound_state("a");
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.loading);
|
||||
assert!(!saves.loaded());
|
||||
|
||||
let idle = from_state(&SavesState::default(), Lang::En.catalog());
|
||||
assert!(!idle.loading, "不在详情页不该画『正在读取』");
|
||||
assert!(!idle.loaded());
|
||||
}
|
||||
|
||||
/// 两个目录都不存在 ⇒ `missing`(画"还没有目录"那句,而不是"这里空空的")。
|
||||
/// 目录在但里面什么都没有 ⇒ `nothing_yet`。
|
||||
#[test]
|
||||
fn missing_directories_and_empty_ones_are_different_states() {
|
||||
let mut state = bound_state("a");
|
||||
let mut gone = view("a");
|
||||
gone.exists = false;
|
||||
gone.screenshots_exists = false;
|
||||
gone.worlds.clear();
|
||||
gone.screenshots.clear();
|
||||
gone.skipped.clear();
|
||||
state.changed(gone);
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.missing);
|
||||
assert!(!saves.nothing_yet(), "缺目录不该被当成『目录在但是空的』");
|
||||
|
||||
let mut state = bound_state("a");
|
||||
let mut empty = view("a");
|
||||
empty.worlds.clear();
|
||||
empty.screenshots.clear();
|
||||
empty.skipped.clear();
|
||||
state.changed(empty);
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(!saves.missing);
|
||||
assert!(saves.nothing_yet());
|
||||
}
|
||||
|
||||
/// 失败:读盘失败**可重试**,打开文件夹失败**不可重试**(重试按钮会去重新列目录,那是错的动作)。
|
||||
///
|
||||
/// 抓得住:把两种失败合成一条「重试」;也抓得住"失败顺手把列表清掉"(用户看不见上一次读到的东西)。
|
||||
#[test]
|
||||
fn only_list_failures_offer_a_retry() {
|
||||
for (kind, retryable) in [
|
||||
(SavesFailureKind::DirUnusable, true),
|
||||
(SavesFailureKind::InstanceUnavailable, true),
|
||||
(SavesFailureKind::OpenFailed, false),
|
||||
] {
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
kind,
|
||||
detail: "Access is denied.".to_string(),
|
||||
});
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(!saves.loading);
|
||||
assert_eq!(saves.retryable, retryable, "{kind:?} 的重试口径错了");
|
||||
assert!(saves.error.is_some(), "{kind:?} 没有画出失败");
|
||||
assert_eq!(saves.worlds.len(), 1, "{kind:?} 把旧列表清掉了");
|
||||
assert_eq!(saves.screenshots.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// **只拿到失败、一份快照都没有**时,不许被画成"这里还没有世界"。
|
||||
///
|
||||
/// 抓得住:`nothing_yet()` 只看"两个列表都空"——那样"读不了"会顶着两句"这里空空的",
|
||||
/// 用户会把权限问题读成"我没有存档"。
|
||||
#[test]
|
||||
fn a_failure_without_a_snapshot_is_not_an_empty_list() {
|
||||
let mut state = bound_state("a");
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
});
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.error.is_some());
|
||||
assert!(!saves.has_view, "没有快照却记成了有");
|
||||
assert!(!saves.nothing_yet(), "纯失败态被画成了空列表");
|
||||
assert_eq!(saves.count, "", "没有快照不该画计数");
|
||||
}
|
||||
|
||||
/// 失败主句跟着语言与大类走,OS 原文只进括号(M161 的文案纪律)。
|
||||
#[test]
|
||||
fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
};
|
||||
let zh = failure_line(&failure, Texts::of(Lang::ZhCn.catalog()));
|
||||
assert!(zh.starts_with("读取 saves/ 失败:"), "{zh}");
|
||||
assert!(zh.ends_with("(Access is denied.)"), "{zh}");
|
||||
|
||||
let en = failure_line(&failure, Texts::of(Lang::En.catalog()));
|
||||
assert!(en.starts_with("Could not read saves/:"), "{en}");
|
||||
assert!(en.contains("Access is denied."), "{en}");
|
||||
|
||||
// 没有原文时不留一对空括号。
|
||||
let bare = SavesFailureView {
|
||||
detail: String::new(),
|
||||
..failure
|
||||
};
|
||||
let text = failure_line(&bare, Texts::of(Lang::ZhCn.catalog()));
|
||||
assert!(!text.contains('('), "空原文留下了一对括号:{text}");
|
||||
}
|
||||
|
||||
/// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。
|
||||
#[test]
|
||||
fn millisecond_stamps_are_rendered_in_utc() {
|
||||
let stamp = |ms: u64| {
|
||||
let mut state = bound_state("a");
|
||||
let mut view = view("a");
|
||||
view.worlds[0].modified_ms = Some(ms);
|
||||
state.changed(view);
|
||||
from_state(&state, Lang::En.catalog()).worlds[0]
|
||||
.detail
|
||||
.clone()
|
||||
};
|
||||
// 1970-01-01 00:00 UTC(纪元)
|
||||
assert!(stamp(0).starts_with("1970-01-01 00:00 UTC"), "{}", stamp(0));
|
||||
// 2024-05-01 10:00 UTC
|
||||
assert!(
|
||||
stamp(STAMP).starts_with("2024-05-01 10:00 UTC"),
|
||||
"{}",
|
||||
stamp(STAMP)
|
||||
);
|
||||
// 2024-02-29 23:59 UTC(闰日)
|
||||
assert!(
|
||||
stamp(1_709_251_140_000).starts_with("2024-02-29 23:59 UTC"),
|
||||
"{}",
|
||||
stamp(1_709_251_140_000)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user