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 ⇒ 回填空值,而不是留一个点不亮的幽灵选中"
);
}
}
@@ -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<Vec<&
Download::from_snapshot(&state, Category::All, &draft, catalog)
}
/// 22 个主版本桶(`1.21 … 1.0`)的清单:chips 档的横向滚动取证用。
fn many(catalog: Catalog) -> 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`(周更)、
@@ -144,5 +144,26 @@ pub fn buckets(rows: &[super::model::VersionRow]) -> Vec<Bucket> {
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;
+99 -44
View File
@@ -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<MajorEntry<'a>> {
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()
+30 -7
View File
@@ -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
+6 -3
View File
@@ -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`。字形、字级、按钮全走同一套令牌,视觉上仍是空态。
@@ -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)
// --------------------------------------------------------------- 快照 → 模型
@@ -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<VersionSummary> = (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"
);
}
@@ -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<VersionSummary>, 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<VersionSummary> {
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 之间切换(不是硬编码同一个串)"
);
}
+7
View File
@@ -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 个版本匹配,但它在别的类型里。"
),
} }
};
}
+4
View File
@@ -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 个版本匹配,但它在别的类型里。"),
];