feat(app-iced): M107 DLVRAIL —— 版本管理页三栏侧栏 + 跨类型搜索

用户令(信息架构):这一页从「下载」改称「版本管理」,版面从「一排 tab + 平铺列表」
改成**页面内三栏**:版本类型栏 + 主版本栏 + 列表/详情,并加搜索框。

- **三栏几何**(`pages/layout/rails.rs`,纯函数 + 数值表):类型栏 140/64、主版本栏 140/64、
  间距 24、列表列下限 424(行宽红线)。四档由**可用宽**裁决:Wide ≥888 / Narrow ≥676 /
  Compact ≥600 / Chips <600,三个门槛都是 `栏宽 + 间距 + 424` 算出来的,不是拍的
- **主版本分组规则**(`download/group.rs`):正式版/预发布按 `major.minor`(`1.21.9-pre3` → `1.21`)、
  周更按年桶(`24w14a` → `24w`)、远古版**没有主版本**(`rd-132211`/`a1.2.6` → 第二级只有「全部」);
  判据用真实版本样本钉住,不按「第一个点」切
- **跨类型搜索**(`search_bar` + 清空 + 命中数):查询词写进 `AppState.download_query`(页面无状态,
  只能这么活),`hits` 是**跨分类**读数;三条口径分开:「有命中」/「没有匹配」/「命中在别的类型里」
- 两级侧栏都用 `navigation::list_item` / `tabs` / `chip` / `buttons::icon`:Tab 可达、hover/pressed/
  选中态全部来自组件,页面不自己写状态层
- 右栏展开(M104 `content_width` 852)⇒ 可用宽 716 ⇒ Narrow 档,列表 464(任务书那句 492 漏扣
  96px 的页边距与栏间距,报告里给了完整数值表)
- 文案 11 条中英进 `crates/i18n`(新域宏接在装配链末尾 ⇒ `Key::ALL` 只追加,金表只追加)
- 护栏:两层侧栏都存在 + 列表按第二级选中态过滤(变异可证)、三档门槛、424 下限扫描、
  版本行单行宽账、搜索条真的装着
- `install.rs` 只动了「已装」分支(加一句「已安装」解释禁用按钮),没碰 M105 的选项区

取证:`DevDocs/artifacts/m107/`(10 张:三档 × 浅深 + 搜索命中 + 空 + 异地 + 远古版)
This commit is contained in:
AskaEth committed 2026-11-05 01:59:13 +08:00
1 parent 72b0c9d465
commit 89a51a63b1
24 files changed
+2000 -340

No files matched your search

