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:
AskaEth committed 2026-11-11 13:51:07 +08:00
1 parent 8c67e4eca6
commit 6c87e2ad8a
8 files changed
+682 -229

No files matched your search

+24
View File
@@ -68,6 +68,9 @@ impl App {
self.sync_saves(); self.sync_saves();
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。 // M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
self.note_mods_failure(); self.note_mods_failure();
// M247(复审 r1 P2-1):被确认的世界已经从快照里消失 ⇒ 收起那个确认框。
// 不这样的话主按钮就是一次静默空操作(点了没删、也不说一句)。
self.settle_saves_confirm();
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
@@ -100,6 +103,27 @@ impl App {
self.refreshes += 1; self.refreshes += 1;
} }
/// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。
///
/// 判据由 presenter 给(`SavesState::take_closed_confirm`:那个世界从快照里消失了),
/// **框的 id 也由它带着**——所以只收我们自己那个框,崩溃卡片/进度框一概不碰。
///
/// 为什么必须收:目标已经作废,主按钮就成了一次**静默空操作**(用户点了「删除世界」,
/// 框关了、什么也没发生、也没一句解释)。少删是安全的,但"看起来坏了"不是可接受的结果。
fn settle_saves_confirm(&mut self) {
let Some(dialog) = self.state.saves.take_closed_confirm() else {
return;
};
if self
.state
.dialogs
.current()
.is_some_and(|current| current.id == dialog)
{
self.close_dialog();
}
}
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。 /// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
/// ///
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种, /// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
+2 -2
View File
@@ -152,15 +152,15 @@ pub fn seed(app: &mut crate::app::state::App, instance: &str) {
detail: String::new(), detail: String::new(),
}), }),
Mode::Confirm => { Mode::Confirm => {
app.state.saves.ask_delete(&dir);
let (title, body, primary, secondary) = let (title, body, primary, secondary) =
crate::pages::saves_delete_prompt(app.lang().catalog(), &dir); crate::pages::saves_delete_prompt(app.lang().catalog(), &dir);
app.state.dialogs.push( let dialog = app.state.dialogs.push(
unlucky_presenter::dialogs::DialogSource::UserAction, unlucky_presenter::dialogs::DialogSource::UserAction,
title, title,
body, body,
unlucky_presenter::dialogs::DialogKind::Confirm { primary, secondary }, unlucky_presenter::dialogs::DialogKind::Confirm { primary, secondary },
); );
app.state.saves.ask_delete(dialog, &dir);
} }
Mode::Plain | Mode::BackedUp => {} Mode::Plain | Mode::BackedUp => {}
} }
+275 -2
View File
@@ -15,8 +15,11 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
DialogMessage::Primary => { DialogMessage::Primary => {
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。 // M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
// 目标记在 presenter 的状态里(`SavesState::confirm`):模态队列只画框,不记"要删谁"。 // 目标记在 presenter 的状态里(`SavesState::confirm`):模态队列只画框,不记"要删谁"。
// 取走即清,所以连点两下不会删两个(第二下拿不到目标 ⇒ 只关框)。 //
if let Some(dir) = app.state.saves.take_confirm() { // **认框不认人**(复审 r1 P1-1):只核销"当前这个框"登记的目标。哪怕某条关闭路径漏清,
// 崩溃卡片的「导出诊断包」也拿不走它——否则就是一次静默删世界。
let current = app.state.dialogs.current().map(|request| request.id);
if let Some(dir) = current.and_then(|id| app.state.saves.take_confirm(id)) {
app.close_dialog(); app.close_dialog();
if let Some(instance) = app.selected_instance_id() { if let Some(instance) = app.selected_instance_id() {
app.state app.state
@@ -118,7 +121,277 @@ pub(super) fn escape(app: &mut App) -> bool {
if app.state.dialogs.current().is_none() { if app.state.dialogs.current().is_none() {
return false; return false;
} }
// M247(复审 r1 P1-1):**三条关闭路径都要清**——次按钮/遮罩清了,`Esc` 这条以前漏了,
// 于是"删存档 → Esc → 崩溃卡片 → 导出诊断包"会把那个世界静默删掉。
// 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。
app.state.saves.clear_confirm();
app.close_dialog(); app.close_dialog();
app.refresh_view(); app.refresh_view();
true true
} }
#[cfg(test)]
mod tests {
//! 主按钮 / Esc 的落点判据(M247,复审 r1 P1-1 / P2-1):**接线**层,不是 presenter 的状态机。
//! 一个 App + 一个只记账的后端,看"点了主按钮之后下发了什么"——「盘上的世界还在」在这里等价于
//! 「没有下发 `DeleteWorld`」(app 不写盘,唯一写入口是 `host::saves::dispatch`)。
//! 真实 `export()` 写没写 zip 由 `app/dialog/tests.rs` 的夹具覆盖,这里不造崩溃卡片。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, SavesCommand, SavesListView, WorldView,
};
use unlucky_presenter::mock_data::mock_instances;
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
use super::{escape, on_dialog};
use crate::app::dialog::Message as DialogMessage;
use crate::app::message::Message;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
const DIR: &str = "Better World";
/// 只记账、按需喂事件的后端(与 `app/tests/presenter.rs` 的 recorder 同款)。
#[derive(Debug, Default)]
struct SpyBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
events: Rc<RefCell<Vec<AppEvent>>>,
}
impl Backend for SpyBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut *self.events.borrow_mut())
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command);
}
}
/// 一个进到详情页、可喂事件、可记账的 App。
struct Harness {
app: App,
dispatched: Rc<RefCell<Vec<AppCommand>>>,
events: Rc<RefCell<Vec<AppEvent>>>,
}
impl Harness {
fn new() -> Self {
let dispatched = Rc::new(RefCell::new(Vec::new()));
let events = Rc::new(RefCell::new(vec![AppEvent::Core(
CoreEvent::InstancesChanged(mock_instances()),
)]));
let backend = SpyBackend {
dispatched: Rc::clone(&dispatched),
events: Rc::clone(&events),
};
let mut harness = Self {
app: App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
),
dispatched,
events,
};
let _ = harness.app.update(Message::Boot);
assert_eq!(
harness.app.state.route(),
&Route::Detail("survival".to_string()),
"首屏该落在详情页(后面每一步都以它为前提交付目标)"
);
harness
}
/// 喂一条事件再抽干(`pump` 是唯一的 apply 入口,它顺带 `refresh_view`)。
fn feed(&mut self, event: AppEvent) {
self.events.borrow_mut().push(event);
self.app.pump();
self.app.refresh_view();
}
/// 点一次「删除 X」(页面消息的真实入口)。
fn request_delete(&mut self) {
let _ = self.app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::RequestDeleteWorld {
dir: DIR.to_string(),
},
)));
}
/// 有没有下发过 `SavesCommand::DeleteWorld`(= core 会不会去删盘上的世界)。
fn deleted(&self) -> bool {
self.dispatched.borrow().iter().any(|command| {
matches!(
command,
AppCommand::Saves(command)
if matches!(**command, SavesCommand::DeleteWorld { .. })
)
})
}
}
/// 一份 `saves/` 快照(`worlds` 决定"那个世界还在不在")。
fn snapshot(worlds: &[&str]) -> AppEvent {
AppEvent::Core(CoreEvent::Saves(Box::new(Ok(SavesListView {
instance: "survival".to_string(),
dir: "C:/mc/saves".to_string(),
exists: true,
worlds: worlds
.iter()
.map(|dir| WorldView {
dir: (*dir).to_string(),
icon: false,
modified_ms: None,
})
.collect(),
screenshots_dir: "C:/mc/screenshots".to_string(),
screenshots_exists: false,
screenshots: Vec::new(),
skipped: Vec::new(),
last_op: None,
}))))
}
/// **P1-1 事故链**:删存档 → `Esc` → 一个不相干的 `Confirm`(崩溃卡片的主按钮是「导出诊断包」)
/// → 点主按钮 ⇒ **没有** `DeleteWorld` 下发,框走它自己那条路(这里没有崩溃卡片 ⇒ 关框)。
#[test]
fn escaping_the_delete_dialog_disarms_it_for_the_next_one() {
let mut harness = Harness::new();
harness.request_delete();
assert!(
harness.app.state.dialogs.current().is_some(),
"确认框没弹出来"
);
assert!(harness.app.state.saves.confirm.is_some(), "目标没登记");
assert!(escape(&mut harness.app), "有框时 Esc 该被吃掉");
assert!(
harness.app.state.saves.confirm.is_none(),
"Esc 之后目标还留着 —— 下一个框的主按钮会把它兑现"
);
assert!(harness.app.state.dialogs.current().is_none());
// 不相干的框:崩溃卡片就是这个形态(`Confirm`,主按钮「导出诊断包」)。
let _ = harness.app.state.dialogs.push(
DialogSource::UserAction,
"启动失败",
"退出码 1",
DialogKind::Confirm {
primary: "导出诊断包".to_string(),
secondary: "关闭".to_string(),
},
);
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(
!harness.deleted(),
"Esc 之后那个目标被下一个框兑现了:世界被静默删除"
);
assert!(
harness.app.state.dialogs.current().is_none(),
"主按钮该走它自己那条路(这里没有崩溃卡片 ⇒ 关框)"
);
}
/// **纵深防御(P1-1 的第二层)**:万一某条路径只关了框、**漏清**目标,下一个不相干的框
/// 点主按钮也**拿不走**它 —— 因为 `take_confirm` 只有在"登记时那个框 == 当前框"时才交付。
///
/// 这条直接构造"漏清"状态(登记一个不存在的框 id),所以它**不依赖** `Esc` 那条路有没有清:
/// 去掉 id 校验,它立刻红。
#[test]
fn a_leaked_target_cannot_be_redeemed_by_another_dialog() {
let mut harness = Harness::new();
// 模拟事故形态:目标还挂着,但那个框早就不在了(登记 999 = 一个不存在的框)。
harness.app.state.saves.ask_delete(999, DIR);
let _ = harness.app.state.dialogs.push(
DialogSource::UserAction,
"启动失败",
"退出码 1",
DialogKind::Confirm {
primary: "导出诊断包".to_string(),
secondary: "关闭".to_string(),
},
);
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(
!harness.deleted(),
"漏清的目标被别的框兑现了:世界被静默删除(P1-1 的事故形态)"
);
assert!(
harness.app.state.saves.confirm.is_some(),
"别人的框不该顺手清掉我们的目标(清它的是关闭路径,不是主按钮)"
);
}
/// 反向:目标属于**当前**这个框时,主按钮仍然真的下发删除(没把功能关死)。
#[test]
fn the_owning_dialog_still_deletes() {
let mut harness = Harness::new();
harness.request_delete();
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(harness.deleted(), "自己的框点主按钮居然没删");
assert!(harness.app.state.saves.confirm.is_none(), "取走即清");
assert!(
harness.app.state.saves.pending.is_some(),
"该把那一行标成在飞(否则能连点)"
);
assert!(harness.app.state.dialogs.current().is_none(), "框该关掉");
}
/// **P2-1**:世界还在 ⇒ 一拍无关快照(备份回程 / 打开文件夹回程)之后,主按钮照样删。
#[test]
fn an_unrelated_snapshot_keeps_the_confirmation_redeemable() {
let mut harness = Harness::new();
harness.request_delete();
harness.feed(snapshot(&[DIR, "Other"]));
assert!(
harness.app.state.saves.confirm.is_some(),
"无关快照把目标吃掉了:主按钮会变成静默空操作"
);
assert!(
harness.app.state.dialogs.current().is_some(),
"框不该被收掉"
);
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(harness.deleted(), "快照之后主按钮点了没反应");
}
/// **P2-1 的另一半**:世界从快照里消失 ⇒ 框被**收起来**(不是留一个点了没反应的按钮),
/// 而且不下发删除(那个世界已经不存在了,下发只会换来一条 `WriteMissing` 噪音)。
#[test]
fn a_vanished_world_closes_its_confirmation() {
let mut harness = Harness::new();
harness.request_delete();
harness.feed(snapshot(&["Other"]));
assert!(
harness.app.state.saves.confirm.is_none(),
"作废的目标还留着"
);
assert!(
harness.app.state.dialogs.current().is_none(),
"框没被收起:主按钮会是一次静默空操作"
);
assert!(!harness.deleted(), "世界都不在了还下发删除");
}
}
+5 -2
View File
@@ -101,15 +101,18 @@ impl App {
// M247:删除**先弹确认框**。确认目标是"哪个世界"必须记在状态里—— // M247:删除**先弹确认框**。确认目标是"哪个世界"必须记在状态里——
// 主按钮落点在 `app/update/dialog.rs`,那里只有 `App`,没有这个页面。 // 主按钮落点在 `app/update/dialog.rs`,那里只有 `App`,没有这个页面。
InstanceMessage::RequestDeleteWorld { dir } => { InstanceMessage::RequestDeleteWorld { dir } => {
self.state.saves.ask_delete(&dir);
let (title, body, primary, secondary) = let (title, body, primary, secondary) =
crate::pages::saves_delete_prompt(self.lang().catalog(), &dir); crate::pages::saves_delete_prompt(self.lang().catalog(), &dir);
self.state.dialogs.push( // **先压框、拿到它的 id,再登记目标**:目标绑在那个框上(复审 r1 P1-1)——
// 反过来(先登记再压框)会让"这一拍还没有框"的中间态挂在状态里,别的框的主按钮
// 就有机会把它兑掉。
let dialog = self.state.dialogs.push(
DialogSource::UserAction, DialogSource::UserAction,
title, title,
body, body,
DialogKind::Confirm { primary, secondary }, DialogKind::Confirm { primary, secondary },
); );
self.state.saves.ask_delete(dialog, &dir);
self.view_dirty = true; self.view_dirty = true;
return Task::none(); return Task::none();
} }
+4 -4
View File
@@ -154,10 +154,10 @@ macro_rules! instances_entries {
InstancesModsReasonNotFile => ("that is not a regular file", "那不是普通文件"), InstancesModsReasonNotFile => ("that is not a regular file", "那不是普通文件"),
InstancesModsReasonCollision => ("a file with that name already exists", "同名文件已存在"), InstancesModsReasonCollision => ("a file with that name already exists", "同名文件已存在"),
InstancesModsReasonRename => ("the rename failed", "改名失败"), InstancesModsReasonRename => ("the rename failed", "改名失败"),
// ── 存档区(M235 · STOREMVP):**只读**列 `saves/` 与 `screenshots/` + 打开文件夹。 // ── 存档区(M235 · STOREMVP 读路径 / M247 · SAVESOPS 写操作)。
// U3 的边界写在 `InstancesSavesReadOnly` 里,页面固定画出来——用户点开就看见 // M235 那条「这一版只读」(`InstancesSavesReadOnly`)**已删**:写操作接上之后它是假的,
// 「这一版只读」,不会以为漏了删除按钮。`saves/` / `screenshots/` 是**目录名**, // 边界改由 core 的 `game::saves::write::boundary` 守(越界 / 符号链接 / 撞名都在那里拒)。
// 两套语言都原样保留(与 MODS 区的 `mods` 同一条口径)。 // `saves/` / `screenshots/` 是**目录名**,两套语言都原样保留(与 MODS 区的 `mods` 同一条口径)。
InstancesSavesTitle => ("SAVES", "存档"), InstancesSavesTitle => ("SAVES", "存档"),
InstancesSavesCount => ("{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"), InstancesSavesCount => ("{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
InstancesSavesLoading => ("Reading saves/…", "正在读取 saves/…"), InstancesSavesLoading => ("Reading saves/…", "正在读取 saves/…"),
+54 -219
View File
@@ -17,16 +17,24 @@
//! |---|---|---|---| //! |---|---|---|---|
//! | `draft` | `start_rename`(预填当前磁盘名)、`edit_rename`(每敲一个字) | `cancel_rename` / `take_rename`(提交时取走)/ `mark_pending`(请求已发出,草稿没用了)/ `bind` / `changed` | **换实例**、**请求发出**、**快照回来**:三者之后草稿都属于"上一次对话",留着会让「改名」按钮改到一个已经在别的实例上的名字 | //! | `draft` | `start_rename`(预填当前磁盘名)、`edit_rename`(每敲一个字) | `cancel_rename` / `take_rename`(提交时取走)/ `mark_pending`(请求已发出,草稿没用了)/ `bind` / `changed` | **换实例**、**请求发出**、**快照回来**:三者之后草稿都属于"上一次对话",留着会让「改名」按钮改到一个已经在别的实例上的名字 |
//! | `pending` | `mark_pending`(发请求之前,一跳都不等) | `changed`(成功)/ `failed`(失败也要清——**按钮不能永远灰着**,用户要能直接再点一次)/ `bind` | **任何一次后台往返收尾**、**换实例** | //! | `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()`:拿到目标的同时字段被置空。所以同一次确认**连点两下只会发一条 //! 三条判据,缺一条就会出安全事故(复审 r1 P1-1 的事故形态就是第三条缺失):
//! 删除**——第二下拿不到目标,只关框。这不是洁癖:删除不可逆,一次误触发两条命令意味着第二条 //!
//! 会去删一个已经被删掉的名字(或者更糟:用户在两次点击之间切了实例,而旧目标还留着)。 //! 1. **取走即清**:主按钮拿到目标的同时字段被置空 ⇒ 同一次确认连点两下只发一条删除。
//! 取消/关闭也必须清,否则"上一次取消掉的删除"会挂在状态里,等下一次点「确定」时兑现。 //! 2. **认框不认人**:`take_confirm(current)` 只在 `current` 等于登记时那个框的 id 时才交付。
//! 用例:`switching_instances_drops_draft_pending_and_confirm`(换实例三样一起清)、 //! 「删存档 → `Esc` → 崩溃卡片 → 点『导出诊断包』」这条链上,第二个框的主按钮**拿不走**目标
//! `the_delete_confirmation_hands_out_once`(取走即清、取消后不再有目标)。 //! (即使 `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` 与 //! 用户很可能点开 A 的详情页、还没等后台回来就切到 B。所以 `view.instance` 与
//! [`SavesState::bound`] 不一致的结果**直接丢掉**(与 [`super::mods`] 同一条纪律)。 //! [`SavesState::bound`] 不一致的结果**直接丢掉**(与 [`super::mods`] 同一条纪律)。
use crate::dialogs::DialogId;
use crate::dto::{SavesFailureView, SavesListView}; use crate::dto::{SavesFailureView, SavesListView};
mod interaction;
use super::AppState; use super::AppState;
/// 行内重命名的输入缓冲(见模块文档的三样交互态)。 /// 行内重命名的输入缓冲(见模块文档的三样交互态)。
@@ -99,8 +110,14 @@ pub struct SavesState {
pub draft: Option<RenameDraft>, pub draft: Option<RenameDraft>,
/// 在飞的写操作(`None` = 空闲)。 /// 在飞的写操作(`None` = 空闲)。
pub pending: Option<SavesOp>, pub pending: Option<SavesOp>,
/// 删除确认框正在等哪个世界(点「确定」时才真的删)。 /// 删除确认框正在等谁:**(框的 id, 世界名)**(点「确定」时才真的删)。
pub confirm: Option<String>, ///
/// 为什么带框 id(复审 r1 P1-1):`take_confirm(current)` 只核销**同一个框**。即使某条
/// 关闭路径漏清,目标也不会被下一个不相干的对话框(例如崩溃卡片的「导出诊断包」)兑成一次删除。
pub confirm: Option<(DialogId, String)>,
/// 刚刚因为「被确认的世界已经不在快照里」而作废的那个框(app 层据此收框,见
/// [`SavesState::take_closed_confirm`])。
pub closed_confirm: Option<DialogId>,
} }
impl SavesState { impl SavesState {
@@ -123,6 +140,7 @@ impl SavesState {
self.draft = None; self.draft = None;
self.pending = None; self.pending = None;
self.confirm = None; self.confirm = None;
self.closed_confirm = None;
} }
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。 /// 一条结果落地(`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) { pub fn changed(&mut self, view: SavesListView) {
if self.bound.as_deref() != Some(view.instance.as_str()) { if self.bound.as_deref() != Some(view.instance.as_str()) {
return; return;
} }
self.failed = None; 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.view = Some(view);
// 写操作成功之后草稿与在飞标记都该没了:行已经按新名字画出来了。 self.draft = None;
self.reset_interaction(); self.pending = None;
if gone == Some(true) {
if let Some((dialog, _)) = self.confirm.take() {
self.closed_confirm = Some(dialog);
}
}
} }
/// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。 /// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。
@@ -153,60 +192,9 @@ impl SavesState {
} }
self.failed = Some(failure); self.failed = Some(failure);
// 失败也收尾:按钮不能永远灰着(用户要能直接再点一次重试)。 // 失败也收尾:按钮不能永远灰着(用户要能直接再点一次重试)。
// `confirm` **不动**(与 [`Self::changed`] 同一条理由):一条读盘失败不该把用户
// 刚点出来的删除确认框变成"点了没反应"。
self.pending = None; 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)] #[cfg(test)]
mod tests { 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(), "取走之后草稿还在");
}
}
@@ -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)
}
}
+227
View File
@@ -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(), "取走之后草稿还在");
}