fix(presenter,app-iced): 「游戏在跑」挡在落 core 之前(M247 r2)
塔裁决:三个写操作在实例启动/运行时必须拒,判据不能只靠界面灰按钮 —— Windows 上是 os error 32 兜底,**Linux 上删一个正在跑的世界会成功**,那是在 用户玩到一半时把存档删掉。 - host/dispatch.rs:`AppCommand::Saves` 与 `AppCommand::Mods` 同款,先查 `CoreHost::in_flight(&id)` 再把 `running` 传下去 - host/saves.rs::dispatch 增 `running`:为真时三个写操作**不 spawn、不碰盘**, 直接回一条 `SavesFailureKind::GameRunning` 的结构化失败;读路径与「打开文件夹」不受影响 - dto:新增 `GameRunning` 类别(不折进 WriteFailed —— 那不是"写失败",是"我们主动没写") - i18n:「游戏正在运行(先退出再操作)」中英成对 + 金表同步 - 用例:在飞时三条写命令盘上指纹不变/世界还在/备份落点没建,且回 GameRunning; 反向用例钉住闸门没有把写操作一并关死;UI 侧断言三种动词的主句都不出现「写盘失败」且不画重试 - 拆分(400 行):host/saves.rs → host/saves/write.rs;两处测试各拆出 write 子模块 - SavesState 模块文档补「生命周期」表:draft / pending / confirm 谁写谁清、何时必须清, 并把 confirm「取走即清」(连点两下不删两个)写成安全不变量 - 出图:ULL_SAVES_FIXTURE=running 一张(失败那句话的样子)
This commit is contained in:
1 parent
c921b9c185
commit
8c67e4eca6
13 files changed
+889
-448
No files matched your search
@@ -91,6 +91,7 @@ pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
|
||||
/// | `rename` | 行内重命名进行中(输入框 + 改名/取消) |
|
||||
/// | `confirm` | 删除的二次确认模态框 |
|
||||
/// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) |
|
||||
/// | `running` | 「游戏正在运行,先退出再操作」那条写失败(M247 的在飞闸门) |
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// 默认:列表 + 行操作区。
|
||||
@@ -101,6 +102,8 @@ pub enum Mode {
|
||||
Confirm,
|
||||
/// 备份成功的回执。
|
||||
BackedUp,
|
||||
/// 写操作被「游戏在运行」挡下。
|
||||
Running,
|
||||
}
|
||||
|
||||
fn mode() -> Mode {
|
||||
@@ -111,6 +114,7 @@ fn mode() -> Mode {
|
||||
"rename" => Mode::Rename,
|
||||
"confirm" => Mode::Confirm,
|
||||
"backed-up" => Mode::BackedUp,
|
||||
"running" => Mode::Running,
|
||||
_ => Mode::Plain,
|
||||
}
|
||||
}
|
||||
@@ -136,6 +140,17 @@ pub fn seed(app: &mut crate::app::state::App, instance: &str) {
|
||||
};
|
||||
match mode {
|
||||
Mode::Rename => app.state.saves.start_rename(&dir),
|
||||
// 在飞闸门那条失败(`host::saves::dispatch` 在 `running` 时直接回它)。
|
||||
Mode::Running => app
|
||||
.state
|
||||
.saves
|
||||
.failed(unlucky_presenter::dto::SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
file: dir,
|
||||
op: Some(unlucky_presenter::dto::SavesFailureOp::Rename),
|
||||
kind: unlucky_presenter::dto::SavesFailureKind::GameRunning,
|
||||
detail: String::new(),
|
||||
}),
|
||||
Mode::Confirm => {
|
||||
app.state.saves.ask_delete(&dir);
|
||||
let (title, body, primary, secondary) =
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
//! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
SavesArea, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView, SavesOped,
|
||||
SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
|
||||
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
|
||||
ScreenshotView, WorldView,
|
||||
};
|
||||
use unlucky_presenter::state::{SavesOp, SavesState};
|
||||
use unlucky_presenter::state::SavesState;
|
||||
|
||||
use super::super::saves::{failure_line, from_state, op_line};
|
||||
use super::super::saves::{failure_line, from_state};
|
||||
use super::super::Texts;
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
@@ -214,137 +214,6 @@ fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
|
||||
assert!(!text.contains('('), "空原文留下了一对括号:{text}");
|
||||
}
|
||||
|
||||
/// 写操作失败:**不画重试**(重试按钮会去重新列目录,那是错的动作),并且主句带**哪个世界**。
|
||||
///
|
||||
/// 抓得住:三种写失败被折叠成同一句「读取 saves/ 失败」——用户看不出是哪一行出了什么事。
|
||||
#[test]
|
||||
fn write_failures_are_named_per_operation_and_never_retryable() {
|
||||
let cases = [
|
||||
(
|
||||
SavesFailureOp::Rename,
|
||||
SavesFailureKind::WriteConflict,
|
||||
"重命名 Better World 失败:同名世界已存在",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Delete,
|
||||
SavesFailureKind::WriteMissing,
|
||||
"删除 Better World 失败:那个世界已不在",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Backup,
|
||||
SavesFailureKind::WriteTooLarge,
|
||||
"备份 Better World 失败:这个世界太大,一个 zip 装不下",
|
||||
),
|
||||
];
|
||||
for (op, kind, expected) in cases {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: "Better World".to_string(),
|
||||
op: Some(op),
|
||||
kind,
|
||||
detail: String::new(),
|
||||
};
|
||||
let line = failure_line(&failure, Texts::of(Lang::ZhCn.catalog()));
|
||||
assert_eq!(line, expected);
|
||||
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.failed(failure);
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
assert!(!saves.retryable, "{op:?} 失败了还画着重试按钮");
|
||||
assert!(saves.error.is_some());
|
||||
assert_eq!(saves.worlds.len(), 1, "{op:?} 失败把列表清了");
|
||||
}
|
||||
}
|
||||
|
||||
/// 三样交互态(草稿 / 在飞 / 回执)都要变成页面字段:改名中的那一行、置灰的那一行、成功那句话。
|
||||
#[test]
|
||||
fn draft_pending_and_receipt_become_page_fields() {
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.start_rename("Better World");
|
||||
state.edit_rename("Renamed".to_string());
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
let editing = saves.editing.as_ref().expect("草稿没进页面模型");
|
||||
assert_eq!(editing.dir, "Better World");
|
||||
assert_eq!(editing.text, "Renamed");
|
||||
assert!(!saves.busy_with("Better World"), "还在打字就算在飞了");
|
||||
|
||||
state.mark_pending(SavesOp::Backup {
|
||||
dir: "Better World".to_string(),
|
||||
});
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.editing.is_none(), "发请求时草稿该收起来");
|
||||
assert!(saves.busy_with("Better World"));
|
||||
assert!(!saves.busy_with("Other"));
|
||||
|
||||
// 回执:host 写在快照里,下一次普通 List 就没了。
|
||||
let mut done = view("a");
|
||||
done.last_op = Some(SavesOped::BackedUp {
|
||||
dir: "Better World".to_string(),
|
||||
file: "Better World-20240501-100000.zip".to_string(),
|
||||
path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(),
|
||||
bytes: 2_048,
|
||||
skipped: 2,
|
||||
});
|
||||
state.changed(done);
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
let notice = saves.notice.as_deref().expect("回执没进页面模型");
|
||||
assert!(notice.contains("2.0 KB"), "回执要带大小:{notice}");
|
||||
assert!(
|
||||
notice.contains("Better World-20240501-100000.zip"),
|
||||
"{notice}"
|
||||
);
|
||||
assert!(
|
||||
notice.contains("跳过 2 个符号链接"),
|
||||
"跳过的链接要报出来:{notice}"
|
||||
);
|
||||
assert!(saves.busy.is_none(), "回程之后还在飞");
|
||||
}
|
||||
|
||||
/// 三种回执的措辞(重命名 / 删除 / 备份)。
|
||||
#[test]
|
||||
fn every_receipt_says_what_happened() {
|
||||
let texts = Texts::of(Lang::ZhCn.catalog());
|
||||
assert_eq!(
|
||||
op_line(
|
||||
&SavesOped::Renamed {
|
||||
from: "A".to_string(),
|
||||
to: "B".to_string()
|
||||
},
|
||||
texts
|
||||
),
|
||||
"已把 A 改名为 B。"
|
||||
);
|
||||
assert_eq!(
|
||||
op_line(
|
||||
&SavesOped::Deleted {
|
||||
dir: "A".to_string()
|
||||
},
|
||||
texts
|
||||
),
|
||||
"已删除 A。"
|
||||
);
|
||||
let backed_up = op_line(
|
||||
&SavesOped::BackedUp {
|
||||
dir: "A".to_string(),
|
||||
file: "A.zip".to_string(),
|
||||
path: "D:/x/A.zip".to_string(),
|
||||
bytes: 0,
|
||||
skipped: 0,
|
||||
},
|
||||
texts,
|
||||
);
|
||||
assert!(
|
||||
backed_up.starts_with("已把 A 备份到 D:/x/A.zip"),
|
||||
"{backed_up}"
|
||||
);
|
||||
assert!(
|
||||
!backed_up.contains("符号链接"),
|
||||
"没跳过就别提链接:{backed_up}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。
|
||||
#[test]
|
||||
fn millisecond_stamps_are_rendered_in_utc() {
|
||||
@@ -372,3 +241,5 @@ fn millisecond_stamps_are_rendered_in_utc() {
|
||||
stamp(1_709_251_140_000)
|
||||
);
|
||||
}
|
||||
|
||||
mod write;
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 存档**写操作**的页面模型判据(M247)——从 `tests/saves.rs` 拆出(那边越过 400 行)。
|
||||
//!
|
||||
//! 三条都在 `from_state` 这一层钉:写失败的主句(哪一行 + 哪个动作 + 什么原因)、
|
||||
//! 「游戏在运行」不是「写盘失败」(不留重试按钮)、以及草稿 / 在飞 / 回执变成页面字段。
|
||||
|
||||
use super::super::super::saves::{failure_line, from_state, op_line};
|
||||
use super::super::super::Texts;
|
||||
use super::{bound_state, view};
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{SavesFailureKind, SavesFailureOp, SavesFailureView, SavesOped};
|
||||
use unlucky_presenter::state::SavesOp;
|
||||
|
||||
/// 写操作失败:**不画重试**(重试按钮会去重新列目录,那是错的动作),并且主句带**哪个世界**。
|
||||
///
|
||||
/// 抓得住:三种写失败被折叠成同一句「读取 saves/ 失败」——用户看不出是哪一行出了什么事。
|
||||
#[test]
|
||||
fn write_failures_are_named_per_operation_and_never_retryable() {
|
||||
let cases = [
|
||||
(
|
||||
SavesFailureOp::Rename,
|
||||
SavesFailureKind::WriteConflict,
|
||||
"重命名 Better World 失败:同名世界已存在",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Delete,
|
||||
SavesFailureKind::WriteMissing,
|
||||
"删除 Better World 失败:那个世界已不在",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Backup,
|
||||
SavesFailureKind::WriteTooLarge,
|
||||
"备份 Better World 失败:这个世界太大,一个 zip 装不下",
|
||||
),
|
||||
];
|
||||
for (op, kind, expected) in cases {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: "Better World".to_string(),
|
||||
op: Some(op),
|
||||
kind,
|
||||
detail: String::new(),
|
||||
};
|
||||
let line = failure_line(&failure, Texts::of(Lang::ZhCn.catalog()));
|
||||
assert_eq!(line, expected);
|
||||
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.failed(failure);
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
assert!(!saves.retryable, "{op:?} 失败了还画着重试按钮");
|
||||
assert!(saves.error.is_some());
|
||||
assert_eq!(saves.worlds.len(), 1, "{op:?} 失败把列表清了");
|
||||
}
|
||||
}
|
||||
|
||||
/// 「游戏在跑」不是「写盘失败」:主句要直说先退出,而且**不画重试**。
|
||||
///
|
||||
/// 抓得住:把这条折进 `WriteFailed` —— 用户会看到「删除 A 失败:写盘失败」,然后以为是权限问题,
|
||||
/// 反复重试;真正该做的事(退出游戏)一个字都没说。
|
||||
#[test]
|
||||
fn a_running_game_says_exit_first_not_write_failed() {
|
||||
let cases = [
|
||||
(
|
||||
SavesFailureOp::Rename,
|
||||
"重命名 Better World 失败:游戏正在运行(先退出再操作)",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Delete,
|
||||
"删除 Better World 失败:游戏正在运行(先退出再操作)",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Backup,
|
||||
"备份 Better World 失败:游戏正在运行(先退出再操作)",
|
||||
),
|
||||
];
|
||||
for (op, expected) in cases {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: "Better World".to_string(),
|
||||
op: Some(op),
|
||||
kind: SavesFailureKind::GameRunning,
|
||||
detail: String::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
failure_line(&failure, Texts::of(Lang::ZhCn.catalog())),
|
||||
expected
|
||||
);
|
||||
assert!(
|
||||
!expected.contains("写盘失败"),
|
||||
"「游戏在跑」被说成了「写盘失败」"
|
||||
);
|
||||
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.failed(failure);
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
assert!(!saves.retryable, "{op:?}:在飞被拒还画重试按钮");
|
||||
assert_eq!(saves.worlds.len(), 1, "{op:?}:被拒把列表清了");
|
||||
}
|
||||
// 英文那一侧同一条判据(主语不同、意思相同)。
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: "Better World".to_string(),
|
||||
op: Some(SavesFailureOp::Delete),
|
||||
kind: SavesFailureKind::GameRunning,
|
||||
detail: String::new(),
|
||||
};
|
||||
let en = failure_line(&failure, Texts::of(Lang::En.catalog()));
|
||||
assert_eq!(
|
||||
en,
|
||||
"Could not delete Better World: the game is running (exit it first)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 三样交互态(草稿 / 在飞 / 回执)都要变成页面字段:改名中的那一行、置灰的那一行、成功那句话。
|
||||
#[test]
|
||||
fn draft_pending_and_receipt_become_page_fields() {
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.start_rename("Better World");
|
||||
state.edit_rename("Renamed".to_string());
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
let editing = saves.editing.as_ref().expect("草稿没进页面模型");
|
||||
assert_eq!(editing.dir, "Better World");
|
||||
assert_eq!(editing.text, "Renamed");
|
||||
assert!(!saves.busy_with("Better World"), "还在打字就算在飞了");
|
||||
|
||||
state.mark_pending(SavesOp::Backup {
|
||||
dir: "Better World".to_string(),
|
||||
});
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.editing.is_none(), "发请求时草稿该收起来");
|
||||
assert!(saves.busy_with("Better World"));
|
||||
assert!(!saves.busy_with("Other"));
|
||||
|
||||
// 回执:host 写在快照里,下一次普通 List 就没了。
|
||||
let mut done = view("a");
|
||||
done.last_op = Some(SavesOped::BackedUp {
|
||||
dir: "Better World".to_string(),
|
||||
file: "Better World-20240501-100000.zip".to_string(),
|
||||
path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(),
|
||||
bytes: 2_048,
|
||||
skipped: 2,
|
||||
});
|
||||
state.changed(done);
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
let notice = saves.notice.as_deref().expect("回执没进页面模型");
|
||||
assert!(notice.contains("2.0 KB"), "回执要带大小:{notice}");
|
||||
assert!(
|
||||
notice.contains("Better World-20240501-100000.zip"),
|
||||
"{notice}"
|
||||
);
|
||||
assert!(
|
||||
notice.contains("跳过 2 个符号链接"),
|
||||
"跳过的链接要报出来:{notice}"
|
||||
);
|
||||
assert!(saves.busy.is_none(), "回程之后还在飞");
|
||||
}
|
||||
|
||||
/// 三种回执的措辞(重命名 / 删除 / 备份)。
|
||||
#[test]
|
||||
fn every_receipt_says_what_happened() {
|
||||
let texts = Texts::of(Lang::ZhCn.catalog());
|
||||
assert_eq!(
|
||||
op_line(
|
||||
&SavesOped::Renamed {
|
||||
from: "A".to_string(),
|
||||
to: "B".to_string()
|
||||
},
|
||||
texts
|
||||
),
|
||||
"已把 A 改名为 B。"
|
||||
);
|
||||
assert_eq!(
|
||||
op_line(
|
||||
&SavesOped::Deleted {
|
||||
dir: "A".to_string()
|
||||
},
|
||||
texts
|
||||
),
|
||||
"已删除 A。"
|
||||
);
|
||||
let backed_up = op_line(
|
||||
&SavesOped::BackedUp {
|
||||
dir: "A".to_string(),
|
||||
file: "A.zip".to_string(),
|
||||
path: "D:/x/A.zip".to_string(),
|
||||
bytes: 0,
|
||||
skipped: 0,
|
||||
},
|
||||
texts,
|
||||
);
|
||||
assert!(
|
||||
backed_up.starts_with("已把 A 备份到 D:/x/A.zip"),
|
||||
"{backed_up}"
|
||||
);
|
||||
assert!(
|
||||
!backed_up.contains("符号链接"),
|
||||
"没跳过就别提链接:{backed_up}"
|
||||
);
|
||||
}
|
||||
@@ -288,6 +288,7 @@ impl Texts {
|
||||
SavesFailureKind::WriteConflict => Key::InstancesSavesReasonConflict,
|
||||
SavesFailureKind::WriteBoundary => Key::InstancesSavesReasonBoundary,
|
||||
SavesFailureKind::WriteTooLarge => Key::InstancesSavesReasonTooLarge,
|
||||
SavesFailureKind::GameRunning => Key::InstancesSavesReasonRunning,
|
||||
SavesFailureKind::WriteFailed => Key::InstancesSavesReasonWrite,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user