diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 6df8a19..bbdc8c3 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -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(); diff --git a/crates/app-iced/src/cli.rs b/crates/app-iced/src/cli.rs index f986780..d740aec 100644 --- a/crates/app-iced/src/cli.rs +++ b/crates/app-iced/src/cli.rs @@ -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 { } #[cfg(test)] -mod tests { - use super::*; - - fn parse(args: &[&str]) -> Result { - 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 --data-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; diff --git a/crates/app-iced/src/cli/tests.rs b/crates/app-iced/src/cli/tests.rs new file mode 100644 index 0000000..e3ed625 --- /dev/null +++ b/crates/app-iced/src/cli/tests.rs @@ -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::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 --data-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()); +} diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index 258835e..b97a376 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -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, - /// 渲染后端请求。 - pub backend: Backend, - /// 强制缩放因子(截图要 100% DPI)。 - pub scale_factor: Option, -} - -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) -> Result { - let mut request = Self::default(); - let mut theme: Option = None; - let mut shot_theme: Option = 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) - } - - /// 出图文件名片段(`---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, - inline: Option, - flag: &str, -) -> Result { - inline - .or_else(|| args.next()) - .ok_or_else(|| format!("{flag} 需要一个值")) -} - -fn number(raw: &str, flag: &str) -> Result { - raw.parse::() - .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 { - 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, window: Option, + /// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读, + /// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。 + hwnd: Option, + /// 窗口落点(`window::position` 的**返回值**,不是请求值)。 + placed: Option, frames: u32, + /// 采样**已派发**(M100 r1 P2-1):置位后不再请求第二张,避免「最后写入者说了算」。 + dispatched: bool, + /// **成功落盘**的次数(共享给 [`run`],终态不变量读它)。 + /// + /// 只在 `shot::save` 返回 `Ok` 之后自增:这是「真的写出去了」,不是「我们打算写」(U1)。 + /// 正常必须恰好 1;打进日志让复审能数。 + written: Arc, + /// 本张图收到过几个鼠标事件(`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::raw_id` 的回值(原生句柄,OS 侧密闭指标要它)。 + RawId(u64), + /// `window::position` 的回值。 + Placed(Option), + /// `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> = Vec::new(); + if self.request.passthrough { + eprintln!("[harness] 指针穿透已开:本窗口不再接收鼠标事件(取证专用)"); + tasks.push(window::enable_mouse_passthrough(id)); + } + tasks.push(window::raw_id::(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 { - 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) } } diff --git a/crates/app-iced/src/pages/harness/app.rs b/crates/app-iced/src/pages/harness/app.rs new file mode 100644 index 0000000..2e2dea1 --- /dev/null +++ b/crates/app-iced/src/pages/harness/app.rs @@ -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(()) +} diff --git a/crates/app-iced/src/pages/harness/caps.rs b/crates/app-iced/src/pages/harness/caps.rs new file mode 100644 index 0000000..4dcbff8 --- /dev/null +++ b/crates/app-iced/src/pages/harness/caps.rs @@ -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) -> &'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)" + ); + } +} diff --git a/crates/app-iced/src/pages/harness/request.rs b/crates/app-iced/src/pages/harness/request.rs new file mode 100644 index 0000000..569271e --- /dev/null +++ b/crates/app-iced/src/pages/harness/request.rs @@ -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, + /// 渲染后端请求。 + pub backend: Backend, + /// 强制缩放因子(截图要 100% DPI)。 + pub scale_factor: Option, + /// 设置页正文画**真控件**(`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) -> Result { + let mut request = Self::default(); + let mut theme: Option = None; + let mut shot_theme: Option = None; + let mut passthrough: Option = 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) + } + + /// 出图文件名片段(`[-real]---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, + inline: Option, + flag: &str, +) -> Result { + inline + .or_else(|| args.next()) + .ok_or_else(|| format!("{flag} 需要一个值")) +} + +fn number(raw: &str, flag: &str) -> Result { + raw.parse::() + .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 { + match raw { + "light" => Ok(SchemeFlavor::Light), + "dark" => Ok(SchemeFlavor::Dark), + other => Err(format!("主题只认 light|dark,收到 {other:?}")), + } +} diff --git a/crates/app-iced/src/pages/harness/tests.rs b/crates/app-iced/src/pages/harness/tests.rs index 37a3e58..730952c 100644 --- a/crates/app-iced/src/pages/harness/tests.rs +++ b/crates/app-iced/src/pages/harness/tests.rs @@ -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::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), "预览模式更不该报错"); +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index c544d65..c00624e 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -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) diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 745416d..95c067b 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -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`。 diff --git a/crates/app-iced/src/pages/settings/tests.rs b/crates/app-iced/src/pages/settings/tests.rs deleted file mode 100644 index 8d413a5..0000000 --- a/crates/app-iced/src/pages/settings/tests.rs +++ /dev/null @@ -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 - ); - } -} diff --git a/crates/app-iced/src/pages/settings/tests/appearance_section.rs b/crates/app-iced/src/pages/settings/tests/appearance_section.rs new file mode 100644 index 0000000..1741086 --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/appearance_section.rs @@ -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)`)" + ); +} diff --git a/crates/app-iced/src/pages/settings/tests/download_section.rs b/crates/app-iced/src/pages/settings/tests/download_section.rs new file mode 100644 index 0000000..8404c39 --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/download_section.rs @@ -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 还没接真控件:接了就改本断言" + ); +} diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs new file mode 100644 index 0000000..4bc8834 --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -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 还没接真控件:接了就改本断言" + ); +} diff --git a/crates/app-iced/src/pages/settings/tests/java_section.rs b/crates/app-iced/src/pages/settings/tests/java_section.rs new file mode 100644 index 0000000..a08df1b --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/java_section.rs @@ -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 还没接真控件:接了就改本断言" + ); +} diff --git a/crates/app-iced/src/pages/settings/tests/mod.rs b/crates/app-iced/src/pages/settings/tests/mod.rs new file mode 100644 index 0000000..665b3a4 --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -0,0 +1,162 @@ +//! 设置页的**跨 section** 守门测试(M100:跨 section 的留在这里,section 自己的断言**新增**在 +//! `tests/
_section.rs`)。 +//! +//! ## 为什么这里只剩「跨 section」的断言 +//! +//! 设置页正文接下来由四条线并行接(java / download / language+appearance / ai+about)。每条线要能 +//! **只改自己的测试**就跑通,所以「某一组自己的行文案 / 图标 / body 现状」一律放在 +//! `tests/
_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 + ); + } +} diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 9be8cb5..5b74c56 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -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) -> 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)`。 diff --git a/crates/app-iced/src/platform/win32/transparency/exstyle.rs b/crates/app-iced/src/platform/win32/transparency/exstyle.rs new file mode 100644 index 0000000..82c8ab4 --- /dev/null +++ b/crates/app-iced/src/platform/win32/transparency/exstyle.rs @@ -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 { + 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 { + 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" + ); + } +} diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index fff52d9..b684080 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -309,6 +309,9 @@ pub(super) fn label<'a>( /// 取证用的状态覆盖(`--force-state`):只改画出来的状态,不动任何状态机。 pub(super) fn overridden(forced: Option, 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; diff --git a/crates/app-iced/src/shell/tests.rs b/crates/app-iced/src/shell/tests.rs new file mode 100644 index 0000000..0aa7b6d --- /dev/null +++ b/crates/app-iced/src/shell/tests.rs @@ -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(缩小是用户点名的)" + ); +}