Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/probe.rs
T
AskaEth d4e73aacdd fix(iced/feedback): 逐环改固定窗判据 + 贴顶边距 + Dialog 下限单测(复审 r3 P2-3/P2-1/P2-2)
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」后触发概率下降)。
2026-11-02 15:56:48 +08:00

343 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 反馈家族的**渲染层**几何断言:对着真截图量像素(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:?}"
);
}