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]