From 32384b952e7eca27bef915d7908ff30fe6d41c19 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 14:25:26 +0800 Subject: [PATCH] =?UTF-8?q?chore(presenter,app-iced):=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=20r2=20=E4=B8=89=E9=A1=B9=EF=BC=88=E6=96=87=E6=A1=A3=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20/=20=E6=8B=86=20dialog.rs=20/=20=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E8=B7=AF=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`(别人关框不清我们的 目标;自己关框才清)。 --- crates/app-iced/src/app/update/dialog.rs | 277 +----------------- .../app-iced/src/app/update/dialog/tests.rs | 262 +++++++++++++++++ crates/presenter/src/state/saves.rs | 11 +- .../presenter/src/state/saves/interaction.rs | 12 +- crates/presenter/src/state/saves/tests.rs | 25 ++ 5 files changed, 316 insertions(+), 271 deletions(-) create mode 100644 crates/app-iced/src/app/update/dialog/tests.rs diff --git a/crates/app-iced/src/app/update/dialog.rs b/crates/app-iced/src/app/update/dialog.rs index 04a82ad..06b546f 100644 --- a/crates/app-iced/src/app/update/dialog.rs +++ b/crates/app-iced/src/app/update/dialog.rs @@ -60,7 +60,10 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task { // 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 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>>, - events: Rc>>, - } - - impl Backend for SpyBackend { - fn drain_events(&mut self) -> Vec { - 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>>, - events: Rc>>, - } - - 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; diff --git a/crates/app-iced/src/app/update/dialog/tests.rs b/crates/app-iced/src/app/update/dialog/tests.rs new file mode 100644 index 0000000..206fc30 --- /dev/null +++ b/crates/app-iced/src/app/update/dialog/tests.rs @@ -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>>, + events: Rc>>, +} + +impl Backend for SpyBackend { + fn drain_events(&mut self) -> Vec { + 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>>, + events: Rc>>, +} + +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(), "世界都不在了还下发删除"); +} diff --git a/crates/presenter/src/state/saves.rs b/crates/presenter/src/state/saves.rs index fdc2491..bcb7bb4 100644 --- a/crates/presenter/src/state/saves.rs +++ b/crates/presenter/src/state/saves.rs @@ -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) { match result { Ok(view) => self.changed(view), diff --git a/crates/presenter/src/state/saves/interaction.rs b/crates/presenter/src/state/saves/interaction.rs index e2130a0..21a9460 100644 --- a/crates/presenter/src/state/saves/interaction.rs +++ b/crates/presenter/src/state/saves/interaction.rs @@ -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; diff --git a/crates/presenter/src/state/saves/tests.rs b/crates/presenter/src/state/saves/tests.rs index 1d37f03..d29570d 100644 --- a/crates/presenter/src/state/saves/tests.rs +++ b/crates/presenter/src/state/saves/tests.rs @@ -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` 关掉删除确认框(某条关闭路径漏清)→ 崩溃卡片弹出来 →