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());
|
||||
|
||||
Reference in new issue
Block a user