//! 渲染层几何断言:**对着真截图量像素**(M68 的教训:纯函数返回对,图可能是错的)。 //! //! `buttons::tests` 只能证明「几何结构体里的数字是 40/20/24」——M68 的药丸正是这样:单测全绿、 //! 图上是 88×32(容器被 `center_x(Length::Fill)` 覆盖)。唯一能抓住这类错误的是**真渲染的像素**。 //! CI 没有桌面,所以这里不自己开窗:先用 `--screen=gallery --screenshot` 出真窗截图,本测试读 //! 那张 PNG 量几何、与令牌逐项比对。**没给图就跳过并大声说出来**(不静默通过)。 //! //! 复现命令(在 worktree 根目录): //! //! ```text //! cargo build -p unlucky-app-iced --bin unlucky-launcher-iced //! ./target/debug/unlucky-launcher-iced --software-renderer --screen=gallery //! --screenshot target/m75/gallery-light.png --screenshot-theme=light //! ULL_GALLERY_SHOT=target/m75/gallery-light.png cargo test -p unlucky-app-iced gallery::probe //! ``` //! //! 量什么(每条对一个已发生过的错误):实心块高 **40**(12b 1.1)· 药丸顶行宽 ≈9 ⇒ 圆角 full=20 //! (12b 1.2,8 圆角会接近通栏)· IconButton **40×40**(12b 2.1)· 字形墨迹 **16~26** //! (Q9:24 盒被填满会到 40)· 分隔线 **1px**(22.1)· 禁用容器 **on-surface@12%** 存在(12b 1.8)。 use unlucky_design::color::{Role, SchemeFlavor}; use unlucky_design::typography::Platform; use crate::theme::{Families, Ui}; use super::scan::{matches, nearer, rgb, Shot}; const FAMILIES: Families = Families { text: "Segoe UI", display: "Segoe UI", mono: "Consolas", }; fn ui(flavor: SchemeFlavor) -> Ui { Ui::with_platform(flavor, Platform::Windows, FAMILIES) } /// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。 fn shot_path() -> Option { let raw = std::env::var_os("ULL_GALLERY_SHOT")?; let path = std::path::PathBuf::from(raw); if path.is_absolute() || path.exists() { return Some(path); } let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); Some(root.join(path)) } /// 一条测试覆盖全部几何断言(每张图各跑一次)。 #[test] fn gallery_geometry_matches_the_tokens() { let Some(path) = shot_path() else { eprintln!( "[probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\ 需要先出一张真窗截图(命令见本文件模块文档)。" ); return; }; let shot = Shot::load(&path).expect("读 gallery 截图"); assert_eq!( (shot.width, shot.height), (1180, 760), "验收图必须是 D30 的 1180×760 @100% DPI" ); // 用左上角背景色判定深浅(页面 padding 区就是 `Surface`) let corner = shot.at(2, 2); let flavor = if matches(corner, rgb(ui(SchemeFlavor::Light).color(Role::Surface))) { SchemeFlavor::Light } else { SchemeFlavor::Dark }; let ui = ui(flavor); // ---- 12b 1.1 / 1.2:实心按钮 40 高、药丸圆角 20 ------------------------------ let primary = shot.bbox_of(rgb(ui.color(Role::Primary))); assert!( primary.len() >= 2, "至少该找到 Filled 按钮与 Filled IconButton 两块实心(实际 {})", primary.len() ); let mut label_button = None; for bbox in &primary { // 字形(OnPrimary)会把实心块割出小碎片,只判「成块的」(40×40 = 1600) if bbox.area() < 800 { continue; } assert!( (39..=41).contains(&bbox.height()), "实心块高 {}(参考 40,12b 1.1)@ {:?}", bbox.height(), (bbox.left, bbox.top) ); if bbox.width() > 60 { label_button = Some(*bbox); } if (39..=41).contains(&bbox.width()) { // 12b 2.1:IconButton 容器 40×40 assert!( (39..=41).contains(&bbox.width()), "IconButton 应为 40×40,实测 {}×{}", bbox.width(), bbox.height() ); } } let label_button = label_button.expect("没找到文字按钮的实心块"); assert!( label_button.width() >= 58, "12b 1.7:最小宽 58,实测 {}", label_button.width() ); // 含抗锯齿的真实外框:中心列上「更接近填充色而不是底色」的连续行 let fill = rgb(ui.color(Role::Primary)); let surface = rgb(ui.color(Role::Surface)); let center = (label_button.left + label_button.right) / 2; let mut true_top = label_button.top; while true_top > 0 && nearer(shot.at(center, true_top - 1), fill, surface) { true_top -= 1; } let mut true_bottom = label_button.bottom; while true_bottom + 1 < shot.height && nearer(shot.at(center, true_bottom + 1), fill, surface) { true_bottom += 1; } let true_height = true_bottom - true_top + 1; // 圆角档位:量**顶部那一行的左内缩**。药丸(r = 高/2 = 20)在离顶端 1px 处内缩 // ≈ r − √(2r−1) = 13.8;r=8 只有 ≈4.1;直角为 0。比「数圆角行数」抗锯齿得多。 let top_row_left = (label_button.left..=label_button.right) .find(|x| matches(shot.at(*x, label_button.top), fill)) .expect("顶部行应至少有一个填充像素"); let top_inset = top_row_left - label_button.left; assert!( (11..=17).contains(&top_inset), "药丸圆角应 = 20(12b 1.2):顶部行左内缩应 ≈14,实测 {top_inset}(8 圆角 ≈4,直角 0)" ); // ---- Q9:IconButton 的字形不许撑满 40 盒 --- // ---- Q9:IconButton 的字形不许撑满 40 盒 ----------------------------------- let tonal = rgb(ui.color(Role::SecondaryContainer)); let tonal_boxes = shot.bbox_of(tonal); let icon_states = tonal_boxes .iter() .filter(|bbox| (39..=41).contains(&bbox.width()) && (39..=41).contains(&bbox.height())) .count(); // 只有 **enabled** 列是精确的 SecondaryContainer(hover/pressed/focus 列叠了状态层,颜色不同), // 所以这里只要求 ≥1:量几何一只就够,五态的可见性由图本身给。 assert!( icon_states >= 1, "tonal IconButton 至少 1 个(实际 {icon_states})" ); let gear = rgb(ui.color(Role::OnSecondaryContainer)); let mut measured_glyph = false; let mut glyph_width = 0; for bbox in tonal_boxes .iter() .filter(|bbox| (39..=41).contains(&bbox.width())) { if let Some(ink) = shot.ink_inside(bbox, gear) { let width = ink.width(); if width > glyph_width { glyph_width = width; } assert!( (16..=26).contains(&width), "图标盒 24 的字形墨迹应 16~26 宽,实测 {width}(Q9 回归时会接近 40)" ); measured_glyph = true; } } assert!(measured_glyph, "没量到 IconButton 的字形"); // ---- 22.1:分隔线 1px ----------------------------------------------------- let outline = rgb(ui.color(Role::OutlineVariant)); let mut runs = 0; for y in 0..shot.height { let mut longest = 0; let mut current = 0; for x in 0..shot.width { if matches(shot.at(x, y), outline) { current += 1; longest = longest.max(current); } else { current = 0; } } if longest > 300 { runs += 1; } } // 分隔线是 1px 高,所以「长横线」的行数 = 屏上分隔线条数(每条约 1 行) assert!(runs >= 2, "至少该有 2 条分隔线(实际 {runs} 行)"); assert!( runs <= 4, "分隔线最多 2 条 × 1px,实测 {runs} 行(22.1:大于这个数说明线不是 1px)" ); // ---- 12b 1.8:禁用格必须与静息格不同 ------------------------------------ let disabled_container = crate::app::components::interaction::disabled_colors(&ui, Role::Surface).0; assert!( shot.bbox_of(rgb(disabled_container)) .iter() .any(|b| b.width() > 60), "没找到禁用态实心按钮(12b 1.8:容器 = on-surface@12%)" ); eprintln!( "[probe] OK flavor={:?}: Filled 药丸 {}×{}(参考 高 40 / 宽 ≥58) 顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0) 实心块 {} 个(含 40×40 IconButton) 分隔线长行 {} 行(每条 1px) 禁用容器 (on-surface@12%) 命中 字形墨迹 {}px(24 盒应 16~26)", flavor, label_button.width(), true_height, top_inset, primary.len(), runs, glyph_width ); }