复审三条(p2-3items / fix-then-merge,其中两条按 p1 对待):
**P2-1 取消后仍为每个剩余文件各发一次请求(p1)**
`select` 先轮询左支 ⇒ `install_one` 第一次 poll 就把 `engine.download(…)` 派出去了;
只把标志检查写在右支,等于取消后「每一个剩余 item 都创建并至少 poll 一次」
(一次整文件 sha1 + 一次请求),盘上已就绪的还会第一 poll 就 `Ready` 并回调 `sink`。
修:`async move` 第一句就 `if is_cancelled(cancel) { return None; }`,这些 item 连
`install_one` 都不进。守门用例 `cancelling_mid_run_stops_creating_remaining_files`
(三文件全在盘上、并发 1、sink 首次回调即置标志 ⇒ 数 sink 回调次数):
修后 1,删掉那两行变异后 **3**(见报告,红/绿均实跑)。
模块文档同步改成与实现一致,并把复审点名的固有边界写进注释:取消瞬间最多有
`concurrency`(GUI 传 16)个 future 已经过了那道门,其中「盘上已就绪」的仍会跑完自己的
回调(靠 presenter 的安装代次丢掉这些进度事件),真在下载的会在同一次 poll 被取消。
**P2-3 真网络装置的断言比声称的弱,且有一条探的是恒不成立的路径(p1)**
① `.ullpart` 原来只断言「5 s 后不再增长」⇒「留下一个不增长的非 0 中间文件」会绿;
改成 `assert_eq!(parts_after, 0)`。
② 实例登记原来探 `<game>/instances`(根本不存在 ⇒ 恒 false,什么也没证明);
改成读 `LauncherLayout::profiles_config()` 的 `read_profiles()` 并断言 0 条。
③ 模块文档里那条探针的路径一并订正。
变异自证:留一个 20 字节 `.ullpart` ⇒ 红(`left: 20 / right: 0`);
走真 `instance::register()` 造一条 profile ⇒ 红(`profiles.json 里 1 条实例`)。
**P2-2 取消文案仍是硬编码中文,与新选项区同屏一中一英**
`presenter` 没有 i18n 依赖,所以不是搬常量能解决的。改成发稳定信号:
新增 `CoreEvent::InstallCancelled`(不带文本),`AppState::install_cancelled` 只计数;
app 层在 `refresh_view` 里比对计数、按 `Key::DownloadCancelled` 现求文本弹一次 Toast
(`pages::download::cancelled_toast`)。`dispatch.rs` 的中文 Toast 删掉。
语言入口收成 `app::view::ui_lang()` 一处(现恒为英文;M106 接上语言只改这里,
M106 合并前中文译文在产品路径不可达)。守门:`cancelling_announces_once_in_the_page_language`
(只弹一条、文案来自目录、不含中文、重复 refresh 不重复弹)。
门禁(真实退出码):fmt=0、clippy(--workspace --all-targets -j 2 -D warnings)=0、
`cargo test -p unlucky-app-iced --lib`=0(440 passed)、`cargo test -p unlucky-core`=0、
`cargo test --workspace --exclude unlucky-app -j 2`=0(57 个 target 全 ok,0 failed)。
223 lines
9.6 KiB
Rust
223 lines
9.6 KiB
Rust
//! 下载页(M94)在 app 层的接线测试:**命令出得去、状态不自己变**。
|
||
//!
|
||
//! 从 `app/update.rs` 外置(那边 348 行 + 这个模块会顶破 400 行硬上限;同一句话的理由见
|
||
//! `crates/i18n/src/catalog/tests.rs`)。`use super::*` 依然指向 `crate::app::update`。
|
||
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
|
||
use unlucky_presenter::dto::{AppEvent, Backend, VersionCategory};
|
||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||
|
||
use super::*;
|
||
|
||
/// 只记账、**不回事件**的后端(同 `app/tests/presenter.rs`):命令出了 UI 层,状态一个字节都不许变。
|
||
/// 不用 `MockBackend`:它同步回事件,「本地乐观回显」与「事件回流」终态相同,断言会**假绿**。
|
||
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
|
||
|
||
impl Backend for Recorder {
|
||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||
Vec::new()
|
||
}
|
||
fn dispatch(&mut self, command: AppCommand) {
|
||
self.0.borrow_mut().push(command);
|
||
}
|
||
}
|
||
|
||
/// 四条下载页消息:三条派真指令,`SelectVersion` 只改视图模型(P3-2 的不乐观回显)。
|
||
/// 抓得住:意图被写成「本地改状态就算完」、或派错指令;抓不住:host 收到后真的开始下载。
|
||
#[test]
|
||
fn download_intents_dispatch_real_commands() {
|
||
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
|
||
let families = crate::theme::Families {
|
||
text: "Segoe UI",
|
||
display: "Segoe UI",
|
||
mono: "Consolas",
|
||
};
|
||
let mut app = App::with_presenter(
|
||
crate::cli::Options::default(),
|
||
families,
|
||
SchemeFlavor::Light,
|
||
Box::new(Recorder(Rc::clone(&log))),
|
||
SettingsBridge::memory_only(),
|
||
);
|
||
// M95 起 `on_page` 返回 `Task`(剪贴板命令要走返回的 Task):这里只关心派发与视图模型,
|
||
// 任务本身没有需要断言的后效,显式丢弃。
|
||
let _ = app.on_page(pages::Message::InstallVersion("1.21.9".to_string()));
|
||
let _ = app.on_page(pages::Message::CancelInstall);
|
||
let _ = app.on_page(pages::Message::SetCategory(pages::Category::Release));
|
||
let _ = app.on_page(pages::Message::SelectVersion("1.21.8".to_string()));
|
||
// M105:「安装」不再是「立刻装原版」,而是把**草稿里的选项**一起交出去
|
||
// (`CreateInstance` 带加载器 / 加载器版本 / 实例名)——这正是用户报的「没有加载器可选」的修复点。
|
||
let expected = vec![
|
||
AppCommand::CreateInstance(unlucky_presenter::dto::CreateRequest {
|
||
version: "1.21.9".to_string(),
|
||
loader: unlucky_presenter::dto::LoaderKind::Vanilla,
|
||
loader_version: String::new(),
|
||
name: String::new(),
|
||
}),
|
||
AppCommand::CancelInstall,
|
||
AppCommand::ListVersions(VersionCategory::Release),
|
||
];
|
||
assert_eq!(
|
||
*log.borrow(),
|
||
expected,
|
||
"三条各派一条;`SelectVersion` 不派"
|
||
);
|
||
// M105:选中项住在**草稿**里(`refresh_view` 每次事件都会整棵重建页面模型,
|
||
// 页面自己存不住它);recorder 不回 `VersionListChanged` ⇒ 列表是空的 ⇒
|
||
// 视图模型里那一行不存在,于是 `selected` 被过滤成 `None`(防「切分类后还留着一个点不动的安装」)。
|
||
assert_eq!(app.download_draft.selected.as_deref(), Some("1.21.8"));
|
||
assert_eq!(
|
||
app.view.download.selected, None,
|
||
"选中的行不在列表里时,视图模型不该拿着它"
|
||
);
|
||
assert_eq!(app.view.download.category, pages::Category::Release);
|
||
assert_eq!(
|
||
app.view.download.rows.len(),
|
||
0,
|
||
"recorder 不回事件 ⇒ 列表不该冒出来"
|
||
);
|
||
// 分类是本地过滤条件:**任何一次视图重建都不许把它打回 `All`**(复审 P2-1 的回归点)。
|
||
// 抓得住:`refresh_view` 忘记把分类带回去(那一行删掉这条就红)。
|
||
// 抓不住:真 host 是否按分类拉清单(那是 presenter 的 `spawn_list`,已报 finding)。
|
||
app.refresh_view();
|
||
assert_eq!(
|
||
app.view.download.category,
|
||
pages::Category::Release,
|
||
"重建视图模型之后分类必须还在"
|
||
);
|
||
}
|
||
|
||
/// **取消之后面板要回到「未安装态」且能立刻再装**(M105 的验收项,CI 可跑的那一半)。
|
||
///
|
||
/// 现场(用户原话):「取消下载按钮点了没反应」。真根因是取消生效了又被旧任务的事件盖回去
|
||
/// (presenter 侧的代次守门在 `host/cancel_tests.rs`);这一条钉的是**页面这一侧**:
|
||
/// 收到 `InstallProgress(None)` 之后,面板必须回到安装选项区、确认键可用、选中的版本还在
|
||
/// ——不需要用户重新点一遍版本。
|
||
///
|
||
/// 抓得住:取消后仍画着进度面板 / 选项区丢了选中 / 确认键还是禁用。
|
||
/// 抓不住:真网络下的取消延迟(那条是 `#[ignore]` 的真网络用例)。
|
||
#[test]
|
||
fn cancelling_gives_the_panel_back_to_the_install_options() {
|
||
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
|
||
let families = crate::theme::Families {
|
||
text: "Segoe UI",
|
||
display: "Segoe UI",
|
||
mono: "Consolas",
|
||
};
|
||
let mut app = App::with_presenter(
|
||
crate::cli::Options::default(),
|
||
families,
|
||
SchemeFlavor::Light,
|
||
Box::new(Recorder(Rc::clone(&log))),
|
||
SettingsBridge::memory_only(),
|
||
);
|
||
app.state.apply(AppEvent::Core(
|
||
unlucky_presenter::dto::CoreEvent::VersionListChanged(vec![
|
||
unlucky_presenter::dto::VersionSummary {
|
||
id: "1.21.9".to_string(),
|
||
kind: unlucky_presenter::dto::VersionKind::Release,
|
||
released: "2026-09-20".to_string(),
|
||
installed: false,
|
||
},
|
||
]),
|
||
));
|
||
app.refresh_view();
|
||
|
||
// 选中 → 面板给出选项区
|
||
let _ = app.on_page(pages::Message::SelectVersion("1.21.9".to_string()));
|
||
let options = app
|
||
.view
|
||
.download
|
||
.options
|
||
.as_ref()
|
||
.expect("选中版本之后要出现安装选项区");
|
||
assert!(options.can_confirm, "原版 + 列表里的版本 = 可以装");
|
||
assert_eq!(options.version, "1.21.9");
|
||
|
||
// 开装:host 回一条「下载中」→ 面板换成进度
|
||
app.state.apply(AppEvent::Core(
|
||
unlucky_presenter::dto::CoreEvent::InstallProgress(Some(
|
||
unlucky_presenter::dto::InstallProgressView {
|
||
version: "1.21.9".to_string(),
|
||
stage: unlucky_presenter::dto::InstallStage::Downloading,
|
||
files_done: 9,
|
||
files_total: 512,
|
||
..unlucky_presenter::dto::InstallProgressView::new()
|
||
},
|
||
)),
|
||
));
|
||
app.refresh_view();
|
||
assert!(app.view.download.install.is_some(), "装的时候面板讲进度");
|
||
assert!(app.view.download.options.is_none(), "进度与选项互斥");
|
||
|
||
// 取消:host 回 `InstallProgress(None)`(`CancelInstall` 的既有语义)
|
||
let _ = app.on_page(pages::Message::CancelInstall);
|
||
app.state.apply(AppEvent::Core(
|
||
unlucky_presenter::dto::CoreEvent::InstallProgress(None),
|
||
));
|
||
app.refresh_view();
|
||
assert!(app.view.download.install.is_none(), "取消后面板必须收回");
|
||
let options = app
|
||
.view
|
||
.download
|
||
.options
|
||
.as_ref()
|
||
.expect("取消后要回到安装选项区(未安装态),而不是只剩一句提示");
|
||
assert_eq!(
|
||
options.version, "1.21.9",
|
||
"选中项要还在,用户不必重新点一遍"
|
||
);
|
||
assert!(options.can_confirm, "取消后要能立刻再装一次");
|
||
}
|
||
|
||
/// **取消提示只说一次,且说的是页面语言**(复审 P2-2)。
|
||
///
|
||
/// 修前:`presenter` 在取消时直接推一条**写死的中文** Toast,而下载页此刻渲染英文 ⇒
|
||
/// 选项区英文、取消弹窗中文,同屏两语。现在 host 只发信号(`CoreEvent::InstallCancelled`),
|
||
/// 文案由页面层按 `Key::DownloadCancelled` 现求,语言跟页面走。
|
||
///
|
||
/// 抓得住:谁把中文写回 presenter(类型上已经做不到——它没有 i18n 依赖)、
|
||
/// 或者 app 层每次 `refresh_view` 都重复弹(下面的 `items().len() == 1`)。
|
||
/// 抓不住:`Lang` 本身接得对不对(`view::ui_lang()` 现恒为英文,那是 M106 的范围)。
|
||
#[test]
|
||
fn cancelling_announces_once_in_the_page_language() {
|
||
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
|
||
let families = crate::theme::Families {
|
||
text: "Segoe UI",
|
||
display: "Segoe UI",
|
||
mono: "Consolas",
|
||
};
|
||
let mut app = App::with_presenter(
|
||
crate::cli::Options::default(),
|
||
families,
|
||
SchemeFlavor::Light,
|
||
Box::new(Recorder(Rc::clone(&log))),
|
||
SettingsBridge::memory_only(),
|
||
);
|
||
assert!(app.state.toasts.is_empty(), "出厂态不该有 Toast");
|
||
|
||
app.state.apply(AppEvent::Core(
|
||
unlucky_presenter::dto::CoreEvent::InstallCancelled,
|
||
));
|
||
app.refresh_view();
|
||
let items = app.state.toasts.items();
|
||
assert_eq!(items.len(), 1, "取消要提示一条(且只一条)");
|
||
let text = items[0].text.clone();
|
||
assert_eq!(
|
||
text,
|
||
pages::cancelled_toast(super::super::view::ui_lang()),
|
||
"提示语必须来自 i18n 目录(不是 presenter 写死的中文)"
|
||
);
|
||
assert!(
|
||
!text.contains('取') && !text.contains('消'),
|
||
"页面此刻是英文,取消提示不该混中文:{text}"
|
||
);
|
||
|
||
// 再重建一次视图:不该重复弹(信号是计数,不是「每拍都算一次」)
|
||
app.refresh_view();
|
||
assert_eq!(app.state.toasts.items().len(), 1, "同一次取消只提示一次");
|
||
let _ = app.state.toasts.len();
|
||
}
|