chore(presenter,app-iced): 复审 r2 三项(文档对齐 / 拆 dialog.rs / 收窄关闭路径)

1. 文档自相矛盾(复审 r2 第 1 项):`SavesState::apply` 的文档还写着「无论成败都清『在飞』与
   『确认中』」——改完之后这是假的,而且与模块文档「安全判据第 3 条」互相打脸。现在逐支写清:
   `Ok` 清 draft/pending、`confirm` 只在被确认的世界已不在快照里时清并登记 `closed_confirm`;
   `Err` 只清 pending。生命周期表里的 `dismissed` 改成真字段名 `closed_confirm`(消费点是
   `app/state/refresh.rs::settle_saves_confirm`)。

2. 拆 `app/update/dialog.rs`(复审裁决):397 行里 262 行是内联 `mod tests`,而它装的是
   主按钮 / Esc / 次按钮三条不可逆分支。照仓内先例(`app/dialog.rs` + `app/dialog/tests.rs`)
   把测试体搬到 `app/update/dialog/tests.rs`,**纯搬移**:`dialog.rs` 397 → 138,
   `dialog/tests.rs` 262;从 `6c87e2a` 的 `dialog.rs` 里抽出 `mod tests` 去一层缩进后与
   新文件**逐行比对 0 处差异**(262 = 262)。

3. 收窄关闭路径(复审 r2 加固):`SavesState::close_confirm(current)` 只在 `current` 等于登记的
   那个框时才清;`clear_confirm()` 保持无条件(换实例/复位用)。`escape()` 与 `Secondary|Dismiss`
   改成传当前框 id。新用例 `closing_another_dialog_leaves_our_target_alone`(别人关框不清我们的
   目标;自己关框才清)。
This commit is contained in:
AskaEth committed 2026-11-11 14:25:26 +08:00
1 parent 6c87e2ad8a
commit 32384b952e
5 files changed
+316 -271

No files matched your search

