feat(app-iced,pages): M100 取证密闭 + harness 参数拆分 + 设置页测试按 section 下放

四件前置事,都是为了设置页正文四条 section 线能并行(不写任何 section 正文):

1. 取证密闭(两条通道各有各的机制,证据分开算)
   - 产品 bin:`cli::ForcedState` 加 `Rest`(静息态),给了 `--screenshot` 且没显式
     `--force-state` 时默认 `Some(Rest)`;交互模式仍是 `None`(`shell::overridden`
     与 `app/view.rs` 的 `model()` 各补一支)。
   - harness:截图窗口开 `window::enable_mouse_passthrough`(winit 的
     `set_cursor_hittest(false)`)⇒ 窗口收不到指针事件 ⇒ 全树控件恒为 Active。
     为什么用通道级而不是逐控件灌 forced:设置页「外观」区的滑块/文本框/裸 button 都不消费
     forced,逐控件做要改十几个组件文件,而新产品组件随时会漏。
   - harness 同时数指针事件并打进日志(`指针事件=N`):让「这张图见过几个鼠标事件」变成
     可读数字,替代「连跑几次 hash 一样」那种恒真的假绿。

2. harness 的外观真状态入口:`--appearance=real` 让设置页走 `appearance: Some(..)`
   (工厂态 + `ULL_APPEARANCE_*`),新入口 `pages::app_with_appearance`。
   `--screen=settings` 之前就有了,但 `appearance` 恒为 None,D30 要的那一屏根本不存在。

3. `pages/harness.rs` 400 行 → 拆 `pages/harness/request.rs`(`#[path]` 范式),
   纯搬移部分 240 行 + 新增密闭代码。拆分等价性:同一 argv 的 PNG sha256 与拆分前一致。

4. `pages/settings/tests.rs` 按 section 下放成 `tests/{general,appearance,java,download}_section.rs`,
   mod 层只留跨 section 断言(每条写清守什么、为什么不能下放)。
   给某组加一条新断言 = 只改它自己那个文件。

顺带:`shell/mod.rs` 在 HEAD 就 399 行、`cli.rs` 394 行,两处都按同一范式把 tests 搬成
`shell/tests.rs` / `cli/tests.rs`(断言一字不动,逐字节可 diff)。

变异验证:把四处实现各改坏一处(默认 Rest、passthrough 默认值、overridden 的 Rest 分支、
view 的 Rest 映射),对应 4 条断言全红(390 passed / 4 failed),恢复后 394 passed。
This commit is contained in:
AskaEth committed 2026-11-03 14:22:40 +08:00
1 parent 2ad808d55c
commit 2061296615
16 files changed
+1152 -469

No files matched your search

