fix(presenter,app-iced): 删除确认绑定到对话框身份 + 快照不再吃掉目标(M247 r2 复审)
复审 r1 P1-1(安全):`SavesState::confirm` 的「关闭即清」在 Esc 这条路径上没有落实, 而主按钮分支无条件先取目标 —— 「删存档 → Esc → 崩溃卡片 → 导出诊断包」会静默删掉那个世界。 两层修,各自有独立用例钉住: 1. **认框不认人**:`confirm` 改成 `Option<(DialogId, String)>`,`take_confirm(current)` 只在 id 相等时交付;`DialogMessage::Primary` 传 `dialogs.current().id`。即使别的路径漏清, 也核销不了别人那个框。用例 `a_leaked_target_cannot_be_redeemed_by_another_dialog` (去掉 id 校验即红:实测「漏清的目标被别的框兑现了:世界被静默删除」)。 2. **关闭路径补全**:`update/dialog.rs::escape()` 补 `clear_confirm()`(次按钮/遮罩早就清了, Esc 漏了)。用例 `escaping_the_delete_dialog_disarms_it_for_the_next_one` 复现整条事故链, 断言没有下发 `DeleteWorld`、框走自己的路关掉(去掉这行即红)。 P2-1:`changed()` 不再无条件清 `confirm`(一拍无关快照会让主按钮变成静默空操作)—— 只在「被确认的世界已经从快照里消失」时清,并登记 `closed_confirm` 让 app 层**收起那个框** (`refresh_view → settle_saves_confirm`),避免留一个点了没反应的按钮。`failed()` 同理不动 confirm。 用例:`an_unrelated_snapshot_keeps_the_confirmation_redeemable`、`a_vanished_world_closes_its_confirmation`、 `an_unrelated_snapshot_no_longer_drops_the_confirmation`、`the_confirmation_is_dropped_and_reported_when_the_world_vanishes`。 P2-2:`catalog/instances.rs` 那段「这一版只读 / InstancesSavesReadOnly」的陈旧说明改成现状 (写操作已接上,边界由 core 的 `write::boundary` 守)。 拆分(400 行):`presenter/src/state/saves.rs` 的交互态方法 → `state/saves/interaction.rs`、 判据 → `state/saves/tests.rs`;`app/update/dialog.rs` 的接线用例内联在该文件(5 条)。
This commit is contained in:
1 parent
8c67e4eca6
commit
6c87e2ad8a
8 files changed
+682
-229
No files matched your search
@@ -17,16 +17,24 @@
|
||||
//! |---|---|---|---|
|
||||
//! | `draft` | `start_rename`(预填当前磁盘名)、`edit_rename`(每敲一个字) | `cancel_rename` / `take_rename`(提交时取走)/ `mark_pending`(请求已发出,草稿没用了)/ `bind` / `changed` | **换实例**、**请求发出**、**快照回来**:三者之后草稿都属于"上一次对话",留着会让「改名」按钮改到一个已经在别的实例上的名字 |
|
||||
//! | `pending` | `mark_pending`(发请求之前,一跳都不等) | `changed`(成功)/ `failed`(失败也要清——**按钮不能永远灰着**,用户要能直接再点一次)/ `bind` | **任何一次后台往返收尾**、**换实例** |
|
||||
//! | `confirm` | `ask_delete`(点「删除」时,随后 app 层把模态框压进队列) | [`SavesState::take_confirm`](**取走即清**)/ `clear_confirm`(取消或关框)/ `failed` / `bind` | **点主按钮那一刻**、**取消/关闭模态框**、**换实例** |
|
||||
//! | `confirm` | `ask_delete(dialog, dir)`(app 层先 `dialogs.push` 拿到 id 再登记) | [`SavesState::take_confirm(current)`](SavesState::take_confirm)(**只核销同一个框、取走即清**)/ [`SavesState::clear_confirm`](次按钮 / 遮罩 / `Esc` / 换实例)/ [`SavesState::changed`](**只在被确认的世界已从快照里消失时**) | **点主按钮那一刻**、**取消/关闭模态框**、**换实例**、**那个世界没了**(`dismissed` 会一并收框) |
|
||||
//! | `closed_confirm` | `changed`(世界从快照里消失时登记作废的那个框) | app 层 `refresh_view` → `settle_saves_confirm`(收框后取走)/ `ask_delete` / `clear_confirm` / `bind` | **同一拍或下一拍**:留着会让"世界已经不在了"的确认框一直挡在屏幕中央 |
|
||||
//!
|
||||
//! ### `confirm` 的「取走即清」是一条**安全不变量**
|
||||
//! ### `confirm` 是**绑定到具体对话框**的一条安全不变量
|
||||
//!
|
||||
//! 主按钮走的是 `take_confirm()`:拿到目标的同时字段被置空。所以同一次确认**连点两下只会发一条
|
||||
//! 删除**——第二下拿不到目标,只关框。这不是洁癖:删除不可逆,一次误触发两条命令意味着第二条
|
||||
//! 会去删一个已经被删掉的名字(或者更糟:用户在两次点击之间切了实例,而旧目标还留着)。
|
||||
//! 取消/关闭也必须清,否则"上一次取消掉的删除"会挂在状态里,等下一次点「确定」时兑现。
|
||||
//! 用例:`switching_instances_drops_draft_pending_and_confirm`(换实例三样一起清)、
|
||||
//! `the_delete_confirmation_hands_out_once`(取走即清、取消后不再有目标)。
|
||||
//! 三条判据,缺一条就会出安全事故(复审 r1 P1-1 的事故形态就是第三条缺失):
|
||||
//!
|
||||
//! 1. **取走即清**:主按钮拿到目标的同时字段被置空 ⇒ 同一次确认连点两下只发一条删除。
|
||||
//! 2. **认框不认人**:`take_confirm(current)` 只在 `current` 等于登记时那个框的 id 时才交付。
|
||||
//! 「删存档 → `Esc` → 崩溃卡片 → 点『导出诊断包』」这条链上,第二个框的主按钮**拿不走**目标
|
||||
//! (即使 `Esc` 那条关闭路径漏清——而它现在也清了)。
|
||||
//! 3. **世界没了就把框收起来**:快照回来时被确认的世界已不在列表里 ⇒ 清 `confirm` 并登记
|
||||
//! `closed_confirm`,app 层据此收框。否则主按钮会变成一次**静默空操作**(点了没删、也不说一句)。
|
||||
//!
|
||||
//! 用例(presenter 层):`the_delete_confirmation_hands_out_once`(取走即清 + 取消后不再有目标)、
|
||||
//! `a_dialog_can_only_redeem_its_own_target`(别的框的主按钮拿不走)、
|
||||
//! `an_unrelated_snapshot_no_longer_drops_the_confirmation`(P2-1:世界还在 ⇒ 目标还兑现得了)、
|
||||
//! `the_confirmation_is_dropped_and_reported_when_the_world_vanishes`(世界没了 ⇒ 清 + 登记收框)。
|
||||
//!
|
||||
//! # 为什么快照住在这里而不是页面
|
||||
//!
|
||||
@@ -43,8 +51,11 @@
|
||||
//! 用户很可能点开 A 的详情页、还没等后台回来就切到 B。所以 `view.instance` 与
|
||||
//! [`SavesState::bound`] 不一致的结果**直接丢掉**(与 [`super::mods`] 同一条纪律)。
|
||||
|
||||
use crate::dialogs::DialogId;
|
||||
use crate::dto::{SavesFailureView, SavesListView};
|
||||
|
||||
mod interaction;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
/// 行内重命名的输入缓冲(见模块文档的三样交互态)。
|
||||
@@ -99,8 +110,14 @@ pub struct SavesState {
|
||||
pub draft: Option<RenameDraft>,
|
||||
/// 在飞的写操作(`None` = 空闲)。
|
||||
pub pending: Option<SavesOp>,
|
||||
/// 删除确认框正在等哪个世界(点「确定」时才真的删)。
|
||||
pub confirm: Option<String>,
|
||||
/// 删除确认框正在等谁:**(框的 id, 世界名)**(点「确定」时才真的删)。
|
||||
///
|
||||
/// 为什么带框 id(复审 r1 P1-1):`take_confirm(current)` 只核销**同一个框**。即使某条
|
||||
/// 关闭路径漏清,目标也不会被下一个不相干的对话框(例如崩溃卡片的「导出诊断包」)兑成一次删除。
|
||||
pub confirm: Option<(DialogId, String)>,
|
||||
/// 刚刚因为「被确认的世界已经不在快照里」而作废的那个框(app 层据此收框,见
|
||||
/// [`SavesState::take_closed_confirm`])。
|
||||
pub closed_confirm: Option<DialogId>,
|
||||
}
|
||||
|
||||
impl SavesState {
|
||||
@@ -123,6 +140,7 @@ impl SavesState {
|
||||
self.draft = None;
|
||||
self.pending = None;
|
||||
self.confirm = None;
|
||||
self.closed_confirm = None;
|
||||
}
|
||||
|
||||
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
|
||||
@@ -136,14 +154,35 @@ impl SavesState {
|
||||
}
|
||||
|
||||
/// 快照落地(整体替换;迟到/串台的丢掉)。
|
||||
///
|
||||
/// 三样交互态在这里**不是一起清**(复审 r1 P2-1):
|
||||
///
|
||||
/// | 字段 | 这一拍怎么处理 |
|
||||
/// |---|---|
|
||||
/// | `draft` / `pending` | **清**:写操作已经收尾,行按新快照画 |
|
||||
/// | `confirm` | **只在「被确认的那个世界已经从快照里消失」时清**,并记下那个框([`Self::closed_confirm`])让 app 收框 |
|
||||
///
|
||||
/// 为什么不能无条件清 `confirm`:备份一个大世界还在飞、「打开存档文件夹」的回程还在路上时,
|
||||
/// 用户点「删除 X」→ 框刚弹出来 → 一拍无关快照先到 → 目标被清 → 用户点「删除世界」只关框、
|
||||
/// 不删、也不说一句。少删是安全的,但**静默就是不安全的另一种形式**(用户以为删了)。
|
||||
pub fn changed(&mut self, view: SavesListView) {
|
||||
if self.bound.as_deref() != Some(view.instance.as_str()) {
|
||||
return;
|
||||
}
|
||||
self.failed = None;
|
||||
// 目标世界还在不在这一份快照里(`dir` 是磁盘上的名字,与列表同一口径)。
|
||||
let gone = self
|
||||
.confirm
|
||||
.as_ref()
|
||||
.map(|(_, dir)| !view.worlds.iter().any(|world| &world.dir == dir));
|
||||
self.view = Some(view);
|
||||
// 写操作成功之后草稿与在飞标记都该没了:行已经按新名字画出来了。
|
||||
self.reset_interaction();
|
||||
self.draft = None;
|
||||
self.pending = None;
|
||||
if gone == Some(true) {
|
||||
if let Some((dialog, _)) = self.confirm.take() {
|
||||
self.closed_confirm = Some(dialog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。
|
||||
@@ -153,60 +192,9 @@ impl SavesState {
|
||||
}
|
||||
self.failed = Some(failure);
|
||||
// 失败也收尾:按钮不能永远灰着(用户要能直接再点一次重试)。
|
||||
// `confirm` **不动**(与 [`Self::changed`] 同一条理由):一条读盘失败不该把用户
|
||||
// 刚点出来的删除确认框变成"点了没反应"。
|
||||
self.pending = None;
|
||||
self.confirm = None;
|
||||
}
|
||||
|
||||
/// 开一个行内重命名(输入框预填当前磁盘名:改个后缀比从零打更常见)。
|
||||
pub fn start_rename(&mut self, dir: &str) {
|
||||
self.draft = Some(RenameDraft {
|
||||
dir: dir.to_string(),
|
||||
text: dir.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 输入框变了(**照原样存**,不做前端过滤:非法名字由 core 拒并给出具名原因)。
|
||||
pub fn edit_rename(&mut self, text: String) {
|
||||
if let Some(draft) = &mut self.draft {
|
||||
draft.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取走草稿并收起来(提交时用;`None` = 当前没有草稿)。
|
||||
pub fn take_rename(&mut self) -> Option<RenameDraft> {
|
||||
self.draft.take()
|
||||
}
|
||||
|
||||
/// 取消行内重命名。
|
||||
pub fn cancel_rename(&mut self) {
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
/// 记下「删除确认框正在等谁」。
|
||||
pub fn ask_delete(&mut self, dir: &str) {
|
||||
self.confirm = Some(dir.to_string());
|
||||
}
|
||||
|
||||
/// 取走确认目标(点「确定」时用;取走即清,避免同一次确认被点两次)。
|
||||
pub fn take_confirm(&mut self) -> Option<String> {
|
||||
self.confirm.take()
|
||||
}
|
||||
|
||||
/// 确认框被取消/关掉:清掉目标。
|
||||
pub fn clear_confirm(&mut self) {
|
||||
self.confirm = None;
|
||||
}
|
||||
|
||||
/// 标记一次写操作在飞(草稿同时收起来:请求已经发出去了)。
|
||||
pub fn mark_pending(&mut self, op: SavesOp) {
|
||||
self.pending = Some(op);
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
/// 这个世界的写操作在飞吗(用来把那一行置灰)。
|
||||
#[must_use]
|
||||
pub fn is_pending(&self, dir: &str) -> bool {
|
||||
self.pending.as_ref().is_some_and(|op| op.dir() == dir)
|
||||
}
|
||||
|
||||
/// 当前绑定实例的列表是否已经有一份快照。
|
||||
@@ -224,157 +212,4 @@ impl AppState {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::{SavesFailureKind, WorldView};
|
||||
|
||||
fn view(instance: &str) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.to_string(),
|
||||
dir: "C:/mc/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "World".to_string(),
|
||||
icon: false,
|
||||
modified_ms: None,
|
||||
}],
|
||||
screenshots_dir: "C:/mc/screenshots".to_string(),
|
||||
screenshots_exists: false,
|
||||
screenshots: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
last_op: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(instance: &str) -> SavesFailureView {
|
||||
SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 换实例必须清空(A 的存档不许画在 B 的详情页上)。
|
||||
#[test]
|
||||
fn binding_another_instance_drops_the_old_view() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.has_view());
|
||||
|
||||
saves.bind(Some("b"));
|
||||
assert!(!saves.has_view(), "换实例后还留着 A 的存档");
|
||||
assert_eq!(saves.bound.as_deref(), Some("b"));
|
||||
|
||||
// 同一个实例再 bind 一次不该清(`refresh_view` 每帧都调)。
|
||||
saves.changed(view("b"));
|
||||
saves.bind(Some("b"));
|
||||
assert!(saves.has_view());
|
||||
}
|
||||
|
||||
/// 迟到/串台的结果被丢掉;成功一次清掉旧失败(重试成功后红字要消失)。
|
||||
#[test]
|
||||
fn late_results_are_dropped_and_success_clears_the_failure() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("b"));
|
||||
assert!(!saves.has_view(), "B 的列表画到了 A 上");
|
||||
|
||||
saves.failed(failure("a"));
|
||||
assert!(saves.failed.is_some());
|
||||
assert!(saves.bound.is_some(), "失败不许把绑定清掉(要能重试)");
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.failed.is_none(), "成功后旧失败没清掉");
|
||||
|
||||
// 不在详情页(`bound = None`)时任何结果都不落地。
|
||||
let mut idle = SavesState::default();
|
||||
idle.changed(view("a"));
|
||||
idle.failed(failure("a"));
|
||||
assert!(!idle.has_view());
|
||||
assert!(idle.failed.is_none());
|
||||
}
|
||||
|
||||
/// 换实例要把三样交互态一起清掉(草稿 / 在飞 / 确认目标)。
|
||||
///
|
||||
/// 抓得住:换到 B 之后「A 的草稿」还留着 ⇒ 用户点确定会去改 B 页上的一个不存在的名字。
|
||||
#[test]
|
||||
fn switching_instances_drops_draft_pending_and_confirm() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
saves.start_rename("World");
|
||||
saves.edit_rename("Renamed".to_string());
|
||||
saves.ask_delete("World");
|
||||
saves.mark_pending(SavesOp::Backup {
|
||||
dir: "World".to_string(),
|
||||
});
|
||||
assert!(saves.draft.is_none(), "发请求时草稿该收起来");
|
||||
assert!(saves.pending.is_some());
|
||||
|
||||
saves.bind(Some("b"));
|
||||
assert!(saves.draft.is_none(), "换实例后草稿还在");
|
||||
assert!(saves.pending.is_none(), "换实例后在飞标记还在");
|
||||
assert!(saves.confirm.is_none(), "换实例后删除确认目标还在");
|
||||
}
|
||||
|
||||
/// 回程(成功或失败)都要把「在飞」收掉:按钮不能永远灰着。
|
||||
#[test]
|
||||
fn both_outcomes_clear_the_pending_marker() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
saves.mark_pending(SavesOp::Delete {
|
||||
dir: "World".to_string(),
|
||||
});
|
||||
assert!(saves.is_pending("World"));
|
||||
saves.failed(failure("a"));
|
||||
assert!(
|
||||
!saves.is_pending("World"),
|
||||
"失败后在飞标记没清:重试按钮会一直是灰的"
|
||||
);
|
||||
|
||||
saves.mark_pending(SavesOp::Rename {
|
||||
dir: "World".to_string(),
|
||||
});
|
||||
assert!(saves.is_pending("World"));
|
||||
saves.changed(view("a"));
|
||||
assert!(!saves.is_pending("World"), "成功后没清在飞标记");
|
||||
}
|
||||
|
||||
/// 确认目标只发一次(同一次确认连点两下不该发两条删除)。
|
||||
#[test]
|
||||
fn the_delete_confirmation_hands_out_once() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.ask_delete("Doomed");
|
||||
assert_eq!(saves.take_confirm().as_deref(), Some("Doomed"));
|
||||
assert!(
|
||||
saves.take_confirm().is_none(),
|
||||
"第二次取还拿得到:连点会删两次"
|
||||
);
|
||||
|
||||
saves.ask_delete("Other");
|
||||
saves.clear_confirm();
|
||||
assert!(saves.take_confirm().is_none(), "取消之后还留着目标");
|
||||
}
|
||||
|
||||
/// 行内草稿照原样存:非法名字不在前端过滤(core 会拒并给出具名原因)。
|
||||
#[test]
|
||||
fn the_rename_draft_keeps_the_typed_text_verbatim() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.start_rename("World");
|
||||
assert_eq!(
|
||||
saves.draft.as_ref().expect("草稿").text,
|
||||
"World",
|
||||
"应预填当前名字"
|
||||
);
|
||||
saves.edit_rename("../escape".to_string());
|
||||
let draft = saves.take_rename().expect("草稿");
|
||||
assert_eq!(draft.dir, "World");
|
||||
assert_eq!(draft.text, "../escape", "输入被前端偷偷改了");
|
||||
assert!(saves.take_rename().is_none(), "取走之后草稿还在");
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,91 @@
|
||||
//! `SavesState` 的三样**交互态**:行内草稿 / 在飞标记 / 删除确认目标(M247)。
|
||||
//!
|
||||
//! 从 `state/saves.rs` 拆出(那个文件加完安全不变量文档之后越过 400 行硬线)。生命周期表在
|
||||
//! 父模块的文档里一张写全——**这里只放实现**,不重复判据。
|
||||
//!
|
||||
//! 三条边界(父模块文档的展开):
|
||||
//! 1. `draft` / `pending` 只跟"这一拍在编辑/在飞什么"有关,快照落地一律清;
|
||||
//! 2. `confirm` **认框**:`take_confirm(current)` 只在 id 相等时交付(见 [`SavesState::take_confirm`]);
|
||||
//! 3. `closed_confirm` 是"这个框该收起来了"的信号,app 层 `refresh_view` 取走并真的收框。
|
||||
|
||||
use crate::dialogs::DialogId;
|
||||
|
||||
use super::{RenameDraft, SavesOp, SavesState};
|
||||
|
||||
impl SavesState {
|
||||
/// 开一个行内重命名(输入框预填当前磁盘名:改个后缀比从零打更常见)。
|
||||
pub fn start_rename(&mut self, dir: &str) {
|
||||
self.draft = Some(RenameDraft {
|
||||
dir: dir.to_string(),
|
||||
text: dir.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 输入框变了(**照原样存**,不做前端过滤:非法名字由 core 拒并给出具名原因)。
|
||||
pub fn edit_rename(&mut self, text: String) {
|
||||
if let Some(draft) = &mut self.draft {
|
||||
draft.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取走草稿并收起来(提交时用;`None` = 当前没有草稿)。
|
||||
pub fn take_rename(&mut self) -> Option<RenameDraft> {
|
||||
self.draft.take()
|
||||
}
|
||||
|
||||
/// 取消行内重命名。
|
||||
pub fn cancel_rename(&mut self) {
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
/// 记下「删除确认框正在等谁」:**框的 id + 世界名**(`dialog` 由调用方 `dialogs.push` 拿到)。
|
||||
pub fn ask_delete(&mut self, dialog: DialogId, dir: &str) {
|
||||
self.confirm = Some((dialog, dir.to_string()));
|
||||
self.closed_confirm = None;
|
||||
}
|
||||
|
||||
/// 取走确认目标(点「确定」时用)——**只核销 `current` 那一个框**。
|
||||
///
|
||||
/// 「取走即清」+「认框不认人」两条合起来是**安全不变量**:
|
||||
/// ①同一次确认连点两下只发一条删除(第二下拿不到目标);
|
||||
/// ②别的框(崩溃卡片的「导出诊断包」、进度框……)点主按钮**拿不走**这个目标——
|
||||
/// 哪怕某条关闭路径漏清(复审 r1 P1-1 的事故形态),也只能核销它自己那个框。
|
||||
pub fn take_confirm(&mut self, current: DialogId) -> Option<String> {
|
||||
match &self.confirm {
|
||||
Some((dialog, _)) if *dialog == current => {
|
||||
self.closed_confirm = None;
|
||||
self.confirm.take().map(|(_, dir)| dir)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认框被取消/关掉(次按钮、遮罩、`Esc` 三条路径都走它):清掉目标。
|
||||
pub fn clear_confirm(&mut self) {
|
||||
self.confirm = None;
|
||||
self.closed_confirm = None;
|
||||
}
|
||||
|
||||
/// 取走「因为世界已经不在快照里而作废」的那个框(app 层据此收框,避免主按钮空操作)。
|
||||
pub fn take_closed_confirm(&mut self) -> Option<DialogId> {
|
||||
self.closed_confirm.take()
|
||||
}
|
||||
|
||||
/// 当前确认目标属于哪个框(`None` = 没有在等的目标)。
|
||||
#[must_use]
|
||||
pub fn confirm_dialog(&self) -> Option<DialogId> {
|
||||
self.confirm.as_ref().map(|(dialog, _)| *dialog)
|
||||
}
|
||||
|
||||
/// 标记一次写操作在飞(草稿同时收起来:请求已经发出去了)。
|
||||
pub fn mark_pending(&mut self, op: SavesOp) {
|
||||
self.pending = Some(op);
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
/// 这个世界的写操作在飞吗(用来把那一行置灰)。
|
||||
#[must_use]
|
||||
pub fn is_pending(&self, dir: &str) -> bool {
|
||||
self.pending.as_ref().is_some_and(|op| op.dir() == dir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! `SavesState` 的判据:绑定 / 迟到丢弃 / 三样交互态(含删除确认的安全不变量)。
|
||||
//!
|
||||
//! 从 `state/saves.rs` 拆出(父模块守 400 行)。**判据一条没改**,另外补了复审 r1 P1-1 / P2-1
|
||||
//! 那两条:认框不认人、无关快照不再吃掉确认目标。
|
||||
|
||||
use super::*;
|
||||
use crate::dto::{SavesFailureKind, WorldView};
|
||||
|
||||
fn view(instance: &str) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.to_string(),
|
||||
dir: "C:/mc/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "World".to_string(),
|
||||
icon: false,
|
||||
modified_ms: None,
|
||||
}],
|
||||
screenshots_dir: "C:/mc/screenshots".to_string(),
|
||||
screenshots_exists: false,
|
||||
screenshots: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
last_op: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(instance: &str) -> SavesFailureView {
|
||||
SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 换实例必须清空(A 的存档不许画在 B 的详情页上)。
|
||||
#[test]
|
||||
fn binding_another_instance_drops_the_old_view() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.has_view());
|
||||
|
||||
saves.bind(Some("b"));
|
||||
assert!(!saves.has_view(), "换实例后还留着 A 的存档");
|
||||
assert_eq!(saves.bound.as_deref(), Some("b"));
|
||||
|
||||
// 同一个实例再 bind 一次不该清(`refresh_view` 每帧都调)。
|
||||
saves.changed(view("b"));
|
||||
saves.bind(Some("b"));
|
||||
assert!(saves.has_view());
|
||||
}
|
||||
|
||||
/// 迟到/串台的结果被丢掉;成功一次清掉旧失败(重试成功后红字要消失)。
|
||||
#[test]
|
||||
fn late_results_are_dropped_and_success_clears_the_failure() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("b"));
|
||||
assert!(!saves.has_view(), "B 的列表画到了 A 上");
|
||||
|
||||
saves.failed(failure("a"));
|
||||
assert!(saves.failed.is_some());
|
||||
assert!(saves.bound.is_some(), "失败不许把绑定清掉(要能重试)");
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.failed.is_none(), "成功后旧失败没清掉");
|
||||
|
||||
// 不在详情页(`bound = None`)时任何结果都不落地。
|
||||
let mut idle = SavesState::default();
|
||||
idle.changed(view("a"));
|
||||
idle.failed(failure("a"));
|
||||
assert!(!idle.has_view());
|
||||
assert!(idle.failed.is_none());
|
||||
}
|
||||
|
||||
/// 换实例要把三样交互态一起清掉(草稿 / 在飞 / 确认目标)。
|
||||
///
|
||||
/// 抓得住:换到 B 之后「A 的草稿」还留着 ⇒ 用户点确定会去改 B 页上的一个不存在的名字。
|
||||
#[test]
|
||||
fn switching_instances_drops_draft_pending_and_confirm() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
saves.start_rename("World");
|
||||
saves.edit_rename("Renamed".to_string());
|
||||
saves.ask_delete(7, "World");
|
||||
saves.mark_pending(SavesOp::Backup {
|
||||
dir: "World".to_string(),
|
||||
});
|
||||
assert!(saves.draft.is_none(), "发请求时草稿该收起来");
|
||||
assert!(saves.pending.is_some());
|
||||
|
||||
saves.bind(Some("b"));
|
||||
assert!(saves.draft.is_none(), "换实例后草稿还在");
|
||||
assert!(saves.pending.is_none(), "换实例后在飞标记还在");
|
||||
assert!(saves.confirm.is_none(), "换实例后删除确认目标还在");
|
||||
}
|
||||
|
||||
/// 回程(成功或失败)都要把「在飞」收掉:按钮不能永远灰着。
|
||||
#[test]
|
||||
fn both_outcomes_clear_the_pending_marker() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
saves.mark_pending(SavesOp::Delete {
|
||||
dir: "World".to_string(),
|
||||
});
|
||||
assert!(saves.is_pending("World"));
|
||||
saves.failed(failure("a"));
|
||||
assert!(
|
||||
!saves.is_pending("World"),
|
||||
"失败后在飞标记没清:重试按钮会一直是灰的"
|
||||
);
|
||||
|
||||
saves.mark_pending(SavesOp::Rename {
|
||||
dir: "World".to_string(),
|
||||
});
|
||||
assert!(saves.is_pending("World"));
|
||||
saves.changed(view("a"));
|
||||
assert!(!saves.is_pending("World"), "成功后没清在飞标记");
|
||||
}
|
||||
|
||||
/// 确认目标只发一次(同一次确认连点两下不该发两条删除)。
|
||||
#[test]
|
||||
fn the_delete_confirmation_hands_out_once() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.ask_delete(7, "Doomed");
|
||||
assert_eq!(saves.take_confirm(7).as_deref(), Some("Doomed"));
|
||||
assert!(
|
||||
saves.take_confirm(7).is_none(),
|
||||
"第二次取还拿得到:连点会删两次"
|
||||
);
|
||||
|
||||
saves.ask_delete(8, "Other");
|
||||
saves.clear_confirm();
|
||||
assert!(saves.take_confirm(8).is_none(), "取消之后还留着目标");
|
||||
}
|
||||
|
||||
/// **P1-1 的核心**:目标只属于登记它的那个框。别的框点主按钮**拿不走**它。
|
||||
///
|
||||
/// 事故形态:`Esc` 关掉删除确认框(某条关闭路径漏清)→ 崩溃卡片弹出来 →
|
||||
/// 点「导出诊断包」(也是 `Confirm` 的主按钮)→ 若 `take_confirm` 无条件交付,
|
||||
/// 那个世界就被静默删掉了。判据是 **id 不等就不交付、也不清**(目标还是那个框的)。
|
||||
#[test]
|
||||
fn a_dialog_can_only_redeem_its_own_target() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.ask_delete(7, "Doomed");
|
||||
|
||||
assert!(
|
||||
saves.take_confirm(99).is_none(),
|
||||
"别的框的主按钮拿走了删除目标"
|
||||
);
|
||||
assert_eq!(saves.confirm_dialog(), Some(7), "拿不走也不该被顺手清掉");
|
||||
assert_eq!(
|
||||
saves.take_confirm(7).as_deref(),
|
||||
Some("Doomed"),
|
||||
"自己的框反而兑不了"
|
||||
);
|
||||
}
|
||||
|
||||
/// **P2-1**:一拍无关快照(世界还在)不许把确认目标吃掉——否则主按钮就是静默空操作。
|
||||
#[test]
|
||||
fn an_unrelated_snapshot_no_longer_drops_the_confirmation() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
saves.ask_delete(7, "World");
|
||||
|
||||
// 「打开存档文件夹」的回程 / 备份在飞时的列表刷新:世界还在快照里。
|
||||
saves.changed(view("a"));
|
||||
assert_eq!(
|
||||
saves.take_confirm(7).as_deref(),
|
||||
Some("World"),
|
||||
"无关快照把确认目标吃掉了:主按钮会变成静默空操作"
|
||||
);
|
||||
|
||||
// 读盘失败同理:一条失败不该把用户刚点出来的框弄成"点了没反应"。
|
||||
saves.ask_delete(8, "World");
|
||||
saves.failed(failure("a"));
|
||||
assert_eq!(saves.take_confirm(8).as_deref(), Some("World"));
|
||||
}
|
||||
|
||||
/// **P2-1 的另一半**:被确认的世界从快照里消失 ⇒ 目标清掉,并且**登记那个框该收了**。
|
||||
///
|
||||
/// 只清不登记就是"框还在、主按钮点了什么也不发生";只登记不清就是留着一个作废的目标。
|
||||
#[test]
|
||||
fn the_confirmation_is_dropped_and_reported_when_the_world_vanishes() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
saves.ask_delete(7, "World");
|
||||
|
||||
// 快照回来时那个世界已经不在列表里(别的进程删掉了 / 改名了)。
|
||||
let mut gone = view("a");
|
||||
gone.worlds.clear();
|
||||
saves.changed(gone);
|
||||
|
||||
assert!(saves.confirm.is_none(), "目标还留着一个已经不存在的世界");
|
||||
assert_eq!(
|
||||
saves.take_closed_confirm(),
|
||||
Some(7),
|
||||
"没有登记那个框 ⇒ 它会一直挡在屏幕中央、主按钮点了没反应"
|
||||
);
|
||||
assert!(saves.take_confirm(7).is_none(), "作废的目标还能被兑现");
|
||||
assert!(saves.take_closed_confirm().is_none(), "信号只发一次");
|
||||
}
|
||||
|
||||
/// 行内草稿照原样存:非法名字不在前端过滤(core 会拒并给出具名原因)。
|
||||
#[test]
|
||||
fn the_rename_draft_keeps_the_typed_text_verbatim() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.start_rename("World");
|
||||
assert_eq!(
|
||||
saves.draft.as_ref().expect("草稿").text,
|
||||
"World",
|
||||
"应预填当前名字"
|
||||
);
|
||||
saves.edit_rename("../escape".to_string());
|
||||
let draft = saves.take_rename().expect("草稿");
|
||||
assert_eq!(draft.dir, "World");
|
||||
assert_eq!(draft.text, "../escape", "输入被前端偷偷改了");
|
||||
assert!(saves.take_rename().is_none(), "取走之后草稿还在");
|
||||
}
|
||||
Reference in new issue
Block a user