fix(dlflow): M105 r1 —— 取消后不再逐个派发剩余文件;取消证据补强;取消提示进 i18n

复审三条(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)。
This commit is contained in:
AskaEth committed 2026-11-04 22:12:22 +08:00
1 parent 30b1ff6f48
commit 609bcb3044
14 files changed
+240 -23

No files matched your search

+14
View File
@@ -25,6 +25,7 @@ use unlucky_presenter::dto::Backend;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::state::AppState;
use unlucky_presenter::toast::ToastKind;
use crate::cli::Options;
use crate::pages;
@@ -136,6 +137,9 @@ pub struct App {
/// 页面自己存不住「用户正在填但还没确认」的东西;这与分类筛选(`view.download.category`)
/// 是同一个理由、同一个做法。
pub(super) download_draft: pages::InstallDraft,
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
pub(super) install_cancelled_seen: u64,
}
impl App {
@@ -224,6 +228,7 @@ impl App {
agent: crate::shell::agent::demo::from_env().unwrap_or_default(),
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(),
install_cancelled_seen: 0,
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
@@ -285,6 +290,15 @@ impl App {
// 是为了让模型拿到的 `content_width` 与页面头部读到的档位来自同一份状态。
self.sync_agent_route();
// M105:取消安装的提示语在**这里**弹(`refresh_view` 是事件 apply 之后必经的那一处)。
// 文案由页面层求(`pages::cancelled_toast`),presenter 只发信号 ——
// 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。
if self.state.install_cancelled != self.install_cancelled_seen {
self.install_cancelled_seen = self.state.install_cancelled;
let text = pages::cancelled_toast(super::view::ui_lang());
self.state.toasts.push(ToastKind::Info, text);
}
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category;
@@ -171,3 +171,52 @@ fn cancelling_gives_the_panel_back_to_the_install_options() {
);
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();
}
+10 -9
View File
@@ -93,6 +93,13 @@ impl App {
// --------------------------------------------------------------- 快照 → 视图模型
/// **产品路径此刻的界面语言**(唯一入口):语言还没接到设置(M106 的范围),所以恒为英文。
/// 收成一处是为了别让第二个地方(如取消提示)各硬编码一次;M106 只改这里。
/// **M106 合并前中文译文在产品路径上不可达**(页面与提示语都是英文)。
pub(super) fn ui_lang() -> Lang {
Lang::En
}
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
///
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
@@ -130,8 +137,7 @@ pub(super) fn model(
_ => None,
};
// 语言入口还没接到 `Ui`(`theme.rs` 归 M93),产品路径先英文。
let lang = Lang::En;
let lang = ui_lang();
pages::Model {
screen,
@@ -282,13 +288,8 @@ mod tests {
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
model(
state,
1180.0,
forced,
pages::Category::All,
&pages::InstallDraft::default(),
)
let draft = pages::InstallDraft::default();
model(state, 1180.0, forced, pages::Category::All, &draft)
}
#[test]
+1 -1
View File
@@ -75,7 +75,7 @@ mod states;
mod tests;
pub use demo::demo;
pub use model::{Category, Download};
pub use model::{cancelled_toast, Category, Download};
pub use options::{InstallDraft, Loader};
use iced::widget::column;
+11 -1
View File
@@ -29,10 +29,20 @@ use unlucky_presenter::dto::{
};
use unlucky_presenter::state::AppState;
use unlucky_i18n::{Catalog, Key};
use unlucky_i18n::{Catalog, Key, Lang};
use super::options::{InstallDraft, OptionsView};
/// 取消安装之后那一句提示(M105)。
///
/// 为什么文案在页面层而不是 presenter:取消的**信号**由 host 发(`CoreEvent::InstallCancelled`),
/// 但 presenter 不依赖 `unlucky-i18n`,写死的中文会和页面语言打架(复审 P2-2 抓的就是这个:
/// 选项区是英文、取消弹窗是中文)。所以文本按 `Key` 现求,语言跟着页面走。
#[must_use]
pub fn cancelled_toast(lang: Lang) -> String {
lang.catalog().text(Key::DownloadCancelled).to_owned()
}
/// 分类筛选(页面上的四个 filter chip)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Category {
+1 -1
View File
@@ -123,7 +123,7 @@ pub(crate) mod settings;
#[cfg(test)]
mod tests;
pub use download::{Category, Download, InstallDraft, Loader};
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message};
+30 -4
View File
@@ -23,15 +23,32 @@
//! [`execute`] 与 CLI 的契约**一字不变**(不传取消标志 = 今天的行为);
//! 取消走 [`execute_with_cancel`],判据三条:
//!
//! 1. **不再起新文件**:每个文件开工前看一眼标志,置位就不进 `install_one`;
//! 1. **不再起新文件**:流里**每个 item 的第一句**就看标志,置位直接返回 `None` ⇒
//! 那些 item **连 `install_one` 都不进**(不发请求、不算整文件 sha1、不回调 `sink`)。
//! 这一句不能挪到 `select` 的右支:`select` 先轮询左支,左支第一次 poll 就把
//! `engine.download(…)` 派出去了(复审 P2-1 就是从这条抓出来的)。
//! 2. **在飞的文件就地掐断**:`install_one` 与「取消 future」`select` 赛跑,
//! 取消赢就 drop 掉下载 future —— reqwest 的响应流随之关闭(socket 断开),
//! `.ullpart` 由 [`PartFile`](crate::download::PartFile) 的 `Drop` 清掉(不留半截垃圾);
//! `.ullpart` 由 [`PartFile`](crate::download::PartFile) 的 `Drop` 清掉(不留半截垃圾)。
//! 3. **半成品不许变成品**:取消后不再写版本 JSON,返回
//! [`InstallErrorKind::Cancelled`](super::InstallErrorKind),调用方(presenter)据此把阶段
//! 记成「已取消」而不是「失败」。
//!
//! 于是取消延迟 = **一个数据块的唤醒**(不是「等一个文件下完」,也不是「等这一段跑完」)。
//!
//! ## 固有边界(复审要求留档,别把它当 bug 修)
//!
//! `buffer_unordered(concurrency)` 在取消那一刻**已经有最多 `concurrency`(GUI 传 16)个 future
//! 被拉起来了**,它们已经过了第 1 条那道门。所以最坏情况下这 16 个文件里:
//!
//! - **盘上已就绪的**(第一 poll 就 `Ready`)会照常「跑完」并被回调一次 `sink` ——
//! presenter 侧靠**安装代次**(`CoreHost::install_epoch`)把这些回调带来的进度事件丢掉,
//! 所以面板不会因此复燃;
//! - **真在下载的**会在同一次 poll 的右支被取消掉(future drop、连接关掉、`.ullpart` 清掉)。
//!
//! 也就是说:**「取消之后一个字节都不会再写」这句话只对「还没被拉起的 item」成立**;
//! 在飞的 16 个里已就绪的那些仍会走完自己的回调。要连这一步都收掉,得让
//! `buffer_unordered` 支持「取消即清空队列」,本轮不做(不在 M105 的验收口径里)。
use std::future::poll_fn;
use std::path::Path;
@@ -160,8 +177,17 @@ pub async fn execute_with_cancel(
.map(|file| {
let sink = sink.clone();
async move {
// 取消后不再起新文件;在飞的那个与取消 future 赛跑,取消赢就 drop 掉下载 future
// (响应流关闭 + `.ullpart` 由 `PartFile::Drop` 清掉),于是取消延迟 = 一个数据块。
// **每个 item 的第一句就查标志**(复审 P2-1,p1):`select` 先轮询左支,
// 左支第一次 poll 就会把 `engine.download(...)` 派发出去 —— 只把标志检查写在
// `select` 的右支,等于「取消之后每个剩余文件仍各发一次整文件 sha1 + 一次请求」,
// 1.20.1 那种四位数文件的 plan 会一直走到列表末尾,而且「盘上已就绪」的文件
// 第一 poll 就 `Ready` ⇒ 还会各回调一次 `sink`。
// 放在这里,取消后这些 item **连 `install_one` 都不进**。
if is_cancelled(cancel) {
return None;
}
// 已经开始的那些(`buffer_unordered` 在飞,最多 `concurrency` 个)与取消 future 赛跑:
// 取消赢就 drop 掉下载 future(响应流关闭 + `.ullpart` 由 `PartFile::Drop` 清掉)。
let outcome = match cancel {
Some(flag) => {
let download = install_one(engine, file);
@@ -314,3 +314,84 @@ fn failure_kind_prefers_integrity() {
assert_eq!(report.failure_kind(), Some(DownloadErrorKind::Integrity));
assert_eq!(report.failures(1).len(), 1);
}
/// **取消之后,剩下的文件连 `install_one` 都不许进**(复审 P2-1 的守门,p1)。
///
/// 现场(修前):标志检查只写在 `select` 的右支,而 `select` 先轮询左支 —— 左支第一次 poll
/// 就把 `engine.download(…)` 派出去了。于是取消后 `buffer_unordered` 立刻让出槽位、流继续被拉,
/// **每一个剩余 item 都会各发一次整文件 sha1 + 一次请求**,1.20.1 那种四位数文件的 plan 会一直
/// 走到列表末尾;盘上已就绪的文件第一 poll 就 `Ready` ⇒ 还会各回调一次 `sink`。
///
/// 这条用例把它钉成可数的东西:三个文件全在盘上(`install_one` 直接判 Skipped、零网络),
/// 并发 1,`sink` 第一次回调时置取消标志 —— 于是「取消后还剩几个文件被处理」= `sink` 的调用次数。
/// 修好后必须是 **1**(只有第一个),修前是 **3**。
///
/// 抓得住:`async move` 开头那句 `if is_cancelled(cancel) { return None; }` 被删掉/挪到 `select` 右支。
/// 抓不住:在飞的那一个文件被掐断的时机(那要真字节流,见 presenter 的真网络装置)。
#[tokio::test]
async fn cancelling_mid_run_stops_creating_remaining_files() {
let root = temp_root("cancel-mid");
let layout = GameLayout::new(&root);
let jar = b"client jar payload".to_vec();
let sha1 = sha1_of(&jar);
write_file(&layout.version_jar("1.20.1"), &jar);
let resolved = VersionJson::from_json_str(&format!(
r#"{{"id": "1.20.1", "mainClass": "net.minecraft.client.main.Main",
"downloads": {{"client": {{"sha1": "{sha1}", "size": {}, "url": "https://piston-data.mojang.com/v1/objects/{sha1}/client.jar"}}}}}}"#,
jar.len()
))
.expect("清单应可解析");
let mut plan = InstallPlan::build(PlanInput {
layout: &layout,
version_id: "1.20.1",
resolved: &resolved,
asset_index: None,
environment: &Environment::host(),
})
.expect("组计划应成功");
// 再挂两个"已在盘上"的库文件:内容与声明的 sha1 一致 ⇒ 跳过判定成立、零网络。
for name in ["org.example:a:1.0", "org.example:b:1.0"] {
let bytes = format!("payload for {name}").into_bytes();
let dest = root
.join("libraries")
.join(format!("{}.jar", name.replace(':', "-")));
write_file(&dest, &bytes);
plan.files.push(PlanFile {
role: FileRole::Library,
name: name.to_string(),
url: format!("https://example.invalid/{name}.jar"),
sha1: Some(sha1_of(&bytes)),
size: Some(bytes.len() as u64),
dest,
resource: ResourceKind::Library,
});
}
assert_eq!(plan.files.len(), 3, "三个文件全在盘上 ⇒ 全程零网络");
let seen = Arc::new(AtomicUsize::new(0));
// `sink` 是 `Fn + 'static`,所以标志也必须 `Arc` 进来;第一次回调就置取消。
let flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
let sink_seen = Arc::clone(&seen);
let sink_flag = Arc::clone(&flag);
let sink: crate::game::install::ProgressSink = Arc::new(move |_outcome: &FileOutcome| {
sink_seen.fetch_add(1, Ordering::SeqCst);
sink_flag.store(true, Ordering::SeqCst);
});
let error = execute_with_cancel(&engine(&root), &plan, 1, Some(sink), Some(flag.as_ref()))
.await
.expect_err("取消之后不许返回成功 report");
assert_eq!(
error.kind(),
crate::game::install::InstallErrorKind::Cancelled
);
assert_eq!(
seen.load(Ordering::SeqCst),
1,
"取消只该让**第一个**文件走完回调;剩下的 item 必须连 install_one 都不进\
(修前这里会是 3:每个剩余文件各多算一次 sha1、各多发一次请求)"
);
assert!(!plan.version_json_path.is_file(), "取消后不许落版本清单");
std::fs::remove_dir_all(&root).ok();
}
+4
View File
@@ -86,6 +86,10 @@ macro_rules! download_entries {
"{loader} needs Minecraft {min} or newer",
"{loader} 需要 Minecraft {min} 或更新"
),
DownloadCancelled => (
"Install cancelled — finished files are kept, the instance was not created",
"安装已取消 —— 已下好的文件保留,实例未登记"
),
} }
};
}
+1
View File
@@ -86,6 +86,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"),
("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"),
("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"),
("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"),
("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"),
("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
("LogsOpenFolder", "Open folder", "打开目录"),
+6
View File
@@ -71,6 +71,12 @@ pub enum CoreEvent {
JavaChanged(JavaPageView),
/// 安装进度(`None` = 清掉进度面板)
InstallProgress(Option<InstallProgressView>),
/// 用户取消了安装(**不带人读文案**)
///
/// 为什么只有信号没有文本:`presenter` 不依赖 `unlucky-i18n`,任何写死的中文都会和
/// 页面语言打架(下载页目前渲染英文,一条中文 Toast 会**同屏一中一英**,复审 P2-2)。
/// 文案由 app 层按 `Key` 现求:见 `pages::download::cancelled_toast`。
InstallCancelled,
/// 某 (加载器, 原版版本) 可用的加载器版本列表(下载页「安装选项」的版本下拉)。
///
/// `result` 用 `Result` 而不是空列表:**取不到**与**确实没有**是两件事,
+20 -6
View File
@@ -24,8 +24,8 @@
//! | 现象 | 怎么量 | 抓住什么 / 抓不住什么 |
//! |---|---|---|
//! | 进度还在推进 | 取消后收到的 `InstallProgress(Some)` 条数与 `files_done` 增量 | 抓得住「UI 侧还在被事件喂」;抓不住事件之外的后台动作 |
//! | 断点文件还在长 | `<game>/**/*.ullpart*` 的总字节,取消时 vs 取消后 20 s | 抓得住「网络/磁盘确实还在写」;抓不住「线程还活着」(`.ullpart` 不再长也可能是卡在等响应) |
//! | 实例被登记 | `<game>/versions/<id>/<id>.json` 与 `.minecraft/instances` 有没有多出来 | 抓得住「取消后仍然落了成品」;抓不住「用户看不到的临时目录」 |
//! | 断点文件 | `<game>/**/*.ullpart*` 的总字节:取消时 vs 取消后 20 s,**并且**绝对值必须是 0 | 抓得住「还在写」与「留了半截垃圾」;抓不住「线程还活着」(`.ullpart` 不再长也可能是卡在等响应) |
//! | 半成品变成品 | `<game>/versions/<id>/<id>.json`(版本清单)与 `<launcher_home>/config/profiles.json`(**实例登记的唯一真相源**,`LauncherLayout::profiles_config()`) | 抓得住「取消后仍然落了成品 / 登记了实例」;抓不住「用户看不到的临时目录」 |
//!
//! 线程与 socket 两样**抓不住**(没有可移植的查询口,见 `probe` 一节),所以这里不用它当判据。
@@ -95,6 +95,9 @@ fn real_install_cancel_stops_progress_and_leaves_no_half_product() {
let env = HostEnv::at_root(&root);
let game = env.game.root().to_path_buf();
// 实例登记的真相源是 `<launcher_home>/config/profiles.json`(`LauncherLayout::profiles_config()`),
// 不是 `<game>/instances` —— 后者根本不存在,探它等于什么都没探(复审 P2-3 第 2 点)。
let launcher = env.layout.clone();
let mut host = CoreHost::headless(env);
// ---- 起点:取消之前 ----
@@ -193,20 +196,22 @@ fn real_install_cancel_stops_progress_and_leaves_no_half_product() {
}
// ---- 断点文件 / 临时目录 / 成品三样 ----
// 版本 JSON 只在「全量就绪」才落盘(`execute` 的 complete 判定),取消后不该有成品;
// 实例登记在最后一步之后,取消后不该多出实例。
// 版本 JSON 只在「全量就绪」才落盘(`execute` 的 complete 判定);
// 实例登记在 `run()` 的最后一步,取消后不该登记(读 profiles.json 这份唯一真相源)。
let version_json = game
.join("versions")
.join(&version)
.join(format!("{version}.json"));
let profiles = unlucky_core::game::install::instance::read_profiles(&launcher)
.expect("profiles.json 应可读(不存在 = 空清单)");
eprintln!(
"[cancel-test] 版本清单 {} / 实例目录 {}",
"[cancel-test] 版本清单{} / profiles.json 里 {} 条实例",
if version_json.is_file() {
"已落盘"
} else {
"未落盘"
},
game.join("instances").is_dir()
profiles.profiles.len()
);
// ---- 判据(修好之后必须成立的三条) ----
@@ -230,6 +235,15 @@ fn real_install_cancel_stops_progress_and_leaves_no_half_product() {
grew, 0,
"取消 {settle:?} 之后断点文件还在长(+{grew} 字节):下载没有真的停"
);
assert_eq!(
parts_after, 0,
"取消之后还留着 {parts_after} 字节的 `.ullpart`:半截垃圾没清掉(D21 要的就是「清掉或标清楚」)"
);
assert!(
profiles.profiles.is_empty(),
"取消之后仍然登记了实例(profiles.json 有 {} 条):半成品被记成成品",
profiles.profiles.len()
);
assert!(
!version_json.is_file(),
"取消之后仍然落了版本清单:半成品被记成「已安装」"
+3 -1
View File
@@ -71,7 +71,9 @@ impl CoreHost {
self.install_epoch = self.install_epoch.wrapping_add(1);
self.install = None;
self.send(CoreEvent::InstallProgress(None));
self.toast(ToastKind::Info, install::CANCELLED);
// 只发信号、不发文案(复审 P2-2):presenter 没有 i18n 依赖,
// 写死的中文会和页面语言打架。提示语由 app 层按 `Key` 现求。
self.send(CoreEvent::InstallCancelled);
}
AppCommand::SetIsolation { id, mode } => {
match game::set_isolation(&self.env, &id, &mode) {
+9
View File
@@ -81,6 +81,11 @@ pub struct AppState {
pub java: JavaPageView,
/// 安装进度(None = 没有安装任务)
pub install: Option<InstallProgressView>,
/// 用户取消安装的**次数**(M105):每次 `CoreEvent::InstallCancelled` +1。
///
/// 只是信号,不带文案:app 层比对前后值决定「要不要弹一条 i18n Toast」,
/// 于是取消提示和页面用同一种语言(复审 P2-2)。
pub install_cancelled: u64,
/// 加载器版本清单(下载页「安装选项」的下拉;M105)
///
/// 两段键:外层是加载器的**稳定 id**(`LoaderKind::as_str`,`LoaderKind` 没派生 `Hash`,
@@ -131,6 +136,7 @@ impl AppState {
device_login: DeviceLoginView::new(),
java: JavaPageView::new(),
install: None,
install_cancelled: 0,
loader_versions: std::collections::HashMap::new(),
settings_groups: Vec::new(),
settings_tab: "download".to_string(),
@@ -245,6 +251,9 @@ impl AppState {
}
None => self.install = None,
},
CoreEvent::InstallCancelled => {
self.install_cancelled = self.install_cancelled.saturating_add(1);
}
CoreEvent::SettingsChanged(groups) => self.settings_groups = groups,
CoreEvent::LoaderVersions {
kind,