test(selection): 像素判据从「存在性」改成「逐处都对」(复审 P2-4)

- 20(±1) 的 Primary 块:**每一块**都不许超过 21(22 = 描边画到框外),且总数恰好 12
- 状态层:**每一块** Ø40 都要四角无墨(原来只看两块角点),数量 ≥5
- switch 轨道 **恰好一块** 52×32、chip **恰好一块** 32 高(原来 any(...) 就算过)
- 新增 P2-1 / P2-3 的像素断言:禁用 chip 4 块完整宽度 + filter·on 仍填充;20×20 禁用块不许是 12%
- 断言与像素扫描搬到 selection/probe.rs + selection/pixel.rs:gallery/selection.rs 只剩「摆矩阵」
  (原文件 458 行,超 400 红线)
This commit is contained in:
AskaEth committed 2026-11-02 06:55:34 +08:00
1 parent 0c9d4049b3
commit 4155429f45
4 files changed
+539 -368

No files matched your search

@@ -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<PathBuf> {
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<BBox> {
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<BBox> {
let mut ink: Option<BBox> = 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::<Vec<_>>()
.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::<f32>() / square;
// 覆盖率 ≥50%(半个像素,取几何边界;更低会把 30% 的抗锯齿也算进去,Ø10 量成 12)
(0.5..=1.0).contains(&t)
&& (0..3)
.map(|i| (offset[i] - t * axis[i]).powi(2))
.sum::<f32>()
.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<BBox> = 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<BBox> = 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()
);
}
}
@@ -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;
@@ -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<PathBuf> {
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<Self, String> {
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::<Vec<_>>()
.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::<f32>() / square;
(0.5..=1.0).contains(&t)
&& (0..3)
.map(|i| (offset[i] - t * axis[i]).powi(2))
.sum::<f32>()
.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<BBox> {
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<BBox> {
let mut ink: Option<BBox> = 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
}
@@ -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()
);
}