+9 -268
View File
@@ -60,7 +60,10 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
}
DialogMessage::Secondary | DialogMessage::Dismiss => {
// M247:取消/关掉删除确认框 ⇒ 忘掉那个目标(不然下一次点确定会删上一个)。
app.state.saves.clear_confirm();
// **只核销当前这个框的目标**(复审 r2 加固):别人的框关掉时不该动我们的。
if let Some(current) = app.state.dialogs.current().map(|request| request.id) {
app.state.saves.close_confirm(current);
}
app.close_dialog();
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
// (r1 复审 P3-1)。
@@ -118,280 +121,18 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
pub(super) fn escape(app: &mut App) -> bool {
if app.state.dialogs.current().is_none() {
let Some(current) = app.state.dialogs.current().map(|request| request.id) else {
return false;
}
};
// M247(复审 r1 P1-1):**三条关闭路径都要清**——次按钮/遮罩清了,`Esc` 这条以前漏了,
// 于是"删存档 → Esc → 崩溃卡片 → 导出诊断包"会把那个世界静默删掉。
// 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。
app.state.saves.clear_confirm();
// 复审 r2 加固:清的时候**只清这个框自己的目标**(`close_confirm`),不碰别人的。
app.state.saves.close_confirm(current);
app.close_dialog();
app.refresh_view();
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(), "世界都不在了还下发删除");
}
}
mod tests;
@@ -0,0 +1,262 @@
//! 主按钮 / 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(), "世界都不在了还下发删除");
}
+9 -2
View File
@@ -17,7 +17,7 @@
//! |---|---|---|---|
//! | `draft` | `start_rename`(预填当前磁盘名)、`edit_rename`(每敲一个字) | `cancel_rename` / `take_rename`(提交时取走)/ `mark_pending`(请求已发出,草稿没用了)/ `bind` / `changed` | **换实例**、**请求发出**、**快照回来**:三者之后草稿都属于"上一次对话",留着会让「改名」按钮改到一个已经在别的实例上的名字 |
//! | `pending` | `mark_pending`(发请求之前,一跳都不等) | `changed`(成功)/ `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` 会一并收框) |
//! | `confirm` | `ask_delete(dialog, dir)`(app 层先 `dialogs.push` 拿到 id 再登记) | [`SavesState::take_confirm(current)`](SavesState::take_confirm)(**只核销同一个框、取走即清**)/ [`SavesState::clear_confirm`](次按钮 / 遮罩 / `Esc` / 换实例)/ [`SavesState::changed`](**只在被确认的世界已从快照里消失时**) | **点主按钮那一刻**、**取消/关闭模态框**、**换实例**、**那个世界没了**(`closed_confirm` 会一并收框) |
//! | `closed_confirm` | `changed`(世界从快照里消失时登记作废的那个框) | app 层 `refresh_view` → `settle_saves_confirm`(收框后取走)/ `ask_delete` / `clear_confirm` / `bind` | **同一拍或下一拍**:留着会让"世界已经不在了"的确认框一直挡在屏幕中央 |
//!
//! ### `confirm` 是**绑定到具体对话框**的一条安全不变量
@@ -145,7 +145,14 @@ impl SavesState {
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
///
/// **无论成败都清「在飞」与「确认中」**:这是一次后台往返的收尾,操作已经不在飞了。
/// **成败对三样交互态的处理不一样**(复审 r1 P2-1;与模块文档的安全判据第 3 条同源):
///
/// | 支 | `draft` / `pending` | `confirm` |
/// |---|---|---|
/// | `Ok`(快照) | 清(这一拍写操作收尾了) | **只在「被确认的那个世界已不在快照里」时**清,并登记 [`SavesState::closed_confirm`] 让 app 收框 |
/// | `Err`(失败) | 只清 `pending`(按钮不能永远灰着) | **不动**:一条读盘失败不该把用户刚点出来的确认框变成"点了没反应" |
///
/// 换言之「无论成败都清确认中」是**旧的**说法,已经不成立:无条件清会让主按钮静默空操作。
pub fn apply(&mut self, result: Result<SavesListView, SavesFailureView>) {
match result {
Ok(view) => self.changed(view),
@@ -60,7 +60,17 @@ impl SavesState {
}
}
/// 确认框被取消/关掉(次按钮、遮罩、`Esc` 三条路径都走它):清掉目标。
/// **某个**确认框被取消/关掉(次按钮、遮罩、`Esc` 三条路径都走它):**只清它自己那个目标**。
///
/// 为什么不无条件清(复审 r2 的加固):`clear_confirm` 不认框,理论上够得着"清掉别人的删除目标"
/// ——今天三条关闭路径关的都是当前框,够不着,但把判据写死在这里比依赖调用顺序稳。
pub fn close_confirm(&mut self, current: DialogId) {
if self.confirm_dialog() == Some(current) {
self.clear_confirm();
}
}
/// 清掉目标(**无条件**):换实例 / 复位时用——那时"哪个框"已经不重要了。
pub fn clear_confirm(&mut self) {
self.confirm = None;
self.closed_confirm = None;
+25
View File
@@ -138,6 +138,31 @@ fn the_delete_confirmation_hands_out_once() {
assert!(saves.take_confirm(8).is_none(), "取消之后还留着目标");
}
/// **复审 r2 加固**:别人那个框被关掉时,不该清掉我们的删除目标。
///
/// `close_confirm(current)` 只在 `current` 等于登记的那个框时才清;`clear_confirm()` 仍然无条件
/// (换实例复位时用——那时"哪个框"已经不重要了)。
#[test]
fn closing_another_dialog_leaves_our_target_alone() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.ask_delete(7, "Doomed");
saves.close_confirm(99);
assert_eq!(
saves.take_confirm(7).as_deref(),
Some("Doomed"),
"别的框关掉时把我们的目标清掉了"
);
saves.ask_delete(8, "Other");
saves.close_confirm(8);
assert!(
saves.take_confirm(8).is_none(),
"自己的框关掉了,目标还在(下一次点确定会删上一个)"
);
}
/// **P1-1 的核心**:目标只属于登记它的那个框。别的框点主按钮**拿不走**它。
///
/// 事故形态:`Esc` 关掉删除确认框(某条关闭路径漏清)→ 崩溃卡片弹出来 →