+5 -74
View File
@@ -309,6 +309,9 @@ pub(super) fn label<'a>(
/// 取证用的状态覆盖(`--force-state`):只改画出来的状态,不动任何状态机。
pub(super) fn overridden(forced: Option<ForcedState>, status: button::Status) -> button::Status {
match forced {
// `Rest` = 取证图的常态:**明确**画成 Active,把系统光标带来的 hover/pressed 压掉。
// 截图通道默认就是它(见 `cli::Options::parse` 的尾部),否则同一份代码会因指针位置出不同的图。
Some(ForcedState::Rest) => button::Status::Active,
Some(ForcedState::Hover) => button::Status::Hovered,
Some(ForcedState::Pressed) => button::Status::Pressed,
None => status,
@@ -323,77 +326,5 @@ pub(super) fn filled(color: Color) -> container::Style {
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::component_map::Component;
use unlucky_design::typography::Platform;
use crate::theme::Families;
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(
flavor,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn a_screen_only_uses_four_text_sizes_at_most() {
let ui = ui(SchemeFlavor::Dark);
let card = Component::OutlinedCard.spec();
let sizes = [
ui.emphasized(TypeRole::TitleLarge).size_px,
ui.emphasized(TypeRole::LabelLarge).size_px,
ui.emphasized(card.text).size_px,
ui.style(card.support.unwrap_or(card.text)).size_px,
ui.style(TypeRole::LabelMedium).size_px,
];
let mut unique = sizes.to_vec();
unique.sort_by(f32::total_cmp);
unique.dedup();
assert!(
unique.len() <= 4,
"rubric D4:一屏字号档 ≤4(实际 {} 档 {unique:?})",
unique.len()
);
assert!(unique[0] >= 12.0, "最小档不下 12px(caption ≥11)");
assert!(unique[unique.len() - 1] <= 28.0, "列表页标题不得 >28px");
}
#[test]
fn flavor_slug_is_stable_for_filenames() {
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Light)), "light");
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Dark)), "dark");
}
/// M86:标题栏 caption 图标缩到 20 后,**容器一个数都没动**(46×48),图标也还在框里。
#[test]
fn caption_icon_shrinks_without_touching_the_container() {
let ui = ui(SchemeFlavor::Light);
let metrics = ui.metrics();
let caption = icons::CAPTION_PX;
assert_eq!(metrics.titlebar_button_width, 46.0, "容器宽不许跟着图标改");
assert_eq!(metrics.titlebar_height, 48.0, "容器高不许跟着图标改");
assert!(
caption < metrics.titlebar_button_width,
"图标 {caption} 必须小于按钮宽 {}",
metrics.titlebar_button_width
);
assert!(
caption < metrics.titlebar_height,
"图标 {caption} 必须小于按钮高 {}",
metrics.titlebar_height
);
// 官方口径 24 仍在令牌里(`IconSize::Toolbar`),偏离只发生在 caption 这一个入口。
assert!(
caption < unlucky_design::icons::IconSize::Toolbar.px(),
"caption 图标必须小于官方 24(缩小是用户点名的)"
);
}
}
#[path = "tests.rs"]
mod tests;
+126
View File
@@ -0,0 +1,126 @@
//! `shell` 的断言(M100 从 `shell/mod.rs` 搬出来:那边在 HEAD 上已经 **399 行**,
//! 加一个 `ForcedState::Rest` 分支就必破 400 行硬线)。
//!
//! 搬移**不改一行断言**:只去掉了 `mod tests { }` 那一层缩进,外加 M100 新增的两条
//! 取证不变量(见文件末尾)。
//!
//! 站位:本文件是 `crate::shell::tests`,`use super::*;` 就够。
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::component_map::Component;
use unlucky_design::typography::Platform;
use crate::theme::Families;
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(
flavor,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn a_screen_only_uses_four_text_sizes_at_most() {
let ui = ui(SchemeFlavor::Dark);
let card = Component::OutlinedCard.spec();
let sizes = [
ui.emphasized(TypeRole::TitleLarge).size_px,
ui.emphasized(TypeRole::LabelLarge).size_px,
ui.emphasized(card.text).size_px,
ui.style(card.support.unwrap_or(card.text)).size_px,
ui.style(TypeRole::LabelMedium).size_px,
];
let mut unique = sizes.to_vec();
unique.sort_by(f32::total_cmp);
unique.dedup();
assert!(
unique.len() <= 4,
"rubric D4:一屏字号档 ≤4(实际 {} 档 {unique:?})",
unique.len()
);
assert!(unique[0] >= 12.0, "最小档不下 12px(caption ≥11)");
assert!(unique[unique.len() - 1] <= 28.0, "列表页标题不得 >28px");
}
#[test]
fn flavor_slug_is_stable_for_filenames() {
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Light)), "light");
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Dark)), "dark");
}
/// **交互模式不许被取证开关污染**(M100 的头号不变量)。
///
/// 抓得住:谁把 `--screenshot` 的默认值 `Some(Rest)` 顺手设成了 `Options::force_state` 的默认值
/// (或者把默认往下挪一层,落到 `overridden` 的 `None` 分支上)——那会让**用户窗口里所有按钮
/// 永远不显示 hover**,是产品事故,而不是取证问题。
/// 抓不住:取证模式有没有真的压掉 hover(那是下一条)。
#[test]
fn interactive_mode_still_hovers() {
use iced::widget::button::Status;
for status in [Status::Hovered, Status::Pressed, Status::Active] {
assert_eq!(
overridden(None, status),
status,
"交互模式(None)必须跟控件自己的状态走,不许替用户钉死"
);
}
}
/// **取证图的常态钉死**:`Rest` 把控件压成 `Active`,不管它自己的运行期状态是什么。
///
/// 抓得住:谁把 `Rest` 接成「保留现状」(那等于没钉:系统指针 hover 照样漏进图——M99 复审实测
/// 连跑 8 次翻盘 1 次就是这里没钉住)。
/// 抓不住:① 不经过 `overridden` 的控件(标题栏以外的裸 `iced::button`,例如设置页「外观」区);
/// ② 窗口到底还收不收鼠标事件(那是 harness 的 passthrough,靠出图日志的 `指针事件=N` 自证)。
#[test]
fn rest_suppresses_cursor_hover() {
use iced::widget::button::Status;
for status in [Status::Hovered, Status::Pressed, Status::Active] {
assert_eq!(
overridden(Some(ForcedState::Rest), status),
Status::Active,
"Rest 必须压掉 {status:?}"
);
}
// 明细态照旧(D30 的状态矩阵图靠它们)
assert_eq!(
overridden(Some(ForcedState::Hover), Status::Active),
Status::Hovered
);
assert_eq!(
overridden(Some(ForcedState::Pressed), Status::Active),
Status::Pressed
);
}
/// M86:标题栏 caption 图标缩到 20 后,**容器一个数都没动**(46×48),图标也还在框里。
#[test]
fn caption_icon_shrinks_without_touching_the_container() {
let ui = ui(SchemeFlavor::Light);
let metrics = ui.metrics();
let caption = icons::CAPTION_PX;
assert_eq!(metrics.titlebar_button_width, 46.0, "容器宽不许跟着图标改");
assert_eq!(metrics.titlebar_height, 48.0, "容器高不许跟着图标改");
assert!(
caption < metrics.titlebar_button_width,
"图标 {caption} 必须小于按钮宽 {}",
metrics.titlebar_button_width
);
assert!(
caption < metrics.titlebar_height,
"图标 {caption} 必须小于按钮高 {}",
metrics.titlebar_height
);
// 官方口径 24 仍在令牌里(`IconSize::Toolbar`),偏离只发生在 caption 这一个入口。
assert!(
caption < unlucky_design::icons::IconSize::Toolbar.px(),
"caption 图标必须小于官方 24(缩小是用户点名的)"
);
}