fix(download): M94 r2 —— 安装终态出口 + 分类真机过滤(复审 P1-1 / P2-1)
P1-1 安装一旦收尾就再也装不了第二个版本(终态面板没有出口)
- 页面侧:`Download::from_snapshot` 把 **Done / Cancelled** 当结束(`InstallView::finished`),
面板让回列表 —— 装完一个版本可以直接选下一个继续装(图:r2-done / r2-done-next)。
不清 `Failed`:失败态带着用户要的东西(停在哪个文件 + 去日志页)。
- 面板加真出口:失败态「Open logs」(实心)+「Dismiss」(文本 → `Message::CancelInstall`,
host 侧对没在跑的任务是安全 no-op),修掉「失败后连重试都不可达」。
- 决策抽成纯函数 `install::panel_state()`(Hint / Ready / Install),`panel()` 只是它的画法,
于是「能不能再装」可以被单测钉死,不用开图形环境。
- 两条新测试**不走 MockBackend**(它从不发 `InstallProgress`,终态永远跑不到 ⇒ 假绿):
直接构造 `AppState` + `apply(CoreEvent::InstallProgress(..))`。
P2-1 分类 chip 在真 host 上是空控件
- `from_snapshot(state, category, catalog)` 收分类并按 `Category::matches` **本地过滤**
(真 host 的 `ListVersions(_)` 丢掉分类参数、回全量清单);
- 分类不再从列表反推(`Category::of` 只留给演示首帧),`app/view.rs::model` 收分类、
`refresh_view` 把上一份视图模型的分类带回去 ⇒ 事件回来 chip 不会跳回「全部」;
- `update.rs` 的 `SetCategory` 写分类后**立刻重建**(chip 与列表同一个谓词),再派真指令。
p3(顺带)
- 失败态与占位态共存:列表空/加载中时若安装还在跑,面板照样出现在底部(r2-installing-empty);
- 修 `install.rs` 的坏 rustdoc 链接;`install.rs` / `model.rs` 的测试模块外置到子文件守住 400 行。
测试文件:`pages/download/{model,install}/tests.rs`(新),`pages/download/tests.rs` 里的
`from_snapshot` 调用补分类参数。
实证(回退法):删掉 `.filter(|view| !view.finished())` → 两条终态测试红并点名
「Done 之后不许再占着安装面板」;删掉 `.filter(|category.matches(..))` → 两条分类测试红
(「只剩正式版」left=3 right=1)。放回修复 → 全绿。
门禁:fmt --check 干净、clippy -D warnings 干净、app-iced 280 + 集成 6 + i18n 23 全绿(-j 4)。
This commit is contained in:
1 parent
9d6ee70301
commit
6453c76750
12 files changed
+432
-74
No files matched your search
@@ -258,7 +258,10 @@ impl App {
|
||||
///
|
||||
/// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。
|
||||
pub(super) fn refresh_view(&mut self) {
|
||||
self.view = super::view::model(&self.state, self.width, self.options.force_state);
|
||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
let category = self.view.download.category;
|
||||
self.view = super::view::model(&self.state, self.width, self.options.force_state, category);
|
||||
self.view_dirty = false;
|
||||
self.refreshes += 1;
|
||||
}
|
||||
|
||||
@@ -289,8 +289,14 @@ impl App {
|
||||
pages::Message::InstallVersion(id) => self.dispatch(AppCommand::InstallVersion(id)),
|
||||
pages::Message::CancelInstall => self.dispatch(AppCommand::CancelInstall),
|
||||
pages::Message::SetCategory(category) => {
|
||||
// chip 立刻反映选中(视图模型),列表由后端回 `VersionListChanged` 换掉
|
||||
// 分类是**本页的过滤条件**,不是后端事实的乐观回显:真 host 会把
|
||||
// `ListVersions` 的分类参数丢掉、回全量清单(复审 P2-1),所以过滤在本页做。
|
||||
// 写进视图模型 → 立刻按它重建(`refresh_view` 会把 category 带回去并据此过滤行),
|
||||
// 于是 chip 与列表用同一个谓词,不存在「chip 说正式版、列表是全量」的两份真相。
|
||||
self.view.download.category = category;
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
// 列表内容仍然由后端给:真指令照派(presenter 修好 `spawn_list` 之后它就是那一路)
|
||||
if let Some(kind) = category.command() {
|
||||
self.dispatch(AppCommand::ListVersions(kind));
|
||||
}
|
||||
|
||||
@@ -62,4 +62,13 @@ fn download_intents_dispatch_real_commands() {
|
||||
0,
|
||||
"recorder 不回事件 ⇒ 列表不该冒出来"
|
||||
);
|
||||
// 分类是本地过滤条件:**任何一次视图重建都不许把它打回 `All`**(复审 P2-1 的回归点)。
|
||||
// 抓得住:`refresh_view` 忘记把分类带回去(那一行删掉这条就红)。
|
||||
// 抓不住:真 host 是否按分类拉清单(那是 presenter 的 `spawn_list`,已报 finding)。
|
||||
app.refresh_view();
|
||||
assert_eq!(
|
||||
app.view.download.category,
|
||||
pages::Category::Release,
|
||||
"重建视图模型之后分类必须还在"
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,7 @@ pub(super) fn model(
|
||||
state: &AppState,
|
||||
width: f32,
|
||||
forced: Option<crate::cli::ForcedState>,
|
||||
category: pages::Category,
|
||||
) -> pages::Model {
|
||||
let route = state.route().clone();
|
||||
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
|
||||
@@ -113,7 +114,7 @@ pub(super) fn model(
|
||||
window_width: width,
|
||||
active: route.nav(),
|
||||
instances: pages::Instances { rows, detail },
|
||||
download: pages::Download::from_snapshot(state, unlucky_i18n::Lang::En.catalog()),
|
||||
download: pages::Download::from_snapshot(state, category, unlucky_i18n::Lang::En.catalog()),
|
||||
forced: forced.map(|state| match state {
|
||||
crate::cli::ForcedState::Hover => Interaction::Hovered,
|
||||
crate::cli::ForcedState::Pressed => Interaction::Pressed,
|
||||
@@ -248,7 +249,7 @@ mod tests {
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
let model = model(&state, 1180.0, None);
|
||||
let model = model(&state, 1180.0, None, pages::Category::All);
|
||||
assert_eq!(model.screen, pages::Screen::Instances);
|
||||
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
|
||||
assert_eq!(model.window_width, 1180.0);
|
||||
@@ -279,7 +280,7 @@ mod tests {
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
let model = model(&state, 1180.0, None);
|
||||
let model = model(&state, 1180.0, None, pages::Category::All);
|
||||
assert_eq!(
|
||||
model.instances.rows[0].status.as_deref(),
|
||||
Some("Downloading · 50%")
|
||||
@@ -317,7 +318,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);
|
||||
let model = model(&state, 1180.0, None, pages::Category::All);
|
||||
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
|
||||
assert_eq!(pages::Screen::of(state.route()), None);
|
||||
assert!(model.instances.detail.is_none());
|
||||
|
||||
@@ -108,24 +108,42 @@ fn body<'a>(
|
||||
catalog: Catalog,
|
||||
model: &'a Download,
|
||||
) -> Element<'a, Message> {
|
||||
let content = if model.is_loading() {
|
||||
states::loading(ui, catalog)
|
||||
} else if model.is_empty() {
|
||||
states::empty(ui, catalog)
|
||||
} else {
|
||||
column![
|
||||
rows::categories(ui, catalog, model.category),
|
||||
rows::head(ui, catalog, model),
|
||||
divider::full(ui),
|
||||
rows::list(ui, catalog, model),
|
||||
divider::full(ui),
|
||||
install::panel(ui, catalog, model),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
if model.is_loading() || model.is_empty() {
|
||||
let placeholder = if model.is_loading() {
|
||||
states::loading(ui, catalog)
|
||||
} else {
|
||||
states::empty(ui, catalog)
|
||||
};
|
||||
// 占位状态下**也**把安装面板带上(复审 p3):列表空/还没到,不代表安装停了——
|
||||
// 没有这一段的话,安装进行中一旦列表被过滤空,进度条会凭空消失。
|
||||
let content: Element<'a, Message> = if model.install.is_some() {
|
||||
column![
|
||||
placeholder,
|
||||
divider::full(ui),
|
||||
install::panel(ui, catalog, model)
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
placeholder
|
||||
};
|
||||
return frame::single_column(metrics, content);
|
||||
}
|
||||
|
||||
let content: Element<'a, Message> = column![
|
||||
rows::categories(ui, catalog, model.category),
|
||||
rows::head(ui, catalog, model),
|
||||
divider::full(ui),
|
||||
rows::list(ui, catalog, model),
|
||||
divider::full(ui),
|
||||
install::panel(ui, catalog, model),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into();
|
||||
|
||||
frame::single_column(metrics, content)
|
||||
}
|
||||
@@ -18,13 +18,25 @@
|
||||
//! --screen=download --screenshot=out.png --screenshot-theme=light --software-renderer
|
||||
//! ```
|
||||
//!
|
||||
//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `installing` / `installing-late` / `failed`。
|
||||
//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `category-release` / `installing` /
|
||||
//! `installing-late` / `installing-empty` / `failed` / `done` / `done-next`。
|
||||
//!
|
||||
//! `category-release` / `done` / `done-next` 都**不手搓结果**,而是拿一份 `AppState` 跑
|
||||
//! [`Download::from_snapshot`]:图证明的是产品路径的行为(分类本地过滤、装完后面板让回列表),
|
||||
//! 不是演示的特权。
|
||||
//!
|
||||
//! `done` / `done-next` 是「**刚装完**」的两个采样点:它们不手搓模型,而是拿一份
|
||||
//! `AppState`(版本列表 + `InstallStage::Done`)跑 [`Download::from_snapshot`],所以图上看到的是
|
||||
//! 产品路径的真行为(复审 p2:`Done` 之后面板必须让回列表,否则第二个版本装不了)。
|
||||
//! **只有 harness 与演示走这里**:产品路径由 `app/view.rs` 从 presenter 快照建模型。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AncientKind, InstallProgressView, InstallStage, VersionKind, VersionSummary,
|
||||
};
|
||||
|
||||
use unlucky_presenter::dto::{AppEvent, CoreEvent};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use unlucky_i18n::Catalog;
|
||||
|
||||
use super::model::{Category, Download, InstallView, VersionRow};
|
||||
@@ -60,6 +72,17 @@ pub fn demo(catalog: Catalog) -> Download {
|
||||
selected: Some("1.21.9".to_string()),
|
||||
..ready(catalog)
|
||||
},
|
||||
// 装完之后:走**产品那条真转换**(见 `finished`),图里看到的就是页面真会画的东西
|
||||
Some("done") => finished(catalog, None),
|
||||
// 装完之后再点下一个版本:面板必须重新给出实心「安装」
|
||||
Some("done-next") => finished(catalog, Some("1.21.9-pre3")),
|
||||
// 安装进行中、但列表被过滤空:面板要跟着占位态一起在(复审 p3 的边角)
|
||||
// 分类筛选(本页本地过滤:真 host 丢弃 ListVersions 的分类参数)——混合清单里只留正式版
|
||||
Some("category-release") => category(catalog, Category::Release),
|
||||
Some("installing-empty") => Download {
|
||||
install: Some(install(InstallStage::Downloading, 0.42, catalog)),
|
||||
..Download::loading()
|
||||
},
|
||||
_ => ready(catalog),
|
||||
}
|
||||
}
|
||||
@@ -69,6 +92,24 @@ pub fn demo(catalog: Catalog) -> Download {
|
||||
/// 不复用 `mock_data::mock_versions()`:演示数据是**出图的基线**,得比 mock 稳定
|
||||
/// (mock 随 P3 的假数据一起漂)。
|
||||
fn ready(catalog: Catalog) -> Download {
|
||||
let versions = demo_versions();
|
||||
let rows: Vec<VersionRow> = versions
|
||||
.iter()
|
||||
.map(|version| VersionRow::of(version, catalog))
|
||||
.collect();
|
||||
|
||||
Download {
|
||||
// 混合分类 → 没有单个 chip 该亮(这正是 `Category::of` 的反推结果)
|
||||
category: Category::of(&versions),
|
||||
rows,
|
||||
selected: None,
|
||||
install: None,
|
||||
loaded: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 演示用的版本表(`ready` 与 `finished` 共用;与 presenter 的 `mock_versions` 同形)。
|
||||
fn demo_versions() -> Vec<VersionSummary> {
|
||||
fn summary(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary {
|
||||
VersionSummary {
|
||||
id: id.to_string(),
|
||||
@@ -78,7 +119,7 @@ fn ready(catalog: Catalog) -> Download {
|
||||
}
|
||||
}
|
||||
|
||||
let versions = vec![
|
||||
vec![
|
||||
summary("1.21.9", VersionKind::Release, "2026-09-20", false),
|
||||
summary("1.21.8", VersionKind::Release, "2026-07-11", true),
|
||||
summary("1.20.4", VersionKind::Release, "2023-12-07", true),
|
||||
@@ -95,21 +136,43 @@ fn ready(catalog: Catalog) -> Download {
|
||||
"2010-08-11",
|
||||
false,
|
||||
),
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
let rows: Vec<VersionRow> = versions
|
||||
.iter()
|
||||
.map(|version| VersionRow::of(version, catalog))
|
||||
.collect();
|
||||
/// 「刚装完一个版本」的演示:**走产品那条真转换**(`AppState` → `Download::from_snapshot`)。
|
||||
///
|
||||
/// 为什么不用手搓模型:本轮复审的 bug 正是「`Done` 之后 `install` 从不清」——手搓一个
|
||||
/// `install: None` 的模型会让图**假绿**(图上好看,真路径照样卡死)。这里真的走一遍快照转换,
|
||||
/// 于是图能证明「装完之后面板让回列表」是产品路径的行为,不是演示的特权。
|
||||
///
|
||||
/// `select_next`:演「用户接着点了下一个版本」,面板应当重新给出实心「安装」。
|
||||
fn finished(catalog: Catalog, select_next: Option<&str>) -> Download {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
|
||||
demo_versions(),
|
||||
)));
|
||||
state.apply(AppEvent::Core(CoreEvent::InstallProgress(Some(
|
||||
InstallProgressView {
|
||||
version: "1.21.8".to_string(),
|
||||
stage: InstallStage::Done,
|
||||
files_done: 512,
|
||||
files_total: 512,
|
||||
..InstallProgressView::new()
|
||||
},
|
||||
))));
|
||||
|
||||
Download {
|
||||
// 混合分类 → 没有单个 chip 该亮(这正是 `Category::of` 的反推结果)
|
||||
category: Category::of(&versions),
|
||||
rows,
|
||||
selected: None,
|
||||
install: None,
|
||||
loaded: true,
|
||||
}
|
||||
let mut model = Download::from_snapshot(&state, Category::All, catalog);
|
||||
model.selected = select_next.map(str::to_string);
|
||||
model
|
||||
}
|
||||
|
||||
/// 「用户点了某个分类」的演示:走真转换(`from_snapshot` 的分类过滤),不手搓过滤结果。
|
||||
fn category(catalog: Catalog, wanted: Category) -> Download {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
|
||||
demo_versions(),
|
||||
)));
|
||||
Download::from_snapshot(&state, wanted, catalog)
|
||||
}
|
||||
|
||||
/// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! |---|---|---|
|
||||
//! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` |
|
||||
//! | 「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
|
||||
//! | 「打开日志」 | [`Message::Navigate(Route::Logs)`] | `AppState::navigate`(不派指令) |
|
||||
//! | 「打开日志」 | [`Message::Navigate`](`Route::Logs`) | `AppState::navigate`(不派指令) |
|
||||
//!
|
||||
//! 按钮按下**不改任何本地状态**:进度条读的是 `model.install`,它只由 presenter 的
|
||||
//! `InstallProgress` 事件灌进来(`apply` → 视图模型重建)。于是「点了没变」= 后端还没回事件。
|
||||
@@ -46,16 +46,51 @@ use crate::theme::Ui;
|
||||
|
||||
use super::super::frame;
|
||||
use super::super::Message;
|
||||
use super::model::{Download, InstallView, VersionRow};
|
||||
use super::model::{Download, InstallView};
|
||||
|
||||
/// 面板要画哪一种形态(**决策与画法分开**:决策是纯函数,单测直接钉它)。
|
||||
///
|
||||
/// 分开的理由就是本轮复审那件事:「装完之后面板还占着、用户再也装不了第二个版本」——
|
||||
/// 这是**决策**错了,画法本身没错;把决策抽出来,测试才能在不开图形环境的情况下断言它。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(super) enum PanelState<'a> {
|
||||
/// 没有安装、也没选中:一句「选一个版本再安装」
|
||||
Hint,
|
||||
/// 选中了一个版本:给出「安装」(已装的给禁用态)
|
||||
Ready {
|
||||
/// 选中的版本 id(= `AppCommand::InstallVersion` 的载荷)
|
||||
id: &'a str,
|
||||
/// 本地已经装过(按钮禁用,列表行上也有「已安装」+ 勾)
|
||||
installed: bool,
|
||||
},
|
||||
/// 有安装(进行中 / 失败):进度或错误面板
|
||||
Install(&'a InstallView),
|
||||
}
|
||||
|
||||
/// 面板此刻该画哪一种(纯函数,无 iced)。
|
||||
///
|
||||
/// 顺序是**安装优先**:有安装时面板讲安装,讲不了列表;安装结束后 `model.install` 由
|
||||
/// [`Download::from_snapshot`](super::model::Download::from_snapshot) 清掉,于是这里自然回到
|
||||
/// [`PanelState::Ready`] / [`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 => PanelState::Hint,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部的安装面板。
|
||||
pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> {
|
||||
let content = match &model.install {
|
||||
Some(install) => installing(ui, catalog, install),
|
||||
None => match model.selected_row() {
|
||||
Some(entry) => ready(ui, catalog, entry),
|
||||
None => hint(ui, catalog),
|
||||
},
|
||||
let content = match panel_state(model) {
|
||||
PanelState::Install(install) => installing(ui, catalog, install),
|
||||
PanelState::Ready { id, installed } => ready(ui, catalog, id, installed),
|
||||
PanelState::Hint => hint(ui, catalog),
|
||||
};
|
||||
|
||||
container(content)
|
||||
@@ -79,24 +114,24 @@ fn hint<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
|
||||
///
|
||||
/// 已装的版本给**禁用**的 Outlined 按钮(`Button::disabled` 只清消息、保留几何):
|
||||
/// 「已装」是列表行上的事实(overline + 勾),按钮再点一次不会更清楚。
|
||||
fn ready<'a>(ui: &'a Ui, catalog: Catalog, entry: &'a VersionRow) -> Element<'a, Message> {
|
||||
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,
|
||||
entry.id.as_str(),
|
||||
id,
|
||||
ui.color(Role::OnSurface),
|
||||
))
|
||||
.width(Length::Fill);
|
||||
|
||||
let action = if entry.installed {
|
||||
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(entry.id.clone()),
|
||||
Message::InstallVersion(id.to_string()),
|
||||
)
|
||||
.leading(IconKind::Download),
|
||||
)
|
||||
@@ -200,14 +235,24 @@ fn failed<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element
|
||||
ui.color(Role::OnErrorContainer),
|
||||
);
|
||||
|
||||
let action = buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::DownloadOpenLogs),
|
||||
Message::Navigate(Route::Logs),
|
||||
)
|
||||
.leading(IconKind::Logs),
|
||||
);
|
||||
let actions = row![
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::DownloadOpenLogs),
|
||||
Message::Navigate(Route::Logs),
|
||||
)
|
||||
.leading(IconKind::Logs),
|
||||
),
|
||||
// 失败把面板钉住了:不给出口的话用户既重试不了、也装不了别的版本(只能重启)。
|
||||
// `CancelInstall` 在 host 侧就是「清 install + 发 InstallProgress(None)」,对没在跑的任务是安全 no-op。
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(catalog.text(Key::DownloadDismiss), Message::CancelInstall),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
container(
|
||||
column![
|
||||
@@ -220,7 +265,7 @@ fn failed<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element
|
||||
catalog.text(Key::DownloadFailedHint),
|
||||
ui.color(Role::OnErrorContainer),
|
||||
),
|
||||
action,
|
||||
actions,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill),
|
||||
@@ -237,3 +282,6 @@ fn failed<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,118 @@
|
||||
//! 安装面板的决策测试(M94 r2)。
|
||||
//!
|
||||
//! 从 `install.rs` 外置(那边加上本轮修复会顶到 400 行)。`use super::*` 仍指向
|
||||
//! `crate::pages::download::install`,`PanelState` / `panel_state` 是那里的 `pub(super)`。
|
||||
//!
|
||||
//! **不走 `MockBackend`**:`MockBackend::script_install` 只发 Dialog + Toast、从不发
|
||||
//! `InstallProgress`(复审 r1 的取证),所以 mock 下 `install` 恒为 `None`,终态分支永远跑不到——
|
||||
//! 用 mock 写的测试会**假绿**。这里直接构造 `AppState` 并 `apply(CoreEvent::InstallProgress(..))`。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppEvent, CoreEvent, InstallProgressView, InstallStage, VersionKind, VersionSummary,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use super::super::model::{Category, Download};
|
||||
use super::{panel_state, PanelState};
|
||||
|
||||
fn version(id: &str) -> VersionSummary {
|
||||
VersionSummary {
|
||||
id: id.to_string(),
|
||||
kind: VersionKind::Release,
|
||||
released: "2026-09-20".to_string(),
|
||||
installed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn progress(stage: InstallStage) -> InstallProgressView {
|
||||
InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage,
|
||||
files_done: 512,
|
||||
files_total: 512,
|
||||
..InstallProgressView::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(stage: InstallStage) -> AppState {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(vec![
|
||||
version("1.21.9"),
|
||||
version("1.21.8"),
|
||||
])));
|
||||
state.apply(AppEvent::Core(CoreEvent::InstallProgress(Some(progress(
|
||||
stage,
|
||||
)))));
|
||||
state
|
||||
}
|
||||
|
||||
/// **装完之后页面必须回到「能再装下一个」**(复审 p2 的修复验收)。
|
||||
///
|
||||
/// 复审现象:`InstallDone` 从不清 `AppState.install`(presenter 只在取消时清),而面板**优先**
|
||||
/// 用 `install`,终态又没有可执行动作 ⇒ 装完第一个版本后按钮永久消失,只能重启进程。
|
||||
///
|
||||
/// 抓得住:`Done` 之后面板还被 `install` 占着(= 装不了第二个版本的根因)、列表没回来、
|
||||
/// 面板不再给「安装」。测试直接钉 [`panel_state`]——那是 `panel()` 唯一的决策入口,两者不会漂。
|
||||
/// 抓不住:presenter 侧 `AppState.install` 仍是 `Some(Done)`(proper fix 不在本 mission,已报 finding);
|
||||
/// 也抓不住「装完后已装标记不刷新」(同一个 finding)。
|
||||
#[test]
|
||||
fn a_finished_install_gives_the_page_back_to_the_list() {
|
||||
let running = Download::from_snapshot(
|
||||
&snapshot(InstallStage::Downloading),
|
||||
Category::All,
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert!(running.install.is_some(), "跑着的时候面板要在");
|
||||
assert!(matches!(panel_state(&running), PanelState::Install(_)));
|
||||
|
||||
let state = snapshot(InstallStage::Done);
|
||||
assert_eq!(
|
||||
state.install.as_ref().map(|view| view.stage),
|
||||
Some(InstallStage::Done),
|
||||
"现实如此:快照里仍留着终态(presenter 不清),页面必须自己让位"
|
||||
);
|
||||
|
||||
let model = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
|
||||
assert!(model.install.is_none(), "Done 之后不许再占着安装面板");
|
||||
assert!(model.selected.is_none(), "装完不替用户选中任何行");
|
||||
assert_eq!(model.rows.len(), 2, "列表要回来");
|
||||
assert_eq!(
|
||||
panel_state(&model),
|
||||
PanelState::Hint,
|
||||
"回到「选一个版本再安装」"
|
||||
);
|
||||
|
||||
// 再装一次:选中下一个版本 → 面板重新给出「安装」(= `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
|
||||
},
|
||||
"装完之后必须能接着装下一个版本"
|
||||
);
|
||||
}
|
||||
|
||||
/// 取消与失败**不许**一起被清:失败态是 P2-4 的错误出口,取消是「已经没事了」。
|
||||
///
|
||||
/// 抓得住:把 `finished()` 写成「terminated()」(那会把失败态的日志入口一起清掉,用户失去原因)。
|
||||
/// 抓不住:presenter 在取消后是否补发 `InstallProgress(None)`(那是 presenter 的行为)。
|
||||
#[test]
|
||||
fn only_done_and_cancelled_release_the_panel() {
|
||||
let failed = Download::from_snapshot(
|
||||
&snapshot(InstallStage::Failed),
|
||||
Category::All,
|
||||
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());
|
||||
assert!(model.install.is_none(), "{stage:?} 之后面板要让位");
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,14 @@ impl InstallView {
|
||||
self.stage.is_terminal()
|
||||
}
|
||||
|
||||
/// **结束且没什么可说**的收尾:成功(`Done`)与取消(`Cancelled`)。
|
||||
///
|
||||
/// 这两种终态页面没有动作可给,留着只会让安装面板停在一个点不动的状态上(复审 p2 的根因)。
|
||||
/// 失败**不在其中**:它还要给原因出口。
|
||||
pub fn finished(&self) -> bool {
|
||||
matches!(self.stage, InstallStage::Done | InstallStage::Cancelled)
|
||||
}
|
||||
|
||||
/// 还在跑(「取消安装」只在这时给)。
|
||||
pub fn running(&self) -> bool {
|
||||
!self.stage.is_terminal()
|
||||
@@ -273,13 +281,21 @@ impl Download {
|
||||
|
||||
/// presenter 快照 → 下载页模型(**唯一的真数据入口**)。
|
||||
///
|
||||
/// 快照里的两个过滤条件(`installed_only` / `download_query`)原样照用:它们是 presenter 的
|
||||
/// 状态,页面不该另存一份;过滤后为空就是「空态」(`loaded` 仍为 `true`)。
|
||||
pub fn from_snapshot(state: &AppState, catalog: Catalog) -> Self {
|
||||
/// 三个过滤条件都是「过滤」,不是「乐观回显」:
|
||||
/// - `installed_only` / `download_query` 是 presenter 自己的状态,页面不该另存一份;
|
||||
/// - `category` 是**本页的过滤条件**(参数传进来,调用方持有它):真 host 的
|
||||
/// `AppCommand::ListVersions(_)` 会丢掉分类参数、回全量清单(见 `host/dispatch.rs`),
|
||||
/// 所以分类**必须在页面这一侧过滤**,否则 chip 只是点亮一下、列表照旧是全量(复审 P2-1)。
|
||||
/// 过滤谓词与 chip 的选中判断是同一个 [`Category::matches`],于是「chip 说的」与「列表画的」
|
||||
/// 永远是同一份真相,事件回来重建也不会跳回「全部」。
|
||||
///
|
||||
/// 过滤后为空就是「空态」(`loaded` 仍为 `true`)。
|
||||
pub fn from_snapshot(state: &AppState, category: Category, catalog: Catalog) -> Self {
|
||||
let query = state.download_query.trim().to_ascii_lowercase();
|
||||
let rows = state
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| category.matches(version.kind))
|
||||
.filter(|version| !state.installed_only || version.installed)
|
||||
.filter(|version| query.is_empty() || version.id.to_ascii_lowercase().contains(&query))
|
||||
.map(|version| VersionRow::of(version, catalog))
|
||||
@@ -288,13 +304,23 @@ impl Download {
|
||||
let install = state
|
||||
.install
|
||||
.as_ref()
|
||||
.map(|view| InstallView::of(view, catalog));
|
||||
.map(|view| InstallView::of(view, catalog))
|
||||
// 安装**已经成功结束/被取消**就不再占用面板(复审 p2,本轮的修复点):
|
||||
// presenter 只在 `InstallProgress(None)`(取消指令)时清 `AppState.install`,`Done`
|
||||
// 会一直留在快照里 ⇒ 面板被一个**没有动作**的终态钉死,用户装完第一个版本就再也发不出
|
||||
// 第二次安装(只能重启进程)。界面这里把「结束」当结束:面板让回列表。
|
||||
//
|
||||
// 为什么不连 `Failed` 一起清:失败态带着用户要的东西(停在哪个文件 + 去日志页),
|
||||
// 那正是 P2-4 要它一眼可辨的理由。
|
||||
.filter(|view| !view.finished());
|
||||
// 快照没有「选中」字段:正在安装的那个版本就是「选中」(见模块文档的缺口表)。
|
||||
let selected = install.as_ref().map(|view| view.version.clone());
|
||||
|
||||
Self {
|
||||
rows,
|
||||
category: Category::of(&state.versions),
|
||||
// 分类不再从列表反推(那正是「chip 跳回全部」的来源):谁选的就是谁。
|
||||
// [`Category::of`] 只留给「不经过用户选择」的入口(演示数据的首帧)。
|
||||
category,
|
||||
selected,
|
||||
install,
|
||||
loaded: !state.versions.is_empty(),
|
||||
@@ -317,3 +343,6 @@ impl Download {
|
||||
self.loaded && self.rows.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,62 @@
|
||||
//! 视图模型的判据(从 `model.rs` 外置:那边加上本轮的分类过滤会顶到 400 行)。
|
||||
|
||||
use unlucky_presenter::dto::{AncientKind, AppEvent, CoreEvent, VersionKind, VersionSummary};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use super::{Category, Download};
|
||||
|
||||
fn version(id: &str, kind: VersionKind) -> VersionSummary {
|
||||
VersionSummary {
|
||||
id: id.to_string(),
|
||||
kind,
|
||||
released: "2026-09-20".to_string(),
|
||||
installed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_with(versions: Vec<VersionSummary>) -> AppState {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions)));
|
||||
state
|
||||
}
|
||||
|
||||
/// 分类筛选是**本页的过滤**(复审 P2-1:真 host 的 `ListVersions(_)` 丢掉分类参数、回全量清单)。
|
||||
///
|
||||
/// 抓得住:`from_snapshot` 忘了按分类过滤——那正是「chip 点亮一下、列表照旧全量、事件回来 chip
|
||||
/// 跳回『全部』」的根因;也抓得住「分类被列表反推覆盖」(`Category::of` 回到 `All`)。
|
||||
/// 抓不住:真 host 是否按分类拉清单(`host/dispatch.rs:30` 丢参数,已报 finding)。
|
||||
#[test]
|
||||
fn the_category_filters_the_list_locally() {
|
||||
let state = state_with(vec![
|
||||
version("1.21.9", VersionKind::Release),
|
||||
version("1.21.9-pre3", VersionKind::Snapshot),
|
||||
version("rd-132211", VersionKind::Ancient(AncientKind::Classic)),
|
||||
]);
|
||||
|
||||
let release = Download::from_snapshot(&state, Category::Release, Lang::En.catalog());
|
||||
assert_eq!(
|
||||
release.category,
|
||||
Category::Release,
|
||||
"谁选的就是谁:不从列表反推(反推会把混合列表打回 All)"
|
||||
);
|
||||
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());
|
||||
assert_eq!(all.rows.len(), 3, "「全部」不过滤");
|
||||
}
|
||||
|
||||
/// 分类把行全滤掉时是**空态**,不是加载态(两态文案与位置不同,别混)。
|
||||
///
|
||||
/// 抓得住:把「筛出来是空」当成「还没拉到」。
|
||||
/// 抓不住:用户看到空态之后会怎么操作(那是 UI 层)。
|
||||
#[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());
|
||||
assert!(ancient.rows.is_empty());
|
||||
assert!(!ancient.is_loading(), "快照里有版本 ⇒ 不是加载中");
|
||||
assert!(ancient.is_empty(), "筛掉所有行 ⇒ 空态");
|
||||
}
|
||||
@@ -93,7 +93,7 @@ fn the_snapshot_becomes_the_page_model() {
|
||||
},
|
||||
))));
|
||||
|
||||
let model = Download::from_snapshot(&state, Lang::En.catalog());
|
||||
let model = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
|
||||
|
||||
assert_eq!(model.rows.len(), 2);
|
||||
assert_eq!(model.rows[0].id, "1.21.9");
|
||||
@@ -101,8 +101,8 @@ fn the_snapshot_becomes_the_page_model() {
|
||||
assert!(!model.rows[0].installed);
|
||||
assert_eq!(
|
||||
model.category,
|
||||
Category::Release,
|
||||
"整份列表都是正式版 → 反推出正式版分类"
|
||||
Category::All,
|
||||
"分类是**调用方给的过滤条件**,不再从列表反推(反推正是 chip 跳回「全部」的来源)"
|
||||
);
|
||||
assert_eq!(
|
||||
model.selected.as_deref(),
|
||||
@@ -134,14 +134,14 @@ fn presenter_filters_still_apply() {
|
||||
|
||||
let installed_only = {
|
||||
state.installed_only = true;
|
||||
Download::from_snapshot(&state, Lang::En.catalog())
|
||||
Download::from_snapshot(&state, Category::All, 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, Lang::En.catalog())
|
||||
Download::from_snapshot(&state, Category::All, Lang::En.catalog())
|
||||
};
|
||||
assert_eq!(queried.rows.len(), 2, "版本号片段匹配两个");
|
||||
assert!(queried.rows.iter().all(|row| row.id.starts_with("1.21")));
|
||||
@@ -151,12 +151,12 @@ fn presenter_filters_still_apply() {
|
||||
/// 抓不住:真机上卡在加载态多久(没有超时信号,见模块文档的缺口表)。
|
||||
#[test]
|
||||
fn loading_and_empty_are_different_states() {
|
||||
let loading = Download::from_snapshot(&AppState::new(), Lang::En.catalog());
|
||||
let loading = Download::from_snapshot(&AppState::new(), Category::All, 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, Lang::En.catalog());
|
||||
let empty = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
|
||||
assert!(!empty.is_loading(), "列表拉到了,不是加载中");
|
||||
assert!(empty.is_empty(), "过滤后没有行 = 空态");
|
||||
}
|
||||
@@ -252,8 +252,8 @@ 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, Lang::En.catalog());
|
||||
let zh = Download::from_snapshot(&state, Lang::ZhCn.catalog());
|
||||
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");
|
||||
|
||||
@@ -166,6 +166,7 @@ catalog! {
|
||||
"完整原因在日志页。"
|
||||
),
|
||||
DownloadOpenLogs => ("Open logs", "打开日志"),
|
||||
DownloadDismiss => ("Dismiss", "关闭"),
|
||||
}
|
||||
|
||||
/// 一套语言的目录(不可变;`Copy`,随手传值)。
|
||||
|
||||
Reference in new issue
Block a user