D45 封顶轮的最后一改,只动这三条: - **P2-3 逐环断言(假绿)**:环本来就在固定 x 栅格(24 + 56·i、宽 40),判据从「主色连通簇数 非空」改成**六个窗逐个测**「窗内墨迹(轨道 ∪ 激活并集)外接成 40(±2)×40(±2)、且不越窗」, 外加「1..5 号窗必须有激活色弧」。簇数只打印不再当判据。G18-i 从「欠账」改成**已收口**, 并删掉模块文档里「要么把 6 个环摆成固定 x 栅格」那句错话(它们本来就在栅格上)。 环带定位也换过两版(「每行墨点 4..20」会把 40 切成 32;「首行..末行」在深色档扩到 274 行), 最终用「第一个窗里连续有墨、高度≈40 的行组」。 变异:涂掉 4 个环 → `第 1 个窗里没有任何环墨迹:环丢了` 红;25% 环缩成 20×20 → `第 1 个窗里的环应 40×40(±2),实测 20×20` 红。 - **P2-1 贴顶边距**:新增「框顶行 → 条顶行 = 36±1」与「取景框高 = 96±1」两条。 变异(`HOST_SHORT` 96 → 72,重编重拍):`D:贴底 ⇒ 框顶到条顶应为 36(96 − 48 − 12),实测 12` 红; 还原后绿。—— 之前只量「条底距框底」,宿高回退成 72 时确实抓不住。 顺带修了取景框判据本身:条自身(深色)比画布更接近框色,会把条自己认成框(实测差值 1), 现在要求「框色连续段比条本身更宽」。 - **P2-2 Dialog 下限**:补单测 `effective_width(200.0) == 280.0`(360/800 两点一并钉住), 并把「不低于官方 280」写进 `Dialog::width` 的文档。 - 欠账登记(只写表、不改代码):G18-j(动作色用全图合计,单卡漂移可能被别卡盖住)、 G18-k(再 4 处文档残留,不为凑数扩大 diff)、G18-l(取景框判据会把条的抗锯齿底行算成框, 余量 0.0008,方向是误报;加了「框高 96」后触发概率下降)。
343 lines
14 KiB
Rust
343 lines
14 KiB
Rust
//! 反馈家族的**渲染层**几何断言:对着真截图量像素(M68 的教训:纯函数对、图可能是错的)。
|
||
//!
|
||
//! 为什么住在 `feedback/` 而不是 `gallery/`:`gallery/feedback.rs` 只负责把矩阵摆进 harness,
|
||
//! 断言加上去会把它顶到 400 行红线。像素工具在 [`super::pixel`],环形与两个「面」的判据
|
||
//! 拆在同级子模块 [`circular`] / [`panels`](`inputs/probe/` 的同一先例)。
|
||
//!
|
||
//! ## 量什么(每条对一个具体错误,打红它的是「变异」那一列)
|
||
//!
|
||
//! | 类 | 判据 | 打红它的变异 |
|
||
//! |---|---|---|
|
||
//! | 线性厚度 / 圆角 | 轨道带**恰好 4 行**;条的四角**透出底色** | 厚度 4→6 / `Full`→`None` |
|
||
//! | 线性不定态 | phase 0.55 列必须**数出两段**(P1-A 回归) | 段不排序(旧实现:两段压成一段) |
|
||
//! | 环形 | 环 40 且四角无墨(真圆) | 直径 40→48 |
|
||
//! | Snackbar 宽上下限 | 宿主 800→**600**、368→**344**、324→**300**(无下限 G17-d) | 上限 600→560 |
|
||
//! | Snackbar 高 | 单行带 **48** / 双行带 **68** | 48→40 |
|
||
//! | Dialog 宽 / 内距 | 最宽行 **480**(在 280..560 内)· 标题墨迹左缩 **≈24**、顶缩 **≈26** | 480→488 / pad 24→16 |
|
||
//! | 空态块 / 间距 / CTA | 块 **96×96** · 块↔标题 **≈16**+bearing · 条目总高 **≈232** · CTA **40** | 96→88 / spacing 双算回来 |
|
||
//! | scrim | 一块 ≥700 宽的遮罩带,颜色 = 预合成实色 | 遮罩改成 surface |
|
||
//!
|
||
//! ## 复现(worktree 根目录;四个部分各一张图,浅/深各一轮)
|
||
//!
|
||
//! ```text
|
||
//! for part in progress snackbar dialog empty; do
|
||
//! ULL_GALLERY_SECTION=feedback ULL_FEEDBACK_PART=$part \
|
||
//! ./target/debug/unlucky-launcher-iced --software-renderer --screen=gallery \
|
||
//! --screenshot target/m78/$part-light.png --screenshot-theme=light
|
||
//! ULL_FEEDBACK_PART=$part ULL_GALLERY_SHOT=target/m78/$part-light.png \
|
||
//! cargo test -p unlucky-app-iced feedback::probe
|
||
//! done
|
||
//! ```
|
||
|
||
mod circular;
|
||
mod panels;
|
||
|
||
use unlucky_design::color::{Role, SchemeFlavor};
|
||
use unlucky_design::typography::Platform;
|
||
|
||
use super::geometry::PHASE_055;
|
||
use super::pixel::{band_width, bands, matches, near, rgb, runs, 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)
|
||
}
|
||
|
||
/// 第三条(phase 0.55)线性轨道的左缘与宽度(gallery 的宿主尺寸:页内边距 24 + 2×332)。
|
||
const TRACK3_X0: usize = 688;
|
||
/// 单条轨道宽(gallery 的 `TRACK_HOST`)。
|
||
const TRACK3_W: usize = 320;
|
||
|
||
/// 一条测试覆盖当前截图对应的那一段(段由 `ULL_FEEDBACK_PART` 指定,默认 `all`)。
|
||
#[test]
|
||
fn feedback_geometry_matches_the_reference() {
|
||
let Some(path) = shot_path() else {
|
||
eprintln!(
|
||
"[feedback-probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\
|
||
需要先出图(命令见本文件模块文档,注意 ULL_GALLERY_SECTION=feedback + ULL_FEEDBACK_PART)。"
|
||
);
|
||
return;
|
||
};
|
||
let shot = Shot::load(&path).expect("读 feedback 截图");
|
||
assert_eq!(
|
||
(shot.width, shot.height),
|
||
(1180, 760),
|
||
"验收图必须 1180×760 @100% DPI"
|
||
);
|
||
|
||
let flavor = if matches(
|
||
shot.at(2, 2),
|
||
rgb(ui(SchemeFlavor::Light).color(Role::Surface)),
|
||
) {
|
||
SchemeFlavor::Light
|
||
} else {
|
||
SchemeFlavor::Dark
|
||
};
|
||
let ui = ui(flavor);
|
||
let part = std::env::var("ULL_FEEDBACK_PART").unwrap_or_else(|_| "all".to_owned());
|
||
let part = part.trim().to_ascii_lowercase();
|
||
|
||
match part.as_str() {
|
||
"progress" => {
|
||
check_linear(&shot, &ui);
|
||
circular::check(&shot, &ui);
|
||
}
|
||
"snackbar" => check_snackbar(&shot, &ui),
|
||
"dialog" => panels::check_dialog(&shot, &ui),
|
||
"empty" => panels::check_empty(&shot, &ui),
|
||
other => {
|
||
// 整屏时只查最不容易被折叠线切掉的那一类;分图才是逐项判据的场合。
|
||
check_linear(&shot, &ui);
|
||
eprintln!("[feedback-probe] part={other:?} 只查了 progress 那一类;其余请在分图里查");
|
||
}
|
||
}
|
||
|
||
eprintln!("[feedback-probe] OK flavor={flavor:?} part={part}");
|
||
}
|
||
|
||
/// §12.1 + §12.4:线性厚度 / 圆角 / **不定态两段**。
|
||
fn check_linear(shot: &Shot, ui: &Ui) {
|
||
// 参考值写字面量(A1):**不能**从 `LinearGeometry` 读回期望值——那样把 4 改成 6 之后
|
||
// 「实测 6 == 期望 6」会照样绿,等于没有断言。
|
||
let surface = ui.color(Role::Surface);
|
||
let track = ui.color(Role::SecondaryContainer);
|
||
let active = ui.color(Role::Primary);
|
||
|
||
// ---- 12.1 厚度:轨道带恰好 4 行(宿主 320 宽 ⇒ 每行 ~320 个轨道墨点)--------
|
||
let bar_bands = bands(shot, track, surface, 300);
|
||
assert!(
|
||
!bar_bands.is_empty(),
|
||
"没找到线性进度轨道(宿主宽 320,应有 ≥300 个墨点的行)"
|
||
);
|
||
for (top, bottom) in &bar_bands {
|
||
let height = bottom - top + 1;
|
||
assert!(
|
||
near(height, 4),
|
||
"12.1:线性进度厚必须 4(A1 Height;实测 {height} @ 行 {top})"
|
||
);
|
||
}
|
||
|
||
// ---- 12.1 圆角 full:条的四角必须透出底色(直角会把角填满)------------------
|
||
let (top, _) = bar_bands[0];
|
||
let (left, len) = runs(shot, top + 1, track, surface)
|
||
.into_iter()
|
||
.find(|(_, len)| *len >= 300)
|
||
.expect("轨道那一行必须有 ≥300 的长段");
|
||
assert!(
|
||
!matches(shot.at(left, top), rgb(track)),
|
||
"12.1:圆角 full(2) 的左上角必须透出底色(直角会填满)"
|
||
);
|
||
assert!(
|
||
matches(shot.at(left + len / 2, top + 1), rgb(track)),
|
||
"条的中间必须有墨(否则是没画出来,不是圆角)"
|
||
);
|
||
|
||
// ---- 12.4 不定态:phase 0.55 那一列必须**数出两段**(复审 P1-A 的回归断言)----
|
||
//
|
||
// 旧实现把两段按返回顺序铺 `FillPortion`(第一线在右、第二线在左),并且份额之和
|
||
// 在某 34/100 相位上 >1,被 iced 归一 ⇒ 图上只剩一段 100px 的台阶(x 749..848)。
|
||
let row = indeterminate_row(shot, active, surface);
|
||
let found: Vec<(usize, usize)> = runs(shot, row, active, surface)
|
||
.into_iter()
|
||
.filter(|(start, len)| {
|
||
*start >= TRACK3_X0 && start + len <= TRACK3_X0 + TRACK3_W + 2 && *len >= 8
|
||
})
|
||
.collect();
|
||
assert_eq!(
|
||
found.len(),
|
||
2,
|
||
"12.4:phase 0.55 必须是**两段**(P1-A 修完才成立),实测 {found:?} @ 行 {row}"
|
||
);
|
||
|
||
// 期望位置与单测**共用同一个常量**(`geometry::PHASE_055`):早先这里写 0.871、单测写
|
||
// 0.860,实测 963 距 want_end 966.7 只剩 0.28px,靠容差活着(复审 B)。
|
||
let expected = PHASE_055;
|
||
for (index, (start, len)) in found.iter().enumerate() {
|
||
let want_start = TRACK3_X0 as f32 + expected[index].0 * TRACK3_W as f32;
|
||
let want_end = TRACK3_X0 as f32 + expected[index].1 * TRACK3_W as f32;
|
||
assert!(
|
||
(*start as f32 - want_start).abs() <= 4.0,
|
||
"12.4 第 {} 段左端应在 {want_start:.1}(实测 {start})",
|
||
index + 1
|
||
);
|
||
assert!(
|
||
((start + len) as f32 - want_end).abs() <= 4.0,
|
||
"12.4 第 {} 段右端应在 {want_end:.1}(实测 {})",
|
||
index + 1,
|
||
start + len
|
||
);
|
||
}
|
||
|
||
eprintln!(
|
||
"[feedback-probe] progress:轨道带 {} 条(高 {:?})· phase 0.55 两段 {found:?}",
|
||
bar_bands.len(),
|
||
bar_bands
|
||
.iter()
|
||
.map(|(top, bottom)| bottom - top + 1)
|
||
.collect::<Vec<_>>(),
|
||
);
|
||
}
|
||
|
||
/// 线性不定态那一行的 y:主色墨迹在**第一条轨道**里出现、且不在确定态行的最上一组。
|
||
///
|
||
/// 不想硬编码坐标(gallery 一改间距就失准),所以按「主色横带 + 排除最高那条带」找。
|
||
fn indeterminate_row(shot: &Shot, active: iced::Color, surface: iced::Color) -> usize {
|
||
let found = bands(shot, active, surface, 20);
|
||
assert!(
|
||
found.len() >= 2,
|
||
"至少要找到确定态行与不定态行两组主色横带,实测 {found:?}"
|
||
);
|
||
// 第一组是确定态(0% 无墨、50%/100% 有墨),第二组是不定态
|
||
let (top, bottom) = found[1];
|
||
(top + bottom) / 2
|
||
}
|
||
|
||
/// §14:宽度上下限 + 单/双行高。
|
||
fn check_snackbar(shot: &Shot, ui: &Ui) {
|
||
// 参考值写字面量(A1):600 = SnackbarKt.ContainerMaxWidth,48/68 = SnackbarTokens。
|
||
let surface = ui.color(Role::Surface);
|
||
let bar = ui.color(Role::InverseSurface);
|
||
|
||
// 行墨量下限 200:最窄的那条只有 300 宽,里面还有 ~60px 的文字 ⇒ 每行 ~240 个实心点。
|
||
// 高度 ≥40 才算提示条(标题行偶有一两行落在这条混色线上,那类 1 行的带要滤掉)。
|
||
let found: Vec<(usize, usize)> = bands(shot, bar, surface, 200)
|
||
.into_iter()
|
||
.filter(|(top, bottom)| bottom - top + 1 >= 40)
|
||
.collect();
|
||
assert!(
|
||
found.len() >= 3,
|
||
"14.x:应有 ≥3 条提示条(上限宽 / 双行 / 两条窄的),实测 {}",
|
||
found.len()
|
||
);
|
||
|
||
// 宽度集 = 每条带里最宽的那一行(留白行没有文字,量得到全宽)。
|
||
let mut widths: Vec<usize> = found
|
||
.iter()
|
||
.map(|band| band_width(shot, *band, bar, surface))
|
||
.collect();
|
||
let mut heights: Vec<usize> = found.iter().map(|(top, bottom)| bottom - top + 1).collect();
|
||
widths.sort_unstable();
|
||
heights.sort_unstable();
|
||
|
||
assert!(
|
||
!widths.iter().any(|w| *w > 601),
|
||
"没有一条可以超过上限 600:实测 {widths:?}"
|
||
);
|
||
assert!(
|
||
widths.iter().any(|w| near(*w, 600)),
|
||
"上限宽必须是 600(SnackbarKt.ContainerMaxWidth,宿主 800 时夹取),实测宽度集 {widths:?}"
|
||
);
|
||
assert!(
|
||
widths.iter().any(|w| near(*w, 344)),
|
||
"宿区 368 那格必须量到 344(G17-d [估] 最小宽口径),实测 {widths:?}"
|
||
);
|
||
assert!(
|
||
widths.iter().any(|w| near(*w, 300)),
|
||
"宿区 324 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}"
|
||
);
|
||
assert!(
|
||
heights.iter().any(|h| near(*h, 48)),
|
||
"14.3:单行高 48,实测高度集 {heights:?}"
|
||
);
|
||
assert!(
|
||
heights.iter().any(|h| near(*h, 68)),
|
||
"14.3:双行高 68,实测高度集 {heights:?}"
|
||
);
|
||
|
||
// 14.6 动作色:`InversePrimary` 必须出现在提示条内(不是按钮家族的 OnSurfaceVariant)
|
||
let action = rgb(ui.color(Role::InversePrimary));
|
||
let action_pixels: usize = (0..shot.height)
|
||
.map(|y| {
|
||
(0..shot.width)
|
||
.filter(|x| matches(shot.at(*x, y), action))
|
||
.count()
|
||
})
|
||
.sum();
|
||
assert!(
|
||
action_pixels > 40,
|
||
"14.6:动作标签应是 InversePrimary(实测该色只有 {action_pixels} px)"
|
||
);
|
||
|
||
// ---- 位置语义(复审 D):两条 bottom 条的**条底距取景框底 = 12** ---------------
|
||
//
|
||
// 取景框是 gallery 给窄宿主画的 1px `OutlineVariant` 边(`framed_host`):透明宿主没有
|
||
// 可见底边时「贴底」在像素上不可证(旧宿主 72 = 48 + 2×12,上下留白相等)。
|
||
let frame = rgb(ui.color(Role::OutlineVariant));
|
||
let mut margins: Vec<usize> = Vec::new();
|
||
for (top, bottom) in &found {
|
||
let width = band_width(shot, (*top, *bottom), bar, surface);
|
||
if !(near(width, 300) || near(width, 344)) {
|
||
continue;
|
||
}
|
||
// 1px 描边常落在半像素上(两行各 ~50% 覆盖),精确色匹配抓不住 ⇒ 用「更靠近框色
|
||
// 而不是画布」判。但条自身(深色)比画布**更接近**框色,所以还要一条:这一行的
|
||
// 框色连续段必须**比条本身更宽**(框横贯整个宿主),否则会认成条自己(实测过:差值 1)。
|
||
let distance = |pixel: [u8; 3], target: [u8; 3]| -> u32 {
|
||
pixel
|
||
.iter()
|
||
.zip(target)
|
||
.map(|(a, b)| u32::from(a.abs_diff(b)))
|
||
.sum()
|
||
};
|
||
let surface_rgb = rgb(surface);
|
||
let frame_run = |y: usize| -> usize {
|
||
let mut longest = 0;
|
||
let mut current = 0;
|
||
for x in 0..shot.width {
|
||
let pixel = shot.at(x, y);
|
||
if distance(pixel, frame) < distance(pixel, surface_rgb) {
|
||
current += 1;
|
||
longest = longest.max(current);
|
||
} else {
|
||
current = 0;
|
||
}
|
||
}
|
||
longest
|
||
};
|
||
// 框的横贯长度 > 条宽 + 10 ⇒ 只有框那一两行满足
|
||
let is_frame_row = |y: usize| frame_run(y) >= width + 10;
|
||
|
||
let frame_row = ((bottom + 1)..shot.height)
|
||
.find(|y| is_frame_row(*y))
|
||
.unwrap_or_else(|| {
|
||
panic!("D:窄条下方没找到宿主取景框(条 y {top}..{bottom},宽 {width})")
|
||
});
|
||
let frame_top_row = (0..*top)
|
||
.rev()
|
||
.find(|y| is_frame_row(*y))
|
||
.unwrap_or_else(|| panic!("D:窄条上方没找到宿主取景框(条顶 y {top})"));
|
||
// 复审 P2-1:宿高回退到 72(上下留白相等)时这条会红。
|
||
assert!(
|
||
(*top as i64 - frame_top_row as i64 - 36).abs() <= 1,
|
||
"D:贴底 ⇒ 框顶到条顶应为 36(96 − 48 − 12),实测 {}",
|
||
*top as i64 - frame_top_row as i64
|
||
);
|
||
let frame_height = frame_row - frame_top_row + 1;
|
||
assert!(
|
||
frame_height.abs_diff(96) <= 1,
|
||
"D:取景框高应 96(宿主高),实测 {frame_height}"
|
||
);
|
||
margins.push(frame_row - bottom);
|
||
}
|
||
assert!(
|
||
!margins.is_empty(),
|
||
"D:应至少量到一条 bottom 条的宿主边距,实测 {margins:?}"
|
||
);
|
||
for margin in &margins {
|
||
assert!(
|
||
near(*margin, 12),
|
||
"D:贴底 ⇒ 条底距宿主底 12(官方外边距),实测 {margin}:{margins:?}"
|
||
);
|
||
}
|
||
|
||
eprintln!(
|
||
"[feedback-probe] snackbar:宽度集 {widths:?} · 高度集 {heights:?} · InversePrimary {action_pixels}px · 底边距 {margins:?}"
|
||
);
|
||
}
|