diff --git a/crates/app-iced/src/app/update/download.rs b/crates/app-iced/src/app/update/download.rs index fec0571..7996fcd 100644 --- a/crates/app-iced/src/app/update/download.rs +++ b/crates/app-iced/src/app/update/download.rs @@ -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>>); + + impl Backend for Recorder { + fn drain_events(&mut self) -> Vec { + 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 ⇒ 回填空值,而不是留一个点不亮的幽灵选中" + ); + } +} diff --git a/crates/app-iced/src/pages/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs index 0592b69..63d9b38 100644 --- a/crates/app-iced/src/pages/download/demo.rs +++ b/crates/app-iced/src/pages/download/demo.rs @@ -104,6 +104,12 @@ pub fn demo(catalog: Catalog) -> Download { Some("search-major") => majored(searched(catalog, Category::All, ""), "1.21"), Some("search-empty") => searched(catalog, Category::All, "zzz"), Some("search-elsewhere") => searched(catalog, Category::Release, "24w"), + // 复审 P2-1 的取样点:**选中 1.21 桶的同时搜 1.20**。修好之后列表里是 2 行命中、 + // 侧栏点亮「全部版本」(第二级在搜索期间放宽),不会出现「2 个匹配 + 没有版本」。 + Some("search-over-bucket") => majored(searched(catalog, Category::All, "1.20"), "1.21"), + // 复审 P2-2 的取样点:**22 个主版本桶**(MOJANG 清单里 `Release` 就是这个量级)。 + // chips 档一行放不下 ⇒ 主版本行必须能横向滚,否则后面的桶永远点不到。 + Some("many-buckets") => many(catalog), Some("installing-empty") => Download { install: Some(install(InstallStage::Downloading, 0.42, catalog)), ..Download::loading() @@ -163,6 +169,23 @@ fn options(catalog: Catalog, version: &str, loader: Loader, cached: Option Download { + let versions = (0..=21) + .rev() + .map(|minor| VersionSummary { + id: format!("1.{minor}.4"), + kind: VersionKind::Release, + released: "2026-09-20".to_string(), + installed: false, + }) + .collect(); + + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions))); + Download::from_snapshot(&state, Category::All, &InstallDraft::default(), catalog) +} + /// 演示用的版本表(`ready` 与 `finished` 共用;与 presenter 的 `mock_versions` 同形)。 /// /// 四种形态各有样本:`1.21.9`(正式)、`1.21.9-pre3`(预发布)、`24w14a`(周更)、 diff --git a/crates/app-iced/src/pages/download/group.rs b/crates/app-iced/src/pages/download/group.rs index d166ee0..ac50acf 100644 --- a/crates/app-iced/src/pages/download/group.rs +++ b/crates/app-iced/src/pages/download/group.rs @@ -144,5 +144,26 @@ pub fn buckets(rows: &[super::model::VersionRow]) -> Vec { grouped } +/// 第二级侧栏此刻**真正生效**的桶(P2-1 的口径)。 +/// +/// 搜索时一律放宽到「全部」([`None`]):命中数 `hits` 是**跨桶**读数,若列表还按桶过滤, +/// 就会出现「N matches + 0 versions + 没有版本」的自相矛盾一屏(复审 P2-1)。 +/// 放宽之后「列表行数 = 当前分类里的命中数」,侧栏也不会有「选中项不在桶列表里」的幽灵态。 +/// `selected_major` 一直留在模型里,只是搜索期间不生效——清空搜索后原来的桶自动回来。 +#[must_use] +pub fn active_bucket<'a>(model: &'a super::model::Download) -> Option<&'a str> { + if model.has_query() { + None + } else { + model.selected_major.as_deref() + } +} + +/// 第二级侧栏里这一项该不该点亮(`None` = 「全部版本」那一项)。 +#[must_use] +pub fn bucket_selected(model: &super::model::Download, key: Option<&str>) -> bool { + active_bucket(model) == key +} + #[cfg(test)] mod tests; diff --git a/crates/app-iced/src/pages/download/rails.rs b/crates/app-iced/src/pages/download/rails.rs index 3b917e8..0aa3b8d 100644 --- a/crates/app-iced/src/pages/download/rails.rs +++ b/crates/app-iced/src/pages/download/rails.rs @@ -24,7 +24,8 @@ //! `buttons::icon`):焦点由 iced 的焦点系统给,Tab 依次走「类型栏 → 主版本栏 → 搜索框 → 版本行 → //! 安装」,Enter 激活。页面不自己写焦点环(那是组件的状态层)。 -use iced::widget::{column, container, row, scrollable}; +use iced::widget::tooltip::Position; +use iced::widget::{column, container, row, scrollable, tooltip}; use iced::{Alignment, Element, Length, Padding}; use unlucky_design::color::Role; @@ -45,7 +46,7 @@ use super::super::frame; use super::super::layout::{VersionRails, RAIL_ICON_WIDTH}; use super::super::Message; use super::model::{Category, Download}; -use super::rows; +use super::{group, rows}; /// 类型栏里一项的图标。 /// @@ -135,17 +136,13 @@ fn type_rail<'a>( let press = Message::SetCategory(category); body = body.push(if icon_only { - // 图标条:40×40 图标按钮(命中区 48 由栏内留白补足),选中 = `Tonal` - container(buttons::icon( - ui, - select_variant(selected), - category_icon(category), - Some(press), - None, - )) - .width(Length::Fill) - .align_x(Alignment::Center) - .into() + // 图标条:40×40 图标按钮(命中区 48 由栏内留白补足),选中 = `Tonal`。 + // 64 宽放不下标签,所以每一项都挂 tooltip(复审建议):四个图标既不说话也没有标题时, + // 用户只能靠猜。tooltip 的容器色/圆角走令牌,不新造样式。 + container(labeled(ui, label, category_icon(category), selected, press)) + .width(Length::Fill) + .align_x(Alignment::Center) + .into() } else { let mut item = ListItem::new(label, press).leading(category_icon(category)); if selected { @@ -158,6 +155,32 @@ fn type_rail<'a>( body.into() } +/// 带 tooltip 的图标项(图标条用)。tooltip 位置在右侧——它是竖栏,右边就是内容区, +/// 弹在下面会盖住下一项。 +fn labeled<'a>( + ui: &'a Ui, + label: &'a str, + kind: IconKind, + selected: bool, + press: Message, +) -> Element<'a, Message> { + let button = buttons::icon(ui, select_variant(selected), kind, Some(press), None); + tooltip( + button, + container(frame::text_role( + ui, + TypeRole::BodySmall, + false, + label, + ui.color(Role::OnSurface), + )) + .padding(ui.space(Space::X2)) + .style(move |_theme| frame::filled(ui.layer(MaterialLayer::Overlay))), + Position::Right, + ) + .into() +} + /// 选中态 → 图标按钮的三式(`Tonal` = 当前项,与 ListItem 的 `SecondaryContainer` 同色系)。 fn select_variant(selected: bool) -> IconVariant { if selected { @@ -231,37 +254,63 @@ fn major_rail<'a>( )); } - body = body.push(major_item( - ui, - catalog.text(Key::VersionMajorAll), - Message::SelectMajor(None), - model.selected_major.is_none(), - )); + for entry in major_entries(catalog, model) { + body = body.push(major_item(ui, entry.label, entry.key, entry.selected)); + } + + body.into() +} + +/// 第二级侧栏的一项(「全部版本」与每个主版本桶共用一套结构,好让**两条渲染路径** +/// ——竖栏与 chips 行 —— 都用同一份数据,测试也只钉这一份)。 +/// +/// 字段全部**借**模型里的桶标签与目录里的静态串:这两条渲染路径都要把标签塞进 iced 控件树, +/// 借本地的 `String` 会让整棵树的生命周期塌到函数作用域里(E0515)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct MajorEntry<'a> { + /// 选中它要发的键(`None` = 「全部版本」) + pub key: Option<&'a str>, + /// 画出来的标签 + pub label: &'a str, + /// 此刻是否选中(**按生效值算**:搜索期间恒为「全部」,见 `group::bucket_selected`) + pub selected: bool, +} + +/// 第二级侧栏要画的全部项:`[全部版本, 1.21, 1.20, …]`,顺序 = 桶的版本序。 +/// +/// 抽成纯函数是为了让「最后一个桶可点」这件事**可断言**(复审 P2-2 的要求): +/// 竖栏与 chips 行都从这一份数据画,chips 行外面还套了横向滚动,所以「画得出来」= 「够得着」。 +/// 返回 `Vec` 而不是迭代器:调用方两处都要 len(chips 的宽度预算)与随机访问。 +pub(super) fn major_entries<'a>(catalog: Catalog, model: &'a Download) -> Vec> { + let mut entries = vec![MajorEntry { + key: None, + label: catalog.text(Key::VersionMajorAll), + selected: group::bucket_selected(model, None), + }]; for bucket in &model.buckets { // `None` 桶 = 远古版那类没有主版本的版本:第二级只有一个「全部」,不画空桶 let Some(key) = bucket.key.as_deref() else { continue; }; - body = body.push(major_item( - ui, - key, - Message::SelectMajor(Some(key.to_string())), - model.selected_major.as_deref() == Some(key), - )); + entries.push(MajorEntry { + key: Some(key), + label: key, + selected: group::bucket_selected(model, Some(key)), + }); } - body.into() + entries } /// 主版本的一项:`ListItem` 单行(64 宽里 `1.21` 仍完整——`BodyLarge` 4 字符 ≈ 32px ≤ 64−32 内边距)。 fn major_item<'a>( ui: &'a Ui, label: &'a str, - press: Message, + key: Option<&str>, selected: bool, ) -> Element<'a, Message> { - let mut item = ListItem::new(label, press); + let mut item = ListItem::new(label, Message::SelectMajor(key.map(str::to_string))); if selected { item = item.selected(); } @@ -288,32 +337,38 @@ pub(super) fn stacked_chips<'a>( types = types.tab(tab); } + // 主版本 chips:**从同一份 `major_entries` 画**,并且整行套横向滚动(复审 P2-2)。 + // + // 为什么必须能滚:真实清单里 `Release` 就有 ~20 个桶(1.21…1.7),chips 档的可用宽不到 600、 + // 一行只放得下 5~6 个。没有横向滚动时后面的桶**永远点不到**——而这正是「主版本下拉」要解决的 + // 那件事(设计系统里没有 dropdown 组件,所以用可横向滚动的 chips 行承担同一职责)。 let mut majors = row![] .spacing(ui.space(Space::X2)) .align_y(Alignment::Center); - majors = majors.push(chip( - ui, - Chip::filter( - catalog.text(Key::VersionMajorAll), - model.selected_major.is_none(), - Message::SelectMajor(None), - ), - )); - for bucket in &model.buckets { - let Some(key) = bucket.key.as_deref() else { - continue; - }; + for entry in major_entries(catalog, model) { majors = majors.push(chip( ui, Chip::filter( - key, - model.selected_major.as_deref() == Some(key), - Message::SelectMajor(Some(key.to_string())), + entry.label, + entry.selected, + Message::SelectMajor(entry.key.map(str::to_string)), ), )); } - column![tabs(ui, types), majors] + let strip = scrollable(majors) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new() + .width(frame::SCROLLBAR_WIDTH) + .scroller_width(frame::SCROLLBAR_WIDTH) + .margin(frame::SCROLLBAR_MARGIN), + )) + .height(Length::Fixed( + ui.metrics().control_height + ui.space(Space::X2), + )) + .style(frame::scroll_style(ui)); + + column![tabs(ui, types), strip] .spacing(ui.space(Space::X2)) .width(Length::Fill) .into() diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index 0928cb6..31f6bc2 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -118,10 +118,7 @@ pub(super) fn search_row<'a>( ui, TypeRole::LabelMedium, false, - catalog.render( - Key::VersionSearchHits, - &[("count", &model.hits.to_string())], - ), + hits_text(catalog, model.hits), ui.color(Role::OnSurfaceVariant), )); line = line.push(buttons::icon( @@ -136,6 +133,29 @@ pub(super) fn search_row<'a>( line.into() } +/// 命中数的文案:`1` 与 `>1` 是两条 key(英文的 `1 matches` 是错的,复审 P2-4)。 +/// +/// 中文两侧都是「个匹配」,但**不能**因此只留一条 key:英文的复数规则要在目录里可读, +/// 而且将来加第三种语言时这条分支已经在那儿了。 +pub(super) fn hits_text(catalog: Catalog, count: usize) -> String { + let key = if count == 1 { + Key::VersionSearchHitsOne + } else { + Key::VersionSearchHits + }; + catalog.render(key, &[("count", &count.to_string())]) +} + +/// 「命中在别的类型里」的正文(同样单复数两条 key)。 +pub(super) fn elsewhere_text(catalog: Catalog, count: usize) -> String { + let key = if count == 1 { + Key::VersionSearchElsewhereOne + } else { + Key::VersionSearchElsewhere + }; + catalog.render(key, &[("count", &count.to_string())]) +} + /// 计数头(`LabelMedium`):一行 = 一个 key,数字走占位符,不拼片段。 pub(super) fn head<'a>(ui: &'a Ui, catalog: Catalog, count: usize) -> Element<'a, Message> { frame::text_role_owned( @@ -147,9 +167,12 @@ pub(super) fn head<'a>(ui: &'a Ui, catalog: Catalog, count: usize) -> Element<'a ) } -/// 当前可见的行:先按分类(模型已过滤),再按第二级侧栏选中的主版本桶。 -fn visible(model: &Download) -> Vec<&VersionRow> { - match model.selected_major.as_deref() { +/// 当前可见的行:先按分类(模型已过滤),再按第二级侧栏**此刻生效**的主版本桶。 +/// +/// 桶的生效值来自 [`super::group::active_bucket`]:搜索期间它恒为「全部」,于是 +/// 「命中数说 N 个、列表却 0 行」这种自相矛盾在结构上不可能出现(复审 P2-1)。 +pub(super) fn visible(model: &Download) -> Vec<&VersionRow> { + match super::group::active_bucket(model) { None => model.rows.iter().collect(), Some(key) => model .rows diff --git a/crates/app-iced/src/pages/download/states.rs b/crates/app-iced/src/pages/download/states.rs index d0f09fb..42215b3 100644 --- a/crates/app-iced/src/pages/download/states.rs +++ b/crates/app-iced/src/pages/download/states.rs @@ -118,10 +118,13 @@ pub(super) fn no_match<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> /// **有命中但不在当前分类**:说清「N 个在别的类型里」+ 一个「看全部类型」的出口。 /// /// 不给出口的话,用户只能自己猜「是不是要切到快照 tab」——这正是本条的验收口径。 +/// +/// 标题**不再复用命中数那条 key**(复审 P2-4):原来同一个数在同屏报三遍 +/// (右上角「1 matches」+ 标题「1 matches」+ 正文「1 versions match」),而且单数时英文是错的。 +/// 现在标题是一条固定文案,正文的单复数由 [`super::rows::elsewhere_text`] 选 key。 pub(super) fn elsewhere<'a>(ui: &'a Ui, catalog: Catalog, hits: usize) -> Element<'a, Message> { - let count = hits.to_string(); - let title = catalog.render(Key::VersionSearchHits, &[("count", count.as_str())]); - let body = catalog.render(Key::VersionSearchElsewhere, &[("count", count.as_str())]); + let title = catalog.text(Key::VersionSearchElsewhereTitle).to_owned(); + let body = super::rows::elsewhere_text(catalog, hits); // 手搭而不是用 `EmptyState`:它的标题/正文借 `&'a str`,而这里的句子是 `render()` 出来的 // `String`(带命中数),借不到 `'a`。字形、字级、按钮全走同一套令牌,视觉上仍是空态。 diff --git a/crates/app-iced/src/pages/download/tests.rs b/crates/app-iced/src/pages/download/tests.rs index b2dde68..f43405b 100644 --- a/crates/app-iced/src/pages/download/tests.rs +++ b/crates/app-iced/src/pages/download/tests.rs @@ -97,6 +97,10 @@ fn extra_width_goes_to_the_page_block_not_to_the_prose_column() { mod rails; +// --------------------------------------------------------------- 搜索一致性 / 复数(见 `tests/search.rs`) + +mod search; + // --------------------------------------------------------------- 文案(i18n) // --------------------------------------------------------------- 快照 → 模型 diff --git a/crates/app-iced/src/pages/download/tests/rails.rs b/crates/app-iced/src/pages/download/tests/rails.rs index a6ae060..e12866e 100644 --- a/crates/app-iced/src/pages/download/tests/rails.rs +++ b/crates/app-iced/src/pages/download/tests/rails.rs @@ -2,7 +2,13 @@ //! //! 从 `download/tests.rs` 拆出来:那边已经贴着 400 行硬线。 -use crate::pages::download::Category; +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{AppEvent, CoreEvent, VersionKind, VersionSummary}; +use unlucky_presenter::state::AppState; + +use crate::pages::download::options::InstallDraft; +use crate::pages::download::rails::major_entries; +use crate::pages::download::{Category, Download}; use crate::pages::layout::{available_width, VersionRailTier, VersionRails, RAIL_GAP}; /// 三栏要吃掉整条可用宽(不是 424 的居中正文列)——三个宽度档各取一个采样点。 @@ -63,8 +69,10 @@ fn both_rail_levels_exist_and_the_selection_reaches_the_list() { rails_source.contains("Message::SetCategory"), "第一级(版本类型)没有可点的消息" ); + // 列表按第二级的**生效值**过滤(搜索期间放宽到「全部」,见 `group::active_bucket`): + // 这两样只要有一样不在,第二级点了就等于没反应。 assert!( - rows_source.contains("selected_major") && rows_source.contains("fn visible"), + rows_source.contains("fn visible") && rows_source.contains("group::active_bucket"), "列表没有按第二级选中态过滤" ); assert_eq!(Category::ALL.len(), 4, "第一级还是原来那四类,不许新造分类"); @@ -79,3 +87,69 @@ fn the_chips_tier_gives_the_whole_width_to_the_list() { assert_eq!(rails.major_rail, 0.0); assert_eq!(rails.list, 564.0); } + +/// **Chips 档的最后一个桶也要够得着**(复审 P2-2)。 +/// +/// 夹具按真实清单的量级造:`1.21 … 1.0` 共 **22 个桶**(`Release` 在 MOJANG 清单里就是这个量级)。 +/// 三条一起钉: +/// 1. `major_entries` 把**每一个桶**都吐出来(含最后一个 `1.0`)——渲染两条路径都吃这一份数据; +/// 2. chips 行外面套了 `Direction::Horizontal` 的滚动(源码层,删掉那一层就够不着后面的桶); +/// 3. 选中态按**生效值**算:搜索期间「全部版本」点亮(P2-1 的放宽)。 +/// +/// 抓得住:把 chips 行的横向滚动拿掉(第 2 条红);或让 `major_entries` 在某个桶处截断(第 1 条红)。 +/// 抓不住:滚动条在真机上好不好拖(那要出图 + 手感,报告里登记为未验)。 +#[test] +fn every_bucket_is_reachable_in_the_chips_tier() { + let versions: Vec = (0..=21) + .rev() + .map(|minor| VersionSummary { + id: format!("1.{minor}.4"), + kind: VersionKind::Release, + released: "2026-09-20".to_string(), + installed: false, + }) + .collect(); + + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions))); + + let catalog = Lang::En.catalog(); + let mut model = + Download::from_snapshot(&state, Category::All, &InstallDraft::default(), catalog); + assert_eq!(model.buckets.len(), 22, "夹具应有 22 个主版本桶"); + + let entries = major_entries(catalog, &model); + assert_eq!(entries.len(), 23, "「全部版本」+ 22 个桶"); + assert_eq!( + entries.last().and_then(|entry| entry.key), + Some("1.0"), + "最后一个桶(版本序最老的那个)必须在列表里,否则它永远点不到" + ); + assert_eq!(entries[1].label, "1.21", "第一条桶是版本序最新的"); + + // 选中态按生效值:选中 1.0 时那一项点亮;搜索期间整体放宽到「全部」 + model.selected_major = Some("1.0".to_string()); + let entries = major_entries(catalog, &model); + assert!(entries.last().expect("有最后一项").selected); + state.download_query = "1.0".to_string(); + let mut searching = + Download::from_snapshot(&state, Category::All, &InstallDraft::default(), catalog); + searching.selected_major = Some("1.0".to_string()); + let entries = major_entries(catalog, &searching); + assert!(entries[0].selected, "搜索期间「全部版本」点亮(P2-1)"); + assert!( + entries.iter().skip(1).all(|entry| !entry.selected), + "搜索期间不许留下幽灵选中" + ); + + // chips 行的横向滚动是「够得着」的实现手段 + let source = include_str!("../rails.rs"); + assert!( + source.contains("Direction::Horizontal"), + "chips 档的主版本行必须可横向滚动(否则 22 个桶里大部分点不到)" + ); + assert!( + source.contains("major_entries(catalog, model)"), + "竖栏与 chips 行必须吃同一份 entries" + ); +} diff --git a/crates/app-iced/src/pages/download/tests/search.rs b/crates/app-iced/src/pages/download/tests/search.rs new file mode 100644 index 0000000..b6b1a78 --- /dev/null +++ b/crates/app-iced/src/pages/download/tests/search.rs @@ -0,0 +1,217 @@ +//! 搜索 × 主版本桶的一致性判据(复审 P2-1)+ 复数口径(P2-4)。 +//! +//! 这两条都是「一屏之内两句话打架」那类问题:命中数说 N 个、列表却 0 行;或者英文写 `1 matches`。 +//! 所以断言都写成**不变式**(对一张输入矩阵扫),而不是挑一个例子。 + +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{AppEvent, CoreEvent, VersionKind, VersionSummary}; +use unlucky_presenter::state::AppState; + +use super::super::group; +use super::super::model::{Category, Download}; +use super::super::options::InstallDraft; +use super::super::rows; +use super::version; + +fn state_with(versions: Vec, query: &str) -> AppState { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions))); + state.download_query = query.to_string(); + state +} + +/// 一份带三个主版本桶的清单(`1.21` / `1.20` / `24w`)。 +fn three_buckets() -> Vec { + vec![ + version("1.21.9", VersionKind::Release, false), + version("1.21.8", VersionKind::Release, true), + version("1.20.4", VersionKind::Release, true), + version("1.20.1", VersionKind::Release, false), + version("24w14a", VersionKind::Snapshot, false), + ] +} + +/// **P2-1 的核心不变式**:搜索有命中时,列表要么有行,要么落到「命中在别的类型里」那条口径。 +/// +/// 扫「四个分类 × 五个桶值 × 四个查询词」:任何一格出现「hits > 0 且可见行为空 **且** +/// `only_elsewhere()` 为假」就是自相矛盾的一屏——那正是复审复现的形态:选中 `1.21` 后搜 `1.20`, +/// `rows` 非空所以 `only_elsewhere()` 也是假,于是页面落到「没有版本」的空态,而同屏写着「2 matches」。 +/// +/// (分类这一层本来就有 `only_elsewhere()`:站在「正式版」搜 `24w` 时列表空是**对的**, +/// 页面会说「N 个命中在别的类型里」并给出口——那不是矛盾,所以判据里要把它排除。) +/// +/// 抓得住:把 `group::active_bucket` 的搜索放宽去掉(那一行删掉,这一格立刻红)。 +/// 抓不住:像素上这两句话是否真的没同屏(那要出图,见 `DevDocs/artifacts/m107/`)。 +#[test] +fn a_search_hit_never_lands_on_an_empty_list() { + // A 组:**有搜索词**。桶可以是任意值(包括「桶列表里不存在」的过期值),分类也随便切—— + // 只要 hits > 0,可见行就不许为空。这一组是 P2-1 的正面判据。 + for category in Category::ALL { + for bucket in [None, Some("1.21"), Some("1.20"), Some("24w"), Some("9.99")] { + for query in ["1.20", "1.21", "24w", "zzz"] { + let state = state_with(three_buckets(), query); + let mut model = Download::from_snapshot( + &state, + category, + &InstallDraft::default(), + Lang::En.catalog(), + ); + model.selected_major = bucket.map(str::to_string); + + let visible = rows::visible(&model); + let contradiction = model.hits > 0 && visible.is_empty() && !model.only_elsewhere(); + assert!( + !contradiction, + "分类 {category:?} / 桶 {bucket:?} / 查询 {query:?}:命中数 {} 但可见行 0 且没有「命中在别的类型」的口径 ⇒ 同屏自相矛盾", + model.hits + ); + } + } + } + + // B 组:**没有搜索词**。只有「桶真的在当前分类里」时才有这条承诺;桶是过期值时列表空是 + // 正确行为(真正要防的是「选中项不在桶列表里」的幽灵态,那由 `rebuild_after_query` 的校验 + // 与 `a_stale_bucket_choice_cannot_hide_the_hits` 两条一起管)。 + for category in Category::ALL { + let state = state_with(three_buckets(), ""); + let model = Download::from_snapshot( + &state, + category, + &InstallDraft::default(), + Lang::En.catalog(), + ); + for bucket in model + .buckets + .iter() + .filter_map(|bucket| bucket.key.as_deref()) + { + let mut filtered = Download::from_snapshot( + &state, + category, + &InstallDraft::default(), + Lang::En.catalog(), + ); + filtered.selected_major = Some(bucket.to_string()); + assert!( + !rows::visible(&filtered).is_empty(), + "分类 {category:?} / 真实桶 {bucket:?}:画出来的桶点下去居然是空列表" + ); + } + } +} + +/// 一个**过期的桶选择**(模型里有个桶列表里不存在的 key)也挡不住搜索命中。 +/// +/// 这种 key 正常路径上不会出现(`rebuild_after_query` 会校验、点选只可能点到真实桶),但它是 +/// 「幽灵选中」的极端形态:搜索结果必须仍然可见,否则用户会看着「N 个命中 + 空列表」。 +#[test] +fn a_stale_bucket_choice_cannot_hide_the_hits() { + let state = state_with(three_buckets(), "1.20"); + let mut model = Download::from_snapshot( + &state, + Category::All, + &InstallDraft::default(), + Lang::En.catalog(), + ); + model.selected_major = Some("9.99".to_string()); + + assert_eq!(model.hits, 2); + assert_eq!(rows::visible(&model).len(), 2, "搜索期间过期桶不生效"); + assert_ne!( + group::active_bucket(&model), + Some("9.99"), + "过期桶不许成为生效值" + ); +} + +/// 搜索期间第二级桶**放宽到「全部」**(`active_bucket` = None),清空后原来的桶回来。 +/// +/// 抓得住:有人「修好」一致性的办法是把 `selected_major` 一起清掉(那会让用户的桶选择丢失, +/// 清空搜索后回不到原位)。 +#[test] +fn searching_relaxes_the_bucket_without_losing_the_choice() { + let state = state_with(three_buckets(), "1.20"); + let mut model = Download::from_snapshot( + &state, + Category::All, + &InstallDraft::default(), + Lang::En.catalog(), + ); + model.selected_major = Some("1.21".to_string()); + + assert!(model.has_query()); + assert_eq!( + model.selected_major.as_deref(), + Some("1.21"), + "选择本身留着" + ); + assert_eq!( + group::active_bucket(&model), + None, + "搜索期间生效值是「全部」" + ); + assert_eq!(rows::visible(&model).len(), 2, "两个 1.20 的行都看得见"); + assert!(group::bucket_selected(&model, None), "侧栏点亮「全部版本」"); + + // 清空搜索:生效值回到用户原来选的那个桶,而它还在(buckets 由全量行聚合) + let state = state_with(three_buckets(), ""); + let mut model = Download::from_snapshot( + &state, + Category::All, + &InstallDraft::default(), + Lang::En.catalog(), + ); + model.selected_major = Some("1.21".to_string()); + assert_eq!(group::active_bucket(&model), Some("1.21")); + assert_eq!(rows::visible(&model).len(), 2, "1.21 桶里两个正式版"); +} + +/// 没有搜索词时第二级桶照常过滤(这条不许被「放宽」误伤)。 +#[test] +fn without_a_query_the_bucket_still_filters() { + let state = state_with(three_buckets(), ""); + let mut model = Download::from_snapshot( + &state, + Category::All, + &InstallDraft::default(), + Lang::En.catalog(), + ); + model.selected_major = Some("1.20".to_string()); + + assert_eq!(rows::visible(&model).len(), 2); + assert!(!group::bucket_selected(&model, None), "「全部」不点亮"); + assert!(group::bucket_selected(&model, Some("1.20"))); +} + +/// 复数口径:`1` 走单数 key,`>1` 走复数 key(复审 P2-4)。 +/// +/// 抓得住:把 `hits_text` 的 `count == 1` 分支删掉(英文重新变成「1 matches」)。 +/// 抓不住:中文的「个」是否自然(中文两边同形,由译审看)。 +#[test] +fn the_search_counts_are_pluralised() { + let en = Lang::En.catalog(); + let zh = Lang::ZhCn.catalog(); + + assert_eq!(rows::hits_text(en, 1), "1 match"); + assert_eq!(rows::hits_text(en, 2), "2 matches"); + assert_eq!(rows::hits_text(en, 0), "0 matches"); + assert_eq!(rows::hits_text(zh, 1), "1 个匹配"); + + assert_eq!( + rows::elsewhere_text(en, 1), + "1 version matches, but it is in another type." + ); + assert_eq!( + rows::elsewhere_text(en, 3), + "3 versions match, but they are in another type." + ); + assert_eq!( + rows::elsewhere_text(zh, 1), + "有 1 个版本匹配,但它在别的类型里。" + ); + assert_ne!( + rows::elsewhere_text(zh, 1), + rows::elsewhere_text(zh, 3), + "中文也在两套 key 之间切换(不是硬编码同一个串)" + ); +} diff --git a/crates/i18n/src/catalog/download.rs b/crates/i18n/src/catalog/download.rs index b47d397..d6073cc 100644 --- a/crates/i18n/src/catalog/download.rs +++ b/crates/i18n/src/catalog/download.rs @@ -123,6 +123,13 @@ macro_rules! version_entries { "有 {count} 个版本匹配,但它们在别的类型里。" ), VersionSearchElsewhereAction => ("Show all types", "看全部类型"), + // --- M107 r2:复数口径与「在别处」的标题(追加在域尾 ⇒ `Key::ALL` 顺序只增不动) --- + VersionSearchHitsOne => ("1 match", "1 个匹配"), + VersionSearchElsewhereTitle => ("Found in another type", "在别的类型里找到了"), + VersionSearchElsewhereOne => ( + "1 version matches, but it is in another type.", + "有 1 个版本匹配,但它在别的类型里。" + ), } } }; } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 6c2eea2..f6a7381 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -159,4 +159,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"), ("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"), ("VersionSearchElsewhereAction", "Show all types", "看全部类型"), + // --- M107 r2(追加在末尾:`Key::ALL` 顺序 = 金表顺序) --- + ("VersionSearchHitsOne", "1 match", "1 个匹配"), + ("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"), + ("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"), ];