docs(app-iced): 值字级注释改真 + probe 只认 buttons/divider + 截图主题钉死回归测试(M229 复审 r2)

1) P1-2 的注释值与量测对齐(四处):`elevated.rs` 的 `PAD`/`Texts::title`/`texts()` 与
   `pages/instances/detail.rs` 还写着「值 14/400」,而实现是 12/400;`PAD` 文档更把 12px 的
   118px 量测挂在「14px 下」。全部改成真值,并把 14px=138px 与 12px=118px 两个量测并列写清。

2) probe 门禁只解决一半:`SECTION=buttons` 绿、`SECTION=all` 仍红(sizes 段的实心块撞「按钮
   40 高」判据),断言文案却写「all 或 buttons」。现在:
   - 只支持 `buttons`(按钮矩阵,实心块判据**只量矩阵区**,后续段不再参与)与 `divider`
     (分隔线两条判据,独立出口);
   - `all` / 其它 / 未给 → 明确失败并给理由(整页比 1180×760 高,分隔线段与按钮矩阵不可能同屏;
     整页图的 chip/卡片会被当按钮量);
   - 模块文档的复现命令补上 `ULL_GALLERY_SECTION`(以前照着跑会 panic);
   - `gallery/mod.rs` 的「五段」→ 七段;`gallery/buttons.rs` 的「8 行 + icon·standard」→ 9 行 + 四式。

3) 截图通道的主题档钉死此前没有回归测试(复审 r2 P2-a)。新增
   `the_screenshot_channel_pins_the_theme_mode_instead_of_reading_the_file`:两份不同的机器配置 +
   同一组截图参数 ⇒ 同一个档位且等于参数;反证非截图通道下两份配置确实分出两档。
   变异自证:把分支改回 `bridge.load().1` → 该测试红(left: Dark, right: Light)。

门禁:fmt --check 0 / check -p unlucky-app-iced --all-targets 0 / test -p unlucky-app-iced 0
(887 passed + 6 integration)/ clippy -p unlucky-app-iced --all-targets -D warnings 0 / 400 行扫描干净。
probe 亲跑:buttons 浅+深两张 sheet OK,divider 一张 OK,`all` 按预期被拒。
This commit is contained in:
AskaEth committed 2026-11-11 01:49:16 +08:00
1 parent 8958b7f67d
commit f5a2610e4d
7 files changed
+205 -69

No files matched your search

