From 111955efae96238f7f333f4390d8eaccaa6de0e5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 11:48:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M79=20r3=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E5=88=86=E9=9A=94=E7=BA=BF=E5=AE=88=E5=8D=AB=E7=9A=84?= =?UTF-8?q?=E4=B8=89=E5=A4=84=E3=80=8C=E5=81=87=E7=BB=BF=E3=80=8D=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB=EF=BC=88=E4=BC=97=E6=95=B0=E5=8F=82=E8=80=83=E8=89=B2?= =?UTF-8?q?=20/=20=E8=A7=92=E8=89=B2=E8=BA=AB=E4=BB=BD=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=20/=20=E9=94=9A=E5=AE=9A=E8=A1=8C=E6=97=A0=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E6=89=93=E5=8D=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代码复审 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}/`,不入库。 --- .../src/components/navigation/probe.rs | 43 ++++----- .../components/navigation/probe/divider.rs | 49 ++++++++++ .../src/components/navigation/probe/scan.rs | 94 ++++++++++++++++--- 3 files changed, 153 insertions(+), 33 deletions(-) create mode 100644 crates/app-iced/src/components/navigation/probe/divider.rs diff --git a/crates/app-iced/src/components/navigation/probe.rs b/crates/app-iced/src/components/navigation/probe.rs index c7ffb24..68d2730 100644 --- a/crates/app-iced/src/components/navigation/probe.rs +++ b/crates/app-iced/src/components/navigation/probe.rs @@ -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 { + 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 { @@ -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)); diff --git a/crates/app-iced/src/components/navigation/probe/divider.rs b/crates/app-iced/src/components/navigation/probe/divider.rs new file mode 100644 index 0000000..966ae49 --- /dev/null +++ b/crates/app-iced/src/components/navigation/probe/divider.rs @@ -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})——\ + 分段画会在交界处留缝" + ); +} diff --git a/crates/app-iced/src/components/navigation/probe/scan.rs b/crates/app-iced/src/components/navigation/probe/scan.rs index 4baf324..1299db9 100644 --- a/crates/app-iced/src/components/navigation/probe/scan.rs +++ b/crates/app-iced/src/components/navigation/probe/scan.rs @@ -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 = 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 { + 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::() / ts.len() as f32) + } + /// 矩形内「与底色不同」的像素的左右范围(量文本墨迹用)。 /// /// 为什么不按精确文字色找:小字号抗锯齿后几乎没有像素落在**精确**色上(容差 3 时实测