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

+32
View File
@@ -126,6 +126,8 @@ pub(super) fn model(
logs: logs_model(state).with_status(lang),
lang,
forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
crate::cli::ForcedState::Rest => Interaction::Enabled,
crate::cli::ForcedState::Hover => Interaction::Hovered,
crate::cli::ForcedState::Pressed => Interaction::Pressed,
}),
@@ -251,6 +253,36 @@ mod tests {
}
}
/// **取证不变量(M100)**:`ForcedState` 的三档在产品路径上必须映射成「页面控件怎么画」。
///
/// 抓得住:① 谁把 `Rest` 映射成 Hovered/Pressed(那会让 `--screenshot` 的默认值反而画出状态层,
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
#[test]
fn the_forced_state_maps_to_the_expected_interaction() {
let state = AppState::new();
let forced = |forced| model(&state, 1180.0, Some(forced), pages::Category::All).forced;
assert_eq!(
forced(crate::cli::ForcedState::Rest),
Some(Interaction::Enabled),
"Rest = 静息态(压掉系统光标 hover)"
);
assert_eq!(
forced(crate::cli::ForcedState::Hover),
Some(Interaction::Hovered)
);
assert_eq!(
forced(crate::cli::ForcedState::Pressed),
Some(Interaction::Pressed)
);
assert_eq!(
model(&state, 1180.0, None, pages::Category::All).forced,
None
);
}
#[test]
fn the_snapshot_becomes_the_page_model() {
let mut state = AppState::new();
+18 -130
View File
@@ -14,7 +14,7 @@
//! | `--screen` | `shell`/`gallery` | 启动画面;**`gallery` = 组件库视觉验收 harness**(D39 §5) |
//! | `--scale-factor` | `>0` | 强制缩放因子(验收要 100%,即 `1`) |
//! | `--diff` / `--diff-scan` | 两个路径 / 非负整数 | 逐像素对比(不开窗)与 ±n px 平移扫描 |
//! | `--force-state` | `hover`/`pressed` | 把整屏可交互控件强制成某态(取证) |
//! | `--force-state` | `rest`/`hover`/`pressed` | 把整屏可交互控件强制成某态(取证);**给了 `--screenshot` 就默认 `rest`** |
//! | `--data-dir` | 路径 | 翻牌兼容:**接受并忽略**(CI 冒烟在用) |
//!
//! **截图主题默认 Light(门禁依赖,不许改回 None)**,理由与不变量见
@@ -110,6 +110,13 @@ pub struct Options {
/// 五态完整矩阵由 `--screen=gallery` 的 `Button::state(..)` 出,本开关只给整屏取证。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForcedState {
/// **常态**(静息):压掉系统光标带来的 hover/pressed。
///
/// 截图通道(给了 `--screenshot`)**默认就是它**,理由是实测:M99 复审在同一个 exe 上连跑 8 次,
/// 第 6 次给出第三个 hash,差异区是某个按钮被画成 `Hovered`——因为 harness/产品都开真窗口,
/// `button::Status` 由系统光标位置决定。取证图不能因为「指针恰好压在某个按钮上」而变色。
/// 显式 `--force-state=hover|pressed` 仍然优先(那是**故意**要画那个态)。
Rest,
/// 所有可交互控件按 hover 画。
Hover,
/// 所有可交互控件按 pressed 画。
@@ -189,11 +196,12 @@ impl Options {
"--force-state" => {
let raw = take(&mut args, inline, "--force-state")?;
options.force_state = Some(match raw.as_str() {
"rest" => ForcedState::Rest,
"hover" => ForcedState::Hover,
"pressed" => ForcedState::Pressed,
other => {
return Err(format!(
"--force-state 只认 hover|pressed,收到 {other:?}"
"--force-state 只认 rest|hover|pressed,收到 {other:?}"
));
}
});
@@ -216,6 +224,12 @@ impl Options {
}
}
// **截图通道默认强制 `Rest`**(M100):取证图不许跟着系统指针的位置变。
// 交互模式保持 `None`——那是用户的真窗口,不许替用户把状态钉死(钉死了就永远看不到 hover)。
if options.screenshot.is_some() && options.force_state.is_none() {
options.force_state = Some(ForcedState::Rest);
}
Ok(options)
}
@@ -264,131 +278,5 @@ fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Options, String> {
Options::parse(args.iter().map(|arg| (*arg).to_owned()))
}
#[test]
fn defaults_are_auto_system_theme_and_shell() {
let options = parse(&[]).expect("空参数要能解析");
assert_eq!(options.backend, Backend::Auto);
assert_eq!((options.theme, options.screenshot), (None, None));
assert_eq!(options.screen, Screen::Shell);
}
#[test]
fn software_renderer_is_an_alias_of_cpu() {
assert_eq!(
parse(&["--software-renderer"]).unwrap().backend,
Backend::Cpu
);
assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu);
assert_eq!(parse(&["--renderer", "gpu"]).unwrap().backend, Backend::Gpu);
}
#[test]
fn both_argv_forms_are_accepted() {
let spaced = parse(&["--screenshot", "out.png"]).unwrap();
let inline = parse(&["--screenshot=out.png"]).unwrap();
assert_eq!(spaced.screenshot, Some(PathBuf::from("out.png")));
assert_eq!(spaced.screenshot, inline.screenshot);
assert_eq!(
parse(&["--diff", "a.png", "b.png"]).unwrap().diff,
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
);
assert_eq!(
parse(&["--screen", "gallery"]).unwrap().screen,
Screen::Gallery
);
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
assert_eq!(
parse(&["--force-state", "hover"]).unwrap().force_state,
Some(ForcedState::Hover)
);
}
#[test]
fn screenshot_theme_wins_over_theme() {
let o = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
assert_eq!(
(o.theme, o.shot_flavor()),
(Some(SchemeFlavor::Dark), Some(SchemeFlavor::Light))
);
}
#[test]
fn bad_values_are_reported_not_panicked() {
for args in [
&["--renderer=metal"][..],
&["--theme=blue"],
&["--scale-factor=0"],
&["--scale-factor=abc"],
&["--screenshot"],
&["--diff", "only-one.png"],
&["--screen=settings"],
&["--force-state=focus"],
&["--diff-scan=-1"],
&["--nope"],
] {
assert!(parse(args).is_err(), "{args:?} 应该报错");
}
}
#[test]
fn data_dir_is_accepted_and_ignored_for_the_flip() {
// 现役 CI 冒烟:--software-renderer --screenshot <png> --data-dir <dir>
let options = parse(&[
"--software-renderer",
"--screenshot",
"shot.png",
"--data-dir",
"/d/tmp/x",
])
.expect("翻牌前必须兼容 --data-dir");
assert_eq!(options.backend, Backend::Cpu);
assert_eq!(options.screenshot, Some(PathBuf::from("shot.png")));
assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x")));
}
/// **门禁依赖**:CI 的 runtime-smoke 不带主题,截图模式必须有确定主题。
/// 退化成 `None` 的话,xvfb 里的 runner 会挂到超时(不是红,更难查)。
#[test]
fn screenshot_without_theme_defaults_to_light() {
let options = parse(&["--screenshot", "out.png"]).unwrap();
assert_eq!(options.shot_flavor(), None, "用户没给主题");
assert_eq!(
options.capture_flavor(),
Some(SchemeFlavor::Light),
"截图模式必须给确定主题(CI 冒烟依赖这条)"
);
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
}
#[test]
fn explicit_themes_still_win_in_screenshot_mode() {
let dark = parse(&["--screenshot", "a.png", "--theme=dark"]).unwrap();
assert_eq!(dark.capture_flavor(), Some(SchemeFlavor::Dark));
let both = parse(&[
"--screenshot",
"a.png",
"--theme=dark",
"--screenshot-theme=light",
]);
assert_eq!(both.unwrap().capture_flavor(), Some(SchemeFlavor::Light));
}
#[test]
fn interactive_mode_still_follows_the_system_theme() {
let interactive = parse(&[]).unwrap();
assert_eq!(interactive.capture_flavor(), None);
assert!(interactive.wants_system_theme(), "交互模式等系统主题");
let pinned = parse(&["--theme=dark"]).unwrap();
assert_eq!(pinned.capture_flavor(), Some(SchemeFlavor::Dark));
assert!(!pinned.wants_system_theme());
}
}
#[path = "cli/tests.rs"]
mod tests;
+172
View File
@@ -0,0 +1,172 @@
//! `cli::Options` 的解析断言(M100 从 `cli.rs` 内联的 `mod tests` 搬出来:那边 394 行,
//! 加一个 `ForcedState::Rest` 就必然破 400)。
//!
//! 搬移**不改一行断言**:只去掉了 `mod tests { }` 那一层缩进,外加 M100 新增的
//! `screenshots_default_to_the_rest_state`(见文件末尾)。
//!
//! 站位:本文件是 `crate::cli::tests`,`use super::*;` 就够(`Options` / `ForcedState` /
//! `Backend` / `Screen` 都在 `cli` 里)。
use super::*;
fn parse(args: &[&str]) -> Result<Options, String> {
Options::parse(args.iter().map(|arg| (*arg).to_owned()))
}
#[test]
fn defaults_are_auto_system_theme_and_shell() {
let options = parse(&[]).expect("空参数要能解析");
assert_eq!(options.backend, Backend::Auto);
assert_eq!((options.theme, options.screenshot), (None, None));
assert_eq!(options.screen, Screen::Shell);
}
#[test]
fn software_renderer_is_an_alias_of_cpu() {
assert_eq!(
parse(&["--software-renderer"]).unwrap().backend,
Backend::Cpu
);
assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu);
assert_eq!(parse(&["--renderer", "gpu"]).unwrap().backend, Backend::Gpu);
}
#[test]
fn both_argv_forms_are_accepted() {
let spaced = parse(&["--screenshot", "out.png"]).unwrap();
let inline = parse(&["--screenshot=out.png"]).unwrap();
assert_eq!(spaced.screenshot, Some(PathBuf::from("out.png")));
assert_eq!(spaced.screenshot, inline.screenshot);
assert_eq!(
parse(&["--diff", "a.png", "b.png"]).unwrap().diff,
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
);
assert_eq!(
parse(&["--screen", "gallery"]).unwrap().screen,
Screen::Gallery
);
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
assert_eq!(
parse(&["--force-state", "hover"]).unwrap().force_state,
Some(ForcedState::Hover)
);
}
#[test]
fn screenshot_theme_wins_over_theme() {
let o = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
assert_eq!(
(o.theme, o.shot_flavor()),
(Some(SchemeFlavor::Dark), Some(SchemeFlavor::Light))
);
}
#[test]
fn bad_values_are_reported_not_panicked() {
for args in [
&["--renderer=metal"][..],
&["--theme=blue"],
&["--scale-factor=0"],
&["--scale-factor=abc"],
&["--screenshot"],
&["--diff", "only-one.png"],
&["--screen=settings"],
&["--force-state=focus"],
&["--diff-scan=-1"],
&["--nope"],
] {
assert!(parse(args).is_err(), "{args:?} 应该报错");
}
}
#[test]
fn data_dir_is_accepted_and_ignored_for_the_flip() {
// 现役 CI 冒烟:--software-renderer --screenshot <png> --data-dir <dir>
let options = parse(&[
"--software-renderer",
"--screenshot",
"shot.png",
"--data-dir",
"/d/tmp/x",
])
.expect("翻牌前必须兼容 --data-dir");
assert_eq!(options.backend, Backend::Cpu);
assert_eq!(options.screenshot, Some(PathBuf::from("shot.png")));
assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x")));
}
/// **取证不变量(M100)**:截图通道默认 Rest,交互模式保持 `None`,显式要态仍然优先。
///
/// 抓得住:① 谁把截图默认值改回 `None`(系统指针 hover 会重新漏进图,M99 复审实测翻盘过一次);
/// ② 谁顺手在交互模式也钉死状态(那会让真窗口永远看不到 hover)。
/// 抓不住:**产品页**上是否还有不消费 forced 的控件——那要出图比对,测试管不了。
#[test]
fn screenshots_default_to_the_rest_state() {
assert_eq!(
parse(&["--force-state=rest"]).unwrap().force_state,
Some(ForcedState::Rest),
"rest 是显式的合法取值"
);
assert_eq!(
parse(&["--screenshot", "out.png"]).unwrap().force_state,
Some(ForcedState::Rest),
"截图通道必须默认 Rest"
);
assert_eq!(
parse(&["--screenshot", "o.png", "--force-state=hover"])
.unwrap()
.force_state,
Some(ForcedState::Hover),
"显式要态时按用户给的画(状态矩阵图靠它)"
);
assert_eq!(
parse(&["--screenshot", "o.png", "--force-state=pressed"])
.unwrap()
.force_state,
Some(ForcedState::Pressed)
);
assert_eq!(
parse(&[]).unwrap().force_state,
None,
"交互模式不许替用户钉死状态"
);
assert_eq!(parse(&["--theme=dark"]).unwrap().force_state, None);
}
/// **门禁依赖**:CI 的 runtime-smoke 不带主题,截图模式必须有确定主题。
/// 退化成 `None` 的话,xvfb 里的 runner 会挂到超时(不是红,更难查)。
#[test]
fn screenshot_without_theme_defaults_to_light() {
let options = parse(&["--screenshot", "out.png"]).unwrap();
assert_eq!(options.shot_flavor(), None, "用户没给主题");
assert_eq!(
options.capture_flavor(),
Some(SchemeFlavor::Light),
"截图模式必须给确定主题(CI 冒烟依赖这条)"
);
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
}
#[test]
fn explicit_themes_still_win_in_screenshot_mode() {
let dark = parse(&["--screenshot", "a.png", "--theme=dark"]).unwrap();
assert_eq!(dark.capture_flavor(), Some(SchemeFlavor::Dark));
let both = parse(&[
"--screenshot",
"a.png",
"--theme=dark",
"--screenshot-theme=light",
]);
assert_eq!(both.unwrap().capture_flavor(), Some(SchemeFlavor::Light));
}
#[test]
fn interactive_mode_still_follows_the_system_theme() {
let interactive = parse(&[]).unwrap();
assert_eq!(interactive.capture_flavor(), None);
assert!(interactive.wants_system_theme(), "交互模式等系统主题");
let pinned = parse(&["--theme=dark"]).unwrap();
assert_eq!(pinned.capture_flavor(), Some(SchemeFlavor::Dark));
assert!(!pinned.wants_system_theme());
}
+135 -167
View File
@@ -3,7 +3,22 @@
//! **刻意不接 `cli.rs`**:等 `cli.rs` 具备 `--screen` / `--window-width` 之后,这个 harness 应当
//! 删掉,改由正式启动参数出图。参数:`--screen=instances|download|settings|logs`、`--width`、
//! `--height`、`--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
//! `--software-renderer`、`--scale-factor`。没给 `--screenshot` 就只是开窗看。
//! `--software-renderer`、`--scale-factor`、`--appearance=real|demo`、`--passthrough=on|off`。
//! 解析与默认值在 [`request`](M100 拆出去:本文件当时正好 400 行、零余量);没给 `--screenshot`
//! 就只是开窗看。
//!
//! ## 取证密闭(M100):取证图不许跟着系统指针变
//!
//! M99 复审实测过:同一个 exe 连跑 8 次,第 6 次给出**第三个 hash**,差异区是某个按钮被画成 Hovered
//! ——根因是 harness 开真窗口,`button::Status` 由**系统光标位置**决定,而「连跑两次一样」不是判据。
//!
//! 所以 `--screenshot` 有一条**通道级**钉死:开窗那一刻调 `window::enable_mouse_passthrough`
//! (winit 的 `set_cursor_hittest(false)`)⇒ 窗口从此收不到指针事件 ⇒ 全树控件恒为 `Active`。
//! **为什么不是给每个控件灌 forced**:设置页「外观」区的滑块 / 文本框 / 裸 `iced::button` 都不消费
//! forced,那样做要改十几个组件文件——为取证去动产品组件,代价与风险都不对,而且新组件随时会漏。
//!
//! 同时数指针事件(`iced::event::listen_with`)并打进日志:**「这张图收到几个鼠标事件」是可读数字**,
//! 于是「hash 稳」不再是「大概没漏」,而是「这张图压根没收到指针事件」。
//!
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- --screen=instances --width=1180 \
@@ -12,171 +27,29 @@
//!
//! 等 3 帧再截图与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,字体首次上屏会抖动。
use std::path::PathBuf;
use std::time::Instant;
use iced::{window, Element, Subscription, Task};
use iced::{window, Element, Point, Subscription, Task};
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs};
use crate::app::components::interaction::Interaction;
use crate::cli::Backend;
use crate::fonts;
use crate::pages::appearance;
use crate::probe::{self, Probe};
use crate::renderer;
use crate::shot;
use crate::theme::Ui;
use super::{app, demo_model, Model, Screen};
use super::{app_with_appearance, demo_model, Model};
/// 截图落盘的帧号(见模块文档);验收窗口尺寸(D30:1180×760、100% DPI)。
mod request;
pub use request::{Request, CAPTURE_POSITION};
/// 截图落盘的帧号(见模块文档)。
const CAPTURE_AT_FRAME: u32 = 3;
const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0);
/// 一次渲染请求。
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
/// 渲染哪一页。
pub screen: Screen,
/// 窗口宽度(**含外壳**:布局档位就是按它判的)。
pub width: f32,
/// 窗口高度。
pub height: f32,
/// 主题(`--screenshot-theme` > `--theme` > 浅色)。
pub flavor: SchemeFlavor,
/// 出图路径(`None` = 只开窗)。
pub screenshot: Option<PathBuf>,
/// 渲染后端请求。
pub backend: Backend,
/// 强制缩放因子(截图要 100% DPI)。
pub scale_factor: Option<f32>,
}
impl Default for Request {
fn default() -> Self {
Self {
screen: Screen::Instances,
width: DEFAULT_SIZE.0,
height: DEFAULT_SIZE.1,
flavor: SchemeFlavor::Light,
screenshot: None,
backend: Backend::Auto,
scale_factor: None,
}
}
}
impl Request {
/// 从进程参数解析(`argv[1..]`)。未知参数 / 缺值 / 数字非法时返回人话。
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut request = Self::default();
let mut theme: Option<SchemeFlavor> = None;
let mut shot_theme: Option<SchemeFlavor> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let (key, inline) = match arg.split_once('=') {
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
None => (arg.clone(), None),
};
match key.as_str() {
"--screen" => {
let raw = take(&mut args, inline, "--screen")?;
request.screen = Screen::from_slug(&raw).ok_or_else(|| {
format!("--screen 只认 instances|download|settings|logs,收到 {raw:?}")
})?;
}
"--width" => {
request.width = number(&take(&mut args, inline, "--width")?, "--width")?
}
"--height" => {
request.height = number(&take(&mut args, inline, "--height")?, "--height")?;
}
"--theme" => theme = Some(flavor(&take(&mut args, inline, "--theme")?)?),
"--screenshot-theme" => {
shot_theme = Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
}
"--screenshot" => {
request.screenshot =
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
}
"--software-renderer" => request.backend = Backend::Cpu,
"--renderer" => {
request.backend = match take(&mut args, inline, "--renderer")?.as_str() {
"auto" => Backend::Auto,
"gpu" => Backend::Gpu,
"cpu" => Backend::Cpu,
other => {
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"))
}
};
}
"--scale-factor" => {
let raw = take(&mut args, inline, "--scale-factor")?;
let value = number(&raw, "--scale-factor")?;
if value <= 0.0 {
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
}
request.scale_factor = Some(value);
}
other => return Err(format!("未知参数 {other:?}")),
}
}
request.flavor = shot_theme.or(theme).unwrap_or(SchemeFlavor::Light);
if request.width < 320.0 || request.height < 320.0 {
return Err(format!(
"窗口太小:{}×{}(至少 320×320)",
request.width, request.height
));
}
Ok(request)
}
/// 出图文件名片段(`<screen>-<class>-<flavor>-w<宽>`,归档用)。
pub fn slug(&self) -> String {
let class = super::layout::WidthClass::of(self.width);
let flavor = if self.flavor.is_dark() {
"dark"
} else {
"light"
};
let width = self.width as u32;
format!("{}-{}-{flavor}-w{width}", self.screen.slug(), class.name())
}
}
fn take(
args: &mut impl Iterator<Item = String>,
inline: Option<String>,
flag: &str,
) -> Result<String, String> {
inline
.or_else(|| args.next())
.ok_or_else(|| format!("{flag} 需要一个值"))
}
fn number(raw: &str, flag: &str) -> Result<f32, String> {
raw.parse::<f32>()
.map_err(|_| format!("{flag} 需要数字,收到 {raw:?}"))
.and_then(|value| {
if value.is_finite() && value > 0.0 {
Ok(value)
} else {
Err(format!("{flag} 必须是正数,收到 {raw:?}"))
}
})
}
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
match raw {
"light" => Ok(SchemeFlavor::Light),
"dark" => Ok(SchemeFlavor::Dark),
other => Err(format!("主题只认 light|dark,收到 {other:?}")),
}
}
/// 启动(阻塞到出图或关窗)。
///
@@ -213,6 +86,14 @@ pub fn run(request: Request) -> Result<(), String> {
size: iced::Size::new(request.width, request.height),
decorations: false,
resizable: true,
// 取证窗口钉在固定落点:这样「把指针放进窗口某个位置」是可复算的(真实矩形由
// `Harness` 用 `window::position` / `window::size` 的返回值打日志报出,见 `Message::Sized`)
position: match request.screenshot {
Some(_) => {
window::Position::Specific(Point::new(CAPTURE_POSITION.0, CAPTURE_POSITION.1))
}
None => window::Position::default(),
},
..window::Settings::default()
};
renderer::acceptance::report_window(
@@ -222,17 +103,53 @@ pub fn run(request: Request) -> Result<(), String> {
None,
);
// 外观**真状态**(`--appearance=real`):工厂态 + `ULL_APPEARANCE_*` 覆盖。
// 这不是给设置页塞常量:状态从 `State::new` / `apply_env` 来,打日志的是它算出来的生效值。
let appearance = request.real_appearance.then(|| {
let mut state = appearance::State::new(
&AppearancePrefs::factory(),
&AppearanceBase::factory(),
request.backend,
request.flavor,
);
state.apply_env();
eprintln!(
"[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \
配色来源={:?} 选中分组={}",
state.window_opacity,
state.panel_alpha,
state.effective_panel_alpha(),
state.bg_enabled,
state.bg_fill,
state.bg_dim,
state.palette_source,
state.selected_group,
);
state
});
let application = |backend: renderer::Kind| {
let boot = request.clone();
let appearance = appearance.clone();
iced::application(
move || {
let mut model = demo_model(boot.screen, boot.width);
// 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot`
// 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced),
// 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。
if boot.screenshot.is_some() {
model.forced = Some(Interaction::Enabled);
}
(
Harness {
ui: Ui::new(boot.flavor, families),
model: demo_model(boot.screen, boot.width),
model,
appearance: appearance.clone(),
request: boot.clone(),
window: None,
placed: None,
frames: 0,
pointer: 0,
started,
first_frame: None,
backend: backend.name(),
@@ -305,8 +222,14 @@ struct Harness {
ui: Ui,
/// 视图模型(演示数据;harness 不接 presenter,只出**单页**图)
model: Model,
/// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。
appearance: Option<appearance::State>,
window: Option<window::Id>,
/// 窗口落点(`window::position` 的**返回值**,不是请求值)。
placed: Option<Point>,
frames: u32,
/// 本张图收到过几个鼠标事件(`Message::Pointer` 的计数;密闭性靠它自证)。
pointer: u32,
/// 进程起点(`run()` 的开头)——`[perf]` 的首帧口径与产品路径一致:从进 `run()` 算。
started: Instant,
/// 第一帧到达的时刻(相对 `started`)。`None` = 一帧都没到过。
@@ -319,8 +242,14 @@ struct Harness {
enum Message {
Boot,
WindowReady(Option<window::Id>),
/// `window::position` 的回值。
Placed(Option<Point>),
/// `window::size` 的回值。
Sized(iced::Size),
Frame,
Captured(window::Screenshot),
/// 收到一个指针事件(只计数,不画;见 [`Harness::subscription`])。
Pointer,
/// 页面里的动作(骨架期只打印,接线后由 app 层处理)。
Page(super::Message),
}
@@ -331,6 +260,35 @@ impl Harness {
Message::Boot => window::latest().map(Message::WindowReady),
Message::WindowReady(id) => {
self.window = id;
// 取证窗口按住**那一刻**就断开指针输入(M100 模块文档「取证密闭」)。
// 交互模式走不到这里:`request.passthrough` 在没有 `--screenshot` 时恒为 false
// (`Request::parse` 里显式给值会直接报错,不给值就是 false)。
let Some(id) = id else {
return Task::none();
};
let mut tasks = Vec::new();
if self.request.passthrough {
eprintln!("[harness] 指针穿透已开:本窗口不再接收鼠标事件(取证专用)");
tasks.push(window::enable_mouse_passthrough(id));
}
tasks.push(window::position(id).map(Message::Placed));
Task::batch(tasks)
}
Message::Placed(position) => {
self.placed = position;
match self.window {
Some(id) => window::size(id).map(Message::Sized),
None => Task::none(),
}
}
Message::Sized(size) => {
match self.placed {
Some(Point { x, y }) => eprintln!(
"[harness] 窗口实测矩形:({x:.0}, {y:.0}) {:.0}×{:.0}(window::position/size 的回值)",
size.width, size.height
),
None => eprintln!("[harness] 窗口落点取不到(window::position 回 None)"),
}
Task::none()
}
Message::Frame => {
@@ -346,21 +304,24 @@ impl Harness {
None => Task::none(),
}
}
Message::Pointer => {
self.pointer += 1;
Task::none()
}
Message::Captured(screenshot) => {
let Some(path) = self.request.screenshot.clone() else {
return Task::none();
};
match shot::save(&screenshot, &path) {
Ok(summary) => {
eprintln!("{}", summary.describe());
eprintln!(
"[harness] slug={} backend={}(这张图的来源,自证用)",
self.request.slug(),
self.backend
);
}
Ok(summary) => eprintln!("{}", summary.describe()),
Err(error) => eprintln!("[harness] 截图失败:{error}"),
}
eprintln!(
"[harness] slug={} backend={} 指针事件={}(这张图的来源与密闭性,自证用)",
self.request.slug(),
self.backend,
self.pointer
);
// `[perf]` 两格都是实测:`first_frame` = 首帧事件的时间戳,`fps` = 帧数 / 时长
// (**取证窗口**的吞吐,不是长跑稳态帧率——那个看产品路径的 `[tick]`)。
renderer::acceptance::report_perf_scene(
@@ -379,11 +340,18 @@ impl Harness {
}
fn subscription(&self) -> Subscription<Message> {
if self.request.screenshot.is_some() {
window::frames().map(|_| Message::Frame)
} else {
Subscription::none()
if self.request.screenshot.is_none() {
return Subscription::none();
}
// 帧节拍 + **指针事件计数**两样都订阅。后者是 M100 加的:以前「取证密闭」靠
// 「同一场景连跑几次 hash 一样」证明,而进程压根收不到鼠标时那条恒真(U5 的同族假绿)。
Subscription::batch([
window::frames().map(|_| Message::Frame),
iced::event::listen_with(|event, _status, _window| match event {
iced::Event::Mouse(_) => Some(Message::Pointer),
_ => None,
}),
])
}
fn theme(&self) -> iced::Theme {
@@ -391,7 +359,7 @@ impl Harness {
}
fn view(&self) -> Element<'_, Message> {
app(&self.ui, &self.model).map(Message::Page)
app_with_appearance(&self.ui, &self.model, self.appearance.as_ref()).map(Message::Page)
}
}
@@ -0,0 +1,233 @@
//! harness 的**参数解析**(M100 从 `harness.rs` 搬出来:那边正好 400 行、零余量)。
//!
//! 搬移**不改行为**:同一份 argv 方言、同一份 slug、同一句报错。等价性判据是「同一 argv 出的 PNG
//! sha256 与拆分前一致」(M100 报告里有实测)。
//!
//! 参数方言与 `crates/app-iced/src/cli.rs` 同源(`--key value` 与 `--key=value` 都认),但**刻意不共用
//! 那个 parser**:harness 是取证通道,默认值与产品启动不同(`--screenshot` 默认开**指针穿透**,见
//! [`Request::passthrough`])。产品 bin 的密闭手段是另一套(`cli::ForcedState::Rest`)——两条通道的
//! 证据分开算,别互相冒充。
use std::path::PathBuf;
use unlucky_design::color::SchemeFlavor;
use crate::cli::Backend;
use crate::pages::{layout::WidthClass, Screen};
/// 验收窗口尺寸(D30:1180×760、100% DPI)。
const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0);
/// 取证窗口的落点请求值(`--screenshot` 时才用)。
///
/// 为什么要固定:密闭验证要把系统指针放进窗口里某个点(**故意压在控件上**),窗口位置每次都飘的话,
/// 「指针到底在不在窗口里」就变成了猜——那正是 U1 的形状。真实矩形由 harness 用
/// `window::position` / `window::size` 的**返回值**打日志报出,脚本按日志放指针,不以这个常量为准
/// (它只是请求值)。
pub const CAPTURE_POSITION: (f32, f32) = (80.0, 80.0);
/// 一次渲染请求。
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
/// 渲染哪一页。
pub screen: Screen,
/// 窗口宽度(**含外壳**:布局档位就是按它判的)。
pub width: f32,
/// 窗口高度。
pub height: f32,
/// 主题(`--screenshot-theme` > `--theme` > 浅色)。
pub flavor: SchemeFlavor,
/// 出图路径(`None` = 只开窗)。
pub screenshot: Option<PathBuf>,
/// 渲染后端请求。
pub backend: Backend,
/// 强制缩放因子(截图要 100% DPI)。
pub scale_factor: Option<f32>,
/// 设置页正文画**真控件**(`appearance: Some(..)`)还是演示行(`--appearance=real|demo`)。
///
/// 只对 `--screen=settings` 有意义:别的页没有外观分组,静默接受会造出「以为接了线」的假图(U1)。
pub real_appearance: bool,
/// 截图窗口开**指针穿透**(`window::enable_mouse_passthrough` ⇒ winit `set_cursor_hittest(false)`)。
///
/// 默认:给了 `--screenshot` 就开;**交互模式(没有截图)一律关**——交互窗口收不到鼠标是产品事故,
/// 所以 `--passthrough` 在交互模式下直接报错,不留「配一下就行」的口子。
///
/// `--passthrough=off` 只服务一件事:对照实验——证明「不开穿透时,同一指针位置会把 hover 漏进图」。
/// 没有这个反向证据的话,「穿透开着时 hash 不变」既可能是真密闭,也可能只是指针压根没进窗口。
pub passthrough: bool,
}
impl Default for Request {
fn default() -> Self {
Self {
screen: Screen::Instances,
width: DEFAULT_SIZE.0,
height: DEFAULT_SIZE.1,
flavor: SchemeFlavor::Light,
screenshot: None,
backend: Backend::Auto,
scale_factor: None,
real_appearance: false,
passthrough: false,
}
}
}
impl Request {
/// 从进程参数解析(`argv[1..]`)。未知参数 / 缺值 / 数字非法时返回人话。
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut request = Self::default();
let mut theme: Option<SchemeFlavor> = None;
let mut shot_theme: Option<SchemeFlavor> = None;
let mut passthrough: Option<bool> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let (key, inline) = match arg.split_once('=') {
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
None => (arg.clone(), None),
};
match key.as_str() {
"--screen" => {
let raw = take(&mut args, inline, "--screen")?;
request.screen = Screen::from_slug(&raw).ok_or_else(|| {
format!("--screen 只认 instances|download|settings|logs,收到 {raw:?}")
})?;
}
"--width" => {
request.width = number(&take(&mut args, inline, "--width")?, "--width")?
}
"--height" => {
request.height = number(&take(&mut args, inline, "--height")?, "--height")?;
}
"--theme" => theme = Some(flavor(&take(&mut args, inline, "--theme")?)?),
"--screenshot-theme" => {
shot_theme = Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
}
"--screenshot" => {
request.screenshot =
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
}
"--software-renderer" => request.backend = Backend::Cpu,
"--renderer" => {
request.backend = match take(&mut args, inline, "--renderer")?.as_str() {
"auto" => Backend::Auto,
"gpu" => Backend::Gpu,
"cpu" => Backend::Cpu,
other => {
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"))
}
};
}
"--scale-factor" => {
let raw = take(&mut args, inline, "--scale-factor")?;
let value = number(&raw, "--scale-factor")?;
if value <= 0.0 {
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
}
request.scale_factor = Some(value);
}
"--appearance" => {
request.real_appearance =
match take(&mut args, inline, "--appearance")?.as_str() {
"real" => true,
"demo" => false,
other => {
return Err(format!("--appearance 只认 real|demo,收到 {other:?}"))
}
};
}
"--passthrough" => {
passthrough = Some(match take(&mut args, inline, "--passthrough")?.as_str() {
"on" => true,
"off" => false,
other => return Err(format!("--passthrough 只认 on|off,收到 {other:?}")),
});
}
other => return Err(format!("未知参数 {other:?}")),
}
}
request.flavor = shot_theme.or(theme).unwrap_or(SchemeFlavor::Light);
// 指针穿透**只服务取证通道**:没有 `--screenshot` 时显式给值就是配置错误,不是「忽略」——
// 静默忽略会让人以为交互窗口也「密闭」了,而它其实照常收鼠标。
if passthrough.is_some() && request.screenshot.is_none() {
return Err(
"--passthrough 只服务 --screenshot 取证通道:交互模式的窗口必须收得到鼠标"
.to_owned(),
);
}
request.passthrough = match request.screenshot {
// 取证默认密闭:开窗口的那一刻就断开指针输入(见 `Harness::update` 的 `WindowReady`)
Some(_) => passthrough.unwrap_or(true),
None => false,
};
if request.real_appearance && request.screen != Screen::Settings {
return Err(format!(
"--appearance=real 只对 --screen=settings 生效(当前 --screen={}),别的页没有外观分组,\
静默接受会出一张假装接了线的图",
request.screen.slug()
));
}
if request.width < 320.0 || request.height < 320.0 {
return Err(format!(
"窗口太小:{}×{}(至少 320×320)",
request.width, request.height
));
}
Ok(request)
}
/// 出图文件名片段(`<screen>[-real]-<class>-<flavor>-w<宽>`,归档用)。
pub fn slug(&self) -> String {
let class = WidthClass::of(self.width);
let flavor = if self.flavor.is_dark() {
"dark"
} else {
"light"
};
let width = self.width as u32;
// 「演示行版」与「真控件版」必须能靠文件名分开,否则归档里两张图分不清谁是谁
let screen = if self.real_appearance {
format!("{}-real", self.screen.slug())
} else {
self.screen.slug().to_owned()
};
format!("{screen}-{}-{flavor}-w{width}", class.name())
}
}
fn take(
args: &mut impl Iterator<Item = String>,
inline: Option<String>,
flag: &str,
) -> Result<String, String> {
inline
.or_else(|| args.next())
.ok_or_else(|| format!("{flag} 需要一个值"))
}
fn number(raw: &str, flag: &str) -> Result<f32, String> {
raw.parse::<f32>()
.map_err(|_| format!("{flag} 需要数字,收到 {raw:?}"))
.and_then(|value| {
if value.is_finite() && value > 0.0 {
Ok(value)
} else {
Err(format!("{flag} 必须是正数,收到 {raw:?}"))
}
})
}
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
match raw {
"light" => Ok(SchemeFlavor::Light),
"dark" => Ok(SchemeFlavor::Dark),
other => Err(format!("主题只认 light|dark,收到 {other:?}")),
}
}
+64 -2
View File
@@ -1,8 +1,13 @@
//! `pages::harness` 的断言(M90 r2 从 `harness.rs` 外置:那边曾 402 行,guards 的 file-size 会判红)。
//!
//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时。
//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时——所以「指针穿透真的把窗口的鼠标输入关了」
//! 这类事这里证明不了,只能靠出图时日志里的 `指针事件=N`(见 `harness.rs` 的模块文档)。
use super::*;
use super::request::Request;
use crate::cli::Backend;
use crate::pages::Screen;
use unlucky_design::color::SchemeFlavor;
fn parse(args: &[&str]) -> Result<Request, String> {
Request::parse(args.iter().map(|arg| (*arg).to_owned()))
@@ -15,6 +20,7 @@ fn defaults_are_the_acceptance_frame() {
assert_eq!((request.width, request.height), (1180.0, 760.0));
assert_eq!(request.flavor, SchemeFlavor::Light);
assert!(request.screenshot.is_none());
assert!(!request.real_appearance, "默认画演示行");
}
#[test]
@@ -61,3 +67,59 @@ fn tiny_windows_are_rejected() {
assert!(parse(&["--width=200"]).is_err());
assert!(parse(&["--height=100"]).is_err());
}
/// **密闭不变量(M100)**:截图通道默认开指针穿透;交互模式(没有 `--screenshot`)一律关。
///
/// 抓得住:① 谁把取证默认值改成关(那会让取证图重新受系统指针影响);
/// ② 谁让交互模式也开穿透(用户窗口收不到鼠标 = 产品事故)。
/// 抓不住:穿透**真的**生效了没有——那要看出图日志的 `指针事件=N`,测试替不了。
#[test]
fn capture_is_sealed_by_default_and_interactive_never_is() {
assert!(
parse(&["--screenshot", "o.png"]).unwrap().passthrough,
"截图通道默认必须开指针穿透"
);
assert!(!parse(&[]).unwrap().passthrough, "交互模式不许开指针穿透");
assert!(
!parse(&["--screen=settings"]).unwrap().passthrough,
"交互模式(带页名)同样不许开"
);
// 显式给值在交互模式下是**配置错误**,不是「忽略」:静默忽略会让人以为交互窗口也密闭了
for flags in [
&["--passthrough=on"][..],
&["--passthrough", "off"],
&["--screen=settings", "--passthrough=on"],
] {
assert!(parse(flags).is_err(), "{flags:?} 应该报错");
}
}
/// 对照实验的口子(`--passthrough=off`):只服务「不开穿透时 hover 会不会漏进图」这一条。
#[test]
fn the_control_run_can_turn_the_passthrough_off() {
let shot = parse(&["--screenshot", "o.png", "--passthrough=off"]).unwrap();
assert!(!shot.passthrough, "显式 off 必须尊重");
assert!(shot.screenshot.is_some(), "off 只对截图通道有意义");
}
/// **外观真状态那一屏(M100)**:`--appearance=real` 只在设置页成立,且 slug 要与演示行版分开。
///
/// 抓得住:① 谁把 real 接到别的页(那会出一张「假装接了线」的图);
/// ② 两张图落成同名文件(归档后无法区分哪张是真控件版)。
/// 抓不住:那张图里到底有没有控件——那是像素的事,由两张图的 sha256 不同 + tower 亲自看图判。
#[test]
fn the_real_control_screen_is_settings_only_and_says_so_in_the_slug() {
let real = parse(&["--screen=settings", "--appearance=real"]).unwrap();
assert!(real.real_appearance);
assert_eq!(real.slug(), "settings-real-expanded-light-w1180");
assert_eq!(
parse(&["--screen=settings"]).unwrap().slug(),
"settings-expanded-light-w1180",
"演示行版的 slug 不许变(既有归档脚本按它找图)"
);
assert!(
parse(&["--screen=instances", "--appearance=real"]).is_err(),
"别的页没有外观分组,静默接受会出一张假图"
);
assert!(parse(&["--screen=settings", "--appearance=x"]).is_err());
}
+15 -1
View File
@@ -312,7 +312,21 @@ fn render<'a>(
/// `pages_demo` example 与单测:它们要一张「只有页面、不接 app 消息」的独立图,而 `shell` 的
/// 回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。
pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
let body = page(ui, model);
app_with_appearance(ui, model, None)
}
/// 同 [`app`],但可以带上**外观运行期状态**。
///
/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section.rs`);
/// `None` ⇒ 画演示行,与 [`app`] 逐像素等价。harness 的 `--appearance=real` 走的就是这一支:
/// M100 之前 harness 恒传 `None`,于是 D30 要的「带真实外观控件」那一屏**根本不存在**——
/// 报告上写着「设置页已接真控件」,图里却只有演示行(U1 的形状)。
pub fn app_with_appearance<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: Option<&'a appearance::State>,
) -> Element<'a, Message> {
let body = render(ui, model, appearance);
let shell = row![nav::rail(ui, model.active), body]
.width(Length::Fill)
+14
View File
@@ -30,6 +30,20 @@
//! | (页面上还没有这个分组) | [`ai`] | ai+about 线:先建分组再接线 |
//! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) |
//!
//! ## 测试也按 section 分(M100)
//!
//! 断言跟着它守的东西走,于是四条 section 线**各改各的测试**:
//!
//! | 测试文件 | 守什么 | 谁会动它 |
//! |---|---|---|
//! | `tests/general_section.rs` | General 自己的行 / 图标 / body 现状 | language+appearance 线 |
//! | `tests/appearance_section.rs` | Appearance 自己的行 / 图标 / body 现状 | language+appearance 线 |
//! | `tests/java_section.rs` | Java 自己的行 / 图标 / body 现状 | java 线 |
//! | `tests/download_section.rs` | Downloads 自己的行 / 图标 / body 现状 | download 线 |
//! | `tests/mod.rs` | **跨 section**:版面契约、行宽上限、窄窗退化、注册表顺序 / 分组下标 / 「只有一组有真控件」 | 谁都别随手改(每条都写了为什么不能下放) |
//!
//! 判据:**给某一组加一条新断言 = 只改它自己那个测试文件**。
//!
//! **加一个分组要动哪里**:① 在它自己的文件里写一个 `pub(super) const GROUP`(名字 / 图标 / 演示行 /
//! 可选的真控件渲染器 `body`);② 本文件的 [`GROUPS`] 加一行、数组长度 +1。**不需要**动 `frame.rs`、
//! 别的分组文件或 `pages/mod.rs`。
@@ -1,95 +0,0 @@
//! 设置页的守门测试(M99 拆目录时**原样搬出来**,用例与断言一字未动;断言强度只增不减)。
//!
//! 站位说明:本文件是 `pages::settings::tests`,所以 `super::super` 就是 `pages`。
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
use super::*;
use crate::pages::appearance::{APPEARANCE_GROUP, GROUP_LABEL};
#[test]
fn side_nav_reuses_the_drawer_width() {
// D33:分组导航走 drawer 240(同一容器 rail 88 ⇄ drawer 240),不是 rail 的 88
assert_eq!(SIDE_NAV_WIDTH, 240.0);
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
assert_eq!(metrics.secondary, Some(240.0));
assert_ne!(metrics.secondary, Some(layout::rail_width()));
}
#[test]
fn form_column_never_exceeds_the_measure_cap() {
for width in [720.0, 840.0, 1180.0, 1920.0] {
let metrics = frame_metrics(PaneKit::Supporting, width);
assert!(
metrics.column <= measure_cap_px() + f32::EPSILON,
"{width} 宽下表单列 {:?} 超过行宽上限",
metrics.column
);
assert!(metrics.column >= layout::measure_floor_px());
}
}
#[test]
fn every_group_has_a_name_icon_and_rows() {
assert!(GROUPS.len() >= 3);
assert!(SELECTED < GROUPS.len());
for group in &GROUPS {
assert!(!group.name.is_empty());
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name);
assert!(!group.rows.is_empty(), "{} 没有设置行", group.name);
for (label, value) in group.rows {
assert!(!label.is_empty() && !value.is_empty());
}
}
}
#[test]
fn narrow_window_stacks_the_groups_instead_of_the_side_nav() {
let narrow = frame_metrics(PaneKit::Supporting, 700.0);
assert_eq!(narrow.layout, PageLayout::SingleColumn);
assert!(narrow.secondary.is_none(), "窄窗不许留侧列");
}
/// M93:设置页「外观」分组的下标与 `pages::appearance` 声明的常量必须一致。
///
/// 抓得住:谁把 `GROUPS` 的顺序调了却忘了改 `APPEARANCE_GROUP`(那样真控件会画到别的分组上,
/// 而支持列显示的分组名也对不上)。
/// 抓不住:控件本身画得对不对(那由截图与 app 侧测试管)。
#[test]
fn the_appearance_group_is_where_the_module_says_it_is() {
assert_eq!(GROUPS[APPEARANCE_GROUP].name, GROUP_LABEL);
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}
/// M99:拆分后**左列顺序**必须还是拆分前那四项(顺序变了就是观感变了,不是「纯搬移」)。
///
/// 抓得住:① 注册表顺序被调换(`GROUPS` 是四条线各自文件拼出来的,最容易在合并时错位);
/// ② 某组被误删/误加(长度不等)。
/// 抓不住:组内那些行还在不在、译文对不对(各组自己的文件与 `catalog` 金表各管一段)。
#[test]
fn the_split_kept_the_left_column_order() {
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
}
/// M99:「外观」那一组之所以能画真控件,是因为**它自己的文件**挂上了 `body`。
///
/// 抓得住:拆分时把 `body: Some(…)` 搬丢 / 搬到了别的组(那会让真控件消失,退回演示行——
/// 而截图对这种「只在有外观状态时出现」的差别不一定抓得住,见报告「没验的部分」)。
/// 抓不住:`body` 指向的那个渲染器画得对不对(那是 `pages/appearance` 的事)。
#[test]
fn only_the_appearance_group_carries_a_real_control_body() {
assert!(
GROUPS[APPEARANCE_GROUP].body.is_some(),
"外观分组必须是接了真控件的那一组"
);
for (index, group) in GROUPS.iter().enumerate() {
if index == APPEARANCE_GROUP {
continue;
}
assert!(
group.body.is_none(),
"{} 现在应该还是演示行(真控件由它自己那条 section 线接)",
group.name
);
}
}
@@ -0,0 +1,48 @@
//! 「Appearance」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
//!
//! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在
//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。
use super::super::appearance;
use unlucky_design::icons::IconKind;
/// 这一组的**演示行**(没有外观运行期状态时才画)。
///
/// 抓得住:四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样);
/// 漏掉/多一条行;标签与值错位。
/// 抓不住:真控件那一屏(那是 `pages/appearance` 的事,靠出图判)。
#[test]
fn the_appearance_group_shows_density_corner_motion_and_text_scale() {
let rows: Vec<(&str, &str)> = appearance::GROUP.rows.to_vec();
assert_eq!(
rows,
vec![
("Density", "Comfortable"),
("Corner style", "Rounded"),
("Motion", "Full"),
("Text scale", "100%"),
]
);
}
/// 左列图标(`IconKind::Sidebar`)。
#[test]
fn the_appearance_group_keeps_its_icon() {
assert_eq!(appearance::GROUP.icon, IconKind::Sidebar);
}
/// **本组的绊线**:外观分组必须一直是「挂了真控件」的那一组。
///
/// 抓得住:language+appearance 线把 `body` 换回 `None`(真控件消失、退回演示行,而截图对这种
/// 「只在有外观状态时出现」的差别不一定抓得住)。
/// 抓不住:`body` 到底指向哪个渲染器——**函数指针相等性断言不了**(rustc
/// `unpredictable_function_pointer_comparisons`:fn 地址不保证唯一,比出来的结果没有意义),
/// 所以「指向 `pages::appearance::section`」这件事只能由
/// `settings/appearance.rs` 那一行源码 + 两张图的 sha256 不同来担保。
#[test]
fn the_appearance_group_keeps_a_real_control_body() {
assert!(
appearance::GROUP.body.is_some(),
"外观分组必须是挂了真控件的那一组(`body = Some(crate::pages::appearance::section)`)"
);
}
@@ -0,0 +1,47 @@
//! 「Downloads」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
//!
//! **归 download 线**:接真控件时改本文件,不必动 `tests/mod.rs`,也不碰别的分组。
//!
//! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。
use super::super::download;
use unlucky_design::icons::IconKind;
/// 这一组的行集合(标签 + 值)。
///
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错);
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Concurrency", "8 threads")` 被写成 `"Off"`)。
/// 抓不住:值对不对(`Automatic (region)` 是不是本机真选中的源)——那是接线后真数据源的事。
#[test]
fn the_downloads_group_shows_source_concurrency_and_checksums() {
let rows: Vec<(&str, &str)> = download::GROUP.rows.to_vec();
assert_eq!(
rows,
vec![
("Preferred source", "Automatic (region)"),
("Concurrency", "8 threads"),
("Verify checksums", "On"),
]
);
}
/// 左列图标(`IconKind::Download`)。与导航轨的「下载」同族,换掉就与 rail 语汇不一致。
#[test]
fn the_downloads_group_keeps_its_icon() {
assert_eq!(download::GROUP.icon, IconKind::Download);
}
/// **绊线**:这一组还没接真控件,画的是演示行。
///
/// 抓得住:download 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
#[test]
fn the_downloads_group_is_still_demo_rows() {
assert!(
download::GROUP.body.is_none(),
"Downloads 还没接真控件:接了就改本断言"
);
}
@@ -0,0 +1,47 @@
//! 「General」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
//!
//! **归 language+appearance 线**:接真控件时改本文件(`GROUP.body` 那条绊线要跟着翻),
//! 不必动 `tests/mod.rs`,也不碰别的分组。
use super::super::general;
use unlucky_design::icons::IconKind;
/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。
///
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错);
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Theme", "Follow system")` 被写成 `("Theme", "Instances")`)。
/// 抓不住:值对不对(`"Follow system"` 是不是当前系统主题的真相)——那是接线后真数据源的事。
#[test]
fn the_general_group_shows_language_theme_startup_and_close() {
let rows: Vec<(&str, &str)> = general::GROUP.rows.to_vec();
assert_eq!(
rows,
vec![
("Language", "English (System)"),
("Theme", "Follow system"),
("Startup page", "Instances"),
("Close button", "Exit to tray"),
]
);
}
/// 左列图标(`IconKind::Settings`)。分组图标是左列的语义标签,换掉就是换观感。
#[test]
fn the_general_group_keeps_its_icon() {
assert_eq!(general::GROUP.icon, IconKind::Settings);
}
/// **绊线**:这一组还没接真控件,画的是演示行。
///
/// 抓得住:language+appearance 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
#[test]
fn the_general_group_is_still_demo_rows() {
assert!(
general::GROUP.body.is_none(),
"General 还没接真控件:接了就改本断言"
);
}
@@ -0,0 +1,50 @@
//! 「Java」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
//!
//! **归 java 线**:接真控件时改本文件,不必动 `tests/mod.rs`,也不碰别的分组。
//! 判据(M100 交付里正面回答的那条):给 java 加一条新断言 = **只改本文件**。
use super::super::java;
use unlucky_design::icons::IconKind;
/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。
///
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错);
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Default memory", "4 GiB")` 被写成 `"8 threads"`)。
/// 抓不住:值对不对(`Temurin 21.0.4` 是不是本机真检测到的运行时)——那是接线后真数据源的事。
#[test]
fn the_java_group_shows_runtime_memory_and_jvm_arguments() {
let rows: Vec<(&str, &str)> = java::GROUP.rows.to_vec();
assert_eq!(
rows,
vec![
("Runtime", "Temurin 21.0.4"),
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
]
);
}
/// 左列图标必须是 `IconKind::Folder`。
///
/// 抓得住:谁把它换回 `IconKind::Play`——那是视觉复审 P2-3 判过的(`Play` 是填充字形,
/// 和同屏其它描边图标语汇打架;Java 组讲的是运行时安装位置,目录图标语义才对得上)。
/// 抓不住:图标画出来的大小/线宽(那是 tokens 层的事)。
#[test]
fn the_java_group_keeps_the_folder_icon_from_the_visual_review() {
assert_eq!(java::GROUP.icon, IconKind::Folder);
}
/// **绊线**:这一组还没接真控件,画的是演示行。
///
/// 抓得住:java 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
#[test]
fn the_java_group_is_still_demo_rows() {
assert!(
java::GROUP.body.is_none(),
"Java 还没接真控件:接了就改本断言"
);
}
@@ -0,0 +1,146 @@
//! 设置页的**跨 section** 守门测试(M100 按 section 下放之后,留在 mod 层的那一份)。
//!
//! ## 为什么这里只剩「跨 section」的断言
//!
//! 设置页正文接下来由四条线并行接(java / download / language+appearance / ai+about)。每条线要能
//! **只改自己的测试**就跑通,所以「某一组自己的行文案 / 图标 / body 现状」一律下放到
//! `tests/<section>_section.rs`;本文件只留**必须四组一起看**的断言——下放到任何单组文件里,
//! 都会变成「某组替其它三组说话」,四条线并行时必然互相踩。
//!
//! 判据(M100 报告里正面回答的那条):**给 java 加一条新断言,改几个文件?** —— 1 个
//! (`tests/java_section.rs`)。本文件不动。
//!
//! ## 站位
//!
//! 本文件是 `pages::settings::tests`:`super` = `pages::settings`,`super::super` = `pages`。
//!
//! ## 命名
//!
//! 子模块带 `_section` 后缀,是为了不和 `use super::*` 带进来的分组模块(`settings::java` 等)
//! 撞名——撞名时显式 `mod` 会静默盖掉 glob,读代码的人会以为 `java` 指的是分组的那个。
mod appearance_section;
mod download_section;
mod general_section;
mod java_section;
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
use super::*;
use crate::pages::appearance::{APPEARANCE_GROUP, GROUP_LABEL};
// --------------------------------------------------------------- 版面(不属于任何一组)
/// 守的是**版面契约**:分组导航走 drawer 240(D33),不是 rail 的 88。
///
/// 不能下放到分组:它不是「某一组」的属性,而是整个 supporting-pane 的几何。
#[test]
fn side_nav_reuses_the_drawer_width() {
assert_eq!(SIDE_NAV_WIDTH, 240.0);
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
assert_eq!(metrics.secondary, Some(240.0));
assert_ne!(metrics.secondary, Some(layout::rail_width()));
}
/// 守的是**行宽上限**(逐行 67 字符 ≤72)在所有宽度档下都成立。
///
/// 不能下放:它是四个分组共用的正文列,任何一组把列撑宽都会连带别人。
#[test]
fn form_column_never_exceeds_the_measure_cap() {
for width in [720.0, 840.0, 1180.0, 1920.0] {
let metrics = frame_metrics(PaneKit::Supporting, width);
assert!(
metrics.column <= measure_cap_px() + f32::EPSILON,
"{width} 宽下表单列 {:?} 超过行宽上限",
metrics.column
);
assert!(metrics.column >= layout::measure_floor_px());
}
}
/// 守的是**窄窗退化**:<720 时侧列消失、分组改为按组堆叠。
///
/// 不能下放:退化是整页行为,四组一起变(单组文件里没法验证「侧列没了」)。
#[test]
fn narrow_window_stacks_the_groups_instead_of_the_side_nav() {
let narrow = frame_metrics(PaneKit::Supporting, 700.0);
assert_eq!(narrow.layout, PageLayout::SingleColumn);
assert!(narrow.secondary.is_none(), "窄窗不许留侧列");
}
// --------------------------------------------------------------- 注册表(四组一起看)
/// 守的是**注册表的结构不变量**:每组都有名字、图标、演示行,且演示下标在范围内。
///
/// 下放不了:它遍历的是 `GROUPS`(四组拼出来的数组),单组文件拿不到其它三组,
/// 而「新增一组忘了填 rows」正是这条要抓的。
#[test]
fn every_group_has_a_name_icon_and_rows() {
assert!(GROUPS.len() >= 3);
assert!(SELECTED < GROUPS.len());
for group in &GROUPS {
assert!(!group.name.is_empty());
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name);
assert!(!group.rows.is_empty(), "{} 没有设置行", group.name);
for (label, value) in group.rows {
assert!(!label.is_empty() && !value.is_empty());
}
}
}
/// M93:设置页「外观」分组的下标与 `pages::appearance` 声明的常量必须一致。
///
/// 抓得住:谁把 `GROUPS` 的顺序调了却忘了改 `APPEARANCE_GROUP`(那样真控件会画到别的分组上,
/// 而支持列显示的分组名也对不上)。
/// 抓不住:控件本身画得对不对(那由截图与 app 侧测试管)。
///
/// 下放不了:它同时读 `GROUPS`(注册表)与 `pages::appearance` 的常量,横跨两处;
/// 放进 `appearance_section.rs` 会让「外观那条线」替注册表顺序作证。
#[test]
fn the_appearance_group_is_where_the_module_says_it_is() {
assert_eq!(GROUPS[APPEARANCE_GROUP].name, GROUP_LABEL);
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}
/// M99:拆分后**左列顺序**必须还是拆分前那四项(顺序变了就是观感变了,不是「纯搬移」)。
///
/// 抓得住:① 注册表顺序被调换(`GROUPS` 是四条线各自文件拼出来的,最容易在合并时错位);
/// ② 某组被误删/误加(长度不等)。
/// 抓不住:组内那些行还在不在、译文对不对(各组自己的文件与 `catalog` 金表各管一段)。
///
/// 下放不了:它是一句「四组顺序 = 这四个名字」的断言,任何单组文件都只能看到自己那一个名字。
#[test]
fn the_split_kept_the_left_column_order() {
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
}
/// M99:「外观」是**当前唯一**接了真控件的那一组。
///
/// 抓得住:拆分时把 `body: Some(…)` 搬丢 / 搬到了别的组(那会让真控件消失,退回演示行——
/// 而截图对这种「只在有外观状态时出现」的差别不一定抓得住,见报告「没验的部分」);
/// 也抓「别组偷偷接了 body 却没人知道」。
/// 抓不住:`body` 指向的那个渲染器画得对不对(那是 `pages::appearance` 的事)。
///
/// **为什么不能下放**(任务书点名要求写清):这条断言的全部内容是**四组的 body 现状对比**
/// ——「只有第 2 组是 Some,其余三组都是 None」。放进 `appearance_section.rs` 它就得替 java /
/// download / general 三组作证(那三条线一接真控件,本文件以外的地方无缘无故变红,且责任方
/// 不是改代码的人);拆成四份「我这组是 None」又会丢掉「唯一」这个要点。
/// 它是**绊线**:哪条线接上真控件,就必须来本文件手工改这一条(T1:绊线保持字面量,
/// 动态取活值等于把绊线剪了)。
#[test]
fn only_the_appearance_group_carries_a_real_control_body() {
assert!(
GROUPS[APPEARANCE_GROUP].body.is_some(),
"外观分组必须是接了真控件的那一组"
);
for (index, group) in GROUPS.iter().enumerate() {
if index == APPEARANCE_GROUP {
continue;
}
assert!(
group.body.is_none(),
"{} 现在应该还是演示行(真控件由它自己那条 section 线接)",
group.name
);
}
}
+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(缩小是用户点名的)"
);
}