StyleSpec.forced 从 Interaction 改成 Option<Interaction>:Some(Enabled) 与 None 语义不同—— 前者强制画静息,后者才跟随 button::Status。修前光标恰好停在某个格子上时,gallery 的 enabled 列会被画成 hover(表现为 text 行出现不该有的灰底容器,看着像 Text 按钮自带容器)。
228 lines
8.8 KiB
Rust
228 lines
8.8 KiB
Rust
//! 渲染层几何断言:**对着真截图量像素**(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<std::path::PathBuf> {
|
||
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
|
||
);
|
||
}
|