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:
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()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user