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
@@ -43,10 +43,13 @@ const ALLOWED: &[&str] = &[
|
||||
// 翻成「存档目录」用户就对不上那两层文件夹了。
|
||||
"saves",
|
||||
"screenshots",
|
||||
// M247:`zip` 是**归档格式名**(备份导出成什么,玩家拿去给别的东西解压时看的也是这个词)。
|
||||
// 中文界面上写「压缩包」会让「备份出来的是 .zip 吗」这个问题没有答案。
|
||||
"zip",
|
||||
];
|
||||
|
||||
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
||||
const PAGE_SOURCES: [(&str, &str); 8] = [
|
||||
const PAGE_SOURCES: [(&str, &str); 9] = [
|
||||
("instances.rs", include_str!("../../instances.rs")),
|
||||
("instances/detail.rs", include_str!("../detail.rs")),
|
||||
("instances/demo.rs", include_str!("../demo.rs")),
|
||||
@@ -63,6 +66,8 @@ const PAGE_SOURCES: [(&str, &str); 8] = [
|
||||
("instances/mods.rs", include_str!("../mods.rs")),
|
||||
// M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
|
||||
("instances/saves.rs", include_str!("../saves.rs")),
|
||||
// M247:SAVES 区的行与操作区(重命名 / 备份 / 删除 + 行内输入框)
|
||||
("instances/saves/rows.rs", include_str!("../saves/rows.rs")),
|
||||
];
|
||||
|
||||
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
|
||||
@@ -80,7 +85,7 @@ const PAGE_SOURCES: [(&str, &str); 8] = [
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 30] = [
|
||||
const ALLOWED_LITERALS: [&str; 35] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
@@ -125,6 +130,13 @@ const ALLOWED_LITERALS: [&str; 30] = [
|
||||
// M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。
|
||||
"worlds",
|
||||
"shots",
|
||||
// M247:删除确认与写操作回执的**占位符名**(`{dir}` 世界名 / `{from}` `{to}` 改名两端 /
|
||||
// `{path}` 备份落点 / `{size}` 人的可读字节数),都不是文案。
|
||||
"dir",
|
||||
"from",
|
||||
"to",
|
||||
"path",
|
||||
"size",
|
||||
];
|
||||
|
||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
//! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
|
||||
ScreenshotView, WorldView,
|
||||
SavesArea, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView, SavesOped,
|
||||
SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
|
||||
};
|
||||
use unlucky_presenter::state::SavesState;
|
||||
use unlucky_presenter::state::{SavesOp, SavesState};
|
||||
|
||||
use super::super::saves::{failure_line, from_state};
|
||||
use super::super::saves::{failure_line, from_state, op_line};
|
||||
use super::super::Texts;
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
@@ -42,6 +42,7 @@ fn view(instance: &str) -> SavesListView {
|
||||
file: "notes.txt".to_string(),
|
||||
reason: SavesSkipKind::NotAnImage,
|
||||
}],
|
||||
last_op: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +152,8 @@ fn only_list_failures_offer_a_retry() {
|
||||
state.changed(view("a"));
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind,
|
||||
detail: "Access is denied.".to_string(),
|
||||
});
|
||||
@@ -172,6 +175,8 @@ fn a_failure_without_a_snapshot_is_not_an_empty_list() {
|
||||
let mut state = bound_state("a");
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
});
|
||||
@@ -187,6 +192,8 @@ fn a_failure_without_a_snapshot_is_not_an_empty_list() {
|
||||
fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
};
|
||||
@@ -207,6 +214,137 @@ fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
|
||||
assert!(!text.contains('('), "空原文留下了一对括号:{text}");
|
||||
}
|
||||
|
||||
/// 写操作失败:**不画重试**(重试按钮会去重新列目录,那是错的动作),并且主句带**哪个世界**。
|
||||
///
|
||||
/// 抓得住:三种写失败被折叠成同一句「读取 saves/ 失败」——用户看不出是哪一行出了什么事。
|
||||
#[test]
|
||||
fn write_failures_are_named_per_operation_and_never_retryable() {
|
||||
let cases = [
|
||||
(
|
||||
SavesFailureOp::Rename,
|
||||
SavesFailureKind::WriteConflict,
|
||||
"重命名 Better World 失败:同名世界已存在",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Delete,
|
||||
SavesFailureKind::WriteMissing,
|
||||
"删除 Better World 失败:那个世界已不在",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Backup,
|
||||
SavesFailureKind::WriteTooLarge,
|
||||
"备份 Better World 失败:这个世界太大,一个 zip 装不下",
|
||||
),
|
||||
];
|
||||
for (op, kind, expected) in cases {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: "Better World".to_string(),
|
||||
op: Some(op),
|
||||
kind,
|
||||
detail: String::new(),
|
||||
};
|
||||
let line = failure_line(&failure, Texts::of(Lang::ZhCn.catalog()));
|
||||
assert_eq!(line, expected);
|
||||
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.failed(failure);
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
assert!(!saves.retryable, "{op:?} 失败了还画着重试按钮");
|
||||
assert!(saves.error.is_some());
|
||||
assert_eq!(saves.worlds.len(), 1, "{op:?} 失败把列表清了");
|
||||
}
|
||||
}
|
||||
|
||||
/// 三样交互态(草稿 / 在飞 / 回执)都要变成页面字段:改名中的那一行、置灰的那一行、成功那句话。
|
||||
#[test]
|
||||
fn draft_pending_and_receipt_become_page_fields() {
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.start_rename("Better World");
|
||||
state.edit_rename("Renamed".to_string());
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
let editing = saves.editing.as_ref().expect("草稿没进页面模型");
|
||||
assert_eq!(editing.dir, "Better World");
|
||||
assert_eq!(editing.text, "Renamed");
|
||||
assert!(!saves.busy_with("Better World"), "还在打字就算在飞了");
|
||||
|
||||
state.mark_pending(SavesOp::Backup {
|
||||
dir: "Better World".to_string(),
|
||||
});
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.editing.is_none(), "发请求时草稿该收起来");
|
||||
assert!(saves.busy_with("Better World"));
|
||||
assert!(!saves.busy_with("Other"));
|
||||
|
||||
// 回执:host 写在快照里,下一次普通 List 就没了。
|
||||
let mut done = view("a");
|
||||
done.last_op = Some(SavesOped::BackedUp {
|
||||
dir: "Better World".to_string(),
|
||||
file: "Better World-20240501-100000.zip".to_string(),
|
||||
path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(),
|
||||
bytes: 2_048,
|
||||
skipped: 2,
|
||||
});
|
||||
state.changed(done);
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
let notice = saves.notice.as_deref().expect("回执没进页面模型");
|
||||
assert!(notice.contains("2.0 KB"), "回执要带大小:{notice}");
|
||||
assert!(
|
||||
notice.contains("Better World-20240501-100000.zip"),
|
||||
"{notice}"
|
||||
);
|
||||
assert!(
|
||||
notice.contains("跳过 2 个符号链接"),
|
||||
"跳过的链接要报出来:{notice}"
|
||||
);
|
||||
assert!(saves.busy.is_none(), "回程之后还在飞");
|
||||
}
|
||||
|
||||
/// 三种回执的措辞(重命名 / 删除 / 备份)。
|
||||
#[test]
|
||||
fn every_receipt_says_what_happened() {
|
||||
let texts = Texts::of(Lang::ZhCn.catalog());
|
||||
assert_eq!(
|
||||
op_line(
|
||||
&SavesOped::Renamed {
|
||||
from: "A".to_string(),
|
||||
to: "B".to_string()
|
||||
},
|
||||
texts
|
||||
),
|
||||
"已把 A 改名为 B。"
|
||||
);
|
||||
assert_eq!(
|
||||
op_line(
|
||||
&SavesOped::Deleted {
|
||||
dir: "A".to_string()
|
||||
},
|
||||
texts
|
||||
),
|
||||
"已删除 A。"
|
||||
);
|
||||
let backed_up = op_line(
|
||||
&SavesOped::BackedUp {
|
||||
dir: "A".to_string(),
|
||||
file: "A.zip".to_string(),
|
||||
path: "D:/x/A.zip".to_string(),
|
||||
bytes: 0,
|
||||
skipped: 0,
|
||||
},
|
||||
texts,
|
||||
);
|
||||
assert!(
|
||||
backed_up.starts_with("已把 A 备份到 D:/x/A.zip"),
|
||||
"{backed_up}"
|
||||
);
|
||||
assert!(
|
||||
!backed_up.contains("符号链接"),
|
||||
"没跳过就别提链接:{backed_up}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。
|
||||
#[test]
|
||||
fn millisecond_stamps_are_rendered_in_utc() {
|
||||
|
||||
Reference in new issue
Block a user