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:
1 parent
89a51a63b1
commit
45544791e5
11 files changed
+622
-56
No files matched your search
@@ -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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 之间切换(不是硬编码同一个串)"
|
||||
);
|
||||
}
|
||||
@@ -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 个版本匹配,但它在别的类型里。"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 个版本匹配,但它在别的类型里。"),
|
||||
];
|
||||
Reference in new issue
Block a user