feat(dlflow): M105 安装选项区(四家加载器 + 官方版本列表 + 实例名)与取消安装真停
用户实跑反馈两条,本轮各自定位到根因并修掉:
1. 「下载页没有前置加载器选项」= 功能未做:`Message::InstallVersion` 直接派
`AppCommand::InstallVersion`(= 只装原版),core 早在 M32/M35/M45 就有四家加载器的
安装管线。现在选中版本之后面板先给「安装选项」区(原版 / Forge / NeoForge / Fabric /
Quilt + 该加载器的官方版本列表 + 实例名),点「安装」才派
`AppCommand::CreateInstance`。
- 加载器版本列表复用 core 的 `LoaderSource::loader_versions`,经新增的
`AppCommand::ListLoaderVersions` / `CoreEvent::LoaderVersions` 回流;
缓存键是 `(加载器, 原版版本)`,切版本不会串列表;取不到给具名原因并**禁掉确认**。
- 不兼容组合用静态规则在**选中那一刻**说清(NeoForge 需 1.20.1+、Fabric/Quilt 需
1.14+、远古版四家都不支持),不让用户「选完才失败」,也不为不可能的组合作无谓联网。
- 选项草稿(选中 + 加载器 + 加载器版本 + 实例名)住在 `App`,`refresh_view` 带进页面
模型(与分类筛选同一做法;页面自己存不住跨事件的东西)。
2. 「点取消没反应」= 真 bug,两跳各断一半:
- 第一跳(UI 侧):取消确实生效了,但**还在跑的后台线程继续发进度**,`InstallProgress(None)`
刚把面板收回,下一条「下载中」立刻又点亮它 —— 用户看到的就是「点了没反应」。
修法:`CoreHost` 加安装代次 `install_epoch`(开工与取消都 +1),`HostTask::Install` /
`InstallDone` 带代次回来,代次不符一律丢。
- 第二跳(core 侧):`execute` 没有任何取消入参,取消只停在段边界,传输根本没停。
新增 `execute_with_cancel(…, cancel: Option<&AtomicBool>)`(既有 `execute` 原样包装,
CLI 一字未改):取消后不再起新文件,在飞的那个与「取消 future」`select` 赛跑,取消赢就
drop 掉下载 future(响应流关闭、`.ullpart` 由 `PartFile::Drop` 清掉),并且不写版本清单,
返回新的 `InstallErrorKind::Cancelled`。
- 另外:取消后**绝不登记实例**(文件段跑完但用户已取消的那一路也拦住);取消文案把
「哪些留着、哪些一定没发生」说清(D21)。
实测(真网络装置 `host/cancel_tests.rs`,`#[ignore]` 守着,跑法见文件头):
取消前 files_done=2 / `.ullpart` 613 字节;取消后 20 s 进度事件 **0 条**、`.ullpart` **0 字节**
(-613)、版本清单未落盘、实例目录不存在。取消前是 +201466 字节(同一个装置量的)。
文案全部进 i18n(中英,9 条新 key + 金表同步)。
This commit is contained in:
1 parent
d690d09b84
commit
30b1ff6f48
39 files changed
+2550
-518
No files matched your search
@@ -5,7 +5,7 @@
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
AncientKind, AppEvent, CoreEvent, InstallProgressView, InstallStage, VersionCategory,
|
||||
VersionKind, VersionSummary,
|
||||
@@ -17,6 +17,9 @@ use crate::theme::{Families, Ui};
|
||||
use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit};
|
||||
use super::super::Message;
|
||||
use super::model::{Category, Download};
|
||||
use super::options::InstallDraft;
|
||||
|
||||
mod i18n;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
@@ -101,7 +104,12 @@ fn the_snapshot_becomes_the_page_model() {
|
||||
},
|
||||
))));
|
||||
|
||||
let model = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
|
||||
let model = Download::from_snapshot(
|
||||
&state,
|
||||
Category::All,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
|
||||
assert_eq!(model.rows.len(), 2);
|
||||
assert_eq!(model.rows[0].id, "1.21.9");
|
||||
@@ -142,14 +150,24 @@ fn presenter_filters_still_apply() {
|
||||
|
||||
let installed_only = {
|
||||
state.installed_only = true;
|
||||
Download::from_snapshot(&state, Category::All, Lang::En.catalog())
|
||||
Download::from_snapshot(
|
||||
&state,
|
||||
Category::All,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
)
|
||||
};
|
||||
assert_eq!(installed_only.rows.len(), 2, "仅已装");
|
||||
|
||||
let queried = {
|
||||
state.installed_only = false;
|
||||
state.download_query = " 1.21 ".to_string();
|
||||
Download::from_snapshot(&state, Category::All, Lang::En.catalog())
|
||||
Download::from_snapshot(
|
||||
&state,
|
||||
Category::All,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
)
|
||||
};
|
||||
assert_eq!(queried.rows.len(), 2, "版本号片段匹配两个");
|
||||
assert!(queried.rows.iter().all(|row| row.id.starts_with("1.21")));
|
||||
@@ -159,12 +177,22 @@ fn presenter_filters_still_apply() {
|
||||
/// 抓不住:真机上卡在加载态多久(没有超时信号,见模块文档的缺口表)。
|
||||
#[test]
|
||||
fn loading_and_empty_are_different_states() {
|
||||
let loading = Download::from_snapshot(&AppState::new(), Category::All, Lang::En.catalog());
|
||||
let loading = Download::from_snapshot(
|
||||
&AppState::new(),
|
||||
Category::All,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert!(loading.is_loading() && !loading.is_empty());
|
||||
|
||||
let mut state = state_with(vec![version("1.21.9", VersionKind::Release, false)]);
|
||||
state.download_query = "no-such-version".to_string();
|
||||
let empty = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
|
||||
let empty = Download::from_snapshot(
|
||||
&state,
|
||||
Category::All,
|
||||
&InstallDraft::default(),
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert!(!empty.is_loading(), "列表拉到了,不是加载中");
|
||||
assert!(empty.is_empty(), "过滤后没有行 = 空态");
|
||||
}
|
||||
@@ -213,81 +241,6 @@ fn category_commands_are_real() {
|
||||
assert!(!Category::Release.matches(VersionKind::Snapshot));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 文案(i18n)
|
||||
|
||||
/// 每个 `Download*` key 都**真的被页面引用**(死 key 守门,M94 r1 / P2-6)。
|
||||
///
|
||||
/// 复审抓到的 `DownloadSelected` 就是这条要拦的东西:目录里定义了、页面没人用。所以这里把页面
|
||||
/// 源码用 `include_str!` 编进来扫一遍。**抓不住什么也说清**:反向(引用了不存在的 key)是编译错误,
|
||||
/// 不需要测试;译文对不对、两套语言都填了没由 `i18n` 自己的 `built_in_catalogs_have_every_key` 守;
|
||||
/// 它只看字面出现,用宏拼 key 名能骗过它。
|
||||
#[test]
|
||||
fn every_download_key_is_referenced_by_the_page() {
|
||||
const SOURCES: [&str; 6] = [
|
||||
include_str!("../download.rs"),
|
||||
include_str!("model.rs"),
|
||||
include_str!("rows.rs"),
|
||||
include_str!("install.rs"),
|
||||
include_str!("states.rs"),
|
||||
include_str!("demo.rs"),
|
||||
];
|
||||
|
||||
let mut checked = 0;
|
||||
for key in Key::ALL
|
||||
.iter()
|
||||
.filter(|key| key.name().starts_with("Download"))
|
||||
{
|
||||
let needle = format!("Key::{}", key.name());
|
||||
assert!(
|
||||
SOURCES.iter().any(|source| source.contains(&needle)),
|
||||
"{needle} 在目录里定义了,但下载页没有任何地方用它(死 key,删掉或接上)"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
assert!(
|
||||
checked > 20,
|
||||
"下载页的 key 不该被成批删掉(只查到 {checked} 条)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 页面级的文案全部按 `Catalog` 走:中文目录出中文,英文目录出英文,**且不含 core 的中文业务串**。
|
||||
///
|
||||
/// 抓得住:页面里又出现硬编码英文/中文字面量、或把 `InstallStage::label()` / `progress.message`
|
||||
/// (core 的中文串)画到界面上。
|
||||
/// 抓不住:目录里那条译文本身写错了。
|
||||
#[test]
|
||||
fn the_model_speaks_the_catalog_language() {
|
||||
let versions = vec![version("1.21.9", VersionKind::Release, false)];
|
||||
let state = state_with(versions);
|
||||
|
||||
let en = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
|
||||
let zh = Download::from_snapshot(&state, Category::All, Lang::ZhCn.catalog());
|
||||
|
||||
assert_eq!(en.rows[0].supporting, "Release · released 2026-09-20");
|
||||
assert_eq!(zh.rows[0].supporting, "正式版 · 发布于 2026-09-20");
|
||||
|
||||
let progress = InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: InstallStage::Planning,
|
||||
message: "正在解析版本清单…".to_string(),
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
let install_en = super::model::InstallView::of(&progress, Lang::En.catalog());
|
||||
assert_eq!(install_en.stage_label, "Planning");
|
||||
assert_eq!(install_en.detail, "Waiting for the first file…");
|
||||
assert!(
|
||||
!install_en.stage_label.contains('解'),
|
||||
"core 的中文阶段串不许直接上屏"
|
||||
);
|
||||
assert!(
|
||||
!install_en.detail.contains('正'),
|
||||
"`InstallProgressView::message` 是中文业务串,页面不许画它"
|
||||
);
|
||||
|
||||
let install_zh = super::model::InstallView::of(&progress, Lang::ZhCn.catalog());
|
||||
assert_eq!(install_zh.stage_label, "解析版本");
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 控件树
|
||||
|
||||
/// 五个状态都能构建出控件树(分支走到、不 panic、消息类型对得上)。
|
||||
@@ -309,6 +262,7 @@ fn every_state_builds_a_tree() {
|
||||
};
|
||||
|
||||
let ready = Download {
|
||||
options: None,
|
||||
rows: vec![super::model::VersionRow::of(
|
||||
&version("1.21.9", VersionKind::Release, false),
|
||||
catalog,
|
||||
|
||||
Reference in new issue
Block a user