fix(app-iced): M79 r3 —— 分隔线守卫的三处「假绿」根治(众数参考色 / 角色身份校验 / 锚定行无条件打印)
代码复审 r3 = p1-2items,两条都是判据自身的假绿,产品代码零改动。
1. **参考色取「跨度中点那一个像素」→ 缺口落在中点就假绿**
`line_segments` 改用该行**墨迹众数色**(`modal_color`):242px 的线 vs ≤4px 的缺口,
众数天然是线自己的颜色。反例 `gap_at_mid`(r2 图在 x=144..145 挖 2px 孔):
改前 `ok. 1 passed`(假绿),改后 **2 段 red**。
2. **行锚定可被「更靠上的另一条细低对比横线」抢走**
`find_thin_line` 增加 `expected` 身份色 + `blend_coverage`:线色必须落在
`Surface → SurfaceVariant` 的插值线上(覆盖率 0.30~1.05,三通道 t 最大差 ≤0.25)。
这比「单通道差 ≤20」稳得多:实测 `OutlineVariant (200,212,224)` → t=(1.84,2.24,3.1) 出界;
hover 8% `(228,233,238)` → t=(0.72,1.0,1.7) 散开 0.98;两者都被拒。
反例 `decoy_outline_gap` / `decoy_hover_gap`:改前 `ok`(假绿,锚到 y=400/401),
改后打印 `divider skip y=… (不是预期角色色的插值,拒绝)` 并退回真分隔线 → **3 段 red**。
3. **锚定行无条件打印**(不管红绿):
`[probe] divider anchored at y=471 span=x24..266 color=[221, 233, 245] segments=1 (light, 1180x760)`
——「锚错行」从此不再静默。
4. **深色档不再是零覆盖**:`divider::check` 浅色必跑,`tabs-dark.png` 存在就跑深色
(实测 `y=471 color=[46,60,70] segments=1 (dark)`)。判据/锚定逻辑抽到 `probe/divider.rs`,
`probe.rs` 393 行仍 <400。
9 张图全跑(退出码即判据):
r2 rc=0(1段) · before rc=101(3段@471) · mutB rc=101(2段) · mutC rc=101(2段) ·
mutE rc=101(3段@471) · mutF rc=101(3段@471) · **gap_at_mid rc=101(2段)** ·
**decoy_outline_gap rc=101(3段@471,y=400 被身份拒)** · **decoy_hover_gap rc=101(3段@471,y=401 被身份拒)**。
已知边界写进 `probe/scan.rs` 模块文档:≤25% 覆盖的淡线 → **loud red**(锚定失败 panic,
不会假绿);身份校验绑定 `SurfaceVariant`(换角色色会 loud red,改 `divider.rs` 一行);
只认横线;深色图缺失时跳过深色断言但不失败。
门禁:fmt ✓ · `cargo test -p unlucky-app-iced` 197 passed · gate 6 passed · probe 5 passed ·
`clippy --all-targets -D warnings` 干净。变异图在 `target/m79/{mutB,mutC,mutE,mutF,gap_at_mid,decoy_outline_gap,decoy_hover_gap}/`,不入库。
This commit is contained in:
1 parent
dffae2c429
commit
111955efae
3 files changed
+153
-33
No files matched your search
@@ -35,9 +35,20 @@ use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::app::components::interaction;
|
||||
|
||||
mod divider;
|
||||
mod long;
|
||||
mod scan;
|
||||
use scan::{rgb, ui, BBox, Rgb, Shot};
|
||||
use scan::{rgb, ui as ui_of, BBox, Rgb, Shot};
|
||||
|
||||
/// 有这张图就读,没有就 `None`(深色档的可选覆盖用)。
|
||||
fn shot_opt(dir: &Path, part: &str, flavor: SchemeFlavor) -> Option<Shot> {
|
||||
let slug = match flavor {
|
||||
SchemeFlavor::Light => "light",
|
||||
SchemeFlavor::Dark => "dark",
|
||||
};
|
||||
let path = dir.join(format!("{part}-{slug}.png"));
|
||||
path.exists().then(|| Shot::load(&path))
|
||||
}
|
||||
|
||||
/// `ULL_NAV_DIR` → 截图目录(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。
|
||||
fn shot_dir() -> Option<PathBuf> {
|
||||
@@ -77,7 +88,7 @@ fn shot(dir: &Path, part: &str, flavor: SchemeFlavor) -> Shot {
|
||||
#[test]
|
||||
fn list_item_rows_are_44_60_76() {
|
||||
let dir = require_dir!();
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let ui = ui_of(SchemeFlavor::Light);
|
||||
let shot = shot(&dir, "list", SchemeFlavor::Light);
|
||||
|
||||
// 矩阵格宽 240(`nav_drawer_width`):选中列的三块填充就是三条行
|
||||
@@ -147,7 +158,7 @@ fn list_item_rows_are_44_60_76() {
|
||||
#[test]
|
||||
fn tabs_indicator_tracks_the_selected_tab() {
|
||||
let dir = require_dir!();
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let ui = ui_of(SchemeFlavor::Light);
|
||||
let shot = shot(&dir, "tabs", SchemeFlavor::Light);
|
||||
|
||||
let primary = rgb(ui.color(Role::Primary));
|
||||
@@ -198,22 +209,12 @@ fn tabs_indicator_tracks_the_selected_tab() {
|
||||
);
|
||||
measured.push((bar.width(), ink));
|
||||
}
|
||||
// 6.6:Secondary 的分隔线必须是**一条连续直线**(逐 tab 分段画会在相邻段之间留缝,
|
||||
// 视觉 r1 P2-2 实测 `25..101 | 103..191 | 193..265`)。
|
||||
//
|
||||
// 判法(三处都是被真实假通过逼出来的,见 `scan::Shot::find_thin_line` 文档):
|
||||
// ① 按「细 + 近乎实心 + 低对比 + 跨度 ≥200」锚定**分隔线那一行**(跳过标题行、
|
||||
// 五态行的 40px 底色带、Primary 指示条);
|
||||
// ② 在该行的**整段墨迹跨度**上数段——不是在某次 run 的 150px 窗口里数,否则缺口会被滑过。
|
||||
let surface = rgb(ui.color(Role::Surface));
|
||||
let (y, x0, x1) = shot
|
||||
.find_thin_line(surface, 200, 120)
|
||||
.expect("Secondary 那一行应有一条细长横线");
|
||||
let segments = shot.line_segments(y, x0, x1);
|
||||
assert_eq!(
|
||||
segments, 1,
|
||||
"Secondary 分隔线应是一条直线,实测 {segments} 段(量到 y={y}, x{x0}..{x1})—— 分段画会在交界处留缝"
|
||||
);
|
||||
// 6.6:Secondary 的分隔线必须是**一条连续直线**(逐 tab 分段画会在相邻段之间留缝)。
|
||||
// 判据与锚定规则全在 `probe/divider.rs`(含「无论红绿都打印锚定行」)。
|
||||
divider::check(&shot, &ui, "light");
|
||||
if let Some(dark) = shot_opt(&dir, "tabs", SchemeFlavor::Dark) {
|
||||
divider::check(&dark, &ui_of(SchemeFlavor::Dark), "dark");
|
||||
}
|
||||
|
||||
let (short_bar, long_bar) = (measured[0].0, measured[1].0);
|
||||
assert!(
|
||||
@@ -229,7 +230,7 @@ fn tabs_indicator_tracks_the_selected_tab() {
|
||||
#[test]
|
||||
fn drawer_is_240_and_rail_is_88_with_inset_pills() {
|
||||
let dir = require_dir!();
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let ui = ui_of(SchemeFlavor::Light);
|
||||
let shot = shot(&dir, "drawer", SchemeFlavor::Light);
|
||||
|
||||
let pad = Space::X2.dp();
|
||||
@@ -320,7 +321,7 @@ fn drawer_is_240_and_rail_is_88_with_inset_pills() {
|
||||
#[test]
|
||||
fn app_bar_is_56_high_in_both_states() {
|
||||
let dir = require_dir!();
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let ui = ui_of(SchemeFlavor::Light);
|
||||
let shot = shot(&dir, "appbar", SchemeFlavor::Light);
|
||||
|
||||
let resting = rgb(ui.color(Role::Surface));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! 分隔线守卫(6.6):**Secondary 的分隔线必须是一条连续直线**。
|
||||
//!
|
||||
//! 逐 tab 分段画会在相邻段之间留缝(视觉 r1 P2-2 实测 `25..101 | 103..191 | 193..265`,
|
||||
//! 交界像素偏亮)。这条守卫被复审三轮打过三次,每次都是「假绿」,所以下面把**每个判据
|
||||
//! 挡的是哪一次假通过**写清楚——改之前先读一遍:
|
||||
//!
|
||||
//! | 判据 | 挡的假通过 |
|
||||
//! |---|---|
|
||||
//! | ① 锚定「细 + 近乎实心 + 薄 + 低对比 + 跨度 ≥200」的一行(见 `scan::Shot::find_thin_line`) | 五态行的 40px 高底色带、`Primary` 指示条顶替分隔线 |
|
||||
//! | ② 线色必须是 `Surface → SurfaceVariant` 的插值(覆盖率 30%~105%) | 更靠上的同类细线顶替:`OutlineVariant` 组间线(t 出界)、hover 8% 带(三通道 t 散开) |
|
||||
//! | ③ 参考色取**该行墨迹众数色**(见 `scan::Shot::line_segments`) | 缺口恰落在跨度中点时,参考色退化成页面底色 → 恒 1 段 |
|
||||
//! | ④ 在该行**整段墨迹跨度**上数段(不是某次 run 的窗口) | 缺口在窗口外看不见、在窗口内被「跳过去重新起跑」滑过 |
|
||||
//!
|
||||
//! ## 锚定行**无条件打印**
|
||||
//!
|
||||
//! 不管红绿都往 stderr 打一行 `[probe] divider anchored at y=… span=x..x color=(r,g,b) segments=N`。
|
||||
//! 之前绿的时候什么都不说,于是「锚错行」是静默的——不看断言在量什么,等于没有断言。
|
||||
//!
|
||||
//! ## 深浅两档
|
||||
//!
|
||||
//! 浅色图必跑;深色图(`tabs-dark.png`)**存在就跑**(深色的 divider 对比更低,更值得测)。
|
||||
//! 没有深色图时不算失败,但会在输出里留一行 `dark 档跳过`。
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::scan::{rgb, Shot};
|
||||
|
||||
/// 在 `shot` 上验证分隔线是一条直线;`label` 只用于日志(`light` / `dark`)。
|
||||
pub(super) fn check(shot: &Shot, ui: &Ui, label: &str) {
|
||||
let surface = rgb(ui.color(Role::Surface));
|
||||
let variant = rgb(ui.color(Role::SurfaceVariant));
|
||||
let (y, x0, x1, color) = shot
|
||||
.find_thin_line(surface, variant, 200, 120)
|
||||
.unwrap_or_else(|| panic!("[{label}] 找不到 Secondary 那一行细线(锚定失败)"));
|
||||
let segments = shot.line_segments(y, x0, x1);
|
||||
eprintln!(
|
||||
"[probe] divider anchored at y={y} span=x{x0}..{x1} color={color:?} segments={segments} \
|
||||
({label}, {shot_width}x{shot_height})",
|
||||
shot_width = shot.width,
|
||||
shot_height = shot.height,
|
||||
);
|
||||
assert_eq!(
|
||||
segments, 1,
|
||||
"[{label}] Secondary 分隔线应是一条直线,实测 {segments} 段(量到 y={y}, x{x0}..{x1})——\
|
||||
分段画会在交界处留缝"
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,21 @@
|
||||
//! `probe` 的读图器(`probe.rs` 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
|
||||
//!
|
||||
//! 只做三件事:读 PNG、找同色的连通块(`BBox`)、量一条横带里某颜色的墨迹范围。
|
||||
//! 判据全在 `probe.rs`。
|
||||
//! 只做三件事:读 PNG、找同色的连通块(`BBox`)、量一条横带里某颜色的墨迹范围
|
||||
//! 与「一条细线」。判据在 `probe.rs` / `probe/divider.rs` / `probe/long.rs`。
|
||||
//!
|
||||
//! ## 已知边界(分隔线守卫,写在这里供以后改的人查)
|
||||
//!
|
||||
//! 1. **≤25% 覆盖的淡线找不到** → 表现是 **loud red**(`find_thin_line` 返回 `None`,
|
||||
//! `divider::check` 直接 panic「锚定失败」),**不是假绿**。原因:`find_thin_line` 的
|
||||
//! `TOL=6` 是单通道可辨差下限,40% 覆盖行实测 `(9,6,4)` 刚好在线上,25% 覆盖行只有
|
||||
//! `(6,4,2)`,会落到判据外——但相邻那行是满覆盖(`(25,17,10)`),所以真实渲染的画法下
|
||||
//! 行锚定不会丢;只有「整条线都画在半像素上」才会既找不到又看起来像没画。
|
||||
//! 2. **身份校验绑定了 `SurfaceVariant`**:`divider::check` 用
|
||||
//! `Surface → SurfaceVariant` 的插值做身份校验。若哪天 Secondary 分隔线换了角色色
|
||||
//! (比如改回 `OutlineVariant`),这里会 loud red——届时改 `divider.rs` 里那一行 `Role`。
|
||||
//! 3. **只认「一条横线」**:竖向分隔线(本项目不用)与圆角/虚线画法不在判据内。
|
||||
//! 4. **深浅两档**:浅色图必跑;深色图存在就跑(`tabs-dark.png`),深色 divider 对比更低
|
||||
//! (实测线色 `(46,60,70)`,与深色 Surface 的差比浅色档小),更值得测。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -200,9 +214,10 @@ impl Shot {
|
||||
pub(super) fn find_thin_line(
|
||||
&self,
|
||||
background: Rgb,
|
||||
expected: Rgb,
|
||||
min_len: usize,
|
||||
max_contrast: u8,
|
||||
) -> Option<(usize, usize, usize)> {
|
||||
) -> Option<(usize, usize, usize, Rgb)> {
|
||||
const TOL: u8 = 6;
|
||||
let is_ink = |pixel: Rgb| differs(pixel, background, TOL);
|
||||
let contrast = |pixel: Rgb| {
|
||||
@@ -244,24 +259,38 @@ impl Shot {
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(y);
|
||||
let line = self.at(
|
||||
(first..=last)
|
||||
.find(|x| is_ink(self.at(*x, best)))
|
||||
.unwrap_or(mid),
|
||||
best,
|
||||
);
|
||||
let line = self.modal_color(best, first, last);
|
||||
let c = contrast(line);
|
||||
if c > max_contrast || c <= TOL {
|
||||
continue;
|
||||
}
|
||||
return Some((best, first, last));
|
||||
// **身份校验**:线色必须落在 `底色 → 预期角色色` 的插值线上(覆盖率 30%~105%)。
|
||||
// 只靠「跨度/薄/低对比」挡不住「更靠上的另一条同类细线」(复审 r3 的
|
||||
// `decoy_outline_gap` / `decoy_hover_gap` 两个反例都因此锚错行)。
|
||||
let Some(coverage) = Self::blend_coverage(line, background, expected) else {
|
||||
eprintln!(
|
||||
"[probe] divider skip y={best} color={line:?}(不是预期角色色的插值,拒绝)"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if !(0.30..=1.05).contains(&coverage) {
|
||||
eprintln!(
|
||||
"[probe] divider skip y={best} color={line:?}(覆盖率 {coverage:.2} 出界,拒绝)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return Some((best, first, last, line));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `y` 行 `[x0, x1]` 上「按该行中点的颜色」数的连通段数(分隔线断没断)。
|
||||
/// `y` 行 `[x0, x1]` 上「按该行**墨迹众数色**」数的连通段数(分隔线断没断)。
|
||||
///
|
||||
/// 参考色为什么不能取「跨度中点那一个像素」:缺口恰落在中点时,参考色变成页面底色,
|
||||
/// 数出来的是「底色有几段」→ 恒为 1 → **假绿**(复审 r3 的 `gap_at_mid` 反例)。
|
||||
/// 众数色在整段跨度里天然是线自己的颜色(242px 的线 vs ≤4px 的缺口)。
|
||||
pub(super) fn line_segments(&self, y: usize, x0: usize, x1: usize) -> usize {
|
||||
let line = self.at((x0 + x1) / 2, y);
|
||||
let line = self.modal_color(y, x0, x1);
|
||||
let mut segments = 0;
|
||||
let mut in_run = false;
|
||||
for x in x0..=x1 {
|
||||
@@ -276,6 +305,47 @@ impl Shot {
|
||||
segments
|
||||
}
|
||||
|
||||
/// `y` 行 `[x0, x1]` 里出现次数最多的颜色(分隔线的线色)。
|
||||
pub(super) fn modal_color(&self, y: usize, x0: usize, x1: usize) -> Rgb {
|
||||
let mut counts: std::collections::HashMap<Rgb, usize> = std::collections::HashMap::new();
|
||||
for x in x0..=x1.min(self.width - 1) {
|
||||
*counts.entry(self.at(x, y)).or_insert(0) += 1;
|
||||
}
|
||||
counts
|
||||
.into_iter()
|
||||
.max_by_key(|(_, n)| *n)
|
||||
.map(|(c, _)| c)
|
||||
.unwrap_or(self.at(x0.min(self.width - 1), y))
|
||||
}
|
||||
|
||||
/// `pixel` 是否落在 `from → to` 的插值线上(覆盖率 t),返回三个通道一致时的平均 t。
|
||||
///
|
||||
/// 用来给「这条线是不是某个角色色」做**身份校验**:比「单通道差 ≤N」稳得多——
|
||||
/// 实测 `OutlineVariant (200,212,224)` → t=(1.84,2.24,3.1) 出界;
|
||||
/// hover 8% `(228,233,238)` → t=(0.72,1.0,1.7) 三通道散开 0.98;两者都被拒。
|
||||
/// 而真分隔线满覆盖 `(221,233,245)` → t=(1,1,1);40% 覆盖的抗锯齿行 → t≈(0.36,0.35,0.40) 被接受。
|
||||
///
|
||||
/// 只统计 `|to - from| > 4` 的通道(差太小的通道不携带信息,噪声会把 t 带偏)。
|
||||
pub(super) fn blend_coverage(pixel: Rgb, from: Rgb, to: Rgb) -> Option<f32> {
|
||||
let mut ts = Vec::new();
|
||||
for ((p, f), t) in pixel.iter().zip(from).zip(to) {
|
||||
let delta = f32::from(t) - f32::from(f);
|
||||
if delta.abs() <= 4.0 {
|
||||
continue;
|
||||
}
|
||||
ts.push((f32::from(*p) - f32::from(f)) / delta);
|
||||
}
|
||||
if ts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let max = ts.iter().cloned().fold(f32::MIN, f32::max);
|
||||
let min = ts.iter().cloned().fold(f32::MAX, f32::min);
|
||||
if max - min > 0.25 {
|
||||
return None;
|
||||
}
|
||||
Some(ts.iter().sum::<f32>() / ts.len() as f32)
|
||||
}
|
||||
|
||||
/// 矩形内「与底色不同」的像素的左右范围(量文本墨迹用)。
|
||||
///
|
||||
/// 为什么不按精确文字色找:小字号抗锯齿后几乎没有像素落在**精确**色上(容差 3 时实测
|
||||
|
||||
Reference in new issue
Block a user