Merge branch 'feat/m100-settingsprep-harness-rest-state-set'
This commit is contained in:
commit
bcb277c1dd
20 files changed
+1805
-587
No files matched your search
@@ -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();
|
||||
|
||||
+23
-130
@@ -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,18 @@ 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` 落成 `Interaction::Enabled`,而 `components::buttons::assemble()`
|
||||
/// 把「没有 `on_press`」的按钮默认画成 **Disabled** —— 于是 `Rest` 会把一个本应禁用的按钮
|
||||
/// 画成可用。当前零影响(所有 `forced_state(` 调用点与 shell 的 caption / nav 按钮都带消息),
|
||||
/// 但给「无消息按钮」出取证图时别套 `Rest`。
|
||||
Rest,
|
||||
/// 所有可交互控件按 hover 画。
|
||||
Hover,
|
||||
/// 所有可交互控件按 pressed 画。
|
||||
@@ -189,11 +201,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 +229,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 +283,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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -3,7 +3,46 @@
|
||||
//! **刻意不接 `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`。
|
||||
//!
|
||||
//! ⚠️ **限定**:上句的因果链在**有指针输入的会话**里才被实测过。本机(RDP 断开)连
|
||||
//! `SetCursorPos` 都失败,所以「`指针事件=0`」在本环境里**既可能是穿透生效,也可能是根本没输入**。
|
||||
//! 要让 `指针事件=N` 变成密闭证据,必须同一 argv 再跑一次 `--passthrough=off` 做对照:
|
||||
//! 那一跑该给出 `N>0`(复审注入后实测 12)**并且** hash 与开穿透时不同。没有这个对照,
|
||||
//! `指针事件=0` 就退回成「没观测到输入」,而不能读成「输入被挡住了」。
|
||||
//!
|
||||
//! ### 证据怎么读(M100 r1 复审订正,优先级从高到低)
|
||||
//!
|
||||
//! 1. **`[caps] passthrough_ext=on|off|unknown`** ← 主证据。winit 0.30 在 Windows 把
|
||||
//! `set_cursor_hittest(false)` 落成 `WS_EX_TRANSPARENT | WS_EX_LAYERED`,这一位可以从
|
||||
//! `GWL_EXSTYLE` 读回来([`caps`] → `platform::win32::transparency::click_through`)。
|
||||
//! 复审探针实测:`--screenshot` 默认 176/214 次采样命中、`--passthrough=off` **0/320**、
|
||||
//! 产品 bin **0/535**。三态里的 `unknown` 是「没读到」,**不许**读成 `off`。
|
||||
//! 2. `指针事件=N`:**辅助**指标。它证明「鼠标消息到没到」,**不**证明「像素会不会变」
|
||||
//! (复审实测:注入 12 个事件而像素不变)。别拿它当密闭证据。
|
||||
//! 3. ~~hash 连跑几次都一样~~:**不是证据**。同 argv 加 `--passthrough=off` 出来的 hash 与
|
||||
//! 开穿透时完全相同 ⇒ 它只能说明「那几帧恰好静止」。
|
||||
//!
|
||||
//! ### 同一族的另一个坑:一次一采样(P2-1)
|
||||
//!
|
||||
//! 以前第 3 帧之后**每一帧**都请求一次采样、每次都覆盖同一文件才 `exit()`,而已派发的采样不会
|
||||
//! 因为 exit 撤回 ⇒ **最后写入者说了算**(复审实测 12 次里 7 次写了两次;把窗口在两次采样之间
|
||||
//! 改成 900×600,落盘的就是 900×600 那张)。现在闸门在 [`should_request_capture`],日志里的
|
||||
//! `落盘次数=` 必须恒为 1。
|
||||
//!
|
||||
//! **为什么不是给每个控件灌 forced**:设置页「外观」区的滑块 / 文本框 / 裸 `iced::button` 都不
|
||||
//! 消费 forced,那样做要改十几个组件文件——为取证去动产品组件,代价与风险都不对,而且新产品
|
||||
//! 组件随时会漏。
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p unlucky-app-iced --example pages_demo -- --screen=instances --width=1180 \
|
||||
@@ -12,292 +51,64 @@
|
||||
//!
|
||||
//! 等 3 帧再截图与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,字体首次上屏会抖动。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use iced::{window, Element, Subscription, Task};
|
||||
use iced::{window, Element, Point, Subscription, Task};
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
|
||||
use crate::cli::Backend;
|
||||
use crate::fonts;
|
||||
use crate::probe::{self, Probe};
|
||||
use crate::pages::appearance;
|
||||
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 app;
|
||||
mod caps;
|
||||
mod request;
|
||||
|
||||
pub use app::run;
|
||||
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:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动(阻塞到出图或关窗)。
|
||||
/// 这一帧要不要请求采样?
|
||||
///
|
||||
/// **取证通道的硬规则(M97)**:宁可红着退出,也不能悄悄换一种后端继续出图(以前 wgpu 挂掉时
|
||||
/// 会静默降级 tiny-skia 照样落 PNG,而报告上写着「GPU 渲染截图」)。所以这里 ① 决策被
|
||||
/// [`renderer::strict`] 剥掉兜底、② 起不来带**具名**错误返回、③ 每张图日志都带 `backend=`。
|
||||
/// **M100 r1 P2-1 的核心判据**:`dispatched` 一旦置位就永不再次请求。以前这里是
|
||||
/// `frames >= CAPTURE_AT_FRAME`,于是第 3 帧之后**每一帧**都发一次 `window::screenshot`,
|
||||
/// 而 `Captured` 每次都覆盖同一文件才 `exit()`——已派发的采样不会因为 exit 被撤回,
|
||||
/// 落盘内容变成调度结果(复审实测 12 次里 7 次写了两次)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 后端探测/字体求值失败、**渲染后端起不来(含 iced 的具名图形错误)**、截图落盘失败时返回人话。
|
||||
pub fn run(request: Request) -> Result<(), String> {
|
||||
let started = Instant::now();
|
||||
/// 单独拎成自由函数是为了让它可被断言(`Harness::update` 要 iced 运行时才能跑)。
|
||||
fn should_request_capture(frames: u32, dispatched: bool, capture_mode: bool) -> bool {
|
||||
capture_mode && !dispatched && frames >= CAPTURE_AT_FRAME
|
||||
}
|
||||
|
||||
let needs_probe = request.backend == Backend::Auto || renderer::acceptance::enabled();
|
||||
let probe = if needs_probe {
|
||||
Probe::run()
|
||||
} else {
|
||||
Probe::default()
|
||||
};
|
||||
// 剥掉兜底:gallery 没有「偷偷换 CPU」这个选项。
|
||||
let decision = renderer::strict(probe::apply(request.backend, &probe));
|
||||
eprintln!(
|
||||
"[shot] 出图后端 = {} reason={}(取证通道无回退:起不来就红着退出,绝不换 CPU 出图)",
|
||||
decision.primary.name(),
|
||||
decision.reason
|
||||
);
|
||||
renderer::acceptance::report_backend_plan(&decision, request.backend);
|
||||
/// 采样「派发了却回不来」的宽限帧数(M100 r2 p3-b)。
|
||||
///
|
||||
/// 为什么需要它:一次性闸门(上面那条)把「每帧重试」换成了「只试一次」,于是若
|
||||
/// `window::screenshot` 的 Task 回不来,harness 既不再采样也不 `exit()` ⇒ CI 上表现成**超时**,
|
||||
/// 而不是红灯。超时会被当成基础设施抖动重跑过去,红才会被人看。
|
||||
///
|
||||
/// ⚠️ **这条兜底只覆盖「还有帧」的情形**(M100 r3 P2-1 订正,我原来在这里写反了):
|
||||
/// `window::frames()` 只在 `Event::Window(RedrawRequested)` 时产消息,**窗口消失 ⇒ 不再有帧
|
||||
/// ⇒ 这里的判据根本求值不到**。那一路由 `run()` 里的**终态不变量**兜住(跑完没图 ⇒ 红着退出);
|
||||
/// 而后端报错是另一回事:`run()` 直接返回 `Err`,本来就是非 0。
|
||||
///
|
||||
/// 30 帧**不换算成秒**(M100 r4 P3-1):帧间隔在不同机器/负载下量级差异极大——复审实测 2~3ms/帧,
|
||||
/// 而本机 `[perf]` 日志里是 `frames=4 elapsed=1133ms` / `frames=5 elapsed=1605ms`(≈200~300ms/帧)。
|
||||
/// 所以这里只承诺「若干帧的宽限」,不写时间预算;要调就按「正常路径是第 3 帧派发、第 4~5 帧落盘」
|
||||
/// 这个结构关系调,别按秒。
|
||||
const CAPTURE_GIVE_UP_FRAMES: u32 = 30;
|
||||
|
||||
let resolved = fonts::resolve();
|
||||
eprintln!("{}", resolved.describe());
|
||||
let families = resolved.families();
|
||||
|
||||
// 这一份就是喂给 iced 的真值:`[window]` 那行从它取字段(M98 r1 p2-1 的同一个教训)
|
||||
let settings = window::Settings {
|
||||
size: iced::Size::new(request.width, request.height),
|
||||
decorations: false,
|
||||
resizable: true,
|
||||
..window::Settings::default()
|
||||
};
|
||||
renderer::acceptance::report_window(
|
||||
&renderer::acceptance::WindowFacts::from_settings(&settings),
|
||||
request.scale_factor,
|
||||
"pages_demo",
|
||||
None,
|
||||
);
|
||||
|
||||
let application = |backend: renderer::Kind| {
|
||||
let boot = request.clone();
|
||||
iced::application(
|
||||
move || {
|
||||
(
|
||||
Harness {
|
||||
ui: Ui::new(boot.flavor, families),
|
||||
model: demo_model(boot.screen, boot.width),
|
||||
request: boot.clone(),
|
||||
window: None,
|
||||
frames: 0,
|
||||
started,
|
||||
first_frame: None,
|
||||
backend: backend.name(),
|
||||
},
|
||||
Task::done(Message::Boot),
|
||||
)
|
||||
},
|
||||
Harness::update,
|
||||
Harness::view,
|
||||
)
|
||||
.theme(Harness::theme)
|
||||
.subscription(Harness::subscription)
|
||||
.default_font(iced::Font {
|
||||
family: iced::font::Family::Name(families.text),
|
||||
..iced::Font::DEFAULT
|
||||
})
|
||||
.window(settings)
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"[harness] screen={} width={} height={} slug={}(布局档位 {})",
|
||||
request.screen.slug(),
|
||||
request.width,
|
||||
request.height,
|
||||
request.slug(),
|
||||
super::layout::WidthClass::of(request.width).name()
|
||||
);
|
||||
|
||||
// 兜底已被 `strict` 剥掉,所以这条最多跑一次;具名错误的口径与 App 启动一致,
|
||||
// 但**取证通道不许降级**:出不了图就红着退出,不换后端(那是另一处 iced 行为)。
|
||||
let session_started = Instant::now();
|
||||
let result = renderer::run_once(decision, |backend| {
|
||||
std::env::set_var("ICED_BACKEND", backend.env_value());
|
||||
let application = application(backend);
|
||||
match request.scale_factor {
|
||||
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||||
None => application.run(),
|
||||
}
|
||||
});
|
||||
let session = session_started.elapsed();
|
||||
|
||||
let outcome = result.map_err(|error| match renderer::named_graphics_error(&error) {
|
||||
Some(named) => format!(
|
||||
"渲染后端 {} 初始化失败(取证通道无回退,未出图):{named}",
|
||||
decision.primary.name()
|
||||
),
|
||||
None => format!("iced 启动失败:{error}"),
|
||||
});
|
||||
|
||||
// 取证通道恒为 evidence 启动 ⇒ 有兜底时 via=refuse、没兜底时 none(不再写死,M98 r1 p3-4)
|
||||
renderer::acceptance::report_outcome(
|
||||
&decision,
|
||||
outcome.as_ref().err().map(String::as_str),
|
||||
renderer::acceptance::failure_via(decision.fallback.is_some(), true),
|
||||
"screenshot",
|
||||
session,
|
||||
);
|
||||
|
||||
outcome?;
|
||||
|
||||
eprintln!(
|
||||
"[harness] 用时 {:.0} ms",
|
||||
started.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
Ok(())
|
||||
/// 采样是不是**卡住了**:过了宽限帧数,一次都没落盘。
|
||||
///
|
||||
/// 不区分「一直没派发」(窗口始终没就绪)与「派发了没回来」——两者对使用者的表现都是
|
||||
/// 「没有图」,都该红着退出而不是挂死。
|
||||
fn capture_stalled(frames: u32, written: u32) -> bool {
|
||||
written == 0 && frames > CAPTURE_AT_FRAME + CAPTURE_GIVE_UP_FRAMES
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
@@ -305,8 +116,27 @@ struct Harness {
|
||||
ui: Ui,
|
||||
/// 视图模型(演示数据;harness 不接 presenter,只出**单页**图)
|
||||
model: Model,
|
||||
/// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。
|
||||
appearance: Option<appearance::State>,
|
||||
window: Option<window::Id>,
|
||||
/// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读,
|
||||
/// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。
|
||||
hwnd: Option<isize>,
|
||||
/// 窗口落点(`window::position` 的**返回值**,不是请求值)。
|
||||
placed: Option<Point>,
|
||||
frames: u32,
|
||||
/// 采样**已派发**(M100 r1 P2-1):置位后不再请求第二张,避免「最后写入者说了算」。
|
||||
dispatched: bool,
|
||||
/// **成功落盘**的次数(共享给 [`run`],终态不变量读它)。
|
||||
///
|
||||
/// 只在 `shot::save` 返回 `Ok` 之后自增:这是「真的写出去了」,不是「我们打算写」(U1)。
|
||||
/// 正常必须恰好 1;打进日志让复审能数。
|
||||
written: Arc<AtomicU32>,
|
||||
/// 本张图收到过几个鼠标事件(`Message::Pointer` 的计数)。
|
||||
///
|
||||
/// ⚠️ 它只证明「鼠标消息到没到」,**不**证明「像素会不会变」(复审实测:注入 12 个事件而像素
|
||||
/// 不变)。密闭的**主证据**是 `[caps] passthrough_ext=`,见 [`caps`]。
|
||||
pointer: u32,
|
||||
/// 进程起点(`run()` 的开头)——`[perf]` 的首帧口径与产品路径一致:从进 `run()` 算。
|
||||
started: Instant,
|
||||
/// 第一帧到达的时刻(相对 `started`)。`None` = 一帧都没到过。
|
||||
@@ -319,8 +149,16 @@ struct Harness {
|
||||
enum Message {
|
||||
Boot,
|
||||
WindowReady(Option<window::Id>),
|
||||
/// `window::raw_id` 的回值(原生句柄,OS 侧密闭指标要它)。
|
||||
RawId(u64),
|
||||
/// `window::position` 的回值。
|
||||
Placed(Option<Point>),
|
||||
/// `window::size` 的回值。
|
||||
Sized(iced::Size),
|
||||
Frame,
|
||||
Captured(window::Screenshot),
|
||||
/// 收到一个指针事件(只计数,不画;见 [`Harness::subscription`])。
|
||||
Pointer,
|
||||
/// 页面里的动作(骨架期只打印,接线后由 app 层处理)。
|
||||
Page(super::Message),
|
||||
}
|
||||
@@ -331,6 +169,40 @@ 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<Task<Message>> = Vec::new();
|
||||
if self.request.passthrough {
|
||||
eprintln!("[harness] 指针穿透已开:本窗口不再接收鼠标事件(取证专用)");
|
||||
tasks.push(window::enable_mouse_passthrough(id));
|
||||
}
|
||||
tasks.push(window::raw_id::<Message>(id).map(Message::RawId));
|
||||
tasks.push(window::position(id).map(Message::Placed));
|
||||
Task::batch(tasks)
|
||||
}
|
||||
Message::RawId(raw) => {
|
||||
self.hwnd = isize::try_from(raw).ok();
|
||||
Task::none()
|
||||
}
|
||||
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 => {
|
||||
@@ -338,29 +210,67 @@ impl Harness {
|
||||
self.first_frame = Some(self.started.elapsed());
|
||||
}
|
||||
self.frames += 1;
|
||||
if self.frames < CAPTURE_AT_FRAME || self.request.screenshot.is_none() {
|
||||
// 兜底(M100 r2 p3-b):采样派发了却回不来时,不许挂死也不许 exit 0。
|
||||
// 这里用 `std::process::exit(1)` 而不是 `iced::exit()`:iced 0.14 没有「带退出码
|
||||
// 结束」的入口,`exit()` 恒 0 —— 而这条的全部意义就是让 CI 看到**红**而不是超时。
|
||||
if capture_stalled(self.frames, self.written.load(Ordering::Relaxed)) {
|
||||
eprintln!(
|
||||
"[harness] 采样超时:已过 CAPTURE_AT_FRAME+{CAPTURE_GIVE_UP_FRAMES} 帧\
|
||||
(当前 {} 帧)仍未收到 Captured,放弃并红着退出",
|
||||
self.frames
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
// **一次一采样**(M100 r1 P2-1):以前这里是 `frames >= CAPTURE_AT_FRAME`,于是第 3 帧
|
||||
// 之后**每一帧**都发一次 `window::screenshot`,而 `Captured` 每次都覆盖同一文件才
|
||||
// `exit()`——已派发的采样不会因为 exit 被撤回 ⇒ **最后写入者说了算**。复审实测 12 次里
|
||||
// 7 次真的写了两次;把窗口在两次采样之间改成 900×600,落盘的就是 900×600 那张。
|
||||
// 现在 `dispatched` 一置位就不再请求(`window` 还没就绪时不置位,保留重试,不会挂死)。
|
||||
if !should_request_capture(
|
||||
self.frames,
|
||||
self.dispatched,
|
||||
self.request.screenshot.is_some(),
|
||||
) {
|
||||
return Task::none();
|
||||
}
|
||||
match self.window {
|
||||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||||
Some(id) => {
|
||||
self.dispatched = true;
|
||||
window::screenshot(id).map(Message::Captured)
|
||||
}
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
Message::Pointer => {
|
||||
self.pointer += 1;
|
||||
Task::none()
|
||||
}
|
||||
Message::Captured(screenshot) => {
|
||||
let Some(path) = self.request.screenshot.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
// 只在真的写成功时计数(U1):这个计数器(旧字段名叫 `writes`)若在 save **之前**
|
||||
// 自增,一次落盘失败也会被记成「有图了」,而 `run()` 的终态不变量正是拿它判「有没有证据」。
|
||||
match shot::save(&screenshot, &path) {
|
||||
Ok(summary) => {
|
||||
eprintln!("{}", summary.describe());
|
||||
eprintln!(
|
||||
"[harness] slug={} backend={}(这张图的来源,自证用)",
|
||||
self.request.slug(),
|
||||
self.backend
|
||||
);
|
||||
self.written.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(error) => eprintln!("[harness] 截图失败:{error}"),
|
||||
}
|
||||
eprintln!(
|
||||
"[harness] slug={} backend={} 落盘次数={} 指针事件={}",
|
||||
self.request.slug(),
|
||||
self.backend,
|
||||
self.written.load(Ordering::Relaxed),
|
||||
self.pointer
|
||||
);
|
||||
// OS 侧密闭指标(M100 r1 P2-2):`WS_EX_TRANSPARENT` 在不在。三态,`unknown` 不许
|
||||
// 写成 `off`。这一行是「窗口真的把命中测试关掉了」的主证据;`指针事件=` 只是辅助。
|
||||
eprintln!(
|
||||
"[caps] passthrough_ext={}(GWL_EXSTYLE 的 WS_EX_TRANSPARENT;unknown = 没读到)",
|
||||
caps::passthrough_label(self.hwnd)
|
||||
);
|
||||
// `[perf]` 两格都是实测:`first_frame` = 首帧事件的时间戳,`fps` = 帧数 / 时长
|
||||
// (**取证窗口**的吞吐,不是长跑稳态帧率——那个看产品路径的 `[tick]`)。
|
||||
renderer::acceptance::report_perf_scene(
|
||||
@@ -379,11 +289,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 +308,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,241 @@
|
||||
//! 截图 harness 的**运行部分**(M100 r1:`harness.rs` 撞 400 行后从那里搬出来)。
|
||||
//!
|
||||
//! 这里只有 [`run`]:探后端 → 造外观真状态 → 起 iced → 出图 → 按验收口径打日志。
|
||||
//! 参数解析在 [`super::request`],窗口状态机与采样闸门在 [`super`],OS 侧密闭指标在
|
||||
//! [`super::caps`]。搬出来没有改行为(同一 argv 的 PNG sha256 不变)。
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use iced::{window, Point, Task};
|
||||
|
||||
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::theme::Ui;
|
||||
|
||||
use super::{demo_model, Harness, Message, Request, CAPTURE_POSITION};
|
||||
|
||||
/// **终态判据**:取证运行跑完了却一张图都没落盘 ⇒ 失败。
|
||||
///
|
||||
/// 单独拎成纯函数是为了让它能被断言(`run()` 要真窗口才能跑)。它守的是 M100 r3 P2-1 那个
|
||||
/// 真实缺陷:窗口在第 3 帧消失时,`window::frames()` 不再产消息 ⇒ 帧超时兜底**求值不到**,
|
||||
/// 而 iced_winit 对「最后一个窗口 Destroyed」直接退出 ⇒ 曾经是 **exit 0 + 没有 PNG**。
|
||||
///
|
||||
/// ## 为什么必须有 `capture_mode` 前提(M100 r4 P2-1)
|
||||
///
|
||||
/// 不给 `--screenshot` 是文档明确支持的**交互预览模式**(「没给 `--screenshot` 就只是开窗看」):
|
||||
/// 该模式 `subscription()` 直接 `Subscription::none()`,**永远不会有 `Captured`** ⇒ `written` 恒 0。
|
||||
/// 没有这个前提,用户**正常关掉预览窗**会拿到 exit 1 和一句「取证结束但没有任何图落盘」的
|
||||
/// 假消息——那是把"从未要求出图"判成"取证失败",通道的退出码就此失去区分力
|
||||
/// (真崩溃 / 真取证失败 / 正常关个预览窗全是 1)。
|
||||
///
|
||||
/// ## 为什么用计数器而不是 `path.exists()`
|
||||
///
|
||||
/// `written` 是**本进程**成功落盘的次数,不会被执行前就存在的旧 PNG 骗过;换成「目标文件存在」
|
||||
/// 会在脏路径下给出「exit 0 + 上一次的旧图」。这一点写在这里,免得后来人「顺手改成 exists」。
|
||||
pub(super) fn no_evidence(capture_mode: bool, written: u32) -> bool {
|
||||
capture_mode && written == 0
|
||||
}
|
||||
|
||||
/// 启动(阻塞到出图或关窗)。
|
||||
///
|
||||
/// **取证通道的硬规则(M97)**:宁可红着退出,也不能悄悄换一种后端继续出图(以前 wgpu 挂掉时
|
||||
/// 会静默降级 tiny-skia 照样落 PNG,而报告上写着「GPU 渲染截图」)。所以这里 ① 决策被
|
||||
/// [`renderer::strict`] 剥掉兜底、② 起不来带**具名**错误返回、③ 每张图日志都带 `backend=`。
|
||||
///
|
||||
/// # Errors
|
||||
/// 后端探测/字体求值失败、**渲染后端起不来(含 iced 的具名图形错误)**、截图落盘失败时返回人话。
|
||||
pub fn run(request: Request) -> Result<(), String> {
|
||||
let started = Instant::now();
|
||||
|
||||
let needs_probe = request.backend == Backend::Auto || renderer::acceptance::enabled();
|
||||
let probe = if needs_probe {
|
||||
Probe::run()
|
||||
} else {
|
||||
Probe::default()
|
||||
};
|
||||
// 剥掉兜底:gallery 没有「偷偷换 CPU」这个选项。
|
||||
let decision = renderer::strict(probe::apply(request.backend, &probe));
|
||||
eprintln!(
|
||||
"[shot] 出图后端 = {} reason={}(取证通道无回退:起不来就红着退出,绝不换 CPU 出图)",
|
||||
decision.primary.name(),
|
||||
decision.reason
|
||||
);
|
||||
renderer::acceptance::report_backend_plan(&decision, request.backend);
|
||||
|
||||
let resolved = fonts::resolve();
|
||||
eprintln!("{}", resolved.describe());
|
||||
let families = resolved.families();
|
||||
|
||||
// 这一份就是喂给 iced 的真值:`[window]` 那行从它取字段(M98 r1 p2-1 的同一个教训)
|
||||
let settings = window::Settings {
|
||||
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(
|
||||
&renderer::acceptance::WindowFacts::from_settings(&settings),
|
||||
request.scale_factor,
|
||||
"pages_demo",
|
||||
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
|
||||
});
|
||||
|
||||
// 落盘计数器(终态不变量 + 日志共用):只在 `shot::save` 成功后自增。
|
||||
let written = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let application = |backend: renderer::Kind| {
|
||||
let boot = request.clone();
|
||||
let appearance = appearance.clone();
|
||||
// 落盘次数要能被 `run()` 读到(终态不变量用它),所以走共享计数器而不是 `Harness` 的私有字段:
|
||||
// 值只在 `shot::save` **成功**之后自增,是「真的写出去了」而不是「我们打算写」(U1)。
|
||||
let written = Arc::clone(&written);
|
||||
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,
|
||||
appearance: appearance.clone(),
|
||||
request: boot.clone(),
|
||||
window: None,
|
||||
hwnd: None,
|
||||
placed: None,
|
||||
frames: 0,
|
||||
dispatched: false,
|
||||
written: Arc::clone(&written),
|
||||
pointer: 0,
|
||||
started,
|
||||
first_frame: None,
|
||||
backend: backend.name(),
|
||||
},
|
||||
Task::done(Message::Boot),
|
||||
)
|
||||
},
|
||||
Harness::update,
|
||||
Harness::view,
|
||||
)
|
||||
.theme(Harness::theme)
|
||||
.subscription(Harness::subscription)
|
||||
.default_font(iced::Font {
|
||||
family: iced::font::Family::Name(families.text),
|
||||
..iced::Font::DEFAULT
|
||||
})
|
||||
.window(settings)
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"[harness] screen={} width={} height={} slug={}(布局档位 {})",
|
||||
request.screen.slug(),
|
||||
request.width,
|
||||
request.height,
|
||||
request.slug(),
|
||||
crate::pages::layout::WidthClass::of(request.width).name()
|
||||
);
|
||||
|
||||
// 兜底已被 `strict` 剥掉,所以这条最多跑一次;具名错误的口径与 App 启动一致,
|
||||
// 但**取证通道不许降级**:出不了图就红着退出,不换后端(那是另一处 iced 行为)。
|
||||
let session_started = Instant::now();
|
||||
let result = renderer::run_once(decision, |backend| {
|
||||
std::env::set_var("ICED_BACKEND", backend.env_value());
|
||||
let application = application(backend);
|
||||
match request.scale_factor {
|
||||
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||||
None => application.run(),
|
||||
}
|
||||
});
|
||||
let session = session_started.elapsed();
|
||||
|
||||
let outcome = result.map_err(|error| match renderer::named_graphics_error(&error) {
|
||||
Some(named) => format!(
|
||||
"渲染后端 {} 初始化失败(取证通道无回退,未出图):{named}",
|
||||
decision.primary.name()
|
||||
),
|
||||
None => format!("iced 启动失败:{error}"),
|
||||
});
|
||||
|
||||
// 取证通道恒为 evidence 启动 ⇒ 有兜底时 via=refuse、没兜底时 none(不再写死,M98 r1 p3-4)
|
||||
renderer::acceptance::report_outcome(
|
||||
&decision,
|
||||
outcome.as_ref().err().map(String::as_str),
|
||||
renderer::acceptance::failure_via(decision.fallback.is_some(), true),
|
||||
"screenshot",
|
||||
session,
|
||||
);
|
||||
|
||||
outcome?;
|
||||
|
||||
// **终态不变量(M100 r3 P2-1)**:取证跑完了却一张图都没落盘 = 失败。
|
||||
//
|
||||
// 为什么要有这一条(复审变异实测):兜底原来只有「帧数超时」,而 `window::frames()` 只在
|
||||
// `Event::Window(RedrawRequested)` 时产消息 —— 窗口一旦消失就不再 redraw ⇒ `Message::Frame`
|
||||
// 不再产生 ⇒ `capture_stalled` **永远求值不到**;同时 iced_winit 对「最后一个窗口 Destroyed」
|
||||
// 直接 `Control::Exit`,`run()` 返回 `Ok(())`。表现是 **exit 0 + 没有 PNG**,与成功跑同一形状
|
||||
// 只少了出图那几行 —— 只看退出码的脚本会读到「成功 + 零证据」。
|
||||
//
|
||||
// 所以判据从「超时」升级成「终态」:**跑完了没有图就是失败**。它比超时强,因为覆盖
|
||||
// 「帧不再产生」这一类;帧超时那条保留,管「有帧但采样回不来」。
|
||||
//
|
||||
// **前提是这次真的是取证运行**(M100 r4 P2-1):不给 `--screenshot` 的交互预览模式本来
|
||||
// 就不出图,判成失败会打出假消息、并让退出码失去区分力。前提写在判据里,不写在这里。
|
||||
if no_evidence(
|
||||
request.screenshot.is_some(),
|
||||
written.load(Ordering::Relaxed),
|
||||
) {
|
||||
eprintln!("[harness] 取证结束但没有任何图落盘(窗口提前消失 / 落盘失败),按失败退出");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"[harness] 用时 {:.0} ms",
|
||||
started.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! 取证窗口的**OS 侧密闭指标**(M100 r1 P2-2):`WS_EX_TRANSPARENT` 在不在。
|
||||
//!
|
||||
//! 为什么单独一个文件:① `harness.rs` 正卡在 400 行硬线上;② 「三态映射」这件事本身值得有断言
|
||||
//! (把 `unknown` 掉成 `off` 是这类日志最典型的假绿)。
|
||||
//!
|
||||
//! 判据来自 winit 0.30 的 Windows 实现:`set_cursor_hittest(false)` ⇒
|
||||
//! `WS_EX_TRANSPARENT | WS_EX_LAYERED`。读位的手在
|
||||
//! [`crate::platform::win32::transparency::click_through`](全仓唯一 unsafe 落点)。
|
||||
|
||||
use crate::platform::win32::transparency;
|
||||
|
||||
/// 窗口是不是点击穿透 → 日志里的三态字面量。
|
||||
///
|
||||
/// - `on` = 读到了,且有 `WS_EX_TRANSPARENT`(winit 已把命中测试关掉);
|
||||
/// - `off` = 读到了,没有这一位;
|
||||
/// - `unknown` = **没读到**(非 Windows / 句柄无效 / 还没拿到句柄)。
|
||||
///
|
||||
/// `unknown` 绝不许写成 `off`:「拿不到」和「没有」是两件事(U1 同族)。
|
||||
#[must_use]
|
||||
pub fn passthrough_label(hwnd: Option<isize>) -> &'static str {
|
||||
match hwnd.and_then(transparency::click_through) {
|
||||
Some(true) => "on",
|
||||
Some(false) => "off",
|
||||
None => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::passthrough_label;
|
||||
|
||||
/// **抓得住**:谁把「没读到」掉成 `off`(那样一次失败的读数就变成了「密闭没开」的假证据,
|
||||
/// 或者反过来——在一台读不到扩展样式的机器上永远看不出密闭是否生效)。
|
||||
/// **抓不住**:真实窗口上的位到底置没置(那要出图日志里看 `on`)。
|
||||
#[test]
|
||||
fn a_missing_handle_is_unknown_not_off() {
|
||||
assert_eq!(
|
||||
passthrough_label(None),
|
||||
"unknown",
|
||||
"没句柄 = 没读到 ⇒ unknown"
|
||||
);
|
||||
assert_eq!(
|
||||
passthrough_label(Some(0)),
|
||||
"unknown",
|
||||
"句柄 0 = 拿不到 ⇒ unknown(不是 off)"
|
||||
);
|
||||
assert_eq!(
|
||||
passthrough_label(Some(1)),
|
||||
"unknown",
|
||||
"无效句柄 ⇒ unknown(不是 off)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:?}")),
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
//! `pages::harness` 的断言(M90 r2 从 `harness.rs` 外置:那边曾 402 行,guards 的 file-size 会判红)。
|
||||
//!
|
||||
//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时。
|
||||
//! 只测**参数解析 / slug / 采样闸门 / 三态标签**,不碰 iced 运行时。所以「穿透在真实窗口上真的
|
||||
//! 关掉了命中测试」这条这里证明不了——它要看出图日志里的 `[caps] passthrough_ext=`(三态,
|
||||
//! 来自 `GWL_EXSTYLE` 的 `WS_EX_TRANSPARENT`);`指针事件=N` 只是辅助指标(它只证明消息到没到)。
|
||||
|
||||
use super::*;
|
||||
use super::request::Request;
|
||||
use super::{capture_stalled, should_request_capture, CAPTURE_AT_FRAME, CAPTURE_GIVE_UP_FRAMES};
|
||||
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 +22,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 +69,179 @@ 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());
|
||||
}
|
||||
|
||||
/// **一次一采样(M100 r1 P2-1)**:采样请求最多发一次,且最早在 `CAPTURE_AT_FRAME`。
|
||||
///
|
||||
/// 抓得住:谁把闸门改回 `frames >= CAPTURE_AT_FRAME`(那样第 3 帧之后每帧都发一次采样,
|
||||
/// `Captured` 每次都覆盖同一文件 ⇒ **最后写入者说了算**;复审实测 12 次里 7 次写了两次,
|
||||
/// 并把窗口在两次采样之间改成 900×600 之后落盘的就是 900×600 那张)。
|
||||
/// 抓不住:「落盘次数真的是 1」——那要看真实运行的日志 `落盘次数=1`(本测试只测闸门逻辑)。
|
||||
#[test]
|
||||
fn the_capture_gate_opens_once_and_never_again() {
|
||||
// 窗口到第 4 帧才就绪也要恰好请求一次(`window` 未就绪时不置位,保留重试,不会挂死)
|
||||
let mut dispatched = false;
|
||||
let mut requests = 0;
|
||||
for frames in 1..=10u32 {
|
||||
let window_ready = frames >= 4;
|
||||
if should_request_capture(frames, dispatched, true) && window_ready {
|
||||
dispatched = true;
|
||||
requests += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(requests, 1, "整个进程只许请求一次采样");
|
||||
|
||||
assert!(!should_request_capture(1, false, true), "第 3 帧前不许采样");
|
||||
assert!(should_request_capture(3, false, true), "第 3 帧取第一张");
|
||||
assert!(
|
||||
!should_request_capture(99, true, true),
|
||||
"已派发过就不再请求(这条就是 P2-1 的修复本身)"
|
||||
);
|
||||
assert!(
|
||||
!should_request_capture(99, false, false),
|
||||
"交互模式(没有 --screenshot)不采样"
|
||||
);
|
||||
}
|
||||
|
||||
/// **密闭日志三态(M100 r1 P2-2)**:拿不到句柄必须记 `unknown`,不许记 `off`。
|
||||
///
|
||||
/// 抓得住:谁把 `None` 掉成 `off`(那会把「没读到」说成「读到了、没开」——这台机器上从此永远
|
||||
/// 看不出密闭到底生效没有)。
|
||||
/// 抓不住:真实窗口上的位(那要看运行日志里的 `passthrough_ext=on`)。
|
||||
#[test]
|
||||
fn the_passthrough_capability_is_three_states() {
|
||||
assert_eq!(super::caps::passthrough_label(None), "unknown");
|
||||
assert_eq!(
|
||||
super::caps::passthrough_label(Some(0)),
|
||||
"unknown",
|
||||
"句柄 0 = 还没拿到窗口"
|
||||
);
|
||||
}
|
||||
|
||||
/// **兜底不许在正常路径误触发**(M100 r2 p3-b)。
|
||||
///
|
||||
/// 抓得住:宽限帧数被改小到「采样还在飞行中就判死」(例如把它写成 0)——那时第 4 帧
|
||||
/// (第 3 帧派发、第 5 帧才落盘)就会误触发,CI 上表现为「正常出图却红」。
|
||||
/// 抓不住:真实 `window::screenshot` 卡死的时序(那要注入一个不回的采样,测试替不了)。
|
||||
#[test]
|
||||
fn the_stall_guard_never_fires_on_the_normal_path() {
|
||||
// 实测时序:第 3 帧派发,第 4~5 帧才收到 Captured(采样在飞行中 writes 还是 0)
|
||||
let mut first_bad: Option<(u32, bool, u32)> = None;
|
||||
for frames in 1..=200u32 {
|
||||
let dispatched = frames >= CAPTURE_AT_FRAME;
|
||||
let writes = u32::from(dispatched && frames >= CAPTURE_AT_FRAME + 2);
|
||||
if capture_stalled(frames, writes) {
|
||||
first_bad.get_or_insert((frames, dispatched, writes));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
first_bad.is_none(),
|
||||
"正常路径误触发兜底(帧, dispatched, writes)={first_bad:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **兜底只在「有帧但没采样」时触发**,且必须过了宽限窗口才触发。
|
||||
///
|
||||
/// 抓得住:① 判据写成 `frames >= CAPTURE_AT_FRAME`(宽限为 0)——正常路径会立刻误触发;
|
||||
/// ② 判据漏掉 `writes == 0`(已经落过盘还报超时);
|
||||
/// ③ 宽限窗口被写成 `>=` 边界错位(差一帧)。
|
||||
/// 抓不住:`exit(1)` 到底有没有被操作系统收到(那要跑真二进制,见报告里的变异实测)。
|
||||
#[test]
|
||||
fn the_stall_guard_fires_only_after_the_grace_window() {
|
||||
let threshold = CAPTURE_AT_FRAME + CAPTURE_GIVE_UP_FRAMES;
|
||||
assert!(
|
||||
!capture_stalled(threshold, 0),
|
||||
"刚好到阈值还不算超时(要给最后一帧留余量)"
|
||||
);
|
||||
assert!(
|
||||
capture_stalled(threshold + 1, 0),
|
||||
"越过阈值且一次都没落盘 ⇒ 必须放弃(红着退出,不许挂死)"
|
||||
);
|
||||
assert!(
|
||||
!capture_stalled(u32::MAX, 1),
|
||||
"已经落过盘就不算卡住(正常路径由 exit() 结束)"
|
||||
);
|
||||
assert!(!capture_stalled(1, 0), "前几帧本来就没有图,不算卡住");
|
||||
// 宽限必须明显大于「派发→落盘」的实测间隔(约 2 帧),否则正常路径会误触发。
|
||||
// 用局部绑定而不是直接比较常量:`assert!(CONST >= 10)` 会被 clippy 判成常量断言。
|
||||
let grace = CAPTURE_GIVE_UP_FRAMES;
|
||||
assert!(grace >= 10, "宽限帧数 {grace} 太小,正常路径会误触发");
|
||||
}
|
||||
|
||||
/// **终态不变量:跑完了没有图就是失败 —— 但只在取证运行里这么判**(r3 P2-1 → r4 P2-1)。
|
||||
///
|
||||
/// 抓得住:① 判据被改成恒 false(终态检查成摆设);② **判据丢掉 `capture_mode` 前提**
|
||||
/// (r4 实测到的真缺陷:不给 `--screenshot` 的交互预览模式本来就不出图,却会被判失败 ⇒
|
||||
/// exit 1 + 一句「取证结束但没有任何图落盘」的假消息,退出码因此失去区分力)。
|
||||
/// 抓不住:`run()` 里那个**调用点**被删掉(判据还在、本测试照绿)——那一层只能靠变异实测
|
||||
/// (M3:第 3 帧改关窗 ⇒ 期望 exit 1),测试替不了。
|
||||
#[test]
|
||||
fn finishing_without_a_single_write_is_a_failure_only_while_capturing() {
|
||||
use super::app::no_evidence;
|
||||
|
||||
// 红线(r4 P2-1):交互预览模式关窗**不是**失败
|
||||
assert!(
|
||||
!no_evidence(false, 0),
|
||||
"交互预览模式(没给 --screenshot)从未要求出图,关窗必须 exit 0"
|
||||
);
|
||||
assert!(
|
||||
no_evidence(true, 0),
|
||||
"取证运行一张图都没落盘 ⇒ 必须判失败(不许 exit 0)"
|
||||
);
|
||||
assert!(!no_evidence(true, 1), "落过盘就算有证据");
|
||||
assert!(!no_evidence(false, 1), "预览模式更不该报错");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,50 @@
|
||||
//! 「Appearance」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
|
||||
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
|
||||
//!
|
||||
//! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在
|
||||
//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。
|
||||
//! 本组已经把 `body` 接上了,所以 `tests/mod.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,49 @@
|
||||
//! 「Downloads」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
|
||||
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
|
||||
//!
|
||||
//! **归 download 线**。接真控件那天要改**三个**文件:本文件 `the_downloads_group_is_still_demo_rows`
|
||||
//! (`body.is_none()` 绊线)+ `settings/download.rs` + `tests/mod.rs` 的跨 section 绊线。
|
||||
//!
|
||||
//! 别把它和下载页(`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,49 @@
|
||||
//! 「General」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
|
||||
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
|
||||
//!
|
||||
//! **归 language+appearance 线**。接真控件那天要改**三个**文件:本文件
|
||||
//! `the_general_group_is_still_demo_rows`(`body.is_none()` 绊线)+ `settings/general.rs` +
|
||||
//! `tests/mod.rs` 的跨 section 绊线。
|
||||
|
||||
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,57 @@
|
||||
//! 「Java」分组自己的断言(M100 **新增**:`settings/tests.rs` 里一条 section 断言都没有,
|
||||
//! 那 7 条全是跨 section 的;所以这 3 条是新增,不是搬移)。
|
||||
//!
|
||||
//! **归 java 线**。接真控件那天要改**三个**文件:
|
||||
//! 1. 本文件 `the_java_group_is_still_demo_rows`(它断言 `body.is_none()`,接上就红);
|
||||
//! 2. `settings/java.rs`(写 `body = Some(..)`);
|
||||
//! 3. `tests/mod.rs` 的 `only_the_appearance_group_carries_a_real_control_body`(跨 section 绊线)。
|
||||
//!
|
||||
//! download / general 同型。
|
||||
//!
|
||||
//! 判据(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,162 @@
|
||||
//! 设置页的**跨 section** 守门测试(M100:跨 section 的留在这里,section 自己的断言**新增**在
|
||||
//! `tests/<section>_section.rs`)。
|
||||
//!
|
||||
//! ## 为什么这里只剩「跨 section」的断言
|
||||
//!
|
||||
//! 设置页正文接下来由四条线并行接(java / download / language+appearance / ai+about)。每条线要能
|
||||
//! **只改自己的测试**就跑通,所以「某一组自己的行文案 / 图标 / body 现状」一律放在
|
||||
//! `tests/<section>_section.rs`(M100 **新增**的 12 条,不是从本文件搬过去的——本文件在 `main`
|
||||
//! 上就只有下面这 7 条跨 section 断言,一条 section 断言都没有);本文件只留**必须四组一起看**的
|
||||
//! 断言——下放到任何单组文件里,都会变成「某组替其它三组说话」,四条线并行时必然互相踩。
|
||||
//!
|
||||
//! 判据(M100 报告里正面回答的那条):**给 java 加一条新断言,改几个文件?** —— 1 个
|
||||
//! (`tests/java_section.rs`)。本文件不动。
|
||||
//!
|
||||
//! 但「java 线**接真控件**那天改几个文件」是另一回事,答案是 **3 个**(M100 r1 复审 p3-3 订正,
|
||||
//! 我最初只写成 2 个):`settings/java.rs`(写 body)+ `tests/java_section.rs` 的
|
||||
//! `the_java_group_is_still_demo_rows`(它断言 `body.is_none()`,接上就红)+ 本文件末尾那条
|
||||
//! `only_the_appearance_group_carries_a_real_control_body`。download / general 同型。
|
||||
//!
|
||||
//! ## ai+about 线要额外改本文件的两处(M100 r1 复审 p3-4)
|
||||
//!
|
||||
//! 它加第 5 组时,除了 `settings/mod.rs` 的 `GROUPS`,**本文件还有两处必改**:
|
||||
//! 1. 顶部 `mod ai_section;`(新测试文件要挂进来,否则永远不编译、永远不跑);
|
||||
//! 2. [`the_split_kept_the_left_column_order`] 断言的是**字面量** `["General","Appearance",
|
||||
//! "Java","Downloads"]`,加第 5 组必红——这是刻意的:左列顺序变了就是观感变了,要人确认。
|
||||
//! 另外 `the_appearance_group_is_where_the_module_says_it_is` 依赖 `APPEARANCE_GROUP == 1`,
|
||||
//! **把新组插在 Appearance 前面会连带红**(所以新组应该排最后)。
|
||||
//!
|
||||
//! ## 站位
|
||||
//!
|
||||
//! 本文件是 `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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,10 @@
|
||||
// D36:这是全仓唯一的 unsafe 落点,CI 的 `unsafe-scope` job 会比对文件集合。
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
mod exstyle;
|
||||
|
||||
pub use exstyle::{click_through, is_click_through_style, WS_EX_TRANSPARENT};
|
||||
|
||||
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
|
||||
|
||||
/// Win32 的 `LWA_ALPHA` 标志位:只改整窗 α(`0x2`)。
|
||||
@@ -187,6 +191,20 @@ fn has_layered(hwnd: isize) -> bool {
|
||||
raw_ex_style(hwnd) & WS_EX_LAYERED != 0
|
||||
}
|
||||
|
||||
/// 句柄是否指向一个**存在**的窗口(全仓唯一 `unsafe` 落点的一部分)。
|
||||
///
|
||||
/// 为什么在这里而不是在取证探针那个文件里:`transparency.rs` 是全仓**唯一**允许 `unsafe` 的
|
||||
/// 文件(`unsafe-scope` 判据 = 「带 `#![allow(unsafe_code)]` 的文件集合恰好等于本文件」)。
|
||||
/// 探针用到 FFI 时就只能**把 FFI 搬进来**,不能让 `unsafe` 溜到没有标记的文件里——
|
||||
/// 那会让门禁照样绿、而「唯一落点」这个性质已经破了(M100 r1 复审实测抓到的形状)。
|
||||
///
|
||||
/// `0` / 无效句柄 → `false`(Win32 的 `IsWindow` 对无效句柄返回 0)。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn window_exists(hwnd: isize) -> bool {
|
||||
// SAFETY: `IsWindow` 只按值接受句柄,不持有指针、无所有权转移;无效句柄返回 0。
|
||||
(unsafe { IsWindow(hwnd) }) != 0
|
||||
}
|
||||
|
||||
/// 把整窗 α 下发到窗口。
|
||||
///
|
||||
/// `alpha` 一般直接来自 [`super::super::effective_window_alpha`](`None` = 拒绝整窗穿透)。
|
||||
@@ -211,6 +229,12 @@ pub fn apply_window_alpha(hwnd: isize, alpha: Option<f64>) -> AlphaOutcome {
|
||||
#[cfg(target_os = "windows")]
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
/// `IsWindow`:句柄是否指向一个存在的窗口。
|
||||
///
|
||||
/// 为什么需要它(M100 r1):`GetWindowLongPtrW` 对**无效句柄**返回 0,与「扩展样式全 0」
|
||||
/// 不可区分。取证探针要分「拿不到」与「读到了没有这一位」,所以先验一次句柄。
|
||||
fn IsWindow(hwnd: isize) -> i32;
|
||||
|
||||
/// `SetLayeredWindowAttributes`:窗口必须已带 `WS_EX_LAYERED`,α 才生效。
|
||||
///
|
||||
/// 签名照 Win32 头文件:`BOOL SetLayeredWindowAttributes(HWND, COLORREF, BYTE, DWORD)`。
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! 取证用的**扩展样式探针**(M100 r1 P2-2):这个窗口是不是「点击穿透」。
|
||||
//!
|
||||
//! ## 本文件零 `unsafe`、零 `extern`
|
||||
//!
|
||||
//! 全仓 `unsafe` 只允许出现在 [`super`](`transparency.rs`)一个文件里,判据是
|
||||
//! 「**带放行属性**的文件集合恰好等于它」。所以探针需要的 FFI 一律由 `transparency.rs` 提供
|
||||
//! **安全包装**([`super::window_exists`] / [`super::raw_ex_style`]),本文件只做纯判断、
|
||||
//! 三态映射与测试。
|
||||
//!
|
||||
//! ⚠️ **别把 FFI 写回来**(M100 r1 复审实测抓过一次):lint 级别沿模块树继承,所以在这里写
|
||||
//! 一个 `unsafe` 块 + `extern` 声明也**编得过**,`unsafe-scope` 门禁也照样绿(它只数「带放行
|
||||
//! 属性的文件」)——但「全仓唯一允许 unsafe 的文件」这个性质就破了。
|
||||
//! 属性的作用是可审计性,不是给 CI 看的。
|
||||
//!
|
||||
//! ## 判据来源
|
||||
//!
|
||||
//! winit 0.30 在 Windows 把 `set_cursor_hittest(false)` 落成
|
||||
//! `WS_EX_TRANSPARENT | WS_EX_LAYERED`(`window_state.rs`)。所以读回这一位,就是
|
||||
//! 「取证窗口真的把命中测试关掉了」的 OS 侧证据——比「出图 hash 不变」有判别力得多
|
||||
//! (实测:同 argv 开/关穿透,hash 完全相同)。
|
||||
|
||||
use super::raw_ex_style;
|
||||
|
||||
/// `WS_EX_TRANSPARENT`:**点击穿透**位(`0x0000_0020`)。
|
||||
pub const WS_EX_TRANSPARENT: isize = 0x0000_0020;
|
||||
|
||||
/// 纯判据:扩展样式里 [`WS_EX_TRANSPARENT`] 在不在。
|
||||
///
|
||||
/// 单独拎成 `const fn` 是为了让「位判断写反」有断言盯着——`& WS_EX_TRANSPARENT != 0` 写成
|
||||
/// `== 0` 是这类 getter 最经典的错,而且只会在「开/关恰好反向」时暴露(两边都"有输出")。
|
||||
#[must_use]
|
||||
pub const fn is_click_through_style(ex_style: isize) -> bool {
|
||||
ex_style & WS_EX_TRANSPARENT != 0
|
||||
}
|
||||
|
||||
/// 取证用:窗口**现在**是否点击穿透(`WS_EX_TRANSPARENT` 在扩展样式里)。
|
||||
///
|
||||
/// ## 为什么是三态而不是 `bool`
|
||||
///
|
||||
/// `None` = **没读到**(非 Windows / 句柄无效 / 句柄还没拿到)。这时调用方必须记 `unknown`,
|
||||
/// **不许记成 `off`**——「拿不到」和「没有」是两件事,混为一谈就是假绿(本项目在别处栽过:
|
||||
/// 扫描读不全时报「没有」)。所以这里宁可 `None`,也不用「读失败的 0」冒充「没这一位」。
|
||||
///
|
||||
/// ## 为什么本文件零 `unsafe`、零 `extern`
|
||||
///
|
||||
/// `unsafe` 全仓只在 [`super`](`transparency.rs`)那一个文件里。探针需要的 FFI
|
||||
/// (`IsWindow` / `GetWindowLongPtrW`)都由 `super` 提供安全包装
|
||||
/// ([`super::window_exists`] / [`super::raw_ex_style`]),本文件只做**纯判断**。
|
||||
///
|
||||
/// ## 用途
|
||||
///
|
||||
/// 出图时把它打进日志(`pages::harness` 的 `[caps] passthrough_ext=…`),于是「取证窗口到底
|
||||
/// 有没有把指针输入关掉」每次运行都能自证,不依赖外部探针。
|
||||
#[cfg(target_os = "windows")]
|
||||
#[must_use]
|
||||
pub fn click_through(hwnd: isize) -> Option<bool> {
|
||||
if hwnd == 0 || !super::window_exists(hwnd) {
|
||||
return None;
|
||||
}
|
||||
Some(is_click_through_style(raw_ex_style(hwnd)))
|
||||
}
|
||||
|
||||
/// 非 Windows:**恒 `None`**。空实现不许冒充「没开穿透」(那就是把「不知道」写成「没有」)。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[must_use]
|
||||
pub fn click_through(_hwnd: isize) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{click_through, is_click_through_style, WS_EX_TRANSPARENT};
|
||||
use crate::platform::win32::transparency::WS_EX_LAYERED;
|
||||
|
||||
/// **抓得住**:`& WS_EX_TRANSPARENT != 0` 被写成 `== 0`(位判断反向)——这是这类 getter 最经典
|
||||
/// 的错,而且只会在「开/关恰好互换」时暴露:日志里永远有值,只是意思反了。
|
||||
/// **抓不住**:真实窗口上 winit 到底置没置这一位(那要出图日志里看 `passthrough_ext=on`)。
|
||||
#[test]
|
||||
fn the_click_through_bit_cannot_be_read_backwards() {
|
||||
assert_eq!(WS_EX_TRANSPARENT, 0x0000_0020, "位值照 Win32 头文件");
|
||||
assert!(is_click_through_style(WS_EX_TRANSPARENT));
|
||||
assert!(
|
||||
is_click_through_style(WS_EX_TRANSPARENT | WS_EX_LAYERED),
|
||||
"winit 的鼠标穿透是两位一起置的,这种组合必须是 true"
|
||||
);
|
||||
assert!(
|
||||
!is_click_through_style(WS_EX_LAYERED),
|
||||
"只有分层位不算穿透——LWA_ALPHA 也加这一位,反向了会把正常窗口误判成穿透"
|
||||
);
|
||||
assert!(!is_click_through_style(0));
|
||||
}
|
||||
|
||||
/// **抓得住**:「拿不到」被记成「没有」。句柄 0 / 无效句柄 / 非 Windows 必须是 `None`
|
||||
/// (调用方据此写 `unknown`),不许用「读失败的 0」冒充「这一位没开」。
|
||||
/// **抓不住**:一个**有效**窗口上的读数(那要真窗口,见 harness 的出图日志)。
|
||||
#[test]
|
||||
fn click_through_reports_unknown_instead_of_off_when_it_cannot_read() {
|
||||
assert_eq!(click_through(0), None, "句柄 0 = 还没拿到窗口 ⇒ unknown");
|
||||
assert_eq!(click_through(1), None, "无效句柄 ⇒ unknown(不许掉成 off)");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(
|
||||
click_through(0x1234),
|
||||
None,
|
||||
"非 Windows 的空实现必须恒 None,不许冒充 off"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,131 @@
|
||||
//! `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 的头号不变量)。
|
||||
///
|
||||
/// 抓得住:`overridden` 的 `None` 分支被改坏——例如把「跟控件自己的状态走」写成了固定
|
||||
/// `Active`/`Hovered`(那会让**用户窗口里所有按钮**不再反映 hover/pressed,是产品事故)。
|
||||
/// 抓不住:**默认值被顺手设成 `Some(Rest)`** 这类错误——`overridden(None, …)` 根本不会被调用,
|
||||
/// 本条照样绿;那一条由 `cli::tests::screenshots_default_to_the_rest_state` 抓(它断言
|
||||
/// 交互模式 `force_state == None`)。两条各守一半,别拿一条当另一条的证据。
|
||||
#[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,看 `[caps] passthrough_ext=`);
|
||||
/// ③ **`Rest` 与「真 Disabled」撞语义**:`components::buttons::assemble()` 把「无 `on_press`」的
|
||||
/// 按钮默认画成 Disabled,而 `Rest => Enabled` 会把它画成可用。当前零影响(8 处 `forced_state(`
|
||||
/// 调用点与 shell 的 caption / nav 全带消息),但将来若有人加一个**无消息**的按钮又被 `Rest` 罩住,
|
||||
/// 那张取证图会画出「一个本应禁用的按钮」而这条断言照绿。
|
||||
#[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(缩小是用户点名的)"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user