+112 -140
View File
@@ -1,7 +1,7 @@
//! 下载页的判据:**快照 → 模型 → 控件树**三段各自的断言。
//! 版本管理页的判据:**快照 → 模型 → 控件树**三段各自的断言。
//!
//! 每条都写清「抓得住什么 / 抓不住什么」——这里只测纯转换与树能构建,**像素**由报告里的
//! 四张图与数值表负责(同 `pages/tests.rs` 的口径)。
//! 每条都写清「抓得住什么 / 抓不住什么」——这里只测纯转换与树能构建,**像素**由
//! `DevDocs/artifacts/m107/` 的十张图与报告的量测表负责(同 `pages/tests.rs` 的口径)。
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
@@ -14,8 +14,7 @@ use unlucky_presenter::state::AppState;
use crate::theme::{Families, Ui};
use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit};
use super::super::Message;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit};
use super::model::{Category, Download};
use super::options::InstallDraft;
@@ -46,19 +45,32 @@ fn state_with(versions: Vec<VersionSummary>) -> AppState {
state
}
// --------------------------------------------------------------- 版面(M84 的三条,M94 一条没动)
/// 一份覆盖四种版本形态的清单(`1.21.x` / `24w14a` / `rd-132211` / `a1.2.6`)。
fn mixed() -> 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("24w14a", VersionKind::Snapshot, false),
version(
"rd-132211",
VersionKind::Ancient(AncientKind::Classic),
false,
),
]
}
/// 单列页:任何窗口宽度都不许出现第二列,正文列不许超过行宽上限。
// --------------------------------------------------------------- 版面
/// 页面仍是单列页(页内三栏是**页内**的事,不改 `PaneKit`):任何宽度都不出现「页面级第二列」。
///
/// 抓得住:`layout` 把下载页切到两列、或把正文列放过 424。
/// 抓不住:页面内部又套了一层更宽的容器(那要看真像素,见报告的量测表)。
/// 抓得住:有人把 `Screen::Download` 的 `kit()` 改成 `ListDetail`(那会让页头与页边距跟着变)。
/// 抓不住:页内三栏的宽度(那由 `layout/rails/tests.rs` 的数值表钉住)。
#[test]
fn download_is_single_column_at_every_width() {
for width in [600.0, 840.0, 900.0, 1180.0, 2400.0] {
fn the_page_kit_is_still_single() {
for width in [700.0, 900.0, 1024.0, 1180.0, 2400.0] {
let metrics = frame_metrics(PaneKit::Single, width);
assert!(metrics.secondary.is_none(), "{width} 宽下载页出现了第二列");
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
assert_eq!(metrics.column, content_column_width(width));
assert!(metrics.secondary.is_none(), "{width} 出现了页面级第二列");
}
}
@@ -81,10 +93,15 @@ fn extra_width_goes_to_the_page_block_not_to_the_prose_column() {
);
}
// --------------------------------------------------------------- 两层侧栏(见 `tests/rails.rs`)
mod rails;
// --------------------------------------------------------------- 文案(i18n)
// --------------------------------------------------------------- 快照 → 模型
/// 真快照进得来:行数、分类、选中项、已装标记全部来自 `AppState`。
/// 抓不住:快照字段在 `app/view.rs` 被漏接(那由 `app/update.rs` 的接线测试兜)。
/// 真快照进得来:行数、分类、选中项、已装标记、主版本桶全部来自 `AppState`。
#[test]
fn the_snapshot_becomes_the_page_model() {
let mut state = state_with(vec![
@@ -114,32 +131,23 @@ fn the_snapshot_becomes_the_page_model() {
assert_eq!(model.rows.len(), 2);
assert_eq!(model.rows[0].id, "1.21.9");
assert!(model.rows[1].installed, "快照说装了就是装了");
assert!(!model.rows[0].installed);
assert_eq!(
model.category,
Category::All,
"分类是**调用方给的过滤条件**,不再从列表反推(反推正是 chip 跳回「全部」的来源)"
);
assert_eq!(model.category, Category::All);
assert_eq!(
model.selected.as_deref(),
Some("1.21.9"),
"正在安装的版本 = 选中行"
);
assert!(model.loaded, "有版本 = 已加载");
assert_eq!(model.buckets.len(), 1, "两个版本同一个主版本桶");
assert_eq!(model.buckets[0].key.as_deref(), Some("1.21"));
assert_eq!(model.hits, 2, "没有搜索词时命中数 = 行数");
let install = model.install.expect("安装进度要转进来");
assert!(install.running(), "Downloading 还没收尾");
assert!(!install.failed());
assert!(install.running());
assert_eq!(install.detail, "128/512 files");
assert_eq!(
install.rate.as_deref(),
Some("1.2 MB/s · 1m15s left"),
"速率与剩余时间走 key,不搬 core 的中文串"
);
}
/// 快照里的两个过滤条件(`installed_only` / `download_query`)原样照用。
/// 抓不住:过滤词大小写之外的语言学规则(那属于 presenter 的搜索口径)。
#[test]
fn presenter_filters_still_apply() {
let mut state = state_with(vec![
@@ -170,11 +178,11 @@ fn presenter_filters_still_apply() {
)
};
assert_eq!(queried.rows.len(), 2, "版本号片段匹配两个");
assert!(queried.rows.iter().all(|row| row.id.starts_with("1.21")));
assert_eq!(queried.query, "1.21", "查询词去掉首尾空格后进模型");
assert_eq!(queried.hits, 2);
}
/// 空快照 = 加载态;过滤掉所有行 = 空态(抓「把还没拉到画成没有版本」,或反过来)。
/// 抓不住:真机上卡在加载态多久(没有超时信号,见模块文档的缺口表)。
/// 空快照 = 加载态;过滤掉所有行 = 空态。
#[test]
fn loading_and_empty_are_different_states() {
let loading = Download::from_snapshot(
@@ -195,6 +203,73 @@ fn loading_and_empty_are_different_states() {
);
assert!(!empty.is_loading(), "列表拉到了,不是加载中");
assert!(empty.is_empty(), "过滤后没有行 = 空态");
assert!(empty.no_match(), "跨类型也没有 ⇒ 没有匹配");
assert!(!empty.only_elsewhere());
}
/// **搜索跨类型**:命中数与当前分类无关,行数相关。
///
/// 抓得住:有人把 `hits` 也算成「当前分类的命中」(那会让「正式版里搜 24w」显示 0 个匹配,
/// 用户以为没有这个版本)。
/// 抓不住:大小写之外的搜索口径(模糊匹配 / 分词属 presenter)。
#[test]
fn search_hits_are_cross_category() {
let mut state = state_with(mixed());
state.download_query = "1.20".to_string();
let all = Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert_eq!(all.rows.len(), 1);
assert_eq!(all.hits, 1);
// 同一个词,站在「预览版」分类里:行数 0,命中数仍是 1
let snapshot = Download::from_snapshot(
&state,
Category::Snapshot,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert_eq!(snapshot.rows.len(), 0);
assert_eq!(snapshot.hits, 1, "命中数是跨类型读数");
assert!(snapshot.only_elsewhere(), "有命中但不在当前分类");
assert!(!snapshot.no_match(), "不是「没有匹配」");
}
/// 「搜不到」与「在当前分类搜不到」是两种画面(文案与出口都不同)。
#[test]
fn the_two_search_misses_are_distinguishable() {
let mut state = state_with(mixed());
state.download_query = "zzz".to_string();
let nothing = Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(nothing.no_match() && !nothing.only_elsewhere());
state.download_query = "rd-".to_string();
let elsewhere = Download::from_snapshot(
&state,
Category::Release,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(elsewhere.only_elsewhere() && !elsewhere.no_match());
let home = Download::from_snapshot(
&state,
Category::Ancient,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert_eq!(home.rows.len(), 1, "切到远古版就看见了");
assert!(!home.no_match() && !home.only_elsewhere());
}
/// 分类反推:混合 = 全部,远古 = 远古(不看子类)。
@@ -220,10 +295,7 @@ fn the_category_is_derived_from_the_list() {
assert_eq!(Category::of(&[]), Category::All);
}
/// 分类 chip 派的是**真指令**:`VersionCategory` 直接喂给 `AppCommand::ListVersions`。
///
/// 抓得住:chip 点到不存在的指令、或把 `All` 也派出去(那会白拉一次列表)。
/// 抓不住:后端是否真按分类回列表(presenter/host 的测试负责)。
/// 分类 chip 派的是**真指令**。
#[test]
fn category_commands_are_real() {
assert_eq!(Category::All.command(), None, "全部 = 画现有快照,不派指令");
@@ -234,112 +306,12 @@ fn category_commands_are_real() {
);
assert_eq!(
Category::Ancient.command(),
Some(VersionCategory::Ancient(AncientKind::Classic)),
"远古版先拉 classic(四子类同屏要 presenter 支持,已登记 finding)"
Some(VersionCategory::Ancient(AncientKind::Classic))
);
assert!(Category::Ancient.matches(VersionKind::Ancient(AncientKind::Unlisted)));
assert!(!Category::Release.matches(VersionKind::Snapshot));
}
// --------------------------------------------------------------- 控件树
// --------------------------------------------------------------- 控件树(见 `tests/view.rs`)
/// 五个状态都能构建出控件树(分支走到、不 panic、消息类型对得上)。
///
/// 抓得住:某个状态分支漏了返回值、或消息类型接错(编译期就红);空态/加载态忘了给内容。
/// 抓不住:**画出来长什么样**——像素由报告里的四张图负责,这条只保证「有树」。
#[test]
fn every_state_builds_a_tree() {
let ui = ui();
let metrics = frame_metrics(PaneKit::Single, 1180.0);
let catalog = Lang::En.catalog();
let mut loading = Download::loading();
loading.selected = None;
let empty = Download {
loaded: true,
..Download::loading()
};
let ready = Download {
options: None,
rows: vec![super::model::VersionRow::of(
&version("1.21.9", VersionKind::Release, false),
catalog,
)],
category: Category::Release,
selected: Some("1.21.9".to_string()),
install: None,
loaded: true,
};
let installing = Download {
install: Some(super::model::InstallView::of(
&InstallProgressView {
version: "1.21.9".to_string(),
stage: InstallStage::Downloading,
files_done: 128,
files_total: 512,
..InstallProgressView::new()
},
catalog,
)),
..ready.clone()
};
let failed = Download {
install: Some(super::model::InstallView::of(
&InstallProgressView {
version: "1.21.9".to_string(),
stage: InstallStage::Failed,
..InstallProgressView::new()
},
catalog,
)),
..ready.clone()
};
for model in [loading, empty, ready, installing, failed] {
let _: iced::Element<'_, Message> = super::view(&ui, metrics, &model, Lang::En);
}
}
/// 安装失败的模型:可读文案 + 出口都有(不静默)。
///
/// 抓得住:失败态被画成「已完成」或什么都不给。
/// 抓不住:失败原因的具体内容(core 只给中文 `message`,页面按规矩不画它,见模块文档)。
#[test]
fn a_failed_install_stays_visible() {
let model = super::model::InstallView::of(
&InstallProgressView {
version: "1.21.9".to_string(),
stage: InstallStage::Failed,
..InstallProgressView::new()
},
Lang::En.catalog(),
);
assert!(model.failed());
assert!(model.terminal());
assert!(!model.running(), "失败之后不再给「取消安装」");
assert_eq!(model.stage_label, "Install failed");
}
/// 演示数据自己也得守规矩(它是出图的基线):抓中文字面量混入 / 行数与分类对不上。
#[test]
fn the_demo_data_is_sane() {
let catalog = Lang::En.catalog();
let demo = super::demo(catalog);
assert!(demo.loaded);
assert_eq!(demo.rows.len(), 6);
assert_eq!(demo.category, Category::All, "混合列表没有单个分类该亮");
assert!(
demo.rows
.iter()
.all(|row| row.supporting.contains("released")),
"英文目录下的副文案走英文 key"
);
assert!(
demo.rows.iter().all(|row| !row.supporting.contains('版')),
"英文目录下不许出现中文类型词"
);
}
mod view;