fix(app-iced): M107 r1 复审五条 p2 —— 搜索与主版本桶一致、chips 可滚、单复数、端到端

复审 r1(p2-5items / fix-then-merge)的五条可达问题,逐条落点:

- **P2-1** 搜索命中数与主版本桶不再自相矛盾:`group::active_bucket` 让第二级在搜索期间
  **自动放宽到「全部」**(选择本身留在模型里,清空搜索后回来),侧栏点亮「全部版本」⇒ 没有幽灵选中;
  `rebuild_after_query` 回填时校验桶仍存在(新桶列表里没有的选择直接丢弃)。
  判据:`a_search_hit_never_lands_on_an_empty_list`(4 分类 × 5 桶值 × 4 查询词的矩阵)+ 出图 11
- **P2-2** chips 档的主版本行套**横向滚动**(显式滚动条),竖栏与 chips 吃同一份 `major_entries`;
  22 桶夹具断言最后一个桶 `1.0` 在列表里,出图 12 看得到滚动条
- **P2-3** 只做搜索这条路径(`rebuild_after_query` + 校验 + app 层测试);`app/state.rs::refresh_view`
  的全量修法**留到 M105 之后**(不在 scope,且 M105 正改同一片文件)——报告里明写
- **P2-4** `1 matches` 修掉:新增 `VersionSearchHitsOne` / `VersionSearchElsewhereOne` 两条单数 key,
  「在别处」用独立标题(不再拿命中数当标题,同屏一个数只报一次)
- **P2-5** 搜索链路补 4 条 **app 层端到端**(键入 → 快照 → 重建 → 列表);变异自证:
  删 `self.state.download_query = query` ⇒ 2 条红;`from_snapshot` 忽略 query ⇒ 8 条红;
  去掉 `active_bucket` 的放宽 ⇒ 4 条红
- 顺带:64 宽图标条每项挂 tooltip(复审建议);`install.rs` 那段自相矛盾的文档订正

门禁:fmt 0 / app-iced lib 431 passed / workspace --exclude unlucky-app 全绿(见报告)
This commit is contained in:
AskaEth committed 2026-11-05 02:01:11 +08:00
1 parent 89a51a63b1
commit 45544791e5
11 files changed
+622 -56

No files matched your search

+135
View File
@@ -191,3 +191,138 @@ impl App {
self.view.download.type_rail_folded = folded;
}
}
#[cfg(test)]
mod tests {
//! 搜索链路的**端到端**断言(复审 P2-5)。
//!
//! 为什么非要在 app 层写:这条链是 `Message → on_page → on_download → AppState.download_query
//! → refresh_view → from_snapshot 过滤`,中间任何一环断掉,页面层测试都看不见(页面只拿到
//! 已经过滤好的模型)。复审原话:「把 `rebuild_after_query` 第一行删掉,现有测试全绿,而搜索
//! 变成假控件」——这几条就是补那个洞的。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_presenter::dto::{AppEvent, Backend, CoreEvent, VersionKind, VersionSummary};
use unlucky_presenter::settings_bridge::SettingsBridge;
use super::*;
/// 只记账、不回事件的后端(同 `app/update/download_wiring.rs`):命令出了 UI 层,状态一个字节
/// 都不许变——否则「本地乐观回显」与「事件回流」终态相同,断言会假绿。
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.0.borrow_mut().push(command);
}
}
fn version(id: &str) -> VersionSummary {
VersionSummary {
id: id.to_string(),
kind: VersionKind::Release,
released: "2026-09-20".to_string(),
installed: false,
}
}
/// 一个装好三行版本的 App(`1.21.9` / `1.20.4` / `1.20.1`)。
fn app() -> App {
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::new(RefCell::new(Vec::new())))),
SettingsBridge::memory_only(),
);
app.state
.apply(AppEvent::Core(CoreEvent::VersionListChanged(vec![
version("1.21.9"),
version("1.20.4"),
version("1.20.1"),
])));
app.refresh_view();
app
}
/// 键入 → 快照 → 列表:一条走完的链。
///
/// 抓得住:`rebuild_after_query` 不再写 `AppState.download_query`(搜索变假控件)、
/// 或 `from_snapshot` 不再按 query 过滤(列表不跟着收窄)。
/// 抓不住:真机上 `text_input` 的按键是否走到这里(那是组件的事,`search_bar` 已接 `on_input`)。
#[test]
fn typing_a_query_reaches_the_state_and_narrows_the_list() {
let mut app = app();
assert_eq!(app.view.download.rows.len(), 3, "初始是全量");
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
assert_eq!(app.state.download_query, "1.20", "查询词落在快照里");
assert_eq!(app.view.download.rows.len(), 2, "列表收窄到两行");
assert_eq!(app.view.download.hits, 2);
assert_eq!(app.view.download.query, "1.20", "模型回显查询词");
// 清空回到全量(`ClearVersionQuery` 那一支)
let _ = app.on_page(pages::Message::ClearVersionQuery);
assert!(app.state.download_query.is_empty());
assert_eq!(app.view.download.rows.len(), 3);
}
/// 查询词**跨事件存活**:再来一次重建,列表仍是收窄的。
///
/// 抓得住:把查询词只写在视图模型里(下一次 `refresh_view` 就丢)——这是「搜索看起来能用、
/// 一来事件就复位」的坏法。
#[test]
fn the_query_survives_a_snapshot_rebuild() {
let mut app = app();
let _ = app.on_page(pages::Message::SetVersionQuery("1.21".to_string()));
assert_eq!(app.view.download.rows.len(), 1);
// 安装进度这类事件回来时,`pump()` 会重建视图:查询词必须还在
app.state
.apply(AppEvent::Core(CoreEvent::InstallProgress(None)));
app.refresh_view();
assert_eq!(app.state.download_query, "1.21", "查询词在快照里,重建不丢");
assert_eq!(app.view.download.rows.len(), 1, "列表仍收窄");
}
/// 搜索时侧栏的**位置与折叠**不许丢(`rebuild_after_query` 的三行)。
///
/// 抓得住:删掉那两行回填(敲第一个字符,类型栏自己弹开、主版本跳回「全部」)。
/// 抓不住:其它事件(安装进度)回来时也会复位 —— 那条要 `app/state.rs` 的 `refresh_view`
/// 一起改,不在本 mission scope,已在报告里明确登记「留到 M105 之后」。
#[test]
fn a_query_keeps_the_rail_position_and_the_fold() {
let mut app = app();
app.view.download.selected_major = Some("1.20".to_string());
app.view.download.type_rail_folded = true;
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
assert_eq!(
app.view.download.selected_major.as_deref(),
Some("1.20"),
"桶选择还在(生效值在搜索期间放宽,但选择本身不许被清)"
);
assert!(app.view.download.type_rail_folded, "折叠状态还在");
// 选中一个**新桶列表里不存在**的桶:搜索后不许把它复活成幽灵选中(P2-1(a))
app.view.download.selected_major = Some("9.99".to_string());
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
assert_eq!(
app.view.download.selected_major, None,
"新桶列表里没有 9.99 ⇒ 回填空值,而不是留一个点不亮的幽灵选中"
);
}
}