test(app-iced): M77 渲染层 probe —— 逐行几何断言(5 条,能被变异打红)
M68 的教训:纯函数返回对、图可能是错的。`inputs/probe.rs` 对着**真窗截图**量像素, 没给 `ULL_INPUTS_DIR` 就大声跳过(与 `gallery::probe` 同一口径)。 量什么(每条对应一次真实可能画错的形态): - 文本框高**精确 56**(320 宽格)、Filled 指示线 静息 1dp / 聚焦 2dp 的墨量比; - Outlined 描边 1dp / 2dp(描边墨量随亚像素相位在 1.0~1.67 浮动,所以用**比值**判); - 错误态:框内标签与框下辅助文本都是 `Error` 墨迹; - 搜索条高 56、前置图标墨迹偏移 20(= pad 12 + offset 4 + viewBox ≈4)、 docked 面板 宽 360 / 头 56 / 1px `Outline` 分隔 / 结果区 240; - Slider 竖条 4×44、0% 贴左、50% 分界在正中、100% 无未激活段(thumb 不越界); - 菜单容器 161(= 8 + 48×3 + 1 + 8)、项高 48、选中指示器内缩 4、分组线 1dp。 像素工具因 `gallery::scan` 是私有模块而自带一份最小读图器(约 60 行),已报 finding。
This commit is contained in:
1 parent
ddd234b3c8
commit
21f2734981
6 files changed
+809
No files matched your search
@@ -31,6 +31,8 @@ pub mod geometry;
|
||||
|
||||
mod field;
|
||||
mod menu;
|
||||
#[cfg(test)]
|
||||
mod probe;
|
||||
mod search;
|
||||
mod slider;
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
//! 渲染层几何断言(D30 / M68 的教训:**纯函数对 ≠ 图画对**)。
|
||||
//!
|
||||
//! ## 为什么这个文件在 `inputs/` 而不是 `gallery/probe.rs`
|
||||
//!
|
||||
//! 像素工具 `gallery::scan` 是 `mod scan;`(**私有**于 `gallery`),我的 probe 拿不到它,
|
||||
//! 而 `gallery/mod.rs` 不在本 mission 的 scope 里。所以这里自带一份最小的读图器
|
||||
//! (约 40 行:读 PNG / 取点 / 连通块 / 行覆盖),判据全在本文件——已作为 finding 报给 tower
|
||||
//! (建议把 `gallery::scan` 提升为 `pub(crate)`,四个家族就能共用一份)。
|
||||
//!
|
||||
//! ## 怎么跑(worktree 根目录)
|
||||
//!
|
||||
//! ```text
|
||||
//! # ① 出图:副本工程只把 inputs 段提到首位(组件代码逐字节相同),见 target/m77/sync.py
|
||||
//! python target/m77/sync.py && (cd /d/tmp/m77-shot && cargo build --no-default-features ...)
|
||||
//! M77_PART=text ./target/debug/unlucky-launcher-iced.exe --software-renderer --screen=gallery \
|
||||
//! --screenshot target/m77/text-light.png --screenshot-theme=light
|
||||
//! # ② 量图
|
||||
//! ULL_INPUTS_DIR=target/m77 cargo test -p unlucky-app-iced inputs::probe
|
||||
//! ```
|
||||
//!
|
||||
//! 没给 `ULL_INPUTS_DIR` 就**大声跳过**(不静默通过),与 `gallery::probe` 的口径一致。
|
||||
//!
|
||||
//! ## 量什么(每条都对着一个「已经可能画错」的形态)
|
||||
//!
|
||||
//! | 断言 | 抓的错误 |
|
||||
//! |---|---|
|
||||
//! | 文本框高 **56**、宽 **320** | 高度被内容/内边距吃掉(不是 56 的 A 级值) |
|
||||
//! | Filled 底部指示线 静息 **1.0** / 聚焦 **2.0** 墨量 | 把 2dp 画成 1dp(或反过来) |
|
||||
//! | Outlined 描边 静息 **1.0** / 聚焦 **2.0** 墨量 | 同上 |
|
||||
//! | 错误态:标签 / 线 / 辅助文本全 `Error` | 只把线染红、忘了标签与辅助文本 |
|
||||
//! | 搜索条高 **56**、图标墨迹偏移 **20**(= 16 + viewBox 4) | 手抄 16/20 当图标边、或 pad 与 offset 少加一段 |
|
||||
//! | docked 面板 宽 **360** / 头 **56** / 分隔线 **1.0 `Outline`** / 体 **240** | 用 `OutlineVariant`、或头部高度与 bar 不一致 |
|
||||
//! | Slider 0%(竖条贴左)、50%(分界在中央)、100%(无未激活段) | 位移区间取错(0..W 而不是 handle/2..W−handle/2) |
|
||||
//! | Slider 竖条 **4×44** | 画成圆点或撑满命中区 |
|
||||
//! | 菜单项 pitch **48**、选中指示器内缩 **4**、容器高 **161** | 项高/内边距抄错 |
|
||||
//! | 分组分隔线 **1.0** 墨量 | 分隔线变成 2px |
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_design::color::{Role, SchemeFlavor};
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 颜色比对容差(抗锯齿/取整)。
|
||||
const TOLERANCE: u8 = 10;
|
||||
|
||||
type Rgb = [u8; 3];
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
fn rgb(color: iced::Color) -> Rgb {
|
||||
[
|
||||
(color.r * 255.0).round() as u8,
|
||||
(color.g * 255.0).round() as u8,
|
||||
(color.b * 255.0).round() as u8,
|
||||
]
|
||||
}
|
||||
|
||||
fn matches(pixel: Rgb, expected: Rgb) -> bool {
|
||||
matches_within(pixel, expected, TOLERANCE)
|
||||
}
|
||||
|
||||
/// 指定容差的同色判断(量**阴影边**时要用更严的容差:阴影叠上去会落在 10 的容差内)。
|
||||
fn matches_within(pixel: Rgb, expected: Rgb, tolerance: u8) -> bool {
|
||||
pixel
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(a, b)| a.abs_diff(b) <= tolerance)
|
||||
}
|
||||
|
||||
/// 一张图的最小访问器(读 PNG / 取点 / 连通块)。
|
||||
struct Shot {
|
||||
width: usize,
|
||||
height: usize,
|
||||
pixels: Vec<Rgb>,
|
||||
}
|
||||
|
||||
/// 一张图里某颜色的连通块外接框(闭区间)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct BBox {
|
||||
left: usize,
|
||||
right: usize,
|
||||
top: usize,
|
||||
bottom: usize,
|
||||
}
|
||||
|
||||
impl BBox {
|
||||
fn width(&self) -> usize {
|
||||
self.right - self.left + 1
|
||||
}
|
||||
|
||||
fn height(&self) -> usize {
|
||||
self.bottom - self.top + 1
|
||||
}
|
||||
}
|
||||
|
||||
impl Shot {
|
||||
fn load(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(),
|
||||
})
|
||||
}
|
||||
|
||||
fn at(&self, x: usize, y: usize) -> Rgb {
|
||||
self.pixels[y * self.width + x]
|
||||
}
|
||||
|
||||
fn bbox_of(&self, color: Rgb, min_area: usize) -> Vec<BBox> {
|
||||
let mut seen = vec![false; self.width * self.height];
|
||||
let mut boxes = Vec::new();
|
||||
for y in 0..self.height {
|
||||
for x in 0..self.width {
|
||||
if seen[y * self.width + x] || !matches(self.at(x, y), color) {
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![(x, y)];
|
||||
seen[y * self.width + x] = true;
|
||||
let mut bbox = BBox {
|
||||
left: x,
|
||||
right: x,
|
||||
top: y,
|
||||
bottom: y,
|
||||
};
|
||||
while let Some((cx, cy)) = stack.pop() {
|
||||
bbox.left = bbox.left.min(cx);
|
||||
bbox.right = bbox.right.max(cx);
|
||||
bbox.top = bbox.top.min(cy);
|
||||
bbox.bottom = bbox.bottom.max(cy);
|
||||
for (nx, ny) in [
|
||||
(cx + 1, cy),
|
||||
(cx.saturating_sub(1), cy),
|
||||
(cx, cy + 1),
|
||||
(cx, cy.saturating_sub(1)),
|
||||
] {
|
||||
if nx < self.width
|
||||
&& ny < self.height
|
||||
&& !seen[ny * self.width + nx]
|
||||
&& matches(self.at(nx, ny), color)
|
||||
{
|
||||
seen[ny * self.width + nx] = true;
|
||||
stack.push((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
if bbox.width() * bbox.height() >= min_area {
|
||||
boxes.push(bbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
boxes
|
||||
}
|
||||
|
||||
fn count_in(&self, color: Rgb, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
|
||||
let mut total = 0;
|
||||
for y in y0..y1.min(self.height) {
|
||||
for x in x0..x1.min(self.width) {
|
||||
if matches(self.at(x, y), color) {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
/// 一行像素相对底色向 `line` 靠拢的比例(0..=1)——1dp 的线在各行上摊开时总和 ≈ 1.0。
|
||||
fn coverage(pixel: Rgb, surface: Rgb, line: Rgb) -> f32 {
|
||||
let channel = |i: usize| {
|
||||
let full = f32::from(line[i]) - f32::from(surface[i]);
|
||||
if full.abs() < 1.0 {
|
||||
return 0.0;
|
||||
}
|
||||
((f32::from(pixel[i]) - f32::from(surface[i])) / full).clamp(0.0, 1.0)
|
||||
};
|
||||
(channel(0) + channel(1) + channel(2)) / 3.0
|
||||
}
|
||||
|
||||
/// 竖直方向上的**线墨量**:`y0..y1` 每行在 x 区间上的平均覆盖之和(1dp ⇒ ≈1.0)。
|
||||
fn line_ink(
|
||||
shot: &Shot,
|
||||
x0: usize,
|
||||
x1: usize,
|
||||
y0: usize,
|
||||
y1: usize,
|
||||
surface: Rgb,
|
||||
line: Rgb,
|
||||
) -> f32 {
|
||||
let mut total = 0.0;
|
||||
for y in y0..y1.min(shot.height) {
|
||||
let mut sum = 0.0;
|
||||
for x in x0..x1.min(shot.width) {
|
||||
sum += coverage(shot.at(x, y), surface, line);
|
||||
}
|
||||
total += sum / (x1 - x0) as f32;
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// 某一行里「两色之一」的最长连续段 `(start, end)`,允许 ≤`max_gap` 的空隙。
|
||||
///
|
||||
/// 用它而不是「本行最左/最右」:深色档的说明文字里有零星像素落在令牌色容差内,
|
||||
/// 取 min/max 会把轨道量成从 x=24 起(实测)。
|
||||
fn longest_run(shot: &Shot, y: usize, a: Rgb, b: Rgb, max_gap: usize) -> Option<(usize, usize)> {
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
let mut start: Option<usize> = None;
|
||||
let mut last: Option<usize> = None;
|
||||
for x in 0..shot.width {
|
||||
if matches(shot.at(x, y), a) || matches(shot.at(x, y), b) {
|
||||
if start.is_none() {
|
||||
start = Some(x);
|
||||
}
|
||||
last = Some(x);
|
||||
} else if let (Some(from), Some(to)) = (start, last) {
|
||||
if x - to > max_gap {
|
||||
if best.is_none_or(|(p, q)| to - from > q - p) {
|
||||
best = Some((from, to));
|
||||
}
|
||||
start = None;
|
||||
last = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(from), Some(to)) = (start, last) {
|
||||
if best.is_none_or(|(p, q)| to - from > q - p) {
|
||||
best = Some((from, to));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// `bbox` 范围内「整行至少 `min_pixels` 个同色像素」的行区间(闭区间)。
|
||||
///
|
||||
/// 菜单的阴影会把 bbox 外扩 ~5px,而阴影行绝不会有 100 个容器色像素——用它把阴影滤掉。
|
||||
fn row_extent(shot: &Shot, bbox: &BBox, color: Rgb, min_pixels: usize) -> (usize, usize) {
|
||||
let mut first = None;
|
||||
let mut last = None;
|
||||
for y in bbox.top..=bbox.bottom {
|
||||
let mut count = 0;
|
||||
for x in bbox.left..=bbox.right {
|
||||
// 容差收到 2:**阴影边**会落在默认容差 10 内,会把容器量高 ~5px
|
||||
if matches_within(shot.at(x, y), color, 2) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count >= min_pixels {
|
||||
first.get_or_insert(y);
|
||||
last = Some(y);
|
||||
}
|
||||
}
|
||||
(
|
||||
first.expect("行内没有足够的同色像素"),
|
||||
last.expect("行内没有足够的同色像素"),
|
||||
)
|
||||
}
|
||||
|
||||
/// `pixel` 更接近 `fill` 还是 `surface`(含平局都算 `fill`)。
|
||||
fn nearer(pixel: Rgb, fill: Rgb, surface: Rgb) -> bool {
|
||||
let dist = |a: Rgb, b: Rgb| -> u32 {
|
||||
a.iter()
|
||||
.zip(b)
|
||||
.map(|(x, y)| (i32::from(*x) - i32::from(y)).unsigned_abs())
|
||||
.sum()
|
||||
};
|
||||
dist(pixel, fill) <= dist(pixel, surface)
|
||||
}
|
||||
|
||||
/// 截图目录(`ULL_INPUTS_DIR`);没给就返回 `None`(调用方大声跳过)。
|
||||
fn shot_dir() -> Option<PathBuf> {
|
||||
let raw = std::env::var_os("ULL_INPUTS_DIR")?;
|
||||
let path = PathBuf::from(raw);
|
||||
if path.is_absolute() || path.is_dir() {
|
||||
return Some(path);
|
||||
}
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
Some(root.join(path))
|
||||
}
|
||||
|
||||
/// 取一张图;缺图直接 panic(给了目录就说明这轮要认真量)。
|
||||
fn shot(dir: &Path, part: &str, flavor: SchemeFlavor) -> Shot {
|
||||
let slug = match flavor {
|
||||
SchemeFlavor::Light => "light",
|
||||
SchemeFlavor::Dark => "dark",
|
||||
};
|
||||
let path = dir.join(format!("{part}-{slug}.png"));
|
||||
let shot = Shot::load(&path).unwrap_or_else(|error| panic!("{error}"));
|
||||
assert_eq!(
|
||||
(shot.width, shot.height),
|
||||
(1180, 760),
|
||||
"验收图必须是 D30 的 1180×760 @100% DPI"
|
||||
);
|
||||
shot
|
||||
}
|
||||
|
||||
/// 一个矩阵格在某列的竖直「实色高度」:从 `bbox.top-4` 往上/下走到不再接近 `fill` 为止。
|
||||
fn vertical_extent(
|
||||
shot: &Shot,
|
||||
bbox: &BBox,
|
||||
fill: Rgb,
|
||||
surface: Rgb,
|
||||
column: usize,
|
||||
) -> (usize, usize) {
|
||||
let mut top = bbox.top;
|
||||
while top > 0 && nearer(shot.at(column, top - 1), fill, surface) {
|
||||
top -= 1;
|
||||
}
|
||||
let mut bottom = bbox.bottom;
|
||||
while bottom + 1 < shot.height && nearer(shot.at(column, bottom + 1), fill, surface) {
|
||||
bottom += 1;
|
||||
}
|
||||
(top, bottom)
|
||||
}
|
||||
|
||||
macro_rules! require_dir {
|
||||
() => {
|
||||
match shot_dir() {
|
||||
Some(dir) => dir,
|
||||
None => {
|
||||
eprintln!(
|
||||
"[inputs::probe] SKIPPED:未设置 ULL_INPUTS_DIR。这是**渲染层**断言,\
|
||||
需要先按本文件模块文档出 8 张图(target/m77/*.png)。"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
mod menu;
|
||||
mod search;
|
||||
mod slider;
|
||||
mod text;
|
||||
@@ -0,0 +1,79 @@
|
||||
//! M77 probe · menu(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 8.3 / 8.4 / 8.6:菜单容器高 161、项 pitch 48、选中指示器内缩 4、分组分隔线 1dp。
|
||||
#[test]
|
||||
fn menu_container_items_and_divider() {
|
||||
let dir = require_dir!();
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let shot = shot(&dir, "menu", flavor);
|
||||
let ui = ui(flavor);
|
||||
let container = rgb(ui.color(Role::SurfaceContainer));
|
||||
let selected = rgb(ui.color(Role::SecondaryContainer));
|
||||
let divider = rgb(ui.color(Role::OutlineVariant));
|
||||
|
||||
// 右侧那只 = 「展开态」菜单。容器色块可能被**分组分隔线 / 选中项**切成多块,
|
||||
// 所以先把 x>290 的块并成一个外接框,再逐行量容器色(容差 2,滤掉阴影外扩的 ~5px)。
|
||||
let menus = shot.bbox_of(container, 2000);
|
||||
let right: Vec<BBox> = menus.iter().filter(|b| b.left > 290).copied().collect();
|
||||
assert!(!right.is_empty(), "找不到右侧的「展开态」菜单");
|
||||
let mut open = right[0];
|
||||
for b in right.iter().skip(1) {
|
||||
open.left = open.left.min(b.left);
|
||||
open.top = open.top.min(b.top);
|
||||
open.right = open.right.max(b.right);
|
||||
open.bottom = open.bottom.max(b.bottom);
|
||||
}
|
||||
let (top, bottom) = row_extent(&shot, &open, container, 1);
|
||||
open.top = top;
|
||||
open.bottom = bottom;
|
||||
|
||||
// 布局高 **161** = 8 + 48×3 + 1 + 8;抗锯齿会吃掉上下各 1 行 ⇒ 实测 160
|
||||
assert!(
|
||||
(159..=162).contains(&open.height()),
|
||||
"8.3/8.4 菜单高应 161(= 8 + 48×3 + 1 + 8):实测 {}",
|
||||
open.height()
|
||||
);
|
||||
|
||||
// 选中项:SecondaryContainer,高 48、左右各内缩 4
|
||||
let selected_box = shot
|
||||
.bbox_of(selected, 2000)
|
||||
.into_iter()
|
||||
.filter(|b| b.height() >= 44 && b.height() <= 50)
|
||||
.max_by_key(|b| b.width())
|
||||
.expect("找不到选中项的 SecondaryContainer");
|
||||
assert!(
|
||||
(46..=48).contains(&selected_box.height()),
|
||||
"8.3 项高应 48:实测 {}",
|
||||
selected_box.height()
|
||||
);
|
||||
// 容器左右缘:在第一项那一行按严格同色扫(bbox 会被阴影外扩 ~2px,不能用)
|
||||
let scan_y = open.top + 10;
|
||||
let row: Vec<usize> = (open.left..=open.right)
|
||||
.filter(|x| matches_within(shot.at(*x, scan_y), container, 2))
|
||||
.collect();
|
||||
let (menu_left, menu_right) = (*row.first().unwrap(), *row.last().unwrap());
|
||||
assert_eq!(selected_box.left - menu_left, 4, "8.6 选中指示器左内缩应 4");
|
||||
assert_eq!(
|
||||
menu_right - selected_box.right,
|
||||
4,
|
||||
"8.6 选中指示器右内缩应 4"
|
||||
);
|
||||
|
||||
// 分组分隔线:1dp OutlineVariant 墨量 ≈1.0(位置在选中项与最后一项之间)
|
||||
let ink = line_ink(
|
||||
&shot,
|
||||
open.left + 20,
|
||||
open.right - 20,
|
||||
selected_box.bottom + 1,
|
||||
selected_box.bottom + 6,
|
||||
container,
|
||||
divider,
|
||||
);
|
||||
assert!(
|
||||
(0.6..=1.4).contains(&ink),
|
||||
"8.x 分组分隔线应 1dp `OutlineVariant`:实测墨量 {ink:.2}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! M77 probe · search(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 20.1 / 20.4 / 20.6 / 20.8 / 20.9:条 56、图标墨迹偏移 20(= 16 + viewBox 4)、
|
||||
/// docked 面板 360 / 头 56 / 分隔线 1.0 `Outline` / 体 240。
|
||||
#[test]
|
||||
fn search_bar_and_docked_panel_geometry() {
|
||||
let dir = require_dir!();
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let shot = shot(&dir, "search", flavor);
|
||||
let ui = ui(flavor);
|
||||
let bar_color = rgb(ui.color(Role::SurfaceContainerHigh));
|
||||
let ink = rgb(ui.color(Role::OnSurface));
|
||||
|
||||
let bars = shot.bbox_of(bar_color, 2000);
|
||||
let bar = bars
|
||||
.iter()
|
||||
.filter(|b| b.height() <= 60 && b.width() >= 360)
|
||||
.min_by_key(|b| b.top)
|
||||
.copied()
|
||||
.expect("找不到搜索条(高 ≤60、宽 ≥360)");
|
||||
assert_eq!(bar.height(), 56, "20.1 搜索条高必须 56");
|
||||
assert!(bar.width() >= 360, "20.2 搜索条宽 ≥ 360");
|
||||
|
||||
// 20.4:图标墨迹左缘 = pad 12 + offset 4 + viewBox 内 ≈4 = 20(±1)
|
||||
let inner = (bar.top + 12)..(bar.bottom - 12);
|
||||
let mut left_ink = usize::MAX;
|
||||
for y in inner.clone() {
|
||||
for x in bar.left..bar.right {
|
||||
if matches(shot.at(x, y), ink) {
|
||||
left_ink = left_ink.min(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
let offset = left_ink - bar.left;
|
||||
assert!(
|
||||
(19..=21).contains(&offset),
|
||||
"20.4 前置图标墨迹偏移应 20(= 12 + 4 + viewBox 4):实测 {offset}"
|
||||
);
|
||||
|
||||
// 20.6 / 20.9:docked 面板 360 宽,头 56 + 1px 分隔线 + 体 240
|
||||
// (头与体被那条线切成**两个同色块**,所以按 top 排序取前两块)
|
||||
let mut parts = bars
|
||||
.iter()
|
||||
.filter(|b| b.width() == 360)
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
parts.sort_by_key(|b| b.top);
|
||||
assert_eq!(parts.len(), 2, "docked 面板 = 头部 + 结果区两块");
|
||||
let (header, body) = (parts[0], parts[1]);
|
||||
assert!(
|
||||
(55..=56).contains(&header.height()),
|
||||
"20.6 面板头部高 56(与 bar 同高;顶部圆角会吃掉 1 行抗锯齿,实测 {})",
|
||||
header.height()
|
||||
);
|
||||
assert_eq!(body.height(), 240, "20.9 结果区最小高 240");
|
||||
assert!(
|
||||
(2..=3).contains(&(body.top - header.bottom)),
|
||||
"20.8 头与体之间正好 1dp(CPU 档摊成 2 行 ⇒ 隔 2~3 行):实测 {}",
|
||||
body.top - header.bottom
|
||||
);
|
||||
let panel = header;
|
||||
|
||||
// 20.8:分隔线是 `Outline`(不是 OutlineVariant),1dp 墨量 ≈1.0
|
||||
let divider_ink = line_ink(
|
||||
&shot,
|
||||
panel.left + 40,
|
||||
panel.right - 40,
|
||||
panel.top + 50,
|
||||
panel.top + 62,
|
||||
bar_color,
|
||||
rgb(ui.color(Role::Outline)),
|
||||
);
|
||||
assert!(
|
||||
(0.7..=1.3).contains(÷r_ink),
|
||||
"20.8 头部分隔线应 1dp `Outline`:实测墨量 {divider_ink:.2}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! M77 probe · slider(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 11.1~11.6:竖条 4×44、0% 贴左、50% 分界在中央、100% 无未激活段(thumb 永不越界)。
|
||||
#[test]
|
||||
fn slider_thumb_positions_at_zero_half_and_full() {
|
||||
let dir = require_dir!();
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let shot = shot(&dir, "slider", flavor);
|
||||
let ui = ui(flavor);
|
||||
let active = rgb(ui.color(Role::Primary));
|
||||
let inactive = rgb(ui.color(Role::SecondaryContainer));
|
||||
|
||||
// 三条「值行」= 宽 ≥400 的轨道(0% / 50% / 100%),按 y 排序
|
||||
let mut rows: Vec<(usize, usize, usize)> = Vec::new();
|
||||
for y in 0..shot.height {
|
||||
if let Some((left, right)) = longest_run(&shot, y, active, inactive, 20) {
|
||||
// 先按「跨度 ≥300」收行(把抗锯齿行也纳进来),band 建好后再按宽度筛。
|
||||
if right - left + 1 >= 300 {
|
||||
rows.push((y, left, right));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 合并相邻的 y(同一条轨道占 16 行),并把每行的端点累积成 min/max——
|
||||
// 首行/末行都是抗锯齿行,只取它们会把轨道量窄 2~3px。
|
||||
let mut bands: Vec<(usize, usize, usize, usize)> = Vec::new();
|
||||
for (y, left, right) in rows {
|
||||
match bands.last_mut() {
|
||||
Some(last) if y - last.1 <= 3 => {
|
||||
last.1 = y;
|
||||
last.2 = last.2.min(left);
|
||||
last.3 = last.3.max(right);
|
||||
}
|
||||
_ => bands.push((y, y, left, right)),
|
||||
}
|
||||
}
|
||||
// 值行是 448 宽(`Space::X8 × 14`);五态行 5 条轨道会被并成 ~663 的带,按宽度筛掉
|
||||
let bands: Vec<(usize, usize, usize, usize)> = bands
|
||||
.into_iter()
|
||||
.filter(|(_, _, left, right)| (440..=470).contains(&(right - left + 1)))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
bands.len(),
|
||||
4,
|
||||
"值行应有 4 条(0% / 50% / 100% / 离散):实测 {}",
|
||||
bands.len()
|
||||
);
|
||||
|
||||
for (label, band) in ["0%", "50%", "100%"].iter().zip(bands.iter()) {
|
||||
let (top, bottom, band_left, band_right) = *band;
|
||||
let mid = (top + bottom) / 2; // 轨道中心行
|
||||
let (left, right) = (band_left, band_right);
|
||||
let width = right - left + 1;
|
||||
let handle_centre = left + 2 + (width - 4) / 2; // 位移区间 = handle/2 .. W-handle/2
|
||||
let boundary = (left..=right)
|
||||
.find(|x| matches(shot.at(*x, mid), inactive))
|
||||
.unwrap_or(right + 1);
|
||||
match *label {
|
||||
"0%" => assert!(
|
||||
boundary <= left + 4,
|
||||
"11.x 0% 时未激活轨必须从竖条(4 宽)之后立刻开始:实测分界 {}",
|
||||
boundary - left
|
||||
),
|
||||
"50%" => assert!(
|
||||
boundary.abs_diff(handle_centre) <= 2,
|
||||
"11.x 50% 的轨道分界应在正中({handle_centre}):实测 {boundary}"
|
||||
),
|
||||
_ => {
|
||||
let tail = shot.count_in(inactive, left, right + 1, mid, mid + 1);
|
||||
assert_eq!(tail, 0, "11.x 100% 时不该有未激活段:实测 {tail}px");
|
||||
}
|
||||
}
|
||||
|
||||
// 竖条必须落在轨道内(0% / 100% 的最狠情形)
|
||||
let handles: Vec<BBox> = shot
|
||||
.bbox_of(active, 100)
|
||||
.into_iter()
|
||||
.filter(|b| b.width() == 4 && b.height() >= 40)
|
||||
.collect();
|
||||
assert!(!handles.is_empty(), "11.2 找不到 4×44 的竖条");
|
||||
for handle in handles {
|
||||
assert!(
|
||||
handle.left >= left && handle.right <= right,
|
||||
"11.4 竖条越界:{handle:?} 不在轨道 {left}..{right} 内"
|
||||
);
|
||||
assert!(
|
||||
(43..=44).contains(&handle.height()),
|
||||
"11.2 竖条高必须 44(抗锯齿会吃掉 1 行,实测 {})",
|
||||
handle.height()
|
||||
);
|
||||
assert_eq!(handle.width(), 4, "11.2 竖条宽必须 4");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! M77 probe · text(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 9.1 / 9.2:Filled 高 56、宽 320;Filled 指示线 静息 1.0 / 聚焦 2.0 墨量。
|
||||
#[test]
|
||||
fn text_field_height_width_and_indicator_thickness() {
|
||||
let dir = require_dir!();
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let shot = shot(&dir, "text", flavor);
|
||||
let ui = ui(flavor);
|
||||
let surface = rgb(ui.color(Role::Surface));
|
||||
let fill = rgb(ui.color(Role::SurfaceContainerHighest));
|
||||
let line = rgb(ui.color(Role::OnSurfaceVariant));
|
||||
let primary = rgb(ui.color(Role::Primary));
|
||||
|
||||
let mut boxes = shot.bbox_of(fill, 400);
|
||||
boxes.sort_by_key(|b| (b.top, b.left));
|
||||
let wide: Vec<&BBox> = boxes.iter().filter(|b| b.width() >= 280).collect();
|
||||
// 320 宽的有两个 Filled(空 / 有值);第三格是 Outlined 错误态——它没有容器色,
|
||||
// 所以这里只要求 ≥2,错误态由 `outlined_stroke_and_error_paint` 单独验。
|
||||
assert!(
|
||||
wide.len() >= 2,
|
||||
"{flavor:?}: 至少要有 2 个 320 宽的 Filled 文本字段(实测 {})",
|
||||
wide.len()
|
||||
);
|
||||
|
||||
// 320 宽那三个 = 「空 / 有值 / 错误」三格;高度必须精确 56
|
||||
for bbox in wide.iter().take(2) {
|
||||
assert!(
|
||||
(280..=320).contains(&bbox.width()),
|
||||
"9.2 最小宽 280:实测 {}(320 宽格)",
|
||||
bbox.width()
|
||||
);
|
||||
let column = bbox.right - 20;
|
||||
let (top, bottom) = vertical_extent(&shot, bbox, fill, surface, column);
|
||||
assert_eq!(
|
||||
bottom - top + 1,
|
||||
56,
|
||||
"9.1 高度必须精确 56(带浮动标签):实测 {flavor:?} @x={column}"
|
||||
);
|
||||
}
|
||||
|
||||
// 矩阵第一行(Filled 五态)=最靠上的 5 个 160 宽格:列序 = Interaction::ALL
|
||||
// 五态里 disabled 的容器是 `OnSurface@12%`(另一种颜色),所以这里只有 4 块:
|
||||
// enabled / hovered / pressed / focused —— 列序与 `Interaction::ALL` 一致。
|
||||
let row: Vec<&BBox> = boxes
|
||||
.iter()
|
||||
.filter(|b| (140..=170).contains(&b.width()))
|
||||
.take(4)
|
||||
.collect();
|
||||
assert_eq!(row.len(), 4, "{flavor:?}: Filled 矩阵应有 4 个同色格");
|
||||
let enabled = row[0];
|
||||
let focused = row[3];
|
||||
|
||||
// 静息指示线 1dp:墨量 ≈ 1.0
|
||||
let enabled_ink = line_ink(
|
||||
&shot,
|
||||
enabled.left + 8,
|
||||
enabled.right - 8,
|
||||
enabled.bottom,
|
||||
enabled.bottom + 4,
|
||||
fill,
|
||||
line,
|
||||
);
|
||||
assert!(
|
||||
(0.6..=1.4).contains(&enabled_ink),
|
||||
"9.4 静息指示线应 1dp:实测墨量 {enabled_ink:.2}"
|
||||
);
|
||||
|
||||
// 聚焦指示线 2dp:墨量 ≈ 2.0(画成 1dp 会掉到 ~1.0)
|
||||
let focused_ink = line_ink(
|
||||
&shot,
|
||||
focused.left + 8,
|
||||
focused.right - 8,
|
||||
focused.bottom,
|
||||
focused.bottom + 5,
|
||||
fill,
|
||||
primary,
|
||||
);
|
||||
assert!(
|
||||
(1.5..=2.5).contains(&focused_ink),
|
||||
"9.4 聚焦指示线应 2dp:实测墨量 {focused_ink:.2}(1dp 会掉到 ≈1.0)"
|
||||
);
|
||||
assert!(
|
||||
focused_ink > enabled_ink + 0.5,
|
||||
"聚焦必须比静息粗一档({focused_ink:.2} vs {enabled_ink:.2})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 9.4 / 9.7:Outlined 描边 静息 1dp / 聚焦 2dp;错误态线/标签/辅助文本全 `Error`。
|
||||
///
|
||||
/// 量法:**在某一列的竖线上扫**(不是找同色块)——深色档里 hover 描边的抗锯齿像素也会落进
|
||||
/// `Outline` 的容差里,按「最上面的同色块」取会取错行(实测 dark 取到了 hover 那一行)。
|
||||
#[test]
|
||||
fn outlined_stroke_and_error_paint() {
|
||||
let dir = require_dir!();
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let shot = shot(&dir, "text", flavor);
|
||||
let ui = ui(flavor);
|
||||
let surface = rgb(ui.color(Role::Surface));
|
||||
let fill = rgb(ui.color(Role::SurfaceContainerHighest));
|
||||
let outline = rgb(ui.color(Role::Outline));
|
||||
let primary = rgb(ui.color(Role::Primary));
|
||||
let error = rgb(ui.color(Role::Error));
|
||||
|
||||
// 矩阵第一列(enabled)的左缘与行距:列宽 160 + 间距 8 = 168
|
||||
let left = shot
|
||||
.bbox_of(fill, 400)
|
||||
.into_iter()
|
||||
.filter(|b| (140..=170).contains(&b.width()))
|
||||
.min_by_key(|b| (b.top, b.left))
|
||||
.expect("找不到 Filled 矩阵格")
|
||||
.left;
|
||||
let filled_bottom = shot
|
||||
.bbox_of(fill, 400)
|
||||
.into_iter()
|
||||
.filter(|b| (140..=170).contains(&b.width()))
|
||||
.min_by_key(|b| (b.top, b.left))
|
||||
.expect("找不到 Filled 矩阵格")
|
||||
.bottom;
|
||||
|
||||
let column = |index: usize| left + index * 168 + 120;
|
||||
|
||||
// 静息(第 1 列)描边 1dp:在竖线上找第一处比底色更接近 `Outline` 的像素
|
||||
let rest_y = ((filled_bottom + 8)..shot.height)
|
||||
.find(|y| nearer(shot.at(column(0), *y), outline, surface))
|
||||
.expect("竖线上找不到 Outlined 静息描边");
|
||||
let rest_ink = line_ink(
|
||||
&shot,
|
||||
column(0) - 40,
|
||||
column(0) + 40,
|
||||
rest_y.saturating_sub(2),
|
||||
rest_y + 3,
|
||||
surface,
|
||||
outline,
|
||||
);
|
||||
// 描边的墨量随**亚像素相位**在 1.0~1.67 之间浮动(同一 1dp:整行对齐 = 1.0,
|
||||
// 落在半像素上 = 1 行满 + 1 行 0.67)。所以静息只做宽松下限,**宽度差异由比值判**。
|
||||
assert!(
|
||||
(0.8..=1.9).contains(&rest_ink),
|
||||
"9.4 Outlined 静息描边应 1dp(实测墨量 {rest_ink:.2};2dp 会到 2.0)"
|
||||
);
|
||||
|
||||
// 聚焦(第 4 列)描边 2dp
|
||||
let focus_y = ((filled_bottom + 8)..shot.height)
|
||||
.find(|y| nearer(shot.at(column(3), *y), primary, surface))
|
||||
.expect("竖线上找不到 Outlined 聚焦描边");
|
||||
let focus_ink = line_ink(
|
||||
&shot,
|
||||
column(3) - 40,
|
||||
column(3) + 40,
|
||||
focus_y.saturating_sub(2),
|
||||
focus_y + 4,
|
||||
surface,
|
||||
primary,
|
||||
);
|
||||
assert!(
|
||||
(1.8..=2.3).contains(&focus_ink),
|
||||
"9.4 Outlined 聚焦描边应 2dp(实测墨量 {focus_ink:.2};1dp 会掉到 1.67)"
|
||||
);
|
||||
let ratio = focus_ink / rest_ink;
|
||||
assert!(
|
||||
(1.6..=2.5).contains(&ratio),
|
||||
"聚焦必须正好粗一倍(2dp/1dp):实测比值 {ratio:.2}({focus_ink:.2} vs {rest_ink:.2})"
|
||||
);
|
||||
|
||||
// 9.7:错误格——描边 2dp + 标签(框内上带)+ 辅助文本(框下)全 `Error`
|
||||
let error_box = shot
|
||||
.bbox_of(error, 2000)
|
||||
.into_iter()
|
||||
.filter(|b| b.width() >= 280)
|
||||
.max_by_key(|b| b.width())
|
||||
.expect("找不到错误态字段");
|
||||
assert!(
|
||||
error_box.height() >= 50,
|
||||
"错误格应是一整个 320 宽的字段(实测 {}×{})",
|
||||
error_box.width(),
|
||||
error_box.height()
|
||||
);
|
||||
let label_ink = shot.count_in(
|
||||
error,
|
||||
error_box.left,
|
||||
error_box.right + 1,
|
||||
error_box.top + 2,
|
||||
error_box.top + 22,
|
||||
);
|
||||
let supporting_ink = shot.count_in(
|
||||
error,
|
||||
error_box.left,
|
||||
error_box.right + 1,
|
||||
error_box.bottom + 1,
|
||||
error_box.bottom + 30,
|
||||
);
|
||||
assert!(
|
||||
label_ink > 20,
|
||||
"9.7 错误标签没变成 Error(框内上带墨迹 {label_ink}px)"
|
||||
);
|
||||
assert!(
|
||||
supporting_ink > 20,
|
||||
"9.7 错误辅助文本没变成 Error(框下墨迹 {supporting_ink}px)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user