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:
AskaEth committed 2026-11-04 22:12:22 +08:00
1 parent d690d09b84
commit 30b1ff6f48
39 files changed
+2550 -518

No files matched your search

+18 -1
View File
@@ -130,6 +130,12 @@ pub struct App {
/// 挂在 `App` 上而不是全局:这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,
/// 状态跟着 App 走才能 per-app、可测(tower 明确否掉了 `OnceLock<Mutex<..>>`)。
pub(super) agent: crate::shell::agent::AgentUi,
/// 下载页的安装草稿(M105:选中版本 + 加载器 + 加载器版本 + 实例名)。
///
/// 为什么住在 `App` 而不是页面模型:`refresh_view` 每次事件都把 `self.view` 整棵重建,
/// 页面自己存不住「用户正在填但还没确认」的东西;这与分类筛选(`view.download.category`)
/// 是同一个理由、同一个做法。
pub(super) download_draft: pages::InstallDraft,
}
impl App {
@@ -216,6 +222,8 @@ impl App {
settings: pages::settings::Groups::default(),
// 取证开关 `ULL_AGENT_*`(三态矩阵 / 空会话)在这里落地;不设变量 = 出厂态
agent: crate::shell::agent::demo::from_env().unwrap_or_default(),
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(),
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
@@ -280,9 +288,18 @@ impl App {
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category;
// 草稿同理(M105):它也是页面自己持有的输入,重建模型时带回去,
// 否则每来一条事件(连安装进度都算)用户填的加载器就被抹回「原版」。
let draft = self.download_draft.clone();
// 右栗占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
let width = self.agent.content_width(self.width);
self.view = super::view::model(&self.state, width, self.options.force_state, category);
self.view = super::view::model(
&self.state,
width,
self.options.force_state,
category,
&draft,
);
self.view_dirty = false;
self.refreshes += 1;
}
+105 -8
View File
@@ -1,16 +1,18 @@
//! 下载页在 app 层的那几支(M94)。
//! 下载页在 app 层的那几支(M94;M105 补上安装选项)。
//!
//! 为什么单独一个文件:M93(外观三件套接线)合入后 `update.rs` 已经贴到 400 行,下载页再往里塞
//! 四个 arm 就顶破硬线;拆出来是既不动 M93 的代码、又能守住 ≤400 的办法(`app/update/**` 属本
//! 几个 arm 就顶破硬线;拆出来是既不动 M93 的代码、又能守住 ≤400 的办法(`app/update/**` 属本
//! mission scope)。
//!
//! ## 四支各自的纪律
//! ## 各支各自的纪律(M105 后的口径)
//!
//! | 消息 | 落点 | 为什么 |
//! |---|---|---|
//! | [`pages::Message::SelectVersion`] | **只改视图模型** | 选中是页面内状态,不是业务命令 |
//! | [`pages::Message::InstallVersion`] | `AppCommand::InstallVersion` | 真指令,回事件后才画进度 |
//! | [`pages::Message::CancelInstall`] | `AppCommand::CancelInstall` | 同上(host 侧对没在跑的任务是 no-op) |
//! | [`pages::Message::SelectVersion`] | 只改**草稿** | 选中是页面内状态,不是业务命令;顺手清掉加载器版本(换了原版版本,旧版本号几乎必然不适用) |
//! | [`pages::Message::SetLoader`] | 改草稿 + 必要时派 `ListLoaderVersions` | 加载器版本列表来自官方元数据,页面不能自己编 |
//! | [`pages::Message::SetLoaderVersion`] / `SetInstanceName` | 只改草稿 | 纯输入 |
//! | [`pages::Message::InstallVersion`] | `AppCommand::CreateInstance(草稿)` | **确认才开工**(带加载器 / 加载器版本 / 实例名) |
//! | [`pages::Message::CancelInstall`] | `AppCommand::CancelInstall` | 见 `host/dispatch.rs`:真停 + 作废旧代次 |
//! | [`pages::Message::SetCategory`] | 写分类 → 重建 → `AppCommand::ListVersions` | 见下 |
//!
//! [「刷新下载源」](pages::Action::RefreshSources) 也在这里:它不是占位动作,具体分类 → 重拉该分类、
@@ -22,6 +24,14 @@
//! 本页做**:写进视图模型 → 立刻 `refresh_view()`(它会把 category 带回去、按同一谓词过滤行)→
//! 再派指令。这不是「乐观回显」:写的是**过滤条件本身**,chip 与列表用同一个
//! [`Category::matches`](crate::pages::Category),于是不可能出现「chip 说正式版、列表是全量」的两份真相。
//!
//! ## 草稿为什么住在这里而不是页面模型里
//!
//! `refresh_view()` 每次事件都把 `self.view` 整棵重建(连安装进度都算),页面存不住「用户正在填、
//! 还没确认」的东西。于是草稿放 `App::download_draft`,与分类筛选同一个做法:
//! 改草稿 → `view_dirty = true` → `refresh_view()`。
use unlucky_presenter::dto::CreateRequest;
use super::*;
@@ -30,10 +40,39 @@ impl App {
pub(super) fn on_download(&mut self, message: &pages::Message) -> bool {
match message {
pages::Message::SelectVersion(id) => {
self.view.download.selected = Some(id.clone());
if self.download_draft.selected.as_deref() != Some(id.as_str()) {
// 换了原版版本:加载器版本是**按 (加载器, 原版版本)** 取的(见
// `CoreEvent::LoaderVersions` 的缓存键),旧的版本号留着只会误导。
self.download_draft.loader_version.clear();
}
self.download_draft.selected = Some(id.clone());
self.mark_download_dirty();
// 选了加载器版本之后换原版版本,列表要跟着换——已经查过的直接从快照读,不重复派。
self.ensure_loader_versions();
}
pages::Message::SetLoader(loader) => {
self.download_draft.loader = *loader;
self.download_draft.loader_version.clear();
self.mark_download_dirty();
self.ensure_loader_versions();
}
pages::Message::SetLoaderVersion(version) => {
self.download_draft.loader_version = version.clone();
self.mark_download_dirty();
}
pages::Message::SetInstanceName(name) => {
self.download_draft.instance_name = name.clone();
self.mark_download_dirty();
}
pages::Message::InstallVersion(id) => {
self.dispatch(AppCommand::InstallVersion(id.clone()));
// 防守:按钮带的是**页面上那一行**的 id,草稿里的选中项理论上与它一致;
// 万一不一致(例如列表刚换过、用户手快),不能让 A 版本的加载器版本落到 B 版本上。
if self.download_draft.selected.as_deref() != Some(id.as_str()) {
self.download_draft.selected = Some(id.clone());
self.download_draft.loader_version.clear();
}
let request = self.install_request(id);
self.dispatch(AppCommand::CreateInstance(request));
}
pages::Message::CancelInstall => self.dispatch(AppCommand::CancelInstall),
pages::Message::SetCategory(category) => {
@@ -54,4 +93,62 @@ impl App {
}
true
}
/// 改草稿之后统一走这一条:置脏 + 立刻重建(不等下一拍,点了就有反应)。
fn mark_download_dirty(&mut self) {
self.view_dirty = true;
self.refresh_view();
}
/// 确认安装时的 `CreateRequest`(草稿 → 指令载荷)。
///
/// 实例名留空就让 core 用版本号(`CreateRequest::instance_name` 的既有口径),
/// 所以这里**不**自己拼一个默认名——两处各拼一份迟早会漂。
fn install_request(&self, id: &str) -> CreateRequest {
let draft = &self.download_draft;
CreateRequest {
name: draft.instance_name.trim().to_string(),
version: id.to_string(),
loader: draft.loader.kind(),
loader_version: draft.loader_version.trim().to_string(),
}
}
/// 当前草稿需要加载器版本列表、而快照里还没有时,派一次查询。
///
/// 判据是 `state.loader_versions_for(...)` 的 `None`(= 没查过):查过的(成功或失败)都不再重复派,
/// 失败要重试走 `RefreshSources` 那条路,页面上的红色原因行不会因为一次点击就消失。
fn ensure_loader_versions(&mut self) {
let loader = self.download_draft.loader;
if loader == pages::Loader::Vanilla {
return;
}
let Some(version) = self.download_draft.selected.clone() else {
return;
};
// 静态就不支持的组合不联网(否则只会换来一条「取不到」的假原因;真正的原因是
// 「这个加载器没有这个版本的构建」,页面已经用静态规则说清了)。
let kind = self
.state
.versions
.iter()
.find(|summary| summary.id == version)
.map(|summary| summary.kind);
if let Some(kind) = kind {
if loader.supports(&version, kind).is_err() {
return;
}
}
if self
.state
.loader_versions_for(loader.kind(), &version)
.is_some()
{
return;
}
self.dispatch(AppCommand::ListLoaderVersions {
kind: loader.kind(),
mc_version: version,
});
}
}
@@ -47,8 +47,15 @@ fn download_intents_dispatch_real_commands() {
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::InstallVersion("1.21.9".to_string()),
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),
];
@@ -57,7 +64,14 @@ fn download_intents_dispatch_real_commands() {
expected,
"三条各派一条;`SelectVersion` 不派"
);
assert_eq!(app.view.download.selected.as_deref(), Some("1.21.8"));
// 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(),
@@ -74,3 +88,86 @@ fn download_intents_dispatch_real_commands() {
"重建视图模型之后分类必须还在"
);
}
/// **取消之后面板要回到「未安装态」且能立刻再装**(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, "取消后要能立刻再装一次");
}
@@ -47,6 +47,9 @@ impl App {
pages::Message::SelectVersion(_)
| pages::Message::InstallVersion(_)
| pages::Message::CancelInstall
| pages::Message::SetLoader(_)
| pages::Message::SetLoaderVersion(_)
| pages::Message::SetInstanceName(_)
| pages::Message::SetCategory(_) => debug_assert!(false, "应由 on_download 消费"),
// M95:清空日志是**呈现层**操作(`log_lines` 是 presenter 的公开快照字段,
// 不派业务命令、不写持久状态)
+23 -9
View File
@@ -105,6 +105,7 @@ pub(super) fn model(
width: f32,
forced: Option<crate::cli::ForcedState>,
category: pages::Category,
draft: &pages::InstallDraft,
) -> pages::Model {
let route = state.route().clone();
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
@@ -137,7 +138,12 @@ pub(super) fn model(
window_width: width,
active: route.nav(),
instances: pages::Instances { rows, detail },
download: pages::Download::from_snapshot(state, category, unlucky_i18n::Lang::En.catalog()),
download: pages::Download::from_snapshot(
state,
category,
draft,
unlucky_i18n::Lang::En.catalog(),
),
logs: logs_model(state).with_status(lang),
lang,
forced: forced.map(|state| match state {
@@ -274,10 +280,21 @@ mod tests {
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
/// 也抓不住不消费 `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(),
)
}
#[test]
fn the_forced_state_maps_to_the_expected_interaction() {
let state = AppState::new();
let forced = |forced| model(&state, 1180.0, Some(forced), pages::Category::All).forced;
let forced = |forced| at(&state, Some(forced)).forced;
assert_eq!(
forced(crate::cli::ForcedState::Rest),
@@ -292,10 +309,7 @@ mod tests {
forced(crate::cli::ForcedState::Pressed),
Some(Interaction::Pressed)
);
assert_eq!(
model(&state, 1180.0, None, pages::Category::All).forced,
None
);
assert_eq!(at(&state, None).forced, None);
}
#[test]
@@ -306,7 +320,7 @@ mod tests {
));
state.navigate(Route::Detail("survival".to_string()));
let model = model(&state, 1180.0, None, pages::Category::All);
let model = at(&state, None);
assert_eq!(model.screen, pages::Screen::Instances);
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
assert_eq!(model.window_width, 1180.0);
@@ -337,7 +351,7 @@ mod tests {
));
state.navigate(Route::Detail("survival".to_string()));
let model = model(&state, 1180.0, None, pages::Category::All);
let model = at(&state, None);
assert_eq!(
model.instances.rows[0].status.as_deref(),
Some("Downloading · 50%")
@@ -375,7 +389,7 @@ mod tests {
fn the_agent_route_has_no_page() {
let mut state = AppState::new();
state.navigate(Route::Agent);
let model = model(&state, 1180.0, None, pages::Category::All);
let model = at(&state, None);
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
assert_eq!(pages::Screen::of(state.route()), None);
assert!(model.instances.detail.is_none());
+12 -1
View File
@@ -41,10 +41,19 @@
//! |---|---|---|
//! | 点分类 tab | [`Message::SetCategory`] | `AppCommand::ListVersions(category)`(`All` 不派:画现有快照) |
//! | 点一行 | [`Message::SelectVersion`] | **只改视图模型**(选中不是业务命令) |
//! | 点「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` |
//! | 点「安装」 | [`Message::InstallVersion`] | `AppCommand::CreateInstance(草稿)`(M105:带加载器与实例名) |
//! | 点「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
//! | 点「刷新」/「打开日志」 | `Message::Action(RefreshSources)` / `Message::Navigate(Logs)` | `ListVersions` / `AppState::navigate` |
//!
//! ## M105:安装选项(加载器维度)
//!
//! 选中版本**不再直接开工**。面板下半块变成「安装选项」区:五家加载器(原版 / Forge /
//! NeoForge / Fabric / Quilt)的 chip、该加载器的官方版本列表、实例名,点「安装」才派
//! `CreateInstance`。选项的决策与画法在 [`options`](子模块),本文件只负责把模型交给它。
//! 加载器版本列表来自 core 的 `LoaderSource::loader_versions`(经
//! `AppCommand::ListLoaderVersions` 回流到快照);取不到时页面给具名原因并**禁掉确认**——
//! 不许「选完才失败」。
//!
//! 进度条只读 `model.install`:它由 presenter 的 `InstallProgress` 事件经 `apply` 灌进来。
//! 于是「点了没变」只有一种解释——后端还没回事件,这正是要的(P3-2 的纪律)。
//!
@@ -58,6 +67,7 @@
mod demo;
mod install;
mod model;
mod options;
mod rows;
mod states;
@@ -66,6 +76,7 @@ mod tests;
pub use demo::demo;
pub use model::{Category, Download};
pub use options::{InstallDraft, Loader};
use iced::widget::column;
use iced::{Element, Length};
+42 -3
View File
@@ -19,7 +19,8 @@
//! ```
//!
//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `category-release` / `installing` /
//! `installing-late` / `installing-empty` / `failed` / `done` / `done-next`。
//! `installing-late` / `installing-empty` / `failed` / `done` / `done-next` /
//! **`options` / `options-fabric` / `options-incompat`**(M105 的安装选项区三态)。
//!
//! `category-release` / `done` / `done-next` 都**不手搓结果**,而是拿一份 `AppState` 跑
//! [`Download::from_snapshot`]:图证明的是产品路径的行为(分类本地过滤、装完后面板让回列表),
@@ -40,6 +41,7 @@ use unlucky_presenter::state::AppState;
use unlucky_i18n::Catalog;
use super::model::{Category, Download, InstallView, VersionRow};
use super::options::{InstallDraft, Loader};
/// 取证开关的名字([`demo`] 读它)。
pub const DEMO_STATE_ENV: &str = "ULL_DL_DEMO";
@@ -63,6 +65,17 @@ pub fn demo(catalog: Catalog) -> Download {
selected: Some("1.21.9".to_string()),
..ready(catalog)
},
// M105:安装选项区三态(未选加载器 / 已选 Fabric / 不兼容组合)。
// 三条都走**产品那条真转换**(`from_snapshot` + 草稿),图里看到的是页面真会画的东西。
Some("options") => options(catalog, "1.21.9", Loader::Vanilla, None),
Some("options-fabric") => options(
catalog,
"1.21.9",
Loader::Fabric,
Some(vec!["0.16.5", "0.16.4", "0.15.11"]),
),
// NeoForge 最早只到 1.20.1,远古版(列表里的 `rd-132211`)根本没有构建 ⇒ 选中即报原因
Some("options-incompat") => options(catalog, "rd-132211", Loader::NeoForge, None),
Some("selected") => Download {
selected: Some("1.21.9".to_string()),
..ready(catalog)
@@ -103,11 +116,36 @@ fn ready(catalog: Catalog) -> Download {
category: Category::of(&versions),
rows,
selected: None,
options: None,
install: None,
loaded: true,
}
}
/// 「用户选了版本、正在填安装选项」的演示(M105)。
///
/// 走真转换:`AppState`(版本列表 + 可选的加载器版本缓存)+ 草稿 → [`Download::from_snapshot`]。
/// 于是图上「默认取最新加载器版本」「不兼容给具名原因」「确认键禁用」都是产品行为,不是演示特权。
fn options(catalog: Catalog, version: &str, loader: Loader, cached: Option<Vec<&str>>) -> Download {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
demo_versions(),
)));
if let Some(items) = cached {
state.apply(AppEvent::Core(CoreEvent::LoaderVersions {
kind: loader.kind(),
mc_version: version.to_string(),
result: Ok(items.into_iter().map(str::to_string).collect()),
}));
}
let draft = InstallDraft {
selected: Some(version.to_string()),
loader,
..InstallDraft::default()
};
Download::from_snapshot(&state, Category::All, &draft, catalog)
}
/// 演示用的版本表(`ready` 与 `finished` 共用;与 presenter 的 `mock_versions` 同形)。
fn demo_versions() -> Vec<VersionSummary> {
fn summary(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary {
@@ -161,7 +199,8 @@ fn finished(catalog: Catalog, select_next: Option<&str>) -> Download {
},
))));
let mut model = Download::from_snapshot(&state, Category::All, catalog);
let mut model =
Download::from_snapshot(&state, Category::All, &InstallDraft::default(), catalog);
model.selected = select_next.map(str::to_string);
model
}
@@ -172,7 +211,7 @@ fn category(catalog: Catalog, wanted: Category) -> Download {
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
demo_versions(),
)));
Download::from_snapshot(&state, wanted, catalog)
Download::from_snapshot(&state, wanted, &InstallDraft::default(), catalog)
}
/// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。
+14 -49
View File
@@ -1,4 +1,8 @@
//! 安装面板(页面底部):选中版本 → 「安装」/ 进度 → 「取消」/ 失败 → 「打开日志」。
//! 安装面板(页面底部):选中版本 → **安装选项** → 「安装」/ 进度 + 「取消」/ 失败 → 「打开日志」。
//!
//! M105 之前这里是「选中版本 → 直接『安装』」:点下去就开工,加载器维度整个不存在
//! (用户实跑的原话:「没是否有选择有前置加载器(版本选择)选项」)。选项区在 `options/`,
//! 本文件只管**面板形态的决策**(纯函数 [`panel_state`])与「安装中 / 失败」两种形态。
//!
//! ## 这一块为什么钉在底部而不是跟着列表滚
//!
@@ -9,7 +13,7 @@
//!
//! | 控件 | 消息 | app 层落点 |
//! |---|---|---|
//! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` |
//! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::CreateInstance(草稿)`(带加载器与实例名) |
//! | 「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
//! | 「打开日志」 | [`Message::Navigate`](`Route::Logs`) | `AppState::navigate`(不派指令) |
//!
@@ -47,6 +51,7 @@ use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::model::{Download, InstallView};
use super::options::OptionsView;
/// 面板要画哪一种形态(**决策与画法分开**:决策是纯函数,单测直接钉它)。
///
@@ -56,13 +61,8 @@ use super::model::{Download, InstallView};
pub(super) enum PanelState<'a> {
/// 没有安装、也没选中:一句「选一个版本再安装」
Hint,
/// 选中了一个版本:给出「安装」(已装的给禁用态)
Ready {
/// 选中的版本 id(= `AppCommand::InstallVersion` 的载荷)
id: &'a str,
/// 本地已经装过(按钮禁用,列表行上也有「已安装」+ 勾)
installed: bool,
},
/// 选中了一个版本:给出**安装选项区**(M105;加载器 / 加载器版本 / 实例名 + 「安装」)
Options(&'a OptionsView),
/// 有安装(进行中 / 失败):进度或错误面板
Install(&'a InstallView),
}
@@ -71,15 +71,12 @@ pub(super) enum PanelState<'a> {
///
/// 顺序是**安装优先**:有安装时面板讲安装,讲不了列表;安装结束后 `model.install` 由
/// [`Download::from_snapshot`](super::model::Download::from_snapshot) 清掉,于是这里自然回到
/// [`PanelState::Ready`] / [`PanelState::Hint`],用户能接着选下一个版本。
/// [`PanelState::Options`] / [`PanelState::Hint`],用户能接着选下一个版本。
pub(super) fn panel_state(model: &Download) -> PanelState<'_> {
match &model.install {
Some(install) => PanelState::Install(install),
None => match model.selected_row() {
Some(row) => PanelState::Ready {
id: row.id.as_str(),
installed: row.installed,
},
None => match &model.options {
Some(options) => PanelState::Options(options),
None => PanelState::Hint,
},
}
@@ -89,7 +86,8 @@ pub(super) fn panel_state(model: &Download) -> PanelState<'_> {
pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> {
let content = match panel_state(model) {
PanelState::Install(install) => installing(ui, catalog, install),
PanelState::Ready { id, installed } => ready(ui, catalog, id, installed),
// 选中版本之后**先给选项,再点「安装」**(M105:以前是选中就直接开工,等于没有加载器维度)
PanelState::Options(options) => super::options::section(ui, catalog, options),
PanelState::Hint => hint(ui, catalog),
};
@@ -110,39 +108,6 @@ fn hint<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
)
}
/// 选好了:版本 id + 「安装」(全屏唯一实心 accent,rubric K2)。
///
/// 已装的版本给**禁用**的 Outlined 按钮(`Button::disabled` 只清消息、保留几何):
/// 「已装」是列表行上的事实(overline + 勾),按钮再点一次不会更清楚。
fn ready<'a>(ui: &'a Ui, catalog: Catalog, id: &'a str, installed: bool) -> Element<'a, Message> {
let label = container(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
id,
ui.color(Role::OnSurface),
))
.width(Length::Fill);
let action = if installed {
buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstall)))
} else {
buttons::filled(
ui,
Button::new(
catalog.text(Key::DownloadInstall),
Message::InstallVersion(id.to_string()),
)
.leading(IconKind::Download),
)
};
row![label, action]
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 安装中 / 已收尾(失败另走 [`failed`])。
fn installing<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element<'a, Message> {
if install.failed() {
@@ -15,6 +15,7 @@ use unlucky_presenter::state::AppState;
use unlucky_i18n::Lang;
use super::super::model::{Category, Download};
use super::super::options::InstallDraft;
use super::{panel_state, PanelState};
fn version(id: &str) -> VersionSummary {
@@ -62,6 +63,7 @@ fn a_finished_install_gives_the_page_back_to_the_list() {
let running = Download::from_snapshot(
&snapshot(InstallStage::Downloading),
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(running.install.is_some(), "跑着的时候面板要在");
@@ -74,7 +76,12 @@ fn a_finished_install_gives_the_page_back_to_the_list() {
"现实如此:快照里仍留着终态(presenter 不清),页面必须自己让位"
);
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!(model.install.is_none(), "Done 之后不许再占着安装面板");
assert!(model.selected.is_none(), "装完不替用户选中任何行");
assert_eq!(model.rows.len(), 2, "列表要回来");
@@ -84,17 +91,22 @@ fn a_finished_install_gives_the_page_back_to_the_list() {
"回到「选一个版本再安装」"
);
// 再装一次:选中下一个版本 → 面板重新给出「安装」(= `Message::InstallVersion(id)`)
let mut next = model.clone();
next.selected = Some("1.21.9".to_string());
assert_eq!(
panel_state(&next),
PanelState::Ready {
id: "1.21.9",
installed: false
// 再装一次:选中下一个版本 → 面板重新给出**安装选项区**(M105 起「安装」在选项区里)
let next = Download::from_snapshot(
&state,
Category::All,
&InstallDraft {
selected: Some("1.21.9".to_string()),
..InstallDraft::default()
},
"装完之后必须能接着装下一个版本"
Lang::En.catalog(),
);
let options = match panel_state(&next) {
PanelState::Options(options) => options,
other => panic!("装完之后必须能接着装下一个版本,实际 {other:?}"),
};
assert_eq!(options.version, "1.21.9");
assert!(options.can_confirm, "原版 + 列表里的版本 = 可以装");
}
/// 取消与失败**不许**一起被清:失败态是 P2-4 的错误出口,取消是「已经没事了」。
@@ -106,13 +118,19 @@ fn only_done_and_cancelled_release_the_panel() {
let failed = Download::from_snapshot(
&snapshot(InstallStage::Failed),
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(failed.install.is_some(), "失败态要留在屏幕上");
assert!(matches!(panel_state(&failed), PanelState::Install(_)));
for stage in [InstallStage::Done, InstallStage::Cancelled] {
let model = Download::from_snapshot(&snapshot(stage), Category::All, Lang::En.catalog());
let model = Download::from_snapshot(
&snapshot(stage),
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(model.install.is_none(), "{stage:?} 之后面板要让位");
}
}
+32 -4
View File
@@ -31,6 +31,8 @@ use unlucky_presenter::state::AppState;
use unlucky_i18n::{Catalog, Key};
use super::options::{InstallDraft, OptionsView};
/// 分类筛选(页面上的四个 filter chip)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Category {
@@ -265,8 +267,10 @@ pub struct Download {
pub rows: Vec<VersionRow>,
/// 当前分类(从快照反推)
pub category: Category,
/// 选中的版本 id(`None` = 没选中 → 安装按钮禁用)
/// 选中的版本 id(`None` = 没选中 → 面板只给一句提示)
pub selected: Option<String>,
/// 安装选项区(M105):选中了版本、又没在装的时候才有
pub options: Option<OptionsView>,
/// 正在跑(或刚跑完)的安装
pub install: Option<InstallView>,
/// 快照里有没有版本列表(`false` = 还没拉到 → 加载态)
@@ -290,7 +294,17 @@ impl Download {
/// 永远是同一份真相,事件回来重建也不会跳回「全部」。
///
/// 过滤后为空就是「空态」(`loaded` 仍为 `true`)。
pub fn from_snapshot(state: &AppState, category: Category, catalog: Catalog) -> Self {
///
/// `draft` 是**用户还没确认的安装输入**(选中版本 + 加载器 + 加载器版本 + 实例名)。
/// 它住在 `App`(照分类筛选的既有做法),因为 `refresh_view` 每次事件都会把整棵模型重建,
/// 页面自己存不住任何东西。选中项还要**在当前列表里**才算数:切分类后那一行不在了,
/// 就不该还留着一个点不动的「安装」。
pub fn from_snapshot(
state: &AppState,
category: Category,
draft: &InstallDraft,
catalog: Catalog,
) -> Self {
let query = state.download_query.trim().to_ascii_lowercase();
let rows = state
.versions
@@ -313,8 +327,21 @@ impl Download {
// 为什么不连 `Failed` 一起清:失败态带着用户要的东西(停在哪个文件 + 去日志页),
// 那正是 P2-4 要它一眼可辨的理由。
.filter(|view| !view.finished());
// 快照没有「选中」字段:正在安装的那个版本就是「选中」(见模块文档的缺口表)。
let selected = install.as_ref().map(|view| view.version.clone());
// 选中项:安装中就是正在装的那个;否则是用户点的那个(草稿),且必须还在当前列表里。
let selected = install
.as_ref()
.map(|view| view.version.clone())
.or_else(|| draft.selected.clone())
.filter(|id| rows.iter().any(|row| &row.id == id));
// 选项区与安装面板**互斥**:在装的时候面板讲进度,讲不了选项。
let options = match &install {
Some(_) => None,
None => selected.as_ref().and_then(|id| {
rows.iter()
.find(|row| &row.id == id)
.map(|row| OptionsView::of(row, state, draft, catalog))
}),
};
Self {
rows,
@@ -322,6 +349,7 @@ impl Download {
// [`Category::of`] 只留给「不经过用户选择」的入口(演示数据的首帧)。
category,
selected,
options,
install,
loaded: !state.versions.is_empty(),
}
@@ -5,6 +5,7 @@ use unlucky_presenter::state::AppState;
use unlucky_i18n::Lang;
use super::super::options::InstallDraft;
use super::{Category, Download};
fn version(id: &str, kind: VersionKind) -> VersionSummary {
@@ -35,7 +36,12 @@ fn the_category_filters_the_list_locally() {
version("rd-132211", VersionKind::Ancient(AncientKind::Classic)),
]);
let release = Download::from_snapshot(&state, Category::Release, Lang::En.catalog());
let release = Download::from_snapshot(
&state,
Category::Release,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert_eq!(
release.category,
Category::Release,
@@ -44,7 +50,12 @@ fn the_category_filters_the_list_locally() {
assert_eq!(release.rows.len(), 1, "只剩正式版");
assert_eq!(release.rows[0].id, "1.21.9");
let all = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
let all = Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert_eq!(all.rows.len(), 3, "「全部」不过滤");
}
@@ -55,7 +66,12 @@ fn the_category_filters_the_list_locally() {
#[test]
fn a_category_that_hides_everything_is_the_empty_state() {
let state = state_with(vec![version("1.21.9", VersionKind::Release)]);
let ancient = Download::from_snapshot(&state, Category::Ancient, Lang::En.catalog());
let ancient = Download::from_snapshot(
&state,
Category::Ancient,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(ancient.rows.is_empty());
assert!(!ancient.is_loading(), "快照里有版本 ⇒ 不是加载中");
assert!(ancient.is_empty(), "筛掉所有行 ⇒ 空态");
@@ -0,0 +1,36 @@
//! 安装选项区(M105):**选中版本之后先给选项,点「安装」才开工**。
//!
//! ## 为什么要有这一步
//!
//! 用户实跑反馈的原话是「点击下载之后它就直接下载了,没有选择前置加载器(版本选择)选项」。
//! 这不是画得不好,是**功能没做**:`Message::InstallVersion` 直接派 `InstallVersion`,
//! 而 core 早就会装四家加载器(M32/M35/M45 已合)。于是「能装什么」与「UI 让你装什么」
//! 之间差着一整个加载器维度。
//!
//! ## 文件分工
//!
//! | 文件 | 内容 |
//! |---|---|
//! | [`model`] | **决策**(无 iced):加载器词表、兼容性静态规则、选项视图、确认键判据 |
//! | [`view`] | 控件树:加载器 chip ×2 行 + 加载器版本列表 + 实例名 + 不兼容提示 |
//! | `tests`(`options/tests.rs`) | 兼容性真值表 + 确认键门(纯函数,不开图形环境) |
//!
//! ## 一条纪律:页面只发意图
//!
//! | 控件 | 消息 | app 层落点 |
//! |---|---|---|
//! | 加载器 chip | [`Message::SetLoader`](super::Message::SetLoader) | 改草稿 + 按需派 `ListLoaderVersions` |
//! | 加载器版本一行 | [`Message::SetLoaderVersion`](super::Message::SetLoaderVersion) | 改草稿 |
//! | 实例名输入 | [`Message::SetInstanceName`](super::Message::SetInstanceName) | 改草稿 |
//! | 「安装」 | [`Message::InstallVersion`](super::Message::InstallVersion) | `AppCommand::CreateInstance(草稿)` |
//!
//! 草稿(选中版本 + 三个选项)住在 `App`,由 `refresh_view` 带进页面模型——与分类筛选同一个做法,
//! 因为 `refresh_view` 每次事件都会把整棵模型重建,页面自己存不住任何东西。
mod model;
#[cfg(test)]
mod tests;
mod view;
pub use model::{InstallDraft, Loader, OptionsView};
pub(crate) use view::section;
@@ -0,0 +1,286 @@
//! 安装选项的**决策**(无 iced):选哪个加载器、它配不配这个原版版本、确认键能不能按。
//!
//! ## 为什么选项要有自己的模型层
//!
//! M105 之前「点安装」直接开工,于是用户装不了带加载器的版本(core 早就会装四家加载器,
//! 只是 UI 没接)。补上这一步之后,页面要回答的问题从一个变成四个:
//!
//! | 问题 | 判据 |
//! |---|---|
//! | 这个加载器配这个原版版本吗 | [`Loader::supports`](静态规则,**不联网**) |
//! | 加载器版本有哪些 | presenter 的 `LoaderVersions` 快照(`state.loader_versions_for`) |
//! | 用哪个加载器版本 | 用户选的;没选就取列表里最新的一条(core 的 `resolve(kind, mc, None)` 也是这个口径) |
//! | 现在能不能点「安装」 | [`OptionsView::can_confirm`](不兼容 / 列表没到位 / 已装 都按不下去) |
//!
//! ## 兼容性为什么是静态规则而不是问网络
//!
//! 「选完才失败」是用户最讨厌的一种交互。凡是**官方根本没有**的组合(NeoForge × 1.12.2、
//! 远古版 × 任何加载器),必须在选中那一刻就说清,不能等点了安装再回一个 404。
//! 反过来说:**能**装但列表拉不到(网络问题)属于「取不到」,由 [`LoaderVersionsView::Failed`]
//! 显示具名原因并禁掉确认——两条都不会让用户「选完才失败」。
//!
//! 规则来源(不猜:都是 core 安装管线自己的判据):
//! - Fabric/Quilt 的 loader 列表从 `1.14` 起才有(core 走 fabric meta,1.13 及以前没有);
//! - NeoForge 的第一个版本是 `1.20.1`(`maven::NEOFORGE_FORGE_LAYOUT_MC`);
//! - Forge 从 `1.1` 起有正式构建;远古版(`rd-*` / `a*` / `b*`)与预览版在 maven 表里没有条目。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{LoaderKind, VersionKind};
use unlucky_presenter::state::AppState;
use super::super::model::VersionRow;
/// 加载器版本下拉此刻的内容。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoaderVersionsView {
/// 还没查过 / 正在查(页面显示「正在获取…」,确认键按下不去)
Loading,
/// 查到了(**最新在前**,与 core 的 `loader_versions` 同序)
Ready(Vec<String>),
/// 查不到:人读原因(页面显示它并禁掉确认,不静默显示成空列表)
Failed(String),
}
/// 页面侧的加载器词表:只列 GUI 真能装的五家(与 `dto::LoaderKind::wizard_choices` 同集)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Loader {
/// 原版(不装加载器)
#[default]
Vanilla,
/// Forge
Forge,
/// NeoForge
NeoForge,
/// Fabric
Fabric,
/// Quilt
Quilt,
}
impl Loader {
/// 展示顺序(任务书的口径:原版 / Forge / NeoForge / Fabric / Quilt)。
pub const ALL: [Loader; 5] = [
Loader::Vanilla,
Loader::Forge,
Loader::NeoForge,
Loader::Fabric,
Loader::Quilt,
];
/// 对应的 presenter 词表(派 `AppCommand` 时用)。
pub const fn kind(self) -> LoaderKind {
match self {
Loader::Vanilla => LoaderKind::Vanilla,
Loader::Forge => LoaderKind::Forge,
Loader::NeoForge => LoaderKind::NeoForge,
Loader::Fabric => LoaderKind::Fabric,
Loader::Quilt => LoaderKind::Quilt,
}
}
/// 从 presenter 词表回来(`OptiFine` / `LiteLoader` 不是本页选项)。
pub const fn of(kind: LoaderKind) -> Option<Self> {
match kind {
LoaderKind::Vanilla => Some(Loader::Vanilla),
LoaderKind::Forge => Some(Loader::Forge),
LoaderKind::NeoForge => Some(Loader::NeoForge),
LoaderKind::Fabric => Some(Loader::Fabric),
LoaderKind::Quilt => Some(Loader::Quilt),
LoaderKind::OptiFine | LoaderKind::LiteLoader => None,
}
}
/// chip 文案。
///
/// 四家加载器的名字是**专有名词**(Fabric/Forge/NeoForge/Quilt 官方各语言都写原样),
/// 所以只有「原版」走 i18n;硬给品牌名建一条 en==zh 的 key 会被
/// `catalog::tests::chinese_catalog_is_actually_chinese` 判为「未翻译」。
pub fn label(self, catalog: Catalog) -> &'static str {
match self {
Loader::Vanilla => catalog.text(Key::LoaderVanilla),
Loader::Forge => "Forge",
Loader::NeoForge => "NeoForge",
Loader::Fabric => "Fabric",
Loader::Quilt => "Quilt",
}
}
/// 这个加载器配不配这个原版版本(静态规则,见模块文档的判据表)。
///
/// 参数是 `(版本 id, 版本类型)` 而不是一整行:app 层在**派查询之前**也要问同一个问题
/// (静态就不支持的组合不该去联网问,否则会白白换来一条「取不到」的 Toast)。
pub fn supports(self, id: &str, kind: VersionKind) -> Result<(), Incompat> {
if self == Loader::Vanilla {
return Ok(());
}
// 远古版:四家都没有构建(maven 表 / fabric meta 都没有这些版本)。
if kind.is_ancient() {
return Err(Incompat::Unsupported);
}
let rank = release_rank(id);
match self {
Loader::Vanilla => Ok(()),
// 预览版:Fabric 系的确会为快照发 loader,交给列表回答(拉不到就显示原因);
// maven 系(Forge/NeoForge)没有快照条目,直接拒。
Loader::Fabric | Loader::Quilt => match rank {
Some(version) if version >= (1, 14, 0) => Ok(()),
Some(_) => Err(Incompat::NeedsAtLeast("1.14")),
None if kind == VersionKind::Snapshot => Ok(()),
None => Err(Incompat::Unsupported),
},
Loader::Forge => match rank {
Some(_) => Ok(()),
None => Err(Incompat::Unsupported),
},
Loader::NeoForge => match rank {
Some(version) if version >= (1, 20, 1) => Ok(()),
Some(_) => Err(Incompat::NeedsAtLeast("1.20.1")),
None => Err(Incompat::Unsupported),
},
}
}
}
/// 不支持的原因(两种,页面各一句文案)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Incompat {
/// 官方没有这个组合的构建
Unsupported,
/// 需要某个最低原版版本
NeedsAtLeast(&'static str),
}
/// `1.20.1` → `(1, 20, 1)`;预览版 / 远古版 / 乱七八糟的串 → `None`。
///
/// 只要有一段落不是纯数字就给 `None`:宁可保守地拒绝一个自定义版本号,也不把
/// `1.20.1-pre3` 当成 `1.20.1` 放过去(那会装出加载器找不到父版本)。
pub fn release_rank(id: &str) -> Option<(u64, u64, u64)> {
let mut pieces = id.trim().split('.');
let major: u64 = pieces.next()?.parse().ok()?;
let minor: u64 = pieces.next()?.parse().ok()?;
let patch: u64 = match pieces.next() {
Some(patch) => patch.parse().ok()?,
None => 0,
};
if pieces.next().is_some() {
return None;
}
Some((major, minor, patch))
}
/// 用户还没确认的安装输入(**app 持有**,不是 presenter 快照的一部分)。
///
/// 为什么住在 app 而不是 presenter:这是「用户正在填、还没提交」的草稿,
/// 和下载页的分类筛选(`App::view.download.category` 的来源)是同一层的东西;
/// 塞进 presenter 快照会让后端视图模型混进 UI 草稿。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InstallDraft {
/// 选中的版本 id(点行得到;点别的版本会清掉加载器版本,见 `app/update/download.rs`)
pub selected: Option<String>,
/// 选中的加载器
pub loader: Loader,
/// 用户指定的加载器版本(空 = 用列表里最新的那条)
pub loader_version: String,
/// 实例名(空 = 用版本 id)
pub instance_name: String,
}
/// 选项区要画的东西(文案都已按 `Catalog` 求好值)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OptionsView {
/// 目标版本 id
pub version: String,
/// 当前选中的加载器
pub loader: Loader,
/// 加载器版本下拉的内容(原版恒为 [`LoaderVersionsView::Ready`] 空表)
pub versions: LoaderVersionsView,
/// **实际会用**的加载器版本(空 = 原版)
pub loader_version: String,
/// 实例名输入框的现值
pub instance_name: String,
/// 不兼容 / 取不到的原因(`Some` ⇒ 确认键按下不去)
pub problem: Option<String>,
/// 本地已装(确认键给「已安装」禁用态)
pub installed: bool,
/// 现在能不能点「安装」
pub can_confirm: bool,
}
impl OptionsView {
/// 选中行 + 快照 + 草稿 → 选项区的样子。
pub fn of(row: &VersionRow, state: &AppState, draft: &InstallDraft, catalog: Catalog) -> Self {
let loader = draft.loader;
let incompat = loader.supports(&row.id, row.kind);
// 静态就不支持的组合**不去查列表**(查了也只会换来一条「取不到」的假原因):
// 空表 + 上面那条「没有适配构建」就是说清这件事的那一句话。
let versions = if loader == Loader::Vanilla || incompat.is_err() {
LoaderVersionsView::Ready(Vec::new())
} else {
match state.loader_versions_for(loader.kind(), &row.id) {
None => LoaderVersionsView::Loading,
Some(Ok(items)) => LoaderVersionsView::Ready(items.clone()),
Some(Err(reason)) => LoaderVersionsView::Failed(reason.clone()),
}
};
// 用户选过就用它;没选过取列表里最新的一条(core 的默认口径也是「最新」)。
let loader_version = if loader == Loader::Vanilla {
String::new()
} else if !draft.loader_version.trim().is_empty() {
draft.loader_version.clone()
} else {
match &versions {
LoaderVersionsView::Ready(items) => items.first().cloned().unwrap_or_default(),
_ => String::new(),
}
};
let problem = match (incompat, &versions) {
(Err(Incompat::Unsupported), _) => Some(catalog.render(
Key::DownloadOptionsIncompatible,
&[
("loader", loader.label(catalog)),
("version", row.id.as_str()),
],
)),
(Err(Incompat::NeedsAtLeast(min)), _) => Some(catalog.render(
Key::DownloadOptionsNeedsAtLeast,
&[("loader", loader.label(catalog)), ("min", min)],
)),
(Ok(()), LoaderVersionsView::Failed(reason)) => Some(catalog.render(
Key::DownloadOptionsLoaderVersionsFailed,
&[("reason", reason.as_str())],
)),
(Ok(()), LoaderVersionsView::Ready(items))
if loader != Loader::Vanilla && items.is_empty() =>
{
Some(catalog.render(
Key::DownloadOptionsLoaderVersionsEmpty,
&[("version", row.id.as_str())],
))
}
(Ok(()), _) => None,
};
// 四条同时成立才按得下去:不兼容没有、列表真的就位(原版不需要列表)、没装过。
// 「列表没到位」既包括 `Loading` 也包括 `Failed` 与空表——它们都已经在 `problem` 里说了原因。
let ready = loader == Loader::Vanilla
|| matches!(&versions, LoaderVersionsView::Ready(items) if !items.is_empty());
let can_confirm = problem.is_none() && ready && !row.installed;
Self {
version: row.id.clone(),
loader,
versions,
loader_version,
instance_name: draft.instance_name.clone(),
problem,
installed: row.installed,
can_confirm,
}
}
/// 确认安装要提交的加载器版本(`None` = 让 core 用官方默认,只在原版路径出现)。
pub fn loader_version_arg(&self) -> Option<&str> {
(!self.loader_version.is_empty()).then_some(self.loader_version.as_str())
}
}
@@ -0,0 +1,254 @@
//! 安装选项的决策单测(M105):兼容性真值表 + 确认键门 + 默认加载器版本。
//!
//! 全部是**纯函数**(`OptionsView::of` 吃 `AppState` 与 `VersionRow`,不碰 iced),
//! 所以不开图形环境也能钉住「什么组合能装、什么组合按下不去」。
//!
//! **为什么用 `AppState` 而不是 mock 后端**:加载器版本列表是快照字段
//! (`state.loader_versions`),灌一条 `CoreEvent::LoaderVersions` 就能造出三种状态
//! (没查过 / 查到了 / 取不到),比 mock 更直接,也不依赖 `MockBackend` 的脚本。
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
AncientKind, AppEvent, CoreEvent, LoaderKind, VersionKind, VersionSummary,
};
use unlucky_presenter::state::AppState;
use super::model::{release_rank, Incompat, InstallDraft, Loader, LoaderVersionsView, OptionsView};
use crate::pages::download::model::VersionRow;
fn row(id: &str, kind: VersionKind) -> VersionRow {
VersionRow::of(
&VersionSummary {
id: id.to_string(),
kind,
released: "2026-09-20".to_string(),
installed: false,
},
Lang::En.catalog(),
)
}
fn state_with(kind: LoaderKind, mc: &str, items: Result<Vec<&str>, &str>) -> AppState {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::LoaderVersions {
kind,
mc_version: mc.to_string(),
result: items
.map(|list| {
list.into_iter()
.map(str::to_string)
.collect::<Vec<String>>()
})
.map_err(str::to_string),
}));
state
}
fn draft(loader: Loader) -> InstallDraft {
InstallDraft {
selected: Some("1.20.1".to_string()),
loader,
loader_version: String::new(),
instance_name: String::new(),
}
}
/// 版本号解析:只有纯数字三段(或缺末段)才算正式版。
#[test]
fn release_rank_only_accepts_plain_release_ids() {
assert_eq!(release_rank("1.20.1"), Some((1, 20, 1)));
assert_eq!(release_rank("1.20"), Some((1, 20, 0)), "缺末段按 0 算");
assert_eq!(release_rank("24w14a"), None, "快照不是正式版号");
assert_eq!(release_rank("rd-132211"), None);
assert_eq!(
release_rank("1.20.1-pre3"),
None,
"预发布不许当正式版放过去"
);
assert_eq!(release_rank("1.20.1.2"), None, "多一段不认");
}
/// 兼容性真值表(任务书点名的「老版本 × NeoForge」在这条里)。
#[test]
fn loader_support_matrix_is_explicit() {
// 原版永远可以
for id in ["1.21.9", "1.12.2", "rd-132211"] {
let kind = if id.starts_with("rd") {
VersionKind::Ancient(AncientKind::Classic)
} else {
VersionKind::Release
};
assert_eq!(Loader::Vanilla.supports(id, kind), Ok(()));
}
// NeoForge 要 1.20.1+
assert_eq!(
Loader::NeoForge.supports("1.20.1", VersionKind::Release),
Ok(())
);
assert_eq!(
Loader::NeoForge.supports("1.12.2", VersionKind::Release),
Err(Incompat::NeedsAtLeast("1.20.1")),
"老版本 × NeoForge 必须在选中那一刻就说清,而不是点安装才失败"
);
// Fabric/Quilt 要 1.14+
assert_eq!(
Loader::Fabric.supports("1.20.1", VersionKind::Release),
Ok(())
);
assert_eq!(
Loader::Fabric.supports("1.12.2", VersionKind::Release),
Err(Incompat::NeedsAtLeast("1.14"))
);
// Forge 任何正式版都行,但预览版/远古版没有 maven 条目
assert_eq!(
Loader::Forge.supports("1.12.2", VersionKind::Release),
Ok(())
);
assert_eq!(
Loader::Forge.supports("24w14a", VersionKind::Snapshot),
Err(Incompat::Unsupported)
);
// 远古版:四家全都不支持
let ancient = ("a1.2.6", VersionKind::Ancient(AncientKind::OldAlpha));
for loader in [
Loader::Fabric,
Loader::Quilt,
Loader::Forge,
Loader::NeoForge,
] {
assert_eq!(
loader.supports(ancient.0, ancient.1),
Err(Incompat::Unsupported)
);
}
}
/// 不兼容的组合:给具名提示,并且**确认键按下不去**。
#[test]
fn incompatible_combination_disables_confirm_with_a_named_reason() {
let state = AppState::new();
let options = OptionsView::of(
&row("1.12.2", VersionKind::Release),
&state,
&draft(Loader::NeoForge),
Lang::En.catalog(),
);
assert!(!options.can_confirm, "不兼容就不许按下确认");
let problem = options.problem.expect("要给出具名原因");
assert!(problem.contains("NeoForge"), "{problem}");
assert!(problem.contains("1.20.1"), "{problem}");
}
/// 默认加载器版本 = 列表里最新的一条(core 的默认口径);用户选过就用用户的。
#[test]
fn loader_version_defaults_to_the_newest_and_honours_the_choice() {
let state = state_with(LoaderKind::Fabric, "1.20.1", Ok(vec!["0.16.5", "0.16.4"]));
let options = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&state,
&draft(Loader::Fabric),
Lang::En.catalog(),
);
assert!(options.can_confirm);
assert_eq!(options.loader_version, "0.16.5", "没选过就用最新");
assert_eq!(options.loader_version_arg(), Some("0.16.5"));
let mut chosen = draft(Loader::Fabric);
chosen.loader_version = "0.16.4".to_string();
let options = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&state,
&chosen,
Lang::En.catalog(),
);
assert_eq!(options.loader_version, "0.16.4", "用户选的就是用户选的");
}
/// 三条「列表还没到位」的状态都不许按下确认,且各自说清是哪种。
#[test]
fn versions_pending_or_failed_blocks_confirm() {
let bare = AppState::new();
let loading = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&bare,
&draft(Loader::Fabric),
Lang::En.catalog(),
);
assert_eq!(loading.versions, LoaderVersionsView::Loading);
assert!(!loading.can_confirm, "还没查过就不许装");
let failed = state_with(LoaderKind::Fabric, "1.20.1", Err("network down"));
let options = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&failed,
&draft(Loader::Fabric),
Lang::En.catalog(),
);
assert!(!options.can_confirm);
assert!(
options
.problem
.as_deref()
.is_some_and(|p| p.contains("network down")),
"取不到要给具名原因(不是空下拉)"
);
let empty = state_with(LoaderKind::Fabric, "1.20.1", Ok(vec![]));
let options = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&empty,
&draft(Loader::Fabric),
Lang::En.catalog(),
);
assert!(!options.can_confirm, "官方没有版本就不许装");
assert!(options.problem.is_some());
}
/// 缓存键带原版版本:A 版本的列表不会挂到 B 版本下面。
#[test]
fn cached_versions_are_keyed_by_minecraft_version() {
let state = state_with(LoaderKind::Fabric, "1.20.1", Ok(vec!["0.16.5"]));
let other = OptionsView::of(
&row("1.21.9", VersionKind::Release),
&state,
&draft(Loader::Fabric),
Lang::En.catalog(),
);
assert_eq!(
other.versions,
LoaderVersionsView::Loading,
"换原版版本就得重新查,不许复用上一版的结果"
);
}
/// 原版:不查列表、不需要加载器版本,确认键直接可用。
#[test]
fn vanilla_needs_no_loader_version_at_all() {
let state = AppState::new();
let options = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&state,
&draft(Loader::Vanilla),
Lang::En.catalog(),
);
assert!(options.can_confirm);
assert_eq!(options.versions, LoaderVersionsView::Ready(Vec::new()));
assert_eq!(options.loader_version, "");
assert_eq!(options.loader_version_arg(), None);
}
/// 已装的版本:确认键给「已安装」禁用态(列表行上也有勾,按钮再点一次不会更清楚)。
#[test]
fn installed_versions_cannot_be_confirmed_again() {
let state = AppState::new();
let mut installed = row("1.20.1", VersionKind::Release);
installed.installed = true;
let options = OptionsView::of(
&installed,
&state,
&draft(Loader::Vanilla),
Lang::En.catalog(),
);
assert!(options.installed);
assert!(!options.can_confirm);
}
@@ -0,0 +1,213 @@
//! 选项区的控件树(M105)。
//!
//! ## 版面(424 正文列里的四段)
//!
//! ```text
//! 1.21.9 [安装]
//! 安装选项
//! [原版][Forge][NeoForge]
//! [Fabric][Quilt]
//! 加载器版本 ← 只在选了加载器时出现
//! ┌ 0.16.5 ┐ ← 官方那份列表(menu + 滚动,最多看两行)
//! │ 0.16.4 │
//! └───────────────────┘
//! [实例名 ]
//! NeoForge 需要 Minecraft 1.20.1 或更新 ← 不兼容时(Error 色)
//! ```
//!
//! ## 为什么加载器分成两行
//!
//! 五家一起排在 424 宽里最宽的那组(`原版 + Forge + NeoForge + Fabric + Quilt`)实测会溢出,
//! 而 chip 的几何是组件自己的真源(`ChipGeometry`),页面不许压它 ⇒ 折成两行。
//! 顺序仍是任务书的口径(原版 / Forge / NeoForge / Fabric / Quilt),只是换行。
//!
//! ## 为什么「不兼容」不禁用 chip、而是禁用确认键
//!
//! 禁用的 chip 点不动,用户就**看不到原因**(那正是「选完才失败」的另一种形态)。
//! 现在的做法:五家永远都能点,选中不兼容的那家时下面出现一行具名原因,同时「安装」变成禁用态。
//! 于是「为什么不能装」和「怎么才能装」永远同屏。
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{menu, Menu, MenuItem};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::model::{Loader, LoaderVersionsView, OptionsView};
/// 五家加载器(一行放得下:实测五枚 chip 合计约 311 dp,正文列 424)。
const LOADERS: [Loader; 5] = [
Loader::Vanilla,
Loader::Forge,
Loader::NeoForge,
Loader::Fabric,
Loader::Quilt,
];
/// 菜单项高度 = 48(`MenuGeometry` 的项高)×2 行 + 上下留白 8×2 = 112。
/// 这么算是为了避免「列表被截掉半行」——滚动区高度必须覆盖整行。
const VERSION_ROWS: f32 = 7.0;
/// 选项区(面板的下半块)。
pub(crate) fn section<'a>(
ui: &'a Ui,
catalog: Catalog,
options: &'a OptionsView,
) -> Element<'a, Message> {
let head = row![
container(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
options.version.as_str(),
ui.color(Role::OnSurface),
))
.width(Length::Fill),
confirm(ui, catalog, options),
]
.width(Length::Fill)
.align_y(Alignment::Center);
let mut body = column![
head,
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::DownloadOptionsTitle),
ui.color(Role::OnSurfaceVariant),
),
loader_row(ui, catalog, options),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
// 「加载器版本」这一段只在**真该有列表**时出现:原版没有、静态不兼容没有(原因在下面那行)、
// 取不到也没有(同样在原因行)。留一个空标题只会让人以为界面缺了一块。
if show_versions(options) {
body = body.push(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::DownloadOptionsLoaderVersion),
ui.color(Role::OnSurfaceVariant),
));
body = body.push(version_list(ui, catalog, options));
}
body = body.push(text_field(
ui,
Field::new(options.instance_name.as_str())
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::DownloadOptionsInstanceName))
.placeholder(catalog.text(Key::DownloadOptionsInstanceNameHint)),
Message::SetInstanceName,
));
if let Some(problem) = &options.problem {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
problem.as_str(),
ui.color(Role::Error),
));
}
body.into()
}
/// 加载器版本这一段要不要画(见 `section` 的注释)。
fn show_versions(options: &OptionsView) -> bool {
if options.loader == Loader::Vanilla || options.problem.is_some() {
return false;
}
match &options.versions {
LoaderVersionsView::Loading => true,
LoaderVersionsView::Ready(items) => !items.is_empty(),
LoaderVersionsView::Failed(_) => false,
}
}
/// 一行加载器 chip(Filter 档:选中 = `SecondaryContainer`)。
fn loader_row<'a>(ui: &'a Ui, catalog: Catalog, options: &'a OptionsView) -> Element<'a, Message> {
let mut line = row![]
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center);
for loader in LOADERS.iter() {
line = line.push(chip(
ui,
Chip::filter(
loader.label(catalog),
options.loader == *loader,
Message::SetLoader(*loader),
),
));
}
line.into()
}
/// 官方那份加载器版本列表(`menu` + 滚动:Fabric 有上百条,不做下拉也不铺满屏)。
fn version_list<'a>(
ui: &'a Ui,
catalog: Catalog,
options: &'a OptionsView,
) -> Element<'a, Message> {
match &options.versions {
LoaderVersionsView::Loading => frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::DownloadOptionsLoaderVersionsLoading),
ui.color(Role::OnSurfaceVariant),
),
// 取不到 / 一条都没有:原因已经在上面那行 `problem` 里说清,这里不再重复。
LoaderVersionsView::Failed(_) => frame::gap(0.0),
LoaderVersionsView::Ready(items) if items.is_empty() => frame::gap(0.0),
LoaderVersionsView::Ready(items) => {
let mut list = Menu::new().width(Length::Fill);
for item in items {
let mut entry =
MenuItem::new(item.as_str(), Message::SetLoaderVersion(item.clone()));
if *item == options.loader_version {
entry = entry.selected();
}
list = list.item(entry);
}
scrollable(menu(ui, list))
.height(Length::Fixed(ui.space(Space::X4) * VERSION_ROWS))
.style(frame::scroll_style(ui))
.into()
}
}
}
/// 「安装」:不兼容 / 列表没到位 / 已装 都按下不去(禁用态保留几何,只清消息)。
fn confirm<'a>(ui: &'a Ui, catalog: Catalog, options: &'a OptionsView) -> Element<'a, Message> {
if options.installed {
return buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstalled)));
}
if !options.can_confirm {
return buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstall)));
}
buttons::filled(
ui,
Button::new(
catalog.text(Key::DownloadInstall),
Message::InstallVersion(options.version.clone()),
)
.leading(IconKind::Download),
)
}
+35 -81
View File
@@ -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,
@@ -0,0 +1,97 @@
//! 下载页的**文案守门**(M105 从 `download/tests.rs` 搬出:那边加了安装选项的用例之后顶破 400 行)。
//!
//! 两条各守一件事:死 key(目录里有、页面没用)与「模型说的是目录的语言」。
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::VersionKind;
use super::*;
// --------------------------------------------------------------- 文案(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; 8] = [
include_str!("../../download.rs"),
include_str!("../model.rs"),
include_str!("../rows.rs"),
include_str!("../install.rs"),
include_str!("../states.rs"),
include_str!("../demo.rs"),
// M105:安装选项区的两条填页线(漏扫的话新 key 会被判成死 key)
include_str!("../options/model.rs"),
include_str!("../options/view.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,
&InstallDraft::default(),
Lang::En.catalog(),
);
let zh = Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
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 = crate::pages::download::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 =
crate::pages::download::model::InstallView::of(&progress, Lang::ZhCn.catalog());
assert_eq!(install_zh.stage_label, "解析版本");
}
+12 -2
View File
@@ -10,7 +10,7 @@
use unlucky_presenter::router::Route;
use super::appearance;
use super::download::Category;
use super::download::{Category, Loader};
use super::settings;
/// 页面消息。
@@ -34,8 +34,18 @@ pub enum Message {
Appearance(appearance::Change),
/// 选中下载页的一个版本(M94):**页面内选中**,app 层只改视图模型,不 dispatch。
SelectVersion(String),
/// 安装指定版本(M94;app 层翻成 `AppCommand::InstallVersion`)。
/// **确认安装**选中的版本(用当前安装选项)。
///
/// M105 起语义变了:以前是「立刻装原版」(`AppCommand::InstallVersion`),现在是
/// 「用选项区里选好的加载器 / 加载器版本 / 实例名开工」(`AppCommand::CreateInstance`)。
/// 载荷仍是版本 id,app 层拿它去草稿里取其余三项。
InstallVersion(String),
/// 改安装选项的加载器(M105;app 层改草稿,必要时派 `AppCommand::ListLoaderVersions`)。
SetLoader(Loader),
/// 改加载器版本(M105;空串 = 用官方最新)。
SetLoaderVersion(String),
/// 改实例名(M105;空串 = 用版本号)。
SetInstanceName(String),
/// 取消正在跑的安装(M94;`AppCommand::CancelInstall`)。
CancelInstall,
/// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。
+1 -1
View File
@@ -123,7 +123,7 @@ pub(crate) mod settings;
#[cfg(test)]
mod tests;
pub use download::{Category, Download};
pub use download::{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};