From 4a41263357ec231458743f5cfbcfb3717509dc44 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 15:26:16 +0800 Subject: [PATCH] =?UTF-8?q?test(app-iced):=20M123=20=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E9=80=89=E9=A1=B9=E5=8C=BA=20=C3=97=20=E4=B8=89=E6=A0=8F?= =?UTF-8?q?=E5=90=8C=E5=B1=8F=E7=9A=84=E5=AE=BD=E5=BA=A6=E4=B8=8E=E8=A1=8C?= =?UTF-8?q?=E8=A3=81=E5=89=AA=E5=AE=88=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安装选项区(`download/options`)与三栏(`download/rails`)各自都有断言, 但它们**同屏**这件事从来没验过:选项区一高列表就矮,窗口一窄两条栏一收列表 就窄,这两种挤压都不会让任何既有断言变红。 - `tests/options_screen.rs`:七档宽度(1180/1024/900/880/812/736/700)× 三种 选项态(原版 / Fabric / NeoForge 不兼容)。三栏数字按 `layout/rails.rs` 的 数值表逐格钉死;列表列在每一档都 ≥ 424(行内四样仍在一行);两条栏与选项 面板在同一份模型里、树能一次构建。 - `tests/options_shot.rs`:真 PNG 上的像素断言(需 `ULL_DL_OPTIONS_SHOT`)。 两栏同屏且像素宽度 = 裁决值 140/140、栏间 24 的缝真空着、第一行版本行整行 44px、选项区最后一行墨迹离底边还有余量。另有一条 `#[ignore]` 的 Chips 档 回归:700×760 下列表被压成 18px(现状红,缺陷已单列报告与 finding)。 出图(38 张)在 `DevDocs/artifacts/m123/`(git 忽略,不入库);旧的 `DevDocs/artifacts/m109/`(内容库页早于 M108 改名,导航轨写的是 `Download`) 已按任务 ② 删除,内容库 12 态在 m123 里按当前 tip 重出。 不动产品行为:本提交只有测试与模块声明。 --- crates/app-iced/src/pages/download/tests.rs | 8 + .../pages/download/tests/options_screen.rs | 188 ++++++++++++ .../src/pages/download/tests/options_shot.rs | 289 ++++++++++++++++++ 3 files changed, 485 insertions(+) create mode 100644 crates/app-iced/src/pages/download/tests/options_screen.rs create mode 100644 crates/app-iced/src/pages/download/tests/options_shot.rs diff --git a/crates/app-iced/src/pages/download/tests.rs b/crates/app-iced/src/pages/download/tests.rs index a1b3e56..f864189 100644 --- a/crates/app-iced/src/pages/download/tests.rs +++ b/crates/app-iced/src/pages/download/tests.rs @@ -300,3 +300,11 @@ fn category_commands_are_real() { // --------------------------------------------------------------- 控件树(见 `tests/view.rs`) mod view; + +// ------------------------------------------------- 选项区 × 三栏同屏(见 `tests/options_screen.rs`) + +mod options_screen; + +// ------------------------------------------------- 选项区 × 三栏的像素断言(见 `tests/options_shot.rs`) + +mod options_shot; diff --git a/crates/app-iced/src/pages/download/tests/options_screen.rs b/crates/app-iced/src/pages/download/tests/options_screen.rs new file mode 100644 index 0000000..8ef8bd0 --- /dev/null +++ b/crates/app-iced/src/pages/download/tests/options_screen.rs @@ -0,0 +1,188 @@ +//! 安装选项区 × 三栏**同屏**(M123 ①):宽度怎么分、窄窗怎么退化、选项区在不在同一棵树里。 +//! +//! ## 为什么这条测试必须存在 +//! +//! 「安装选项区」(`download/options`)与「三栏」(`download/rails`)各自都出过图、也各自都有 +//! 断言,但它们**同屏**这件事从来没被验过。选项区挤在列表列的最底下:它一高,列表就矮; +//! 窗口一窄,两条栏一收,列表就窄。这两种挤压都不会让任何既有断言变红——那些断言分别只看 +//! 「宽度裁决的数字」「选项区的纯决策」「树能构建出来」,没有一条把它们放在同一屏里看。 +//! +//! ## 抓得住什么 / 抓不住什么 +//! +//! 抓得住:①七档宽度下 `VersionRails` 的三栏数字与 `pages/layout/rails.rs` 模块文档的表逐格一致; +//! ②选项区的三种状态(原版 / Fabric / 不兼容)在**每一档**上都能与两份侧栏的数据一起构建出树, +//! 而且列表列**从不低于** [`version_list_min_width`](= 424:行内「版本号 + 类型 + 日期 + 安装」 +//! 四样仍在一行,不会因为宽度而折行/裁字);③`Chips` 档两条栏归零、列表吃满可用宽(退化是 +//! 逐档下沉,不是「挤一挤」)。 +//! +//! 抓不住:**画出来长什么样**——两栏是不是真同屏、选项区有没有被窗口底边切掉、某一格文本会不会 +//! 仍被裁。前两条由同目录的 `tests/options_shot.rs`(像素断言,需 `ULL_DL_OPTIONS_SHOT`)在真 PNG +//! 上验证,文本本身由 `DevDocs/artifacts/m123/` 的图人工看。 +//! 更根本的一条:**没有 iced 的渲染器就拿不到布局**(`Element::layout` 要 `&Renderer`,而本 crate +//! 的 `Renderer` 在 `gpu` feature 下是 wgpu/tiny-skia 的 `fallback::Renderer`,测试里构造不出来), +//! 所以这里断的是**喂给树的模型 + 裁决出来的数字**,不是 iced 亲手算的矩形。 + +use iced::Element; +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{AncientKind, AppEvent, CoreEvent, VersionKind, VersionSummary}; +use unlucky_presenter::state::AppState; + +use crate::pages::download::model::{Category, Download}; +use crate::pages::download::{view, InstallDraft, Loader, Message}; +use crate::pages::layout::rails::version_list_min_width; +use crate::pages::layout::{frame_metrics, PaneKit, VersionRailTier, VersionRails}; + +use super::ui; + +/// 一档宽度的期望(数字来源:`pages/layout/rails.rs` 模块文档 §4 的数值表)。 +/// +/// 表里每一格都写死,**不**从被策源反算——否则「裁决算错了」与「表抄错了」会互相掩护。 +const WIDTHS: [(f32, VersionRailTier, f32, f32, f32); 7] = [ + (1180.0, VersionRailTier::Wide, 140.0, 140.0, 716.0), + (1024.0, VersionRailTier::Wide, 140.0, 140.0, 560.0), + (900.0, VersionRailTier::Narrow, 64.0, 140.0, 512.0), + (880.0, VersionRailTier::Narrow, 64.0, 140.0, 492.0), + // 812 = `Narrow` 档的门槛:类型栏已收成 64,列表仍停在 424 + (812.0, VersionRailTier::Narrow, 64.0, 140.0, 424.0), + // 736 = `Compact` 档:两条栏都收成 64,列表仍停在 424 + (736.0, VersionRailTier::Compact, 64.0, 64.0, 424.0), + // 700 = `Chips` 档:三栏不成立,列表吃满可用宽 + (700.0, VersionRailTier::Chips, 0.0, 0.0, 564.0), +]; + +/// 出图那一屏的演示版本表(三种形态就够:正式 / 预览 / 远古,两条侧栏都有内容)。 +fn versions() -> Vec { + fn row(id: &str, kind: VersionKind) -> VersionSummary { + VersionSummary { + id: id.to_string(), + kind, + released: "2026-09-20".to_string(), + installed: false, + } + } + + vec![ + row("1.21.9", VersionKind::Release), + row("1.20.4", VersionKind::Release), + row("24w14a", VersionKind::Snapshot), + row("rd-132211", VersionKind::Ancient(AncientKind::Classic)), + ] +} + +/// 选项态模型:`AppState` + 草稿 → **产品那条真转换**(与 `demo::options` 同一条路径)。 +/// +/// `cached` = 加载器版本列表(Fabric 那一屏才有;它是选项区里最高的一段,也正是「面板一高 +/// 列表就矮」的那一态)。 +fn options_model(version: &str, loader: Loader, cached: Option<&[&str]>) -> Download { + let catalog = Lang::En.catalog(); + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions()))); + if let Some(items) = cached { + state.apply(AppEvent::Core(CoreEvent::LoaderVersions { + kind: loader.kind(), + mc_version: version.to_string(), + result: Ok(items.iter().map(|item| (*item).to_string()).collect()), + })); + } + let draft = InstallDraft { + selected: Some(version.to_string()), + loader, + ..InstallDraft::default() + }; + Download::from_snapshot(&state, Category::All, &draft, catalog) +} + +/// 选项区的三种取样(原版 / Fabric 带版本列表 / NeoForge 对远古版不兼容)。 +fn samples() -> [(&'static str, Download); 3] { + [ + ("options", options_model("1.21.9", Loader::Vanilla, None)), + ( + "options-fabric", + options_model( + "1.21.9", + Loader::Fabric, + Some(&["0.16.5", "0.16.4", "0.15.11"]), + ), + ), + ( + "options-incompat", + options_model("rd-132211", Loader::NeoForge, None), + ), + ] +} + +/// 七档宽度 × 三种选项态:三栏数字一致、树能构建、列表列不被挤到 424 以下。 +/// +/// 424 这条是「不裁字」的宽度侧前提:行内四样的最小宽 `layout::rails::version_row_min_width()` +/// = 412 < 424,所以只要列表列 ≥ 424,版本号那一格就还有 12px 余量,四样不会折行。 +#[test] +fn the_options_state_keeps_all_three_columns_at_every_width() { + let ui = ui(); + let floor = version_list_min_width(); + let samples = samples(); + + for (width, tier, type_rail, major_rail, list) in WIDTHS { + let metrics = frame_metrics(PaneKit::Single, width); + let rails = VersionRails::of_metrics(metrics); + + assert_eq!(rails.tier, tier, "{width} 的档位"); + assert!((rails.type_rail - type_rail).abs() < 0.01, "{width} 类型栏"); + assert!( + (rails.major_rail - major_rail).abs() < 0.01, + "{width} 主版本栏" + ); + assert!((rails.list - list).abs() < 0.01, "{width} 列表列"); + assert!( + rails.list >= floor - 0.01, + "{width}:列表列 {} 低于行宽下限 {floor} ⇒ 行内四样会折行/裁字", + rails.list + ); + // 三栏档两条栏都 >0(同屏的前提);Chips 档两条都归零、列表吃满(逐档下沉) + if rails.tier.is_three_column() { + assert!(rails.type_rail > 0.0 && rails.major_rail > 0.0, "{width}"); + } else { + assert_eq!((rails.type_rail, rails.major_rail), (0.0, 0.0), "{width}"); + } + + for (name, model) in &samples { + // 同一份模型里:两条栏的内容(4 个类型 / 主版本桶)+ 行 + 选项面板都在 + assert_eq!(Category::ALL.len(), 4, "第一栏是四个固定项"); + assert!(!model.buckets.is_empty(), "{name}@{width} 第二栏没有桶"); + assert!(!model.rows.is_empty(), "{name}@{width} 列表没有行"); + assert!( + model.options.is_some(), + "{name}@{width} 面板不是选项态——这张图就不再是「选项区 × 三栏」" + ); + // 树真的建得出来(两栏 + 列表 + 面板同一次装配) + let _: Element<'_, Message> = view(&ui, metrics, model, Lang::En); + } + } +} + +/// 窄窗退化是**逐档下沉**:类型栏先成图标条、再两条都成图标条,列表列在两种情形下都不低于 424。 +/// +/// 抓得住:某天有人为了「窄窗也放得下两条栏」把 64 改成 48 或把列表下限往下调 —— 那时这里的 +/// 逐档数字会先红。抓不住:图标条里的 tooltip 在真机上是否弹得出来(静态图拍不到 hover 层)。 +#[test] +fn the_narrow_tiers_step_down_before_they_squeeze_the_list() { + let mut previous = VersionRails::of_metrics(frame_metrics(PaneKit::Single, 2400.0)); + assert_eq!(previous.tier, VersionRailTier::Wide); + + for width in [1600.0, 1180.0, 1024.0, 900.0, 812.0, 736.0, 700.0, 600.0] { + let rails = VersionRails::of_metrics(frame_metrics(PaneKit::Single, width)); + // 档位只降不升,且降档时**收的是侧栏**,列表不会在降档的那一格突然变窄再变宽 + assert!( + rails.list >= version_list_min_width() - 0.01, + "{width}:列表列 {} 跌破 424", + rails.list + ); + if rails.tier.is_three_column() && previous.tier.is_three_column() { + assert!( + rails.type_rail <= previous.type_rail + 0.01 + && rails.major_rail <= previous.major_rail + 0.01, + "{width}:侧栏在宽窗上反而变宽了" + ); + } + previous = rails; + } +} diff --git a/crates/app-iced/src/pages/download/tests/options_shot.rs b/crates/app-iced/src/pages/download/tests/options_shot.rs new file mode 100644 index 0000000..cdb6006 --- /dev/null +++ b/crates/app-iced/src/pages/download/tests/options_shot.rs @@ -0,0 +1,289 @@ +//! 选项区 × 三栏的**像素**验收(M123 ①):真 PNG 上的几何断言。 +//! +//! ## 为什么还要一条像素测试 +//! +//! [`super::options_screen`] 断的是「喂给树的模型 + 裁决出来的数字」,它抓不住**画出来**: +//! 两栏是不是真同屏、栏宽有没有按裁决落地、版本行有没有被压成一条缝、确认键是不是还在屏内。 +//! 那些只有图能回答——所以这里直接量 `pages_demo` 出的 PNG,按**产品自己的裁决函数** +//! ([`VersionRails::of_metrics`] + [`frame_metrics`])推出每一栏该在的 x 区间,再要求像素同意。 +//! +//! ## 开关与跑法(与 `components/*/probe.rs` 同一族范式:没有图就 SKIP,不假绿) +//! +//! ```text +//! # ① 1180 宽(Wide 档,两栏都展开) +//! ULL_DL_DEMO=options-fabric cargo run -p unlucky-app-iced --example pages_demo -- \ +//! --screen=download --width=1180 --height=760 --screenshot=opt.png \ +//! --screenshot-theme=light --software-renderer +//! ULL_DL_OPTIONS_SHOT=opt.png cargo test -p unlucky-app-iced --lib options_shot +//! ``` +//! +//! 颜色**不**写死:`run_at` 只要求「同一段颜色连续」,栏与栏的边界按「颜色变了」判, +//! 于是浅色/深色都能跑同一条断言(实测浅色下 Card=(235,245,255)、Canvas=(246,250,255))。 + +use std::path::PathBuf; + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; + +use crate::pages::layout::rails::RAIL_GAP; +use crate::pages::layout::{available_width, frame_metrics, PaneKit, VersionRails}; +use crate::theme::{Families, Ui}; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 1180 宽那张(`01-options-fabric-en-light.png`)。 +const WIDE_SHOT: &str = "ULL_DL_OPTIONS_SHOT"; +/// 700 宽那张(`03-options-fabric-en-light-chips700.png`)——只在 `--ignored` 那条里用。 +const CHIPS_SHOT: &str = "ULL_DL_CHIPS_SHOT"; + +fn ui() -> Ui { + Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) +} + +fn shot_path(key: &str) -> Option { + let path = PathBuf::from(std::env::var_os(key)?); + if path.is_absolute() || path.exists() { + return Some(path); + } + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + Some(root.join(path)) +} + +/// 一张 RGB 图(只留本文件要用的那两件事:宽高 + 取点)。 +struct Shot { + width: usize, + height: usize, + pixels: Vec<[u8; 3]>, +} + +impl Shot { + fn load(path: &std::path::Path) -> Self { + let bytes = std::fs::read(path).expect("读截图"); + let image = image::load_from_memory(&bytes).expect("解 PNG").to_rgb8(); + let (width, height) = (image.width() as usize, image.height() as usize); + Self { + width, + height, + pixels: image.pixels().map(|pixel| pixel.0).collect(), + } + } + + fn at(&self, x: usize, y: usize) -> [u8; 3] { + self.pixels[y * self.width + x] + } + + /// `(起点, 长度)`:`(x, y)` 所在的那一段**同色**横条。 + fn run_at(&self, x: usize, y: usize) -> (usize, usize) { + let color = self.at(x, y); + let mut start = x; + while start > 0 && self.at(start - 1, y) == color { + start -= 1; + } + let mut end = x; + while end + 1 < self.width && self.at(end + 1, y) == color { + end += 1; + } + (start, end - start + 1) + } + + /// 在 `x` 这一竖列上,`[from, to)` 里**第一段**与 `background` 不同的连续区:`(top, height)`。 + /// + /// 用它量「第一行版本行有多高」:选中行有底色,行外是页面底色,两段一夹就是行高。 + fn first_filled_band( + &self, + x: usize, + from: usize, + to: usize, + background: [u8; 3], + ) -> Option<(usize, usize)> { + let filled = |y: usize| self.at(x, y) != background; + let top = (from..to.min(self.height)).find(|y| filled(*y))?; + let mut bottom = top; + while bottom + 1 < to.min(self.height) && filled(bottom + 1) { + bottom += 1; + } + Some((top, bottom - top + 1)) + } + + /// 列表列里最后一行**有内容**的 y(`None` = 整列都是底色)。 + fn last_ink_row(&self, left: usize, right: usize, background: [u8; 3]) -> Option { + (0..self.height).rev().find(|y| { + (left..right).any(|x| { + let pixel = self.at(x, *y); + pixel + .iter() + .zip(background) + .map(|(a, b)| u32::from(a.abs_diff(b))) + .sum::() + > 12 + }) + }) + } +} + +/// 三栏的 x 区间,全部从**产品自己的裁决函数**推出来(不是手抄的像素坐标)。 +struct Bands { + type_rail: (usize, f32), + major_rail: (usize, f32), + list: (usize, f32), +} + +fn bands(width: f32) -> Bands { + let ui = ui(); + let rails = VersionRails::of_metrics(frame_metrics(PaneKit::Single, width)); + // 可用宽 = 窗口 − 导航轨 − 两侧页边距 ⇒ 页边距反推出来(只有左侧那一份进左缘) + let avail = available_width(width); + let gutter = (width - avail - ui.metrics().nav_rail_width) / 2.0; + assert!( + (gutter - 24.0).abs() < 0.01, + "页边距应当是一格 24:{gutter}" + ); + let left = (width - avail - gutter).round() as usize; + assert_eq!( + left, + (ui.metrics().nav_rail_width + gutter).round() as usize, + "页左缘 = 导航轨 + 一格页边距" + ); + assert_eq!(left, 112, "harness 的导航轨 88 + 页边距 24"); + let type_rail = (left, rails.type_rail); + let major_rail = ( + left + rails.type_rail as usize + RAIL_GAP as usize, + rails.major_rail, + ); + let list = ( + major_rail.0 + rails.major_rail as usize + RAIL_GAP as usize, + rails.list, + ); + Bands { + type_rail, + major_rail, + list, + } +} + +/// `01-options-fabric-en-light.png`(1180×760)上的四条断言。 +/// +/// 抓得住:①两条侧栏**同时**在同一条扫描线上画出来(不是只有一条、也不是其中一条塌成 0 宽); +/// ②两条栏各自的像素宽度 = `VersionRails` 裁决出来的 140/140,栏间 24 的缝真的空着(栏没有重叠); +/// ③版本行至少有一行**完整**(44 = `row_height`),即列表没有被面板压成「缝」; +/// ④选项区的确认键与「实例名」都在屏内(列表列最下面一行墨迹离窗口底边还有余量)。 +/// +/// 抓不住:文本有没有被裁(那要看字形宽度,见报告「没验的部分」)、hover/tooltip 层(静态图拍不到)、 +/// 深色主题下的对比度(本条只跑浅色图;深色另用同一 argv 换 `--screenshot-theme` 出图人看)。 +#[test] +fn an_options_shot_shows_both_rails_and_the_whole_panel() { + let Some(path) = shot_path(WIDE_SHOT) else { + eprintln!( + "[options-shot] SKIPPED:未设置 {WIDE_SHOT}。这是**渲染层**几何断言,\ + 需要先出图(见本文件模块文档的 1180 那张)。" + ); + return; + }; + let shot = Shot::load(&path); + assert_eq!( + (shot.width, shot.height), + (1180, 760), + "验收图必须 1180×760" + ); + + let bands = bands(1180.0); + let ui = ui(); + let y = 400; + + // ① 两条栏同时画在同一条扫描线上,且宽度 = 裁决值(像素边界一条不差) + for (name, (start, width)) in [("类型栏", bands.type_rail), ("主版本栏", bands.major_rail)] + { + let (run_start, run_len) = shot.run_at(start + 8, y); + assert_eq!( + (run_start, run_len as f32), + (start, width), + "{name}:像素区间与 `VersionRails` 裁决不一致(y={y})" + ); + assert_ne!( + shot.at(start, y), + shot.at(start.saturating_sub(1), y), + "{name}:左缘没有边界" + ); + } + // 栏间 24 的缝真的空着(两条栏没有叠在一起),且列表列不是面板底色 + let gap_x = bands.type_rail.0 + bands.type_rail.1 as usize + RAIL_GAP as usize / 2; + assert_eq!( + shot.at(gap_x, y), + shot.at(gap_x - 1, y), + "两栏之间的缝被填掉了(栏重叠或间距丢失)" + ); + assert_ne!( + shot.at(gap_x, y), + shot.at(bands.type_rail.0, y), + "缝与栏同色 ⇒ 读不出「两条栏」" + ); + assert_ne!( + shot.at(bands.list.0 + 8, y), + shot.at(bands.major_rail.0, y), + "列表列被侧栏底色盖住了" + ); + + // ③ 完整的一行版本行:第一行(选中行)的底色带必须正好一行高(`row_height` = 44) + let canvas = shot.at(bands.list.0 + 2, 12); + let (_, row_height) = shot + .first_filled_band(bands.list.0 + 60, 235, 520, canvas) + .expect("列表列里应当有版本行"); + assert_eq!( + row_height as f32, + ui.metrics().row_height, + "版本行被裁/被压:实测可视行高 {row_height},应为 {}", + ui.metrics().row_height + ); + + // ④ 选项区整块在屏内:列表列最下面的墨迹离底边还有余量(被切的话它会贴到 759) + let last = shot + .last_ink_row( + bands.list.0, + bands.list.0 + bands.list.1 as usize - 2, + canvas, + ) + .expect("列表列里应当有内容"); + assert!( + last + 8 < shot.height, + "选项区被窗口底边切掉:最后一行墨迹在 y={last}(图高 {})", + shot.height + ); + assert!( + last > 600, + "选项区不在屏上:最后一行墨迹在 y={last},解析应当有「实例名」那一格" + ); +} + +/// `02-options-fabric-en-light-chips700.png`(700×760)——**这条现在是红的**(M123 的缺陷登记)。 +/// +/// 现状(2026-11-05,tiny-skia,700×760,`ULL_DL_DEMO=options-fabric`):Chips 档多出两行 chip, +/// 选项区又整块钉在列表下面,于是列表列只剩 ~22px 的缝,选中行的底色带实测 19px(行高 44)。 +/// 断言写的是**该有的**结果;修好之后删掉 `#[ignore]` 就是它的回归守门。 +/// +/// ```text +/// ULL_DL_CHIPS_SHOT=02-options-fabric-en-light-chips700.png \ +/// cargo test -p unlucky-app-iced --lib options_shot -- --ignored +/// ``` +#[test] +#[ignore = "M123 缺陷:Chips 档(窗口 <736)列表被压成 ~19px,见报告「发现的缺陷」第 1 条"] +fn a_chips_tier_shot_keeps_a_whole_version_row_readable() { + let Some(path) = shot_path(CHIPS_SHOT) else { + eprintln!("[options-shot] SKIPPED:未设置 {CHIPS_SHOT}。"); + return; + }; + let shot = Shot::load(&path); + let bands = bands(700.0); + let canvas = shot.at(bands.list.0 + 2, 12); + let (_, row_height) = shot + .first_filled_band(bands.list.0 + 60, 360, 440, canvas) + .expect("列表列里应当有版本行"); + assert_eq!( + row_height as f32, + ui().metrics().row_height, + "Chips 档的版本行被压成 {row_height}px(应为 44)" + ); +}