diff --git a/crates/app-iced/src/components/gallery/selection.rs b/crates/app-iced/src/components/gallery/selection.rs index c8fb0c8..2efe01a 100644 --- a/crates/app-iced/src/components/gallery/selection.rs +++ b/crates/app-iced/src/components/gallery/selection.rs @@ -1,25 +1,19 @@ -//! gallery 里的选择类小节(D30:浅/深 × 五态的状态矩阵)+ **渲染层几何断言**。 +//! gallery 里的选择类小节:把状态矩阵摆进 harness(**断言不在这里**)。 //! //! 矩阵本体的构造在 [`selection::matrix`](12 行 × 5 列:checkbox 四态 / radio / switch / -//! chip 三类);本文件只负责「摆进 gallery」,消息用 `()` 再映射成 `DemoMessage`(组件不碰业务)。 +//! chip 三类),消息用 `()`(矩阵只供视觉验收),这里一次 `.map(..)` 接成 `DemoMessage`。 //! -//! ## 断言量什么(每条对一个具体错误,容差 ±1) +//! ## 渲染层几何断言去哪了 //! -//! Checkbox 外缘 20 且**选中/未选中同尺寸**(§18.3:描边向内收;向外画会变 22)· Radio 内点 -//! **10 不是 12** 且环是真圆(§19.2/§19.6)· 状态层 **Ø40 且是圆**(§18.5/§19.7)· 勾形墨迹 -//! ≈14.8(端点 12 + 方头外扩)与 indeterminate 是短横(§18.4/§18.6)· Switch 轨道 52×32 -//! (§10.1)· Chip 高 32(§13.1)· 禁用墨迹是 38% 的 on-surface。 +//! 在 [`crate::app::components::selection::probe`](`selection/probe.rs`)。挪出去的原因是 +//! 400 行红线(复审 P2-5 同一类问题):断言 + 像素扫描会把这个文件顶到 458 行。 //! -//! 定位方式:**颜色 + 含抗锯齿的墨迹块外接框**,不假设行号——段落过滤或段落顺序变化后仍成立。 -//! -//! 复现(worktree 根目录): +//! 跑法(worktree 根目录): //! //! ```text -//! cargo build -p unlucky-app-iced --bin unlucky-launcher-iced -//! ULL_GALLERY_SECTION=selection ./target/debug/unlucky-launcher-iced \ -//! --software-renderer --screen=gallery \ -//! --screenshot target/m76/selection-light.png --screenshot-theme=light -//! ULL_GALLERY_SHOT=target/m76/selection-light.png cargo test -p unlucky-app-iced selection +//! ULL_GALLERY_SECTION=selection ./target/debug/unlucky-launcher-iced --software-renderer \ +//! --screen=gallery --screenshot target/m76/selection-light.png --screenshot-theme=light +//! ULL_GALLERY_SHOT=target/m76/selection-light.png cargo test -p unlucky-app-iced selection::probe //! ``` use iced::widget::column; @@ -41,356 +35,3 @@ pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { .spacing(ui.space(unlucky_design::spacing::Space::X2)) .into() } - -#[cfg(test)] -mod probe { - // 判据与复现命令见本文件顶部模块文档。 - use std::path::PathBuf; - - use iced::Color; - use unlucky_design::color::{Role, SchemeFlavor}; - use unlucky_design::typography::Platform; - - use crate::app::components::interaction::{self, Interaction}; - use crate::theme::{Families, Ui}; - - use super::super::scan::{matches, rgb, BBox, 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) - } - - fn center(bbox: &BBox) -> (usize, usize) { - ((bbox.left + bbox.right) / 2, (bbox.top + bbox.bottom) / 2) - } - - /// `ULL_GALLERY_SHOT` → 截图路径(相对路径按工作区根解释,与 `gallery/probe.rs` 同口径)。 - fn shot_path() -> Option { - let path = PathBuf::from(std::env::var_os("ULL_GALLERY_SHOT")?); - if path.is_absolute() || path.exists() { - return Some(path); - } - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); - Some(root.join(path)) - } - - /// 「墨迹块」:`fill` 与「底色→fill 的混色」都算墨,再按外接框尺寸筛。 - /// - /// 踩过的两个坑:① 按「精确色」连通(`Shot::bbox_of`)会把圆形的环切成碎片(每片面积都不到 - /// 200 而被丢掉,连内点都量不到);② 按「更接近 fill 还是底色」判会让**另一种深色墨**混进来 - /// (`OnSurfaceVariant` 比 `Primary` 更接近深色,于是未选中的框被当成 20×20 的 Primary 块)。 - /// 判据因此是「像素落在底色→fill 的混色线上」:抗锯齿像素在线,另一种墨色不在。 - fn ink_boxes(shot: &Shot, color: Color, under: Color, min: usize, max: usize) -> Vec { - let (fill, base) = (rgb(color), rgb(under)); - let mut seen = vec![false; shot.width * shot.height]; - let mut boxes = Vec::new(); - - for y in 0..shot.height { - for x in 0..shot.width { - if seen[y * shot.width + x] || !on_ramp(shot.at(x, y), fill, base) { - continue; - } - let mut stack = vec![(x, y)]; - seen[y * shot.width + x] = true; - let mut bbox = BBox::new(x, y); - while let Some((cx, cy)) = stack.pop() { - bbox.include(cx, cy); - for (nx, ny) in [ - (cx + 1, cy), - (cx.saturating_sub(1), cy), - (cx, cy + 1), - (cx, cy.saturating_sub(1)), - ] { - let inside = nx < shot.width && ny < shot.height; - if inside - && !seen[ny * shot.width + nx] - && on_ramp(shot.at(nx, ny), fill, base) - { - seen[ny * shot.width + nx] = true; - stack.push((nx, ny)); - } - } - } - if (min..=max).contains(&bbox.width()) && (min..=max).contains(&bbox.height()) { - boxes.push(bbox); - } - } - } - - boxes - } - - /// 尺寸容差 ±1(rubric §6 的默认容差):控件常落在半像素上,实测会多一行抗锯齿。 - fn near(value: usize, expected: usize) -> bool { - value.abs_diff(expected) <= 1 - } - - /// `bbox` 内缩 `inset` 后、落在 **`under → color` 混色线上**的墨迹框(量内点用)。 - /// - /// 内缩 4 而不是 3(描边内缘 AA 正好压在 3 上,会把 Ø10 量成 14);用混色线而不是精确色 - /// (Ø10 的圆只有 ~8×8 是精确色,边缘 1px 全是抗锯齿)。 - fn inner_ink( - shot: &Shot, - bbox: &BBox, - color: Color, - under: Color, - inset: usize, - ) -> Option { - let mut ink: Option = None; - for y in (bbox.top + inset)..=(bbox.bottom - inset) { - for x in (bbox.left + inset)..=(bbox.right - inset) { - if on_ramp(shot.at(x, y), rgb(color), rgb(under)) { - match &mut ink { - Some(found) => found.include(x, y), - None => ink = Some(BBox::new(x, y)), - } - } - } - } - ink - } - - fn dims(boxes: &[BBox]) -> String { - boxes - .iter() - .map(|bbox| format!("{}×{}", bbox.width(), bbox.height())) - .collect::>() - .join(", ") - } - - /// `pixel` 是否落在 `under → fill` 这条混色线上(覆盖率 ≥30%、偏离线 ≤14)。 - /// - /// 覆盖率 ≥30% 是为了把**底色本身**排除(否则整片背景会连成一块); - /// 偏离线 ≤14 是为了把「另一种深色墨」排除(它不在线上,见 [`ink_boxes`] 的坑 2)。 - fn on_ramp(pixel: [u8; 3], fill: [u8; 3], under: [u8; 3]) -> bool { - let axis = [0, 1, 2].map(|i| f32::from(fill[i]) - f32::from(under[i])); - let offset = [0, 1, 2].map(|i| f32::from(pixel[i]) - f32::from(under[i])); - let square: f32 = axis.iter().map(|a| a * a).sum(); - if square <= f32::EPSILON { - return false; - } - let t: f32 = (0..3).map(|i| axis[i] * offset[i]).sum::() / square; - // 覆盖率 ≥50%(半个像素,取几何边界;更低会把 30% 的抗锯齿也算进去,Ø10 量成 12) - (0.5..=1.0).contains(&t) - && (0..3) - .map(|i| (offset[i] - t * axis[i]).powi(2)) - .sum::() - .sqrt() - <= 14.0 - } - - /// 一条测试覆盖全部几何断言(浅/深各跑一次)。 - #[test] - fn selection_geometry_matches_the_reference() { - let Some(path) = shot_path() else { - eprintln!( - "[selection-probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\ - 需要先出图(见本文件模块文档,注意 ULL_GALLERY_SECTION=selection)。" - ); - return; - }; - let shot = Shot::load(&path).expect("读 selection 截图"); - assert_eq!( - (shot.width, shot.height), - (1180, 760), - "验收图必须 1180×760 @100% DPI" - ); - let light = matches( - shot.at(2, 2), - rgb(ui(SchemeFlavor::Light).color(Role::Surface)), - ); - let flavor = if light { - SchemeFlavor::Light - } else { - SchemeFlavor::Dark - }; - let ui = ui(flavor); - let surface = ui.color(Role::Surface); - let primary = ui.color(Role::Primary); - let on_primary = ui.color(Role::OnPrimary); - let unselected = ui.color(Role::OnSurfaceVariant); - - // ---- §18.1 / §18.3 / §19.1:20 的外缘,选中与未选中**同尺寸** ----------------- - // 选中/部分选中(填 `Primary`)与 Radio 环都该是 20×20;**描边若向外画**,未选中会是 22。 - let filled = ink_boxes(&shot, primary, surface, 15, 24); - let twenty: Vec = filled - .iter() - .copied() - .filter(|bbox| near(bbox.width(), 20) && near(bbox.height(), 20)) - .collect(); - assert!( - twenty.len() >= 8, - "18.1:至少 8 个 20×20 的 Primary 块(checkbox 选中/部分选中 + radio 环),\ - 实际 {} 个,尺寸:{}", - twenty.len(), - dims(&filled) - ); - - let candidates = ink_boxes(&shot, unselected, surface, 17, 24); - let rings: Vec = candidates - .iter() - .copied() - .filter(|bbox| !matches(shot.at(center(bbox).0, bbox.top + 3), rgb(unselected))) - .collect(); - assert!( - !rings.is_empty(), - "18.2:没找到未选中的复选框(OnSurfaceVariant 描边、内部透明);17~24 候选:{}", - dims(&candidates) - ); - for bbox in &rings { - assert!( - near(bbox.width(), 20) && near(bbox.height(), 20), - "18.3:未选中外缘必须与选中同尺寸 20(描边向内收),实测 {}×{} @ {:?}\ - (22 = 描边画到框外,是历史错误)", - bbox.width(), - bbox.height(), - (bbox.left, bbox.top) - ); - assert!( - !matches(shot.at(center(bbox).0, center(bbox).1), rgb(unselected)), - "18.2:未选中框内部必须透出底色" - ); - } - // A1:hover/pressed/focused 三列的未选中描边提到 `OnSurface` - let on_surface = ui.color(Role::OnSurface); - let lifted = ink_boxes(&shot, on_surface, surface, 17, 24) - .iter() - .filter(|bbox| !matches(shot.at(center(bbox).0, bbox.top + 3), rgb(on_surface))) - .count(); - assert!( - lifted >= 3, - "A1:未选中复选框在 hover/pressed/focused 的描边应是 OnSurface(3 个),实际 {lifted}" - ); - - // ---- §19.2:Radio 内点 **10 不是 12**;§19.6:环是真圆 ------------------------ - // 环/实心看**上沿内缩 3px 的点**(环在那里是内孔);不看正中心——短横正好压中心。 - let mut dots = 0; - for bbox in &twenty { - if matches(shot.at(center(bbox).0, bbox.top + 3), rgb(primary)) { - continue; - } - assert!( - !matches(shot.at(bbox.left + 1, bbox.top + 1), rgb(primary)), - "19.6:Radio 外圈必须是真圆(不是圆角方)@ {:?}", - (bbox.left, bbox.top) - ); - let dot = inner_ink(&shot, bbox, primary, surface, 4).expect("选中的 radio 必须有内点"); - assert!( - near(dot.width(), 10) && near(dot.height(), 10), - "19.2:Radio 内点可视直径 10(12 是含描边前的名义值),实测 {}×{}", - dot.width(), - dot.height() - ); - assert_ne!(dot.width(), 12, "19.2:照名义值抄 12 会明显偏大"); - dots += 1; - } - assert!( - dots >= 4, - "19.2:至少 4 个选中的 radio(四态),实际 {dots}" - ); - - // ---- §18.4 / §18.6:勾形墨迹跨度;indeterminate 是**短横**不是勾 -------------- - let mut bars = 0; - let mut checks = 0; - for bbox in &twenty { - let Some(glyph) = inner_ink(&shot, bbox, on_primary, primary, 3) else { - continue; - }; - if glyph.height() <= 4 { - bars += 1; - assert!( - glyph.width() >= 12, - "18.6:indeterminate 必须是居中短横,实测 {}×{}", - glyph.width(), - glyph.height() - ); - } else { - checks += 1; - // 端点 12 + 方头两侧各 2/√2 ≈ 1.41 → 墨迹 ≈14.8(见 `ControlGeometry`) - assert!( - (13..=16).contains(&glyph.width()), - "18.4:勾形墨迹横向应 ≈14.8(端点 12 + 方头外扩),实测 {}", - glyph.width() - ); - assert!( - glyph.height() >= 6, - "18.4:勾形有折点,实测高 {}", - glyph.height() - ); - } - } - assert!( - bars >= 4 && checks >= 4, - "18.4/18.6:勾 {checks} 个、短横 {bars} 个(各应 ≥4)" - ); - - // ---- §18.5 / §19.7:状态层直径 40,且是**圆**(外接框四角不许有墨)------------- - let hover_layer = interaction::overlay_color( - &ui, - Interaction::Hovered, - Role::OnSurfaceVariant, - Role::Surface, - ) - .expect("hover 有状态层"); - let circles = ink_boxes(&shot, hover_layer, surface, 37, 43); - assert!( - circles - .iter() - .any(|bbox| near(bbox.width(), 40) && near(bbox.height(), 40)), - "18.5:没找到 Ø40 的状态层(颜色 {:?};实测尺寸:{})", - rgb(hover_layer), - dims(&circles) - ); - for circle in circles - .iter() - .filter(|bbox| near(bbox.width(), 40) && near(bbox.height(), 40)) - { - for (x, y) in [(circle.left, circle.top), (circle.right, circle.bottom)] { - assert!( - !matches(shot.at(x, y), rgb(hover_layer)), - "18.5:状态层必须是圆(外接框四角不该有墨)@ ({x},{y})" - ); - } - } - - // ---- §10.1 Switch 轨道 52×32 · §13.1 Chip 高 32 · §18.8 禁用 38% ------------- - let on_tracks = ink_boxes(&shot, primary, surface, 28, 57); - assert!( - on_tracks - .iter() - .any(|bbox| near(bbox.width(), 52) && near(bbox.height(), 32)), - "10.1:选中的 switch 轨道也是 52×32,实测 {}", - dims(&on_tracks) - ); - let chips = ink_boxes(&shot, ui.color(Role::SecondaryContainer), surface, 20, 140); - assert!( - chips - .iter() - .any(|bbox| near(bbox.height(), 32) && bbox.width() >= 58), - "13.1:选中的 filter chip 高必须 32,实测 {}", - dims(&chips) - ); - let disabled_ink = interaction::disabled_content_over_role(&ui, Role::OnSurface, false); - let disabled = ink_boxes(&shot, disabled_ink, surface, 17, 24); - assert!( - disabled.len() >= 2, - "18.8:禁用态复选框/单选环应存在(on-surface@38%),实际 {} 个", - disabled.len() - ); - - eprintln!( - "[selection-probe] OK flavor={flavor:?}:20(±1) Primary 块 {} · 未选中环 {} · 内点 {dots} · \ - 勾 {checks} / 短横 {bars} · 状态层 {} · 禁用块 {}", - twenty.len(), - rings.len(), - circles.len(), - disabled.len() - ); - } -} diff --git a/crates/app-iced/src/components/selection/mod.rs b/crates/app-iced/src/components/selection/mod.rs index de52f1d..1fae516 100644 --- a/crates/app-iced/src/components/selection/mod.rs +++ b/crates/app-iced/src/components/selection/mod.rs @@ -33,6 +33,10 @@ pub mod geometry; pub mod matrix; pub mod paint; +#[cfg(test)] +mod pixel; +#[cfg(test)] +mod probe; #[cfg(test)] mod tests; diff --git a/crates/app-iced/src/components/selection/pixel.rs b/crates/app-iced/src/components/selection/pixel.rs new file mode 100644 index 0000000..d5528ab --- /dev/null +++ b/crates/app-iced/src/components/selection/pixel.rs @@ -0,0 +1,205 @@ +//! 像素取证的小工具(**仅测试**):读 PNG、按「底色→墨色混色线」找连通块、量内缩墨迹。 +//! +//! 为什么不去用 `gallery/scan.rs`:它的可见性是 `pub(in crate::app::components::gallery)`, +//! 跨目录拿不到;这里只重写 4 个纯函数(`rgb` / `matches` / 像素访问 / 外接框),其余判据 +//! ([`on_ramp`] / [`ink_boxes`] / [`inner_ink`])是一次性的测量逻辑,本来就只服务本家族。 + +use std::path::PathBuf; + +use iced::Color; + +/// 8bit RGB。 +pub(super) fn rgb(color: Color) -> [u8; 3] { + [ + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8, + ] +} + +/// 容差比较(抗锯齿/取整)。 +pub(super) fn matches(pixel: [u8; 3], expected: [u8; 3]) -> bool { + pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 10) +} + +/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按工作区根解释)。 +pub(super) fn shot_path() -> Option { + let path = PathBuf::from(std::env::var_os("ULL_GALLERY_SHOT")?); + if path.is_absolute() || path.exists() { + return Some(path); + } + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + Some(root.join(path)) +} + +/// 一张图的像素访问器(只做本家族需要的事)。 +pub(super) struct Shot { + pub(super) width: usize, + pub(super) height: usize, + pixels: Vec<[u8; 3]>, +} + +impl Shot { + pub(super) fn load(path: &std::path::Path) -> Result { + let bytes = std::fs::read(path).map_err(|error| format!("读不动 {path:?}:{error}"))?; + let image = image::load_from_memory(&bytes) + .map_err(|error| format!("解 PNG 失败:{error}"))? + .to_rgb8(); + let (width, height) = (image.width() as usize, image.height() as usize); + Ok(Self { + width, + height, + pixels: image.pixels().map(|p| p.0).collect(), + }) + } + + pub(super) fn at(&self, x: usize, y: usize) -> [u8; 3] { + self.pixels[y * self.width + x] + } +} + +/// 外接框(闭区间)。 +#[derive(Debug, Clone, Copy)] +pub(super) struct BBox { + pub(super) left: usize, + pub(super) right: usize, + pub(super) top: usize, + pub(super) bottom: usize, +} + +impl BBox { + pub(super) fn new(x: usize, y: usize) -> Self { + Self { + left: x, + right: x, + top: y, + bottom: y, + } + } + + pub(super) fn include(&mut self, x: usize, y: usize) { + self.left = self.left.min(x); + self.right = self.right.max(x); + self.top = self.top.min(y); + self.bottom = self.bottom.max(y); + } + + pub(super) fn width(&self) -> usize { + self.right - self.left + 1 + } + + pub(super) fn height(&self) -> usize { + self.bottom - self.top + 1 + } +} + +pub(super) fn center(bbox: &BBox) -> (usize, usize) { + ((bbox.left + bbox.right) / 2, (bbox.top + bbox.bottom) / 2) +} + +/// 尺寸容差 ±1(rubric §6 的默认容差):控件常落在半像素上,实测会多一行抗锯齿。 +pub(super) fn near(value: usize, expected: usize) -> bool { + value.abs_diff(expected) <= 1 +} + +pub(super) fn dims(boxes: &[BBox]) -> String { + boxes + .iter() + .map(|bbox| format!("{}×{}", bbox.width(), bbox.height())) + .collect::>() + .join(", ") +} + +/// `pixel` 是否落在 `under → fill` 这条混色线上(覆盖率 ≥50%、偏离线 ≤14)。 +/// +/// 覆盖率 ≥50% 取几何边界(30% 会把半格抗锯齿也算进去,Ø10 量成 12); +/// 偏离线 ≤14 把「另一种深色墨」排除(它不在线上,见 [`ink_boxes`] 的坑 2)。 +fn on_ramp(pixel: [u8; 3], fill: [u8; 3], under: [u8; 3]) -> bool { + let axis = [0, 1, 2].map(|i| f32::from(fill[i]) - f32::from(under[i])); + let offset = [0, 1, 2].map(|i| f32::from(pixel[i]) - f32::from(under[i])); + let square: f32 = axis.iter().map(|a| a * a).sum(); + if square <= f32::EPSILON { + return false; + } + let t: f32 = (0..3).map(|i| axis[i] * offset[i]).sum::() / square; + (0.5..=1.0).contains(&t) + && (0..3) + .map(|i| (offset[i] - t * axis[i]).powi(2)) + .sum::() + .sqrt() + <= 14.0 +} + +/// 「墨迹块」:`fill` 与「底色→fill 的混色」都算墨,按外接框尺寸筛。 +/// +/// 踩过的两个坑:① 按「精确色」连通会把圆形的环切成碎片(每片面积很小,量不到内点); +/// ② 按「更接近 fill 还是底色」判会让**另一种深色墨**混进来(`OnSurfaceVariant` 比 `Primary` +/// 更接近深色,于是未选中的框被当成 20×20 的 Primary 块)。所以判据是「落在混色线上」。 +pub(super) fn ink_boxes( + shot: &Shot, + color: Color, + under: Color, + min: usize, + max: usize, +) -> Vec { + let (fill, base) = (rgb(color), rgb(under)); + let mut seen = vec![false; shot.width * shot.height]; + let mut boxes = Vec::new(); + + for y in 0..shot.height { + for x in 0..shot.width { + if seen[y * shot.width + x] || !on_ramp(shot.at(x, y), fill, base) { + continue; + } + let mut stack = vec![(x, y)]; + seen[y * shot.width + x] = true; + let mut bbox = BBox::new(x, y); + while let Some((cx, cy)) = stack.pop() { + bbox.include(cx, cy); + for (nx, ny) in [ + (cx + 1, cy), + (cx.saturating_sub(1), cy), + (cx, cy + 1), + (cx, cy.saturating_sub(1)), + ] { + let inside = nx < shot.width && ny < shot.height; + if inside && !seen[ny * shot.width + nx] && on_ramp(shot.at(nx, ny), fill, base) + { + seen[ny * shot.width + nx] = true; + stack.push((nx, ny)); + } + } + } + if (min..=max).contains(&bbox.width()) && (min..=max).contains(&bbox.height()) { + boxes.push(bbox); + } + } + } + + boxes +} + +/// `bbox` 内缩 `inset` 后、落在 **`under → color` 混色线上**的墨迹框(量内点 / 勾形用)。 +/// +/// 内缩 4 而不是 3(描边内缘 AA 正好压在 3 上,会把 Ø10 量成 14);用混色线而不是精确色 +/// (Ø10 的圆只有 ~8×8 是精确色,边缘 1px 全是抗锯齿)。 +pub(super) fn inner_ink( + shot: &Shot, + bbox: &BBox, + color: Color, + under: Color, + inset: usize, +) -> Option { + let mut ink: Option = None; + for y in (bbox.top + inset)..=(bbox.bottom - inset) { + for x in (bbox.left + inset)..=(bbox.right - inset) { + if on_ramp(shot.at(x, y), rgb(color), rgb(under)) { + match &mut ink { + Some(found) => found.include(x, y), + None => ink = Some(BBox::new(x, y)), + } + } + } + } + ink +} diff --git a/crates/app-iced/src/components/selection/probe.rs b/crates/app-iced/src/components/selection/probe.rs new file mode 100644 index 0000000..a91bb1d --- /dev/null +++ b/crates/app-iced/src/components/selection/probe.rs @@ -0,0 +1,321 @@ +//! 选择类家族的**渲染层**几何断言:对着真截图量像素(M68 的教训:纯函数对、图可能是错的)。 +//! +//! 为什么住在 `selection/` 而不是 `gallery/`:`gallery/selection.rs` 只负责「把矩阵摆进 harness」, +//! 断言加上去会把它顶到 400 行红线。像素扫描的 4 个纯函数在 [`super::pixel`](同一目录、`cfg(test)`)。 +//! +//! 量什么(每条对一个具体错误,容差 ±1): +//! +//! - **Checkbox 外缘 20,且选中/未选中同尺寸**(§18.3:描边向内收;向外画会变 22)——逐块断言; +//! - **Radio 内点 10 不是 12**(§19.2)· 环是真圆、不是圆角方(§19.6); +//! - **状态层直径 40 且四角无墨**(§18.5 / §19.7)——**每一块**都查; +//! - 勾形墨迹 ≈14.8(端点 12 + 方头外扩)· **indeterminate 是短横**(§18.4/§18.6); +//! - Switch 轨道**恰好一块** 52×32(§10.1)· Chip **恰好一块** 32 高(§13.1); +//! - 禁用:chip 只降不删(P2-1)· 选中禁用的 Checkbox 容器是 38% 不是 12%(P2-3)。 +//! +//! 复现(worktree 根目录): +//! +//! ```text +//! cargo build -p unlucky-app-iced --bin unlucky-launcher-iced +//! ULL_GALLERY_SECTION=selection ./target/debug/unlucky-launcher-iced \ +//! --software-renderer --screen=gallery \ +//! --screenshot target/m76/selection-light.png --screenshot-theme=light +//! ULL_GALLERY_SHOT=target/m76/selection-light.png cargo test -p unlucky-app-iced selection::probe +//! ``` + +use unlucky_design::color::{Role, SchemeFlavor}; +use unlucky_design::typography::Platform; + +use super::super::interaction::{self, Interaction}; +use super::pixel::{center, dims, ink_boxes, inner_ink, matches, near, rgb, shot_path, Shot}; +use crate::theme::{Families, Ui}; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) +} + +/// 一条测试覆盖全部几何断言(浅/深各跑一次)。 +#[test] +fn selection_geometry_matches_the_reference() { + let Some(path) = shot_path() else { + eprintln!( + "[selection-probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\ + 需要先出图(见本文件模块文档,注意 ULL_GALLERY_SECTION=selection)。" + ); + return; + }; + let shot = Shot::load(&path).expect("读 selection 截图"); + assert_eq!( + (shot.width, shot.height), + (1180, 760), + "验收图必须 1180×760 @100% DPI" + ); + let light = matches( + shot.at(2, 2), + rgb(ui(SchemeFlavor::Light).color(Role::Surface)), + ); + let flavor = if light { + SchemeFlavor::Light + } else { + SchemeFlavor::Dark + }; + let ui = ui(flavor); + let surface = ui.color(Role::Surface); + let primary = ui.color(Role::Primary); + let on_primary = ui.color(Role::OnPrimary); + let unselected = ui.color(Role::OnSurfaceVariant); + + // ---- §18.1 / §18.3 / §19.1:20 的外缘,选中与未选中**同尺寸** ----------------- + // 逐块断言(复审 P2-4):**每一块** Primary 都必须是 20(±1)——任何一块到 22 就说明描边 + // 画到了框外。只数「20×20 有几块」的时候,「把其中一格放大成 22」照样绿。 + let filled = ink_boxes(&shot, primary, surface, 15, 24); + for bbox in &filled { + assert!( + bbox.width() <= 21 && bbox.height() <= 21, + "18.3:Primary 块不得超过 21(22 = 描边画到框外),实测 {}×{} @ {:?}", + bbox.width(), + bbox.height(), + (bbox.left, bbox.top) + ); + } + let twenty: Vec<_> = filled + .iter() + .copied() + .filter(|bbox| near(bbox.width(), 20) && near(bbox.height(), 20)) + .collect(); + assert_eq!( + twenty.len(), + 12, + "18.1:应**恰好** 12 块 20(±1) 的 Primary(checkbox 选中 4 + 部分选中 4 + radio 环 4),\ + 实际 {}:{}", + twenty.len(), + dims(&filled) + ); + + // 未选中(OnSurfaceVariant 描边):逐块都必须 20(±1),且内部透底 + let candidates = ink_boxes(&shot, unselected, surface, 17, 24); + let rings: Vec<_> = candidates + .iter() + .copied() + .filter(|bbox| !matches(shot.at(center(bbox).0, bbox.top + 3), rgb(unselected))) + .collect(); + assert!( + !rings.is_empty(), + "18.2:没找到未选中的复选框;候选:{}", + dims(&candidates) + ); + for bbox in &rings { + assert!( + near(bbox.width(), 20) && near(bbox.height(), 20), + "18.3:未选中外缘必须与选中同尺寸 20(描边向内收),实测 {}×{} @ {:?}", + bbox.width(), + bbox.height(), + (bbox.left, bbox.top) + ); + assert!( + !matches(shot.at(center(bbox).0, center(bbox).1), rgb(unselected)), + "18.2:未选中框内部必须透出底色" + ); + } + // A1:hover/pressed/focused 三列的未选中描边提到 `OnSurface` + let on_surface = ui.color(Role::OnSurface); + let lifted: Vec<_> = ink_boxes(&shot, on_surface, surface, 17, 24) + .into_iter() + .filter(|bbox| !matches(shot.at(center(bbox).0, bbox.top + 3), rgb(on_surface))) + .collect(); + assert!( + lifted.len() >= 3, + "A1:未选中复选框在 hover/pressed/focused 的描边应是 OnSurface(3 个),实际 {}", + lifted.len() + ); + for bbox in &lifted { + assert!( + near(bbox.width(), 20) && near(bbox.height(), 20), + "18.3:提到 OnSurface 之后外缘仍是 20,实测 {}×{}", + bbox.width(), + bbox.height() + ); + } + + // ---- §19.2:Radio 内点 **10 不是 12**;§19.6:环是真圆 ------------------------ + // 环/实心看**上沿内缩 3px 的点**(环在那里是内孔);不看正中心——短横正好压中心。 + let mut dots = 0; + for bbox in &twenty { + if matches(shot.at(center(bbox).0, bbox.top + 3), rgb(primary)) { + continue; + } + assert!( + !matches(shot.at(bbox.left + 1, bbox.top + 1), rgb(primary)), + "19.6:Radio 外圈必须是真圆(不是圆角方)@ {:?}", + (bbox.left, bbox.top) + ); + let dot = inner_ink(&shot, bbox, primary, surface, 4).expect("选中的 radio 必须有内点"); + assert!( + near(dot.width(), 10) && near(dot.height(), 10), + "19.2:Radio 内点可视直径 10(12 是含描边前的名义值),实测 {}×{}", + dot.width(), + dot.height() + ); + assert_ne!(dot.width(), 12, "19.2:照名义值抄 12 会明显偏大"); + dots += 1; + } + assert_eq!( + dots, 4, + "19.2:应恰好 4 个选中的 radio(四态),实际 {dots}" + ); + + // ---- §18.4 / §18.6:勾形墨迹跨度;indeterminate 是**短横**不是勾 -------------- + let mut bars = 0; + let mut checks = 0; + for bbox in &twenty { + let Some(glyph) = inner_ink(&shot, bbox, on_primary, primary, 3) else { + continue; + }; + if glyph.height() <= 4 { + bars += 1; + assert!( + glyph.width() >= 12, + "18.6:indeterminate 必须是居中短横,实测 {}×{}", + glyph.width(), + glyph.height() + ); + } else { + checks += 1; + // 端点 12 + 方头两侧各 2/√2 ≈ 1.41 → 墨迹 ≈14.8(见 `ControlGeometry`) + assert!( + (13..=16).contains(&glyph.width()), + "18.4:勾形墨迹横向应 ≈14.8(端点 12 + 方头外扩),实测 {}", + glyph.width() + ); + assert!( + glyph.height() >= 6, + "18.4:勾形有折点,实测高 {}", + glyph.height() + ); + } + } + assert!( + bars >= 4 && checks >= 4, + "18.4/18.6:勾 {checks} 个、短横 {bars} 个(各应 ≥4)" + ); + + // ---- §18.5 / §19.7:状态层直径 40,且**每一块**都是圆(四角无墨)---------------- + let hover_layer = interaction::overlay_color( + &ui, + Interaction::Hovered, + Role::OnSurfaceVariant, + Role::Surface, + ) + .expect("hover 有状态层"); + let circles: Vec<_> = ink_boxes(&shot, hover_layer, surface, 37, 43) + .into_iter() + .filter(|bbox| near(bbox.width(), 40) && near(bbox.height(), 40)) + .collect(); + assert!( + circles.len() >= 5, + "18.5:应找到 ≥5 块 Ø40 状态层(checkbox×3 + radio + switch),实测 {}:{}", + circles.len(), + dims(&circles) + ); + for circle in &circles { + for (x, y) in [ + (circle.left, circle.top), + (circle.right, circle.top), + (circle.left, circle.bottom), + (circle.right, circle.bottom), + ] { + assert!( + !matches(shot.at(x, y), rgb(hover_layer)), + "18.5:状态层必须是圆(外接框四角不该有墨)@ ({x},{y})" + ); + } + } + + // ---- §10.1 Switch 轨道 52×32 · §13.1 Chip 高 32 ------------------------------- + let on_tracks: Vec<_> = ink_boxes(&shot, primary, surface, 28, 57) + .into_iter() + .filter(|bbox| near(bbox.width(), 52) && near(bbox.height(), 32)) + .collect(); + assert_eq!( + on_tracks.len(), + 1, + "10.1:选中的 switch 轨道应**恰好一块** 52×32,实测 {}:{}", + on_tracks.len(), + dims(&on_tracks) + ); + let chips: Vec<_> = ink_boxes(&shot, ui.color(Role::SecondaryContainer), surface, 20, 140) + .into_iter() + .filter(|bbox| near(bbox.height(), 32)) + .collect(); + assert_eq!( + chips.len(), + 1, + "13.1:选中的 filter chip 应**恰好一块**(32 高),实测 {}:{}", + chips.len(), + dims(&chips) + ); + assert!( + chips[0].width() >= 58, + "13.1:chip 宽应 ≥58(图标 16 + 两侧各 8 + 文字),实测 {}", + chips[0].width() + ); + + // ---- 复审 P2-1:禁用的 chip **只降不删**(图标 / 选中态都要在)------------------ + let disabled_container = interaction::disabled_colors(&ui, Role::Surface).0; + let disabled_chips: Vec<_> = ink_boxes(&shot, disabled_container, surface, 28, 140) + .into_iter() + .filter(|bbox| near(bbox.height(), 32) && bbox.width() >= 58) + .collect(); + assert_eq!( + disabled_chips.len(), + 4, + "P2-1:禁用列应有 4 个**完整宽度**的 chip(图标没被删掉),实测 {}:{}", + disabled_chips.len(), + dims(&disabled_chips) + ); + assert!( + disabled_chips.iter().any(|bbox| matches( + shot.at(center(bbox).0, center(bbox).1), + rgb(disabled_container) + )), + "P2-1:禁用列里 filter·on 必须仍是**填充**容器(选中态不能被丢掉)" + ); + + // ---- 复审 P2-3:**选中且禁用**的 Checkbox 容器是 38%,不是 12% ----------------- + let disabled_ink = interaction::disabled_content_over_role(&ui, Role::OnSurface, false); + let ink38: Vec<_> = ink_boxes(&shot, disabled_ink, surface, 17, 24) + .into_iter() + .filter(|bbox| near(bbox.width(), 20) && near(bbox.height(), 20)) + .collect(); + assert!( + ink38.len() >= 3, + "18.8:禁用列应有 ≥3 块 20(±1) 的 38% 叠色(选中禁用填充 + 未选中禁用描边 + radio 环),\ + 实测 {}:{}", + ink38.len(), + dims(&ink38) + ); + let container_20: Vec<_> = ink_boxes(&shot, disabled_container, surface, 19, 21) + .into_iter() + .filter(|bbox| near(bbox.width(), 20) && near(bbox.height(), 20)) + .collect(); + assert!( + container_20.is_empty(), + "P2-3:20×20 的禁用块不许是 12% 容器色(那是普通禁用容器,选中禁用是 38%),实测 {}", + dims(&container_20) + ); + + eprintln!( + "[selection-probe] OK flavor={flavor:?}:20(±1) Primary 块 {} · 未选中环 {} · 内点 {dots} · \ + 勾 {checks} / 短横 {bars} · 状态层 {} · 禁用 38% {} · 禁用 chip {}", + twenty.len(), + rings.len(), + circles.len(), + ink38.len(), + disabled_chips.len() + ); +}