+101
View File
@@ -84,3 +84,104 @@ fn the_theme_message_switches_the_board_and_persists_it() {
assert_eq!(app.refreshes, refreshes, "同一档不该再重建视图模型");
std::fs::remove_dir_all(&dir).ok();
}
/// **截图通道把主题档钉死在 `--screenshot-theme`**,不读机器上的 `launcher.json`(D30 可复现)。
///
/// 复审 r2 P2-a 指出:`construct.rs` 里新加的这条分支没有回归测试——于是「哪天有人把它改回
/// `bridge.load().1`」不会有人发现,两次不同配置的机器又会出不同颜色的图。
///
/// 判据:**同一份截图参数 + 两份不同的机器配置 ⇒ 同一个档位**,且等于参数(浅→`Light`、深→`Dark`);
/// 反证:非截图通道下同样两份配置**确实**分出两个档(否则这条测试等于没测到分支)。
#[test]
fn the_screenshot_channel_pins_the_theme_mode_instead_of_reading_the_file() {
use std::path::{Path, PathBuf};
/// 写一份只含 `appearance.theme_mode` 的临时配置,并用它构造 App。
fn with_mode(
dir_name: &str,
json: &str,
options: Options,
flavor: SchemeFlavor,
) -> (App, PathBuf) {
let dir =
std::env::temp_dir().join(format!("ull-shot-theme-{dir_name}-{}", std::process::id()));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
std::fs::write(&path, json).expect("写配置文件");
let app = App::with_presenter(
options,
FAMILIES,
flavor,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, dir)
}
fn cleanup(dir: &Path) {
std::fs::remove_dir_all(dir).ok();
}
let dark_file = r#"{"appearance":{"theme_mode":"dark"}}"#;
let light_file = r#"{"appearance":{"theme_mode":"light"}}"#;
// ① 截图参数给浅色:机器配置是 dark 也一样出 Light(钉死值赢)
let shot_light = Options {
screenshot: Some(PathBuf::from("unused.png")),
screenshot_theme: Some(SchemeFlavor::Light),
..Options::default()
};
let (from_dark_file, d1) = with_mode(
"light-a",
dark_file,
shot_light.clone(),
SchemeFlavor::Light,
);
let (from_light_file, d2) = with_mode("light-b", light_file, shot_light, SchemeFlavor::Light);
assert_eq!(
from_dark_file.theme_mode(),
ThemeMode::Light,
"截图模式下档位必须由 --screenshot-theme 决定,不许读文件里的 dark"
);
assert_eq!(
from_dark_file.theme_mode(),
from_light_file.theme_mode(),
"两份机器配置必须出同一个档位"
);
assert_eq!(from_dark_file.board_name(), "silver");
assert_eq!(from_light_file.board_name(), "silver");
// ② 反方向:截图参数给深色 ⇒ 即使文件写 light 也是 Dark/Ink
let shot_dark = Options {
screenshot: Some(PathBuf::from("unused.png")),
screenshot_theme: Some(SchemeFlavor::Dark),
..Options::default()
};
let (from_light_file_dark, d3) = with_mode("dark-a", light_file, shot_dark, SchemeFlavor::Dark);
assert_eq!(from_light_file_dark.theme_mode(), ThemeMode::Dark);
assert_eq!(from_light_file_dark.board_name(), "ink");
// ③ 反证:非截图通道下,同样的两份配置**确实**分出两个档(证明这条分支真的存在)
let (interactive_dark, d4) =
with_mode("plain-a", dark_file, Options::default(), SchemeFlavor::Dark);
let (interactive_light, d5) = with_mode(
"plain-b",
light_file,
Options::default(),
SchemeFlavor::Light,
);
assert_eq!(
interactive_dark.theme_mode(),
ThemeMode::Dark,
"产品路径要读文件"
);
assert_eq!(
interactive_light.theme_mode(),
ThemeMode::Light,
"产品路径要读文件"
);
for dir in [d1, d2, d3, d4, d5] {
cleanup(&dir);
}
}
@@ -9,13 +9,16 @@
//! elevated |
//! outlined |
//! text |
//! icon·standard | [ico] | ...
//! icon·filled |
//! icon·filled | [ico] | ...
//! icon·tonal |
//! icon·outlined | ← M229 第四式(1px 环;此前与 plain 逐像素相同)
//! icon·plain |
//! ```
//!
//! 40 个单元格共 5 列 × 8 行,一屏(1180×760)装不下,靠 `gallery/mod.rs` 的 scrollable 滚动;
//! 截图取的是**整窗**,所以首屏能看到按钮家族的全部 5 行(图标行可能需要滚动,报告里会说明)。
//! 45 个单元格共 5 列 × **9 行**(5 个文字变体 + **4 个图标变体**),一屏(1180×760)装不下,
//! 靠 `gallery/mod.rs` 的 scrollable 滚动;截图取的是**整窗**,所以首屏能看到前 8 行左右
//! (最下面一行可能要滚动)。行序 = `Variant::ALL` 之后 `IconVariant::ALL`,`probe/rows.rs`
//! 的逐行表按同一顺序排。
//!
//! ## 为什么每格都强制状态
//!
@@ -9,7 +9,7 @@
//!
//! ## 并行地基(后续四条线怎么加东西)
//!
//! 五个家族文件在下面 `mod` 里**全部预先声明**了,每个都有自己的 `section()`:
//! 各家族文件在下面 `mod` 里**全部预先声明**了,每个都有自己的 `section()`:
//!
//! ```text
//! gallery/buttons.rs ← COMPBASE(本 mission)
@@ -38,12 +38,13 @@
//!
//! ## 段落过滤:环境变量 `ULL_GALLERY_SECTION`(M76 起)
//!
//! 五段拼成一列、外面套 `scrollable`,而截图只截**首屏** `1180×760`。段数一多,后面的家族
//! **七段**(buttons / sizes / divider / selection / inputs / feedback / navigation)拼成一列、
//! 外面套 `scrollable`,而截图只截**首屏** `1180×760`。段数一多,后面的家族
//! 就落到折叠线以下(实测:selection 段从 y≈735 起,只剩标题一行)——那条线的
//! 「渲染层像素断言」就没有一个像素可量。所以这里加一个**段落选择器**:
//!
//! ```text
//! ULL_GALLERY_SECTION=all # 默认:五段全渲染(行为与加开关之前逐像素一致)
//! ULL_GALLERY_SECTION=all # 默认:七段全渲染(行为与加开关之前逐像素一致)
//! ULL_GALLERY_SECTION=buttons # 只渲染 buttons 段
//! ULL_GALLERY_SECTION=selection # 只渲染 selection 段(本 mission 的取证与 probe 用它)
//! ```
@@ -105,7 +106,7 @@ struct Sections {
}
impl Sections {
/// 五段全渲染(默认)。
/// 七段全渲染(默认)。
const ALL: Self = Self {
buttons: true,
sizes: true,
@@ -333,7 +334,7 @@ fn divider_proof<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
/// 一个家族小节的标题(`index.title`)+ 一行判据说明。
///
/// 五个家族文件共用(`pub(super)`),保证五段的节奏一致——这也是 rubric D4
/// 各段共用(`pub(super)`),保证七段的节奏一致——这也是 rubric D4
/// 「一屏字号档 ≤4」的落点:小节只有 `TitleMedium` + `BodySmall` 两档。
pub(crate) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<'a, DemoMessage> {
let note_style = ui.style(TypeRole::BodySmall);
+84 -55
View File
@@ -92,11 +92,6 @@ fn delta_ratio(pixel: [u8; 3], surface: [u8; 3], line: [u8; 3]) -> f32 {
/// `ULL_GALLERY_SECTION=buttons`,不满足就明确失败——「跳过」会把「指错了图」变成静默通过。
fn shot_path() -> Option<std::path::PathBuf> {
let raw = std::env::var_os("ULL_GALLERY_SHOT")?;
let section = std::env::var("ULL_GALLERY_SECTION").unwrap_or_default();
assert!(
!section.trim().is_empty(),
"这份 probe 只认 `--screen=gallery` 出的 sheet:请同时给 `ULL_GALLERY_SECTION` (`all` 或 `buttons`)。整页图(页面自己的 chip / 卡片,实心块高 21)会被当成按钮量, 得到的是误导性的红。"
);
let path = std::path::PathBuf::from(raw);
if path.is_absolute() || path.exists() {
return Some(path);
@@ -132,6 +127,31 @@ fn gallery_geometry_matches_the_tokens() {
};
let ui = ui(flavor);
// 这份 probe **只认两种 sheet**(复审 r2 P2-b 的结论):
//
// - `ULL_GALLERY_SECTION=buttons`:按钮矩阵(五式 × 五态 + 图标四式)——本 probe 的主判据;
// - `ULL_GALLERY_SECTION=divider`:分隔线的两条判据(1px / 墨量覆盖率)。
//
// **为什么不支持 `all`**:gallery 整页比 1180×760 的视口高,`all` 的 sheet 首屏里根本看不到
// 分隔线段(实测 0 条长横线),而 sizes 段的实心块又会被「按钮 40 高」的判据量到。一张图装不下
// 两段判据,所以按段出图、按段断言。
//
// **为什么必须给 SECTION**:整页图(下载页/设置页)的 chip 与卡片会被当成按钮量,
// 那是误导性的红,不是缺陷。
let section = std::env::var("ULL_GALLERY_SECTION")
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
assert!(
section == "buttons" || section == "divider",
"这份 probe 只支持 `ULL_GALLERY_SECTION=buttons`(按钮矩阵)或 `divider`(分隔线); 当前 {section:?}。`all` 不行:整页比视口高,分隔线段与按钮矩阵不可能同屏。"
);
// 分隔线那段是**独立出口**:`divider` 段的 sheet 里没有按钮矩阵,判据也不同。
if section == "divider" {
check_divider(&shot, &ui);
return;
}
// ---- 12b 1.1 / 1.2:实心按钮 40 高、药丸圆角 20 ------------------------------
let primary = shot.bbox_of(rgb(ui.color(Role::Primary)));
assert!(
@@ -140,12 +160,32 @@ fn gallery_geometry_matches_the_tokens() {
primary.len()
);
// **只判「按钮矩阵那一段」**:`--screen=gallery` 默认(`ULL_GALLERY_SECTION=all`)会把
// sizes / divider / selection / inputs / feedback / navigation 也画进来,它们各自的实心块
// (chips 底、卡片、stop indicator…)不该被「按钮 40 高」这条判据量到 —— 复审 r2 P2-b 的现场
// 就是 `all` 模式下在 y≈715 撞到一块 21 高的实心块,probe 红在一个根本不在量程里的东西上。
//
// 矩阵区的上下界由**第一块文字按钮**(宽 > 60、高 40)的顶边决定,往下 9 行 + 8px 余量。
let matrix_top = primary
.iter()
.filter(|bbox| {
bbox.area() >= 800 && bbox.width() > 60 && (39..=41).contains(&bbox.height())
})
.map(|bbox| bbox.top)
.min()
.expect("没找到文字按钮的实心块(矩阵不在画面里?)");
let matrix_bottom = matrix_top + rows::ROW_COUNT * rows::ROW_STEP + 8;
let mut label_button = None;
for bbox in &primary {
// 字形(OnPrimary)会把实心块割出小碎片,只判「成块的」(40×40 = 1600)
if bbox.area() < 800 {
continue;
}
// 矩阵之外(后续段)的实心块不在这条判据的量程里。
if bbox.top >= matrix_bottom {
continue;
}
assert!(
(39..=41).contains(&bbox.height()),
"实心块高 {}(参考 40,12b 1.1)@ {:?}",
@@ -263,52 +303,6 @@ fn gallery_geometry_matches_the_tokens() {
}
assert!(measured_glyph, "没量到 IconButton 的字形");
// ---- 22.1:分隔线 1px(按「非底色的长横线」判,不按精确色:线被抗锯齿摊到两行)----
//
// **只在分隔线段被渲染时判**:`ULL_GALLERY_SECTION=buttons` 的 sheet 里没有分隔线,
// 那一段是「没画」而不是「画错了」,断言只会误报(复审 P2 的同一类问题)。
let section = std::env::var("ULL_GALLERY_SECTION")
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let has_divider = section.is_empty() || section == "all" || section == "divider";
let surface = rgb(ui.color(Role::Surface));
let mut runs = 0usize;
let mut coverage = f32::NAN;
if has_divider {
for y in 0..shot.height {
let mut longest = 0;
let mut current = 0;
for x in 0..shot.width {
if !matches(shot.at(x, y), surface) {
current += 1;
longest = longest.max(current);
} else {
current = 0;
}
}
if longest > 300 {
runs += 1;
}
}
// 两条线 × 各摊 1~2 行 ⇒ 2..=4 行
assert!(
(2..=4).contains(&runs),
"分隔线应 2 条(每条 1px,抗锯齿最多摊 2 行)⇒ 2..=4 行,实测 {runs} 行"
);
// **墨量覆盖率**:一条 1dp 的线,其各行相对底色的不透明覆盖率之和应 ≈1.0。
// 1.5dp 的线会到 ≈1.5,2dp 会到 ≈2.0 —— 这是「容忍 1.5 倍粗」的补丁。
let full = rgb(ui.color(Role::OutlineVariant));
// 屏上正好 2 条线(通栏 + 列表内缩),所以把总墨量除以 2 得到「单条线的覆盖率」
coverage = line_coverage(&shot, surface, full) / 2.0;
assert!(
(0.8..=1.3).contains(&coverage),
"单条分隔线墨量覆盖率应 ≈1.0(= 1dp),实测 {coverage:.2}(1.5dp 会到 ~1.5)"
);
} else {
eprintln!("[probe] section={section:?} 未渲染 divider 段,跳过分隔线判据");
}
// ---- 12b 1.8 禁用态:容器 + **每一行**的内容色(A1 逐变体不同)----------------
//
// 上一版把两个 reviewer 都指出的重复块留在这里(`disabled_colors` 被调两次、
@@ -350,18 +344,53 @@ fn gallery_geometry_matches_the_tokens() {
Filled 药丸 {}×{}(参考 高 40 / 宽 ≥58)
顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0)
实心块 {} 个(含 40×40 IconButton)
分隔线长行 {} 行(每条 1px;未渲染 divider 段时为 0)
禁用容器 (on-surface@10%,按钮族) 命中 · 逐行禁用内容断言 {} 行通过
分隔线墨量覆盖率 {:.2}(1dp 应 ≈1.0)
字形墨迹 {}px(24 盒应 16~26)",
flavor,
label_button.width(),
true_height,
top_inset,
primary.len(),
runs,
rows::ROW_COUNT,
coverage,
glyph_width
);
}
/// `ULL_GALLERY_SECTION=divider` 的两条判据:分隔线 2 条(各摊 1~2 行)与单条墨量覆盖率 ≈1.0。
///
/// 单独成函数:`divider` 段的 sheet 没有按钮矩阵,走不到主流程的按钮判据;而主流程也不需要
/// 分隔线(`buttons` 段的 sheet 里根本没画线,断言只会误报)。
fn check_divider(shot: &Shot, ui: &Ui) {
let surface = rgb(ui.color(Role::Surface));
let mut runs = 0usize;
for y in 0..shot.height {
let mut longest = 0;
let mut current = 0;
for x in 0..shot.width {
if !matches(shot.at(x, y), surface) {
current += 1;
longest = longest.max(current);
} else {
current = 0;
}
}
if longest > 300 {
runs += 1;
}
}
// 两条线 × 各摊 1~2 行 ⇒ 2..=4 行
assert!(
(2..=4).contains(&runs),
"分隔线应 2 条(每条 1px,抗锯齿最多摊 2 行)⇒ 2..=4 行,实测 {runs} 行"
);
// **墨量覆盖率**:一条 1dp 的线,其各行相对底色的不透明覆盖率之和应 ≈1.0。
// 1.5dp 的线会到 ≈1.5,2dp 会到 ≈2.0 —— 这是「容忍 1.5 倍粗」的补丁。
let full = rgb(ui.color(Role::OutlineVariant));
// 屏上正好 2 条线(通栏 + 列表内缩),所以把总墨量除以 2 得到「单条线的覆盖率」
let coverage = line_coverage(shot, surface, full) / 2.0;
assert!(
(0.8..=1.3).contains(&coverage),
"单条分隔线墨量覆盖率应 ≈1.0(= 1dp),实测 {coverage:.2}(1.5dp 会到 ~1.5)"
);
eprintln!("[probe] OK section=divider:分隔线 {runs} 行、单条覆盖率 {coverage:.2}");
}
@@ -10,7 +10,7 @@ use crate::app::components::interaction;
use crate::theme::Ui;
/// 矩阵行距(40 高按钮 + 8 间距)。
const ROW_STEP: usize = 48;
pub(super) const ROW_STEP: usize = 48;
/// 行内扫描高度(可见 40 + 上下各 2 的余量)。
const ROW_HEIGHT: usize = 44;
/// 矩阵行数(5 个文字变体 + 4 个图标变体):probe 的汇总日志用它。
@@ -34,7 +34,8 @@ use crate::theme::Ui;
/// ELEVATED CARD 的内边距:**20**(= `LayoutMetrics::card_padding`)。
///
/// 为什么不是规格卡量到的 **24**(卡片文字 `x=88` − 框 `x=64`):本组件唯一的消费者是实例页的
/// 元信息卡,外框是 `META_CARD_MIN_WIDTH`(160),卡内必须放下最长的值串(14px 下实测 118px)。
/// 元信息卡,外框是 `META_CARD_MIN_WIDTH`(160),卡内必须放下最长的值串(**12px 下实测 118px**;
/// 同一个串在 14px 下是 138px,见 [`TITLE_ROLE`])。
/// 24 ⇒ 内宽 112 ⇒ 值串折行 ⇒ 同排卡底不齐(M229 复审 P1-2 的现场);20 ⇒ 内宽 120 ≥ 118,
/// 且**不动卡片宽度契约**(`pages/layout.rs` 的 160 一个字没改)。
///
@@ -77,13 +78,13 @@ pub fn card<'a, M: 'a>(ui: &'a Ui, content: impl Into<Element<'a, M>>) -> Elemen
pub struct Texts<'a> {
/// 上标注(`ELEVATED CARD` 那种小字标签)。
pub overline: Option<&'a str>,
/// 值 / 标题(字级是 [`TITLE_ROLE`] = 14/400)。
/// 值 / 标题(字级是 [`TITLE_ROLE`] = **12/400**)。
pub title: &'a str,
/// 副文案(`BodyMedium` 14/400)。
pub support: Option<&'a str>,
}
/// 三段式内容(上标注 11/600 + 值 [`TITLE_ROLE`] 14/400 + 副文案 14/400,行间 4)。
/// 三段式内容(上标注 11/600 + 值 [`TITLE_ROLE`] **12/400** + 副文案 14/400,行间 4)。
pub fn texts<'a, M: 'a>(ui: &'a Ui, parts: Texts<'a>) -> Element<'a, M> {
let mut stack = column![].spacing(ui.space(Space::X1));
@@ -231,7 +231,8 @@ fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail) -> Eleme
let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill);
for cell in chunk {
// M229:元信息卡 = 规格卡的 **ELEVATED CARD**(无描边、`SurfaceContainer` 底、16 圆角)。
// 内距 20 + 值 14/400(见 `elevated::PAD` 的登记):外框 160 − 2×20 = 120 必须放下
// 内距 20 + 值 **12/400**(见 `elevated::PAD` / `elevated::TITLE_ROLE` 的登记):
// 外框 160 − 2×20 = 120 必须放下
// 最长值串,24 + 16 会让时戳折行、同排卡底不齐(复审 P1-2)。
let card = elevated::card(
ui,