fix(presenter,app-iced): 删除确认绑定到对话框身份 + 快照不再吃掉目标(M247 r2 复审)
复审 r1 P1-1(安全):`SavesState::confirm` 的「关闭即清」在 Esc 这条路径上没有落实, 而主按钮分支无条件先取目标 —— 「删存档 → Esc → 崩溃卡片 → 导出诊断包」会静默删掉那个世界。 两层修,各自有独立用例钉住: 1. **认框不认人**:`confirm` 改成 `Option<(DialogId, String)>`,`take_confirm(current)` 只在 id 相等时交付;`DialogMessage::Primary` 传 `dialogs.current().id`。即使别的路径漏清, 也核销不了别人那个框。用例 `a_leaked_target_cannot_be_redeemed_by_another_dialog` (去掉 id 校验即红:实测「漏清的目标被别的框兑现了:世界被静默删除」)。 2. **关闭路径补全**:`update/dialog.rs::escape()` 补 `clear_confirm()`(次按钮/遮罩早就清了, Esc 漏了)。用例 `escaping_the_delete_dialog_disarms_it_for_the_next_one` 复现整条事故链, 断言没有下发 `DeleteWorld`、框走自己的路关掉(去掉这行即红)。 P2-1:`changed()` 不再无条件清 `confirm`(一拍无关快照会让主按钮变成静默空操作)—— 只在「被确认的世界已经从快照里消失」时清,并登记 `closed_confirm` 让 app 层**收起那个框** (`refresh_view → settle_saves_confirm`),避免留一个点了没反应的按钮。`failed()` 同理不动 confirm。 用例:`an_unrelated_snapshot_keeps_the_confirmation_redeemable`、`a_vanished_world_closes_its_confirmation`、 `an_unrelated_snapshot_no_longer_drops_the_confirmation`、`the_confirmation_is_dropped_and_reported_when_the_world_vanishes`。 P2-2:`catalog/instances.rs` 那段「这一版只读 / InstancesSavesReadOnly」的陈旧说明改成现状 (写操作已接上,边界由 core 的 `write::boundary` 守)。 拆分(400 行):`presenter/src/state/saves.rs` 的交互态方法 → `state/saves/interaction.rs`、 判据 → `state/saves/tests.rs`;`app/update/dialog.rs` 的接线用例内联在该文件(5 条)。
This commit is contained in:
1 parent
8c67e4eca6
commit
6c87e2ad8a
8 files changed
+682
-229
No files matched your search
@@ -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`)。
|
||||||
///
|
///
|
||||||
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
|
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
|
||||||
|
|||||||
@@ -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 => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(), "世界都不在了还下发删除");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/…"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! `SavesState` 的判据:绑定 / 迟到丢弃 / 三样交互态(含删除确认的安全不变量)。
|
||||||
|
//!
|
||||||
|
//! 从 `state/saves.rs` 拆出(父模块守 400 行)。**判据一条没改**,另外补了复审 r1 P1-1 / P2-1
|
||||||
|
//! 那两条:认框不认人、无关快照不再吃掉确认目标。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::dto::{SavesFailureKind, WorldView};
|
||||||
|
|
||||||
|
fn view(instance: &str) -> SavesListView {
|
||||||
|
SavesListView {
|
||||||
|
instance: instance.to_string(),
|
||||||
|
dir: "C:/mc/saves".to_string(),
|
||||||
|
exists: true,
|
||||||
|
worlds: vec![WorldView {
|
||||||
|
dir: "World".to_string(),
|
||||||
|
icon: false,
|
||||||
|
modified_ms: None,
|
||||||
|
}],
|
||||||
|
screenshots_dir: "C:/mc/screenshots".to_string(),
|
||||||
|
screenshots_exists: false,
|
||||||
|
screenshots: Vec::new(),
|
||||||
|
skipped: Vec::new(),
|
||||||
|
last_op: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure(instance: &str) -> SavesFailureView {
|
||||||
|
SavesFailureView {
|
||||||
|
instance: instance.to_string(),
|
||||||
|
file: String::new(),
|
||||||
|
op: None,
|
||||||
|
kind: SavesFailureKind::DirUnusable,
|
||||||
|
detail: "Access is denied.".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 换实例必须清空(A 的存档不许画在 B 的详情页上)。
|
||||||
|
#[test]
|
||||||
|
fn binding_another_instance_drops_the_old_view() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.changed(view("a"));
|
||||||
|
assert!(saves.has_view());
|
||||||
|
|
||||||
|
saves.bind(Some("b"));
|
||||||
|
assert!(!saves.has_view(), "换实例后还留着 A 的存档");
|
||||||
|
assert_eq!(saves.bound.as_deref(), Some("b"));
|
||||||
|
|
||||||
|
// 同一个实例再 bind 一次不该清(`refresh_view` 每帧都调)。
|
||||||
|
saves.changed(view("b"));
|
||||||
|
saves.bind(Some("b"));
|
||||||
|
assert!(saves.has_view());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 迟到/串台的结果被丢掉;成功一次清掉旧失败(重试成功后红字要消失)。
|
||||||
|
#[test]
|
||||||
|
fn late_results_are_dropped_and_success_clears_the_failure() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.changed(view("b"));
|
||||||
|
assert!(!saves.has_view(), "B 的列表画到了 A 上");
|
||||||
|
|
||||||
|
saves.failed(failure("a"));
|
||||||
|
assert!(saves.failed.is_some());
|
||||||
|
assert!(saves.bound.is_some(), "失败不许把绑定清掉(要能重试)");
|
||||||
|
saves.changed(view("a"));
|
||||||
|
assert!(saves.failed.is_none(), "成功后旧失败没清掉");
|
||||||
|
|
||||||
|
// 不在详情页(`bound = None`)时任何结果都不落地。
|
||||||
|
let mut idle = SavesState::default();
|
||||||
|
idle.changed(view("a"));
|
||||||
|
idle.failed(failure("a"));
|
||||||
|
assert!(!idle.has_view());
|
||||||
|
assert!(idle.failed.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 换实例要把三样交互态一起清掉(草稿 / 在飞 / 确认目标)。
|
||||||
|
///
|
||||||
|
/// 抓得住:换到 B 之后「A 的草稿」还留着 ⇒ 用户点确定会去改 B 页上的一个不存在的名字。
|
||||||
|
#[test]
|
||||||
|
fn switching_instances_drops_draft_pending_and_confirm() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.changed(view("a"));
|
||||||
|
saves.start_rename("World");
|
||||||
|
saves.edit_rename("Renamed".to_string());
|
||||||
|
saves.ask_delete(7, "World");
|
||||||
|
saves.mark_pending(SavesOp::Backup {
|
||||||
|
dir: "World".to_string(),
|
||||||
|
});
|
||||||
|
assert!(saves.draft.is_none(), "发请求时草稿该收起来");
|
||||||
|
assert!(saves.pending.is_some());
|
||||||
|
|
||||||
|
saves.bind(Some("b"));
|
||||||
|
assert!(saves.draft.is_none(), "换实例后草稿还在");
|
||||||
|
assert!(saves.pending.is_none(), "换实例后在飞标记还在");
|
||||||
|
assert!(saves.confirm.is_none(), "换实例后删除确认目标还在");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回程(成功或失败)都要把「在飞」收掉:按钮不能永远灰着。
|
||||||
|
#[test]
|
||||||
|
fn both_outcomes_clear_the_pending_marker() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.changed(view("a"));
|
||||||
|
saves.mark_pending(SavesOp::Delete {
|
||||||
|
dir: "World".to_string(),
|
||||||
|
});
|
||||||
|
assert!(saves.is_pending("World"));
|
||||||
|
saves.failed(failure("a"));
|
||||||
|
assert!(
|
||||||
|
!saves.is_pending("World"),
|
||||||
|
"失败后在飞标记没清:重试按钮会一直是灰的"
|
||||||
|
);
|
||||||
|
|
||||||
|
saves.mark_pending(SavesOp::Rename {
|
||||||
|
dir: "World".to_string(),
|
||||||
|
});
|
||||||
|
assert!(saves.is_pending("World"));
|
||||||
|
saves.changed(view("a"));
|
||||||
|
assert!(!saves.is_pending("World"), "成功后没清在飞标记");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确认目标只发一次(同一次确认连点两下不该发两条删除)。
|
||||||
|
#[test]
|
||||||
|
fn the_delete_confirmation_hands_out_once() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.ask_delete(7, "Doomed");
|
||||||
|
assert_eq!(saves.take_confirm(7).as_deref(), Some("Doomed"));
|
||||||
|
assert!(
|
||||||
|
saves.take_confirm(7).is_none(),
|
||||||
|
"第二次取还拿得到:连点会删两次"
|
||||||
|
);
|
||||||
|
|
||||||
|
saves.ask_delete(8, "Other");
|
||||||
|
saves.clear_confirm();
|
||||||
|
assert!(saves.take_confirm(8).is_none(), "取消之后还留着目标");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P1-1 的核心**:目标只属于登记它的那个框。别的框点主按钮**拿不走**它。
|
||||||
|
///
|
||||||
|
/// 事故形态:`Esc` 关掉删除确认框(某条关闭路径漏清)→ 崩溃卡片弹出来 →
|
||||||
|
/// 点「导出诊断包」(也是 `Confirm` 的主按钮)→ 若 `take_confirm` 无条件交付,
|
||||||
|
/// 那个世界就被静默删掉了。判据是 **id 不等就不交付、也不清**(目标还是那个框的)。
|
||||||
|
#[test]
|
||||||
|
fn a_dialog_can_only_redeem_its_own_target() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.ask_delete(7, "Doomed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
saves.take_confirm(99).is_none(),
|
||||||
|
"别的框的主按钮拿走了删除目标"
|
||||||
|
);
|
||||||
|
assert_eq!(saves.confirm_dialog(), Some(7), "拿不走也不该被顺手清掉");
|
||||||
|
assert_eq!(
|
||||||
|
saves.take_confirm(7).as_deref(),
|
||||||
|
Some("Doomed"),
|
||||||
|
"自己的框反而兑不了"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P2-1**:一拍无关快照(世界还在)不许把确认目标吃掉——否则主按钮就是静默空操作。
|
||||||
|
#[test]
|
||||||
|
fn an_unrelated_snapshot_no_longer_drops_the_confirmation() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.changed(view("a"));
|
||||||
|
saves.ask_delete(7, "World");
|
||||||
|
|
||||||
|
// 「打开存档文件夹」的回程 / 备份在飞时的列表刷新:世界还在快照里。
|
||||||
|
saves.changed(view("a"));
|
||||||
|
assert_eq!(
|
||||||
|
saves.take_confirm(7).as_deref(),
|
||||||
|
Some("World"),
|
||||||
|
"无关快照把确认目标吃掉了:主按钮会变成静默空操作"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 读盘失败同理:一条失败不该把用户刚点出来的框弄成"点了没反应"。
|
||||||
|
saves.ask_delete(8, "World");
|
||||||
|
saves.failed(failure("a"));
|
||||||
|
assert_eq!(saves.take_confirm(8).as_deref(), Some("World"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P2-1 的另一半**:被确认的世界从快照里消失 ⇒ 目标清掉,并且**登记那个框该收了**。
|
||||||
|
///
|
||||||
|
/// 只清不登记就是"框还在、主按钮点了什么也不发生";只登记不清就是留着一个作废的目标。
|
||||||
|
#[test]
|
||||||
|
fn the_confirmation_is_dropped_and_reported_when_the_world_vanishes() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.changed(view("a"));
|
||||||
|
saves.ask_delete(7, "World");
|
||||||
|
|
||||||
|
// 快照回来时那个世界已经不在列表里(别的进程删掉了 / 改名了)。
|
||||||
|
let mut gone = view("a");
|
||||||
|
gone.worlds.clear();
|
||||||
|
saves.changed(gone);
|
||||||
|
|
||||||
|
assert!(saves.confirm.is_none(), "目标还留着一个已经不存在的世界");
|
||||||
|
assert_eq!(
|
||||||
|
saves.take_closed_confirm(),
|
||||||
|
Some(7),
|
||||||
|
"没有登记那个框 ⇒ 它会一直挡在屏幕中央、主按钮点了没反应"
|
||||||
|
);
|
||||||
|
assert!(saves.take_confirm(7).is_none(), "作废的目标还能被兑现");
|
||||||
|
assert!(saves.take_closed_confirm().is_none(), "信号只发一次");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 行内草稿照原样存:非法名字不在前端过滤(core 会拒并给出具名原因)。
|
||||||
|
#[test]
|
||||||
|
fn the_rename_draft_keeps_the_typed_text_verbatim() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.start_rename("World");
|
||||||
|
assert_eq!(
|
||||||
|
saves.draft.as_ref().expect("草稿").text,
|
||||||
|
"World",
|
||||||
|
"应预填当前名字"
|
||||||
|
);
|
||||||
|
saves.edit_rename("../escape".to_string());
|
||||||
|
let draft = saves.take_rename().expect("草稿");
|
||||||
|
assert_eq!(draft.dir, "World");
|
||||||
|
assert_eq!(draft.text, "../escape", "输入被前端偷偷改了");
|
||||||
|
assert!(saves.take_rename().is_none(), "取走之后草稿还在");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user