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:
1 parent
6c87e2ad8a
commit
32384b952e
5 files changed
+316
-271
No files matched your search
@@ -60,7 +60,10 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
|||||||
}
|
}
|
||||||
DialogMessage::Secondary | DialogMessage::Dismiss => {
|
DialogMessage::Secondary | DialogMessage::Dismiss => {
|
||||||
// M247:取消/关掉删除确认框 ⇒ 忘掉那个目标(不然下一次点确定会删上一个)。
|
// 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();
|
app.close_dialog();
|
||||||
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
||||||
// (r1 复审 P3-1)。
|
// (r1 复审 P3-1)。
|
||||||
@@ -118,280 +121,18 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
|||||||
|
|
||||||
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
|
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
|
||||||
pub(super) fn escape(app: &mut App) -> bool {
|
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;
|
return false;
|
||||||
}
|
};
|
||||||
// M247(复审 r1 P1-1):**三条关闭路径都要清**——次按钮/遮罩清了,`Esc` 这条以前漏了,
|
// M247(复审 r1 P1-1):**三条关闭路径都要清**——次按钮/遮罩清了,`Esc` 这条以前漏了,
|
||||||
// 于是"删存档 → Esc → 崩溃卡片 → 导出诊断包"会把那个世界静默删掉。
|
// 于是"删存档 → Esc → 崩溃卡片 → 导出诊断包"会把那个世界静默删掉。
|
||||||
// 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。
|
// 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。
|
||||||
app.state.saves.clear_confirm();
|
// 复审 r2 加固:清的时候**只清这个框自己的目标**(`close_confirm`),不碰别人的。
|
||||||
|
app.state.saves.close_confirm(current);
|
||||||
app.close_dialog();
|
app.close_dialog();
|
||||||
app.refresh_view();
|
app.refresh_view();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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(), "世界都不在了还下发删除");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(), "世界都不在了还下发删除");
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
//! |---|---|---|---|
|
//! |---|---|---|---|
|
||||||
//! | `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(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` | **同一拍或下一拍**:留着会让"世界已经不在了"的确认框一直挡在屏幕中央 |
|
//! | `closed_confirm` | `changed`(世界从快照里消失时登记作废的那个框) | app 层 `refresh_view` → `settle_saves_confirm`(收框后取走)/ `ask_delete` / `clear_confirm` / `bind` | **同一拍或下一拍**:留着会让"世界已经不在了"的确认框一直挡在屏幕中央 |
|
||||||
//!
|
//!
|
||||||
//! ### `confirm` 是**绑定到具体对话框**的一条安全不变量
|
//! ### `confirm` 是**绑定到具体对话框**的一条安全不变量
|
||||||
@@ -145,7 +145,14 @@ impl SavesState {
|
|||||||
|
|
||||||
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
|
/// 一条结果落地(`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>) {
|
pub fn apply(&mut self, result: Result<SavesListView, SavesFailureView>) {
|
||||||
match result {
|
match result {
|
||||||
Ok(view) => self.changed(view),
|
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) {
|
pub fn clear_confirm(&mut self) {
|
||||||
self.confirm = None;
|
self.confirm = None;
|
||||||
self.closed_confirm = None;
|
self.closed_confirm = None;
|
||||||
|
|||||||
@@ -138,6 +138,31 @@ fn the_delete_confirmation_hands_out_once() {
|
|||||||
assert!(saves.take_confirm(8).is_none(), "取消之后还留着目标");
|
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 的核心**:目标只属于登记它的那个框。别的框点主按钮**拿不走**它。
|
/// **P1-1 的核心**:目标只属于登记它的那个框。别的框点主按钮**拿不走**它。
|
||||||
///
|
///
|
||||||
/// 事故形态:`Esc` 关掉删除确认框(某条关闭路径漏清)→ 崩溃卡片弹出来 →
|
/// 事故形态:`Esc` 关掉删除确认框(某条关闭路径漏清)→ 崩溃卡片弹出来 →
|
||||||
|
|||||||
Reference in new issue
Block a user