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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 的公开快照字段,
|
||||
// 不派业务命令、不写持久状态)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。
|
||||
|
||||
@@ -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:?} 之后面板要让位");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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, "解析版本");
|
||||
}
|
||||
@@ -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` 只画现有快照)。
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in new issue
Block a user