From 2061296615522f5002107692ce460da799071a62 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 14:22:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-iced,pages):=20M100=20=E5=8F=96?= =?UTF-8?q?=E8=AF=81=E5=AF=86=E9=97=AD=20+=20harness=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=8B=86=E5=88=86=20+=20=E8=AE=BE=E7=BD=AE=E9=A1=B5=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=8C=89=20section=20=E4=B8=8B=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四件前置事,都是为了设置页正文四条 section 线能并行(不写任何 section 正文): 1. 取证密闭(两条通道各有各的机制,证据分开算) - 产品 bin:`cli::ForcedState` 加 `Rest`(静息态),给了 `--screenshot` 且没显式 `--force-state` 时默认 `Some(Rest)`;交互模式仍是 `None`(`shell::overridden` 与 `app/view.rs` 的 `model()` 各补一支)。 - harness:截图窗口开 `window::enable_mouse_passthrough`(winit 的 `set_cursor_hittest(false)`)⇒ 窗口收不到指针事件 ⇒ 全树控件恒为 Active。 为什么用通道级而不是逐控件灌 forced:设置页「外观」区的滑块/文本框/裸 button 都不消费 forced,逐控件做要改十几个组件文件,而新产品组件随时会漏。 - harness 同时数指针事件并打进日志(`指针事件=N`):让「这张图见过几个鼠标事件」变成 可读数字,替代「连跑几次 hash 一样」那种恒真的假绿。 2. harness 的外观真状态入口:`--appearance=real` 让设置页走 `appearance: Some(..)` (工厂态 + `ULL_APPEARANCE_*`),新入口 `pages::app_with_appearance`。 `--screen=settings` 之前就有了,但 `appearance` 恒为 None,D30 要的那一屏根本不存在。 3. `pages/harness.rs` 400 行 → 拆 `pages/harness/request.rs`(`#[path]` 范式), 纯搬移部分 240 行 + 新增密闭代码。拆分等价性:同一 argv 的 PNG sha256 与拆分前一致。 4. `pages/settings/tests.rs` 按 section 下放成 `tests/{general,appearance,java,download}_section.rs`, mod 层只留跨 section 断言(每条写清守什么、为什么不能下放)。 给某组加一条新断言 = 只改它自己那个文件。 顺带:`shell/mod.rs` 在 HEAD 就 399 行、`cli.rs` 394 行,两处都按同一范式把 tests 搬成 `shell/tests.rs` / `cli/tests.rs`(断言一字不动,逐字节可 diff)。 变异验证:把四处实现各改坏一处(默认 Rest、passthrough 默认值、overridden 的 Rest 分支、 view 的 Rest 映射),对应 4 条断言全红(390 passed / 4 failed),恢复后 394 passed。 --- crates/app-iced/src/app/view.rs | 32 ++ crates/app-iced/src/cli.rs | 148 ++------- crates/app-iced/src/cli/tests.rs | 172 ++++++++++ crates/app-iced/src/pages/harness.rs | 302 ++++++++---------- crates/app-iced/src/pages/harness/request.rs | 233 ++++++++++++++ crates/app-iced/src/pages/harness/tests.rs | 66 +++- crates/app-iced/src/pages/mod.rs | 16 +- crates/app-iced/src/pages/settings/mod.rs | 14 + crates/app-iced/src/pages/settings/tests.rs | 95 ------ .../settings/tests/appearance_section.rs | 48 +++ .../pages/settings/tests/download_section.rs | 47 +++ .../pages/settings/tests/general_section.rs | 47 +++ .../src/pages/settings/tests/java_section.rs | 50 +++ .../app-iced/src/pages/settings/tests/mod.rs | 146 +++++++++ crates/app-iced/src/shell/mod.rs | 79 +---- crates/app-iced/src/shell/tests.rs | 126 ++++++++ 16 files changed, 1152 insertions(+), 469 deletions(-) create mode 100644 crates/app-iced/src/cli/tests.rs create mode 100644 crates/app-iced/src/pages/harness/request.rs delete mode 100644 crates/app-iced/src/pages/settings/tests.rs create mode 100644 crates/app-iced/src/pages/settings/tests/appearance_section.rs create mode 100644 crates/app-iced/src/pages/settings/tests/download_section.rs create mode 100644 crates/app-iced/src/pages/settings/tests/general_section.rs create mode 100644 crates/app-iced/src/pages/settings/tests/java_section.rs create mode 100644 crates/app-iced/src/pages/settings/tests/mod.rs create mode 100644 crates/app-iced/src/shell/tests.rs 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..bfdb72d 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,13 @@ pub struct Options { /// 五态完整矩阵由 `--screen=gallery` 的 `Button::state(..)` 出,本开关只给整屏取证。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ForcedState { + /// **常态**(静息):压掉系统光标带来的 hover/pressed。 + /// + /// 截图通道(给了 `--screenshot`)**默认就是它**,理由是实测:M99 复审在同一个 exe 上连跑 8 次, + /// 第 6 次给出第三个 hash,差异区是某个按钮被画成 `Hovered`——因为 harness/产品都开真窗口, + /// `button::Status` 由系统光标位置决定。取证图不能因为「指针恰好压在某个按钮上」而变色。 + /// 显式 `--force-state=hover|pressed` 仍然优先(那是**故意**要画那个态)。 + Rest, /// 所有可交互控件按 hover 画。 Hover, /// 所有可交互控件按 pressed 画。 @@ -189,11 +196,12 @@ impl Options { "--force-state" => { let raw = take(&mut args, inline, "--force-state")?; options.force_state = Some(match raw.as_str() { + "rest" => ForcedState::Rest, "hover" => ForcedState::Hover, "pressed" => ForcedState::Pressed, other => { return Err(format!( - "--force-state 只认 hover|pressed,收到 {other:?}" + "--force-state 只认 rest|hover|pressed,收到 {other:?}" )); } }); @@ -216,6 +224,12 @@ impl Options { } } + // **截图通道默认强制 `Rest`**(M100):取证图不许跟着系统指针的位置变。 + // 交互模式保持 `None`——那是用户的真窗口,不许替用户把状态钉死(钉死了就永远看不到 hover)。 + if options.screenshot.is_some() && options.force_state.is_none() { + options.force_state = Some(ForcedState::Rest); + } + Ok(options) } @@ -264,131 +278,5 @@ fn flavor(raw: &str) -> Result { } #[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..4e29697 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -3,7 +3,22 @@ //! **刻意不接 `cli.rs`**:等 `cli.rs` 具备 `--screen` / `--window-width` 之后,这个 harness 应当 //! 删掉,改由正式启动参数出图。参数:`--screen=instances|download|settings|logs`、`--width`、 //! `--height`、`--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、 -//! `--software-renderer`、`--scale-factor`。没给 `--screenshot` 就只是开窗看。 +//! `--software-renderer`、`--scale-factor`、`--appearance=real|demo`、`--passthrough=on|off`。 +//! 解析与默认值在 [`request`](M100 拆出去:本文件当时正好 400 行、零余量);没给 `--screenshot` +//! 就只是开窗看。 +//! +//! ## 取证密闭(M100):取证图不许跟着系统指针变 +//! +//! M99 复审实测过:同一个 exe 连跑 8 次,第 6 次给出**第三个 hash**,差异区是某个按钮被画成 Hovered +//! ——根因是 harness 开真窗口,`button::Status` 由**系统光标位置**决定,而「连跑两次一样」不是判据。 +//! +//! 所以 `--screenshot` 有一条**通道级**钉死:开窗那一刻调 `window::enable_mouse_passthrough` +//! (winit 的 `set_cursor_hittest(false)`)⇒ 窗口从此收不到指针事件 ⇒ 全树控件恒为 `Active`。 +//! **为什么不是给每个控件灌 forced**:设置页「外观」区的滑块 / 文本框 / 裸 `iced::button` 都不消费 +//! forced,那样做要改十几个组件文件——为取证去动产品组件,代价与风险都不对,而且新组件随时会漏。 +//! +//! 同时数指针事件(`iced::event::listen_with`)并打进日志:**「这张图收到几个鼠标事件」是可读数字**, +//! 于是「hash 稳」不再是「大概没漏」,而是「这张图压根没收到指针事件」。 //! //! ```text //! cargo run -p unlucky-app-iced --example pages_demo -- --screen=instances --width=1180 \ @@ -12,171 +27,29 @@ //! //! 等 3 帧再截图与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,字体首次上屏会抖动。 -use std::path::PathBuf; use std::time::Instant; -use iced::{window, Element, Subscription, Task}; +use iced::{window, Element, Point, Subscription, Task}; -use unlucky_design::color::SchemeFlavor; +use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs}; +use crate::app::components::interaction::Interaction; use crate::cli::Backend; use crate::fonts; +use crate::pages::appearance; use crate::probe::{self, Probe}; use crate::renderer; use crate::shot; use crate::theme::Ui; -use super::{app, demo_model, Model, Screen}; +use super::{app_with_appearance, demo_model, Model}; -/// 截图落盘的帧号(见模块文档);验收窗口尺寸(D30:1180×760、100% DPI)。 +mod request; + +pub use request::{Request, CAPTURE_POSITION}; + +/// 截图落盘的帧号(见模块文档)。 const CAPTURE_AT_FRAME: u32 = 3; -const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0); - -/// 一次渲染请求。 -#[derive(Debug, Clone, PartialEq)] -pub struct Request { - /// 渲染哪一页。 - pub screen: Screen, - /// 窗口宽度(**含外壳**:布局档位就是按它判的)。 - pub width: f32, - /// 窗口高度。 - pub height: f32, - /// 主题(`--screenshot-theme` > `--theme` > 浅色)。 - pub flavor: SchemeFlavor, - /// 出图路径(`None` = 只开窗)。 - pub screenshot: Option, - /// 渲染后端请求。 - 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:?}")), - } -} /// 启动(阻塞到出图或关窗)。 /// @@ -213,6 +86,14 @@ pub fn run(request: Request) -> Result<(), String> { size: iced::Size::new(request.width, request.height), decorations: false, resizable: true, + // 取证窗口钉在固定落点:这样「把指针放进窗口某个位置」是可复算的(真实矩形由 + // `Harness` 用 `window::position` / `window::size` 的返回值打日志报出,见 `Message::Sized`) + position: match request.screenshot { + Some(_) => { + window::Position::Specific(Point::new(CAPTURE_POSITION.0, CAPTURE_POSITION.1)) + } + None => window::Position::default(), + }, ..window::Settings::default() }; renderer::acceptance::report_window( @@ -222,17 +103,53 @@ pub fn run(request: Request) -> Result<(), String> { None, ); + // 外观**真状态**(`--appearance=real`):工厂态 + `ULL_APPEARANCE_*` 覆盖。 + // 这不是给设置页塞常量:状态从 `State::new` / `apply_env` 来,打日志的是它算出来的生效值。 + let appearance = request.real_appearance.then(|| { + let mut state = appearance::State::new( + &AppearancePrefs::factory(), + &AppearanceBase::factory(), + request.backend, + request.flavor, + ); + state.apply_env(); + eprintln!( + "[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \ + 配色来源={:?} 选中分组={}", + state.window_opacity, + state.panel_alpha, + state.effective_panel_alpha(), + state.bg_enabled, + state.bg_fill, + state.bg_dim, + state.palette_source, + state.selected_group, + ); + state + }); + let application = |backend: renderer::Kind| { let boot = request.clone(); + let appearance = appearance.clone(); iced::application( move || { + let mut model = demo_model(boot.screen, boot.width); + // 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot` + // 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced), + // 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。 + if boot.screenshot.is_some() { + model.forced = Some(Interaction::Enabled); + } ( Harness { ui: Ui::new(boot.flavor, families), - model: demo_model(boot.screen, boot.width), + model, + appearance: appearance.clone(), request: boot.clone(), window: None, + placed: None, frames: 0, + pointer: 0, started, first_frame: None, backend: backend.name(), @@ -305,8 +222,14 @@ struct Harness { ui: Ui, /// 视图模型(演示数据;harness 不接 presenter,只出**单页**图) model: Model, + /// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。 + appearance: Option, window: Option, + /// 窗口落点(`window::position` 的**返回值**,不是请求值)。 + placed: Option, frames: u32, + /// 本张图收到过几个鼠标事件(`Message::Pointer` 的计数;密闭性靠它自证)。 + pointer: u32, /// 进程起点(`run()` 的开头)——`[perf]` 的首帧口径与产品路径一致:从进 `run()` 算。 started: Instant, /// 第一帧到达的时刻(相对 `started`)。`None` = 一帧都没到过。 @@ -319,8 +242,14 @@ struct Harness { enum Message { Boot, WindowReady(Option), + /// `window::position` 的回值。 + Placed(Option), + /// `window::size` 的回值。 + Sized(iced::Size), Frame, Captured(window::Screenshot), + /// 收到一个指针事件(只计数,不画;见 [`Harness::subscription`])。 + Pointer, /// 页面里的动作(骨架期只打印,接线后由 app 层处理)。 Page(super::Message), } @@ -331,6 +260,35 @@ impl Harness { Message::Boot => window::latest().map(Message::WindowReady), Message::WindowReady(id) => { self.window = id; + // 取证窗口按住**那一刻**就断开指针输入(M100 模块文档「取证密闭」)。 + // 交互模式走不到这里:`request.passthrough` 在没有 `--screenshot` 时恒为 false + // (`Request::parse` 里显式给值会直接报错,不给值就是 false)。 + let Some(id) = id else { + return Task::none(); + }; + let mut tasks = Vec::new(); + if self.request.passthrough { + eprintln!("[harness] 指针穿透已开:本窗口不再接收鼠标事件(取证专用)"); + tasks.push(window::enable_mouse_passthrough(id)); + } + tasks.push(window::position(id).map(Message::Placed)); + Task::batch(tasks) + } + Message::Placed(position) => { + self.placed = position; + match self.window { + Some(id) => window::size(id).map(Message::Sized), + None => Task::none(), + } + } + Message::Sized(size) => { + match self.placed { + Some(Point { x, y }) => eprintln!( + "[harness] 窗口实测矩形:({x:.0}, {y:.0}) {:.0}×{:.0}(window::position/size 的回值)", + size.width, size.height + ), + None => eprintln!("[harness] 窗口落点取不到(window::position 回 None)"), + } Task::none() } Message::Frame => { @@ -346,21 +304,24 @@ impl Harness { None => Task::none(), } } + Message::Pointer => { + self.pointer += 1; + Task::none() + } Message::Captured(screenshot) => { let Some(path) = self.request.screenshot.clone() else { return Task::none(); }; match shot::save(&screenshot, &path) { - Ok(summary) => { - eprintln!("{}", summary.describe()); - eprintln!( - "[harness] slug={} backend={}(这张图的来源,自证用)", - self.request.slug(), - self.backend - ); - } + Ok(summary) => eprintln!("{}", summary.describe()), Err(error) => eprintln!("[harness] 截图失败:{error}"), } + eprintln!( + "[harness] slug={} backend={} 指针事件={}(这张图的来源与密闭性,自证用)", + self.request.slug(), + self.backend, + self.pointer + ); // `[perf]` 两格都是实测:`first_frame` = 首帧事件的时间戳,`fps` = 帧数 / 时长 // (**取证窗口**的吞吐,不是长跑稳态帧率——那个看产品路径的 `[tick]`)。 renderer::acceptance::report_perf_scene( @@ -379,11 +340,18 @@ impl Harness { } fn subscription(&self) -> Subscription { - if self.request.screenshot.is_some() { - window::frames().map(|_| Message::Frame) - } else { - Subscription::none() + if self.request.screenshot.is_none() { + return Subscription::none(); } + // 帧节拍 + **指针事件计数**两样都订阅。后者是 M100 加的:以前「取证密闭」靠 + // 「同一场景连跑几次 hash 一样」证明,而进程压根收不到鼠标时那条恒真(U5 的同族假绿)。 + Subscription::batch([ + window::frames().map(|_| Message::Frame), + iced::event::listen_with(|event, _status, _window| match event { + iced::Event::Mouse(_) => Some(Message::Pointer), + _ => None, + }), + ]) } fn theme(&self) -> iced::Theme { @@ -391,7 +359,7 @@ impl Harness { } fn view(&self) -> Element<'_, Message> { - app(&self.ui, &self.model).map(Message::Page) + app_with_appearance(&self.ui, &self.model, self.appearance.as_ref()).map(Message::Page) } } 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..f04b6a7 100644 --- a/crates/app-iced/src/pages/harness/tests.rs +++ b/crates/app-iced/src/pages/harness/tests.rs @@ -1,8 +1,13 @@ //! `pages::harness` 的断言(M90 r2 从 `harness.rs` 外置:那边曾 402 行,guards 的 file-size 会判红)。 //! -//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时。 +//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时——所以「指针穿透真的把窗口的鼠标输入关了」 +//! 这类事这里证明不了,只能靠出图时日志里的 `指针事件=N`(见 `harness.rs` 的模块文档)。 -use super::*; +use super::request::Request; +use crate::cli::Backend; +use crate::pages::Screen; + +use unlucky_design::color::SchemeFlavor; fn parse(args: &[&str]) -> Result { Request::parse(args.iter().map(|arg| (*arg).to_owned())) @@ -15,6 +20,7 @@ fn defaults_are_the_acceptance_frame() { assert_eq!((request.width, request.height), (1180.0, 760.0)); assert_eq!(request.flavor, SchemeFlavor::Light); assert!(request.screenshot.is_none()); + assert!(!request.real_appearance, "默认画演示行"); } #[test] @@ -61,3 +67,59 @@ fn tiny_windows_are_rejected() { assert!(parse(&["--width=200"]).is_err()); assert!(parse(&["--height=100"]).is_err()); } + +/// **密闭不变量(M100)**:截图通道默认开指针穿透;交互模式(没有 `--screenshot`)一律关。 +/// +/// 抓得住:① 谁把取证默认值改成关(那会让取证图重新受系统指针影响); +/// ② 谁让交互模式也开穿透(用户窗口收不到鼠标 = 产品事故)。 +/// 抓不住:穿透**真的**生效了没有——那要看出图日志的 `指针事件=N`,测试替不了。 +#[test] +fn capture_is_sealed_by_default_and_interactive_never_is() { + assert!( + parse(&["--screenshot", "o.png"]).unwrap().passthrough, + "截图通道默认必须开指针穿透" + ); + assert!(!parse(&[]).unwrap().passthrough, "交互模式不许开指针穿透"); + assert!( + !parse(&["--screen=settings"]).unwrap().passthrough, + "交互模式(带页名)同样不许开" + ); + // 显式给值在交互模式下是**配置错误**,不是「忽略」:静默忽略会让人以为交互窗口也密闭了 + for flags in [ + &["--passthrough=on"][..], + &["--passthrough", "off"], + &["--screen=settings", "--passthrough=on"], + ] { + assert!(parse(flags).is_err(), "{flags:?} 应该报错"); + } +} + +/// 对照实验的口子(`--passthrough=off`):只服务「不开穿透时 hover 会不会漏进图」这一条。 +#[test] +fn the_control_run_can_turn_the_passthrough_off() { + let shot = parse(&["--screenshot", "o.png", "--passthrough=off"]).unwrap(); + assert!(!shot.passthrough, "显式 off 必须尊重"); + assert!(shot.screenshot.is_some(), "off 只对截图通道有意义"); +} + +/// **外观真状态那一屏(M100)**:`--appearance=real` 只在设置页成立,且 slug 要与演示行版分开。 +/// +/// 抓得住:① 谁把 real 接到别的页(那会出一张「假装接了线」的图); +/// ② 两张图落成同名文件(归档后无法区分哪张是真控件版)。 +/// 抓不住:那张图里到底有没有控件——那是像素的事,由两张图的 sha256 不同 + tower 亲自看图判。 +#[test] +fn the_real_control_screen_is_settings_only_and_says_so_in_the_slug() { + let real = parse(&["--screen=settings", "--appearance=real"]).unwrap(); + assert!(real.real_appearance); + assert_eq!(real.slug(), "settings-real-expanded-light-w1180"); + assert_eq!( + parse(&["--screen=settings"]).unwrap().slug(), + "settings-expanded-light-w1180", + "演示行版的 slug 不许变(既有归档脚本按它找图)" + ); + assert!( + parse(&["--screen=instances", "--appearance=real"]).is_err(), + "别的页没有外观分组,静默接受会出一张假图" + ); + assert!(parse(&["--screen=settings", "--appearance=x"]).is_err()); +} 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..96726bb --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/appearance_section.rs @@ -0,0 +1,48 @@ +//! 「Appearance」分组自己的断言(M100 从 `settings/tests.rs` 下放)。 +//! +//! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在 +//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。 + +use super::super::appearance; +use unlucky_design::icons::IconKind; + +/// 这一组的**演示行**(没有外观运行期状态时才画)。 +/// +/// 抓得住:四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样); +/// 漏掉/多一条行;标签与值错位。 +/// 抓不住:真控件那一屏(那是 `pages/appearance` 的事,靠出图判)。 +#[test] +fn the_appearance_group_shows_density_corner_motion_and_text_scale() { + let rows: Vec<(&str, &str)> = appearance::GROUP.rows.to_vec(); + assert_eq!( + rows, + vec![ + ("Density", "Comfortable"), + ("Corner style", "Rounded"), + ("Motion", "Full"), + ("Text scale", "100%"), + ] + ); +} + +/// 左列图标(`IconKind::Sidebar`)。 +#[test] +fn the_appearance_group_keeps_its_icon() { + assert_eq!(appearance::GROUP.icon, IconKind::Sidebar); +} + +/// **本组的绊线**:外观分组必须一直是「挂了真控件」的那一组。 +/// +/// 抓得住:language+appearance 线把 `body` 换回 `None`(真控件消失、退回演示行,而截图对这种 +/// 「只在有外观状态时出现」的差别不一定抓得住)。 +/// 抓不住:`body` 到底指向哪个渲染器——**函数指针相等性断言不了**(rustc +/// `unpredictable_function_pointer_comparisons`:fn 地址不保证唯一,比出来的结果没有意义), +/// 所以「指向 `pages::appearance::section`」这件事只能由 +/// `settings/appearance.rs` 那一行源码 + 两张图的 sha256 不同来担保。 +#[test] +fn the_appearance_group_keeps_a_real_control_body() { + assert!( + appearance::GROUP.body.is_some(), + "外观分组必须是挂了真控件的那一组(`body = Some(crate::pages::appearance::section)`)" + ); +} 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..db55145 --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/download_section.rs @@ -0,0 +1,47 @@ +//! 「Downloads」分组自己的断言(M100 从 `settings/tests.rs` 下放)。 +//! +//! **归 download 线**:接真控件时改本文件,不必动 `tests/mod.rs`,也不碰别的分组。 +//! +//! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。 + +use super::super::download; +use unlucky_design::icons::IconKind; + +/// 这一组的行集合(标签 + 值)。 +/// +/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错); +/// ② 漏掉/多出一条行;③ 标签与值错位(`("Concurrency", "8 threads")` 被写成 `"Off"`)。 +/// 抓不住:值对不对(`Automatic (region)` 是不是本机真选中的源)——那是接线后真数据源的事。 +#[test] +fn the_downloads_group_shows_source_concurrency_and_checksums() { + let rows: Vec<(&str, &str)> = download::GROUP.rows.to_vec(); + assert_eq!( + rows, + vec![ + ("Preferred source", "Automatic (region)"), + ("Concurrency", "8 threads"), + ("Verify checksums", "On"), + ] + ); +} + +/// 左列图标(`IconKind::Download`)。与导航轨的「下载」同族,换掉就与 rail 语汇不一致。 +#[test] +fn the_downloads_group_keeps_its_icon() { + assert_eq!(download::GROUP.icon, IconKind::Download); +} + +/// **绊线**:这一组还没接真控件,画的是演示行。 +/// +/// 抓得住:download 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。 +/// 抓不住:界面上真控件长什么样。 +/// +/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的 +/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。 +#[test] +fn the_downloads_group_is_still_demo_rows() { + assert!( + download::GROUP.body.is_none(), + "Downloads 还没接真控件:接了就改本断言" + ); +} 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..262598d --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -0,0 +1,47 @@ +//! 「General」分组自己的断言(M100 从 `settings/tests.rs` 下放)。 +//! +//! **归 language+appearance 线**:接真控件时改本文件(`GROUP.body` 那条绊线要跟着翻), +//! 不必动 `tests/mod.rs`,也不碰别的分组。 + +use super::super::general; +use unlucky_design::icons::IconKind; + +/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。 +/// +/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错); +/// ② 漏掉/多出一条行;③ 标签与值错位(`("Theme", "Follow system")` 被写成 `("Theme", "Instances")`)。 +/// 抓不住:值对不对(`"Follow system"` 是不是当前系统主题的真相)——那是接线后真数据源的事。 +#[test] +fn the_general_group_shows_language_theme_startup_and_close() { + let rows: Vec<(&str, &str)> = general::GROUP.rows.to_vec(); + assert_eq!( + rows, + vec![ + ("Language", "English (System)"), + ("Theme", "Follow system"), + ("Startup page", "Instances"), + ("Close button", "Exit to tray"), + ] + ); +} + +/// 左列图标(`IconKind::Settings`)。分组图标是左列的语义标签,换掉就是换观感。 +#[test] +fn the_general_group_keeps_its_icon() { + assert_eq!(general::GROUP.icon, IconKind::Settings); +} + +/// **绊线**:这一组还没接真控件,画的是演示行。 +/// +/// 抓得住:language+appearance 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。 +/// 抓不住:界面上真控件长什么样。 +/// +/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的 +/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。 +#[test] +fn the_general_group_is_still_demo_rows() { + assert!( + general::GROUP.body.is_none(), + "General 还没接真控件:接了就改本断言" + ); +} 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..b30e0fb --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/java_section.rs @@ -0,0 +1,50 @@ +//! 「Java」分组自己的断言(M100 从 `settings/tests.rs` 下放)。 +//! +//! **归 java 线**:接真控件时改本文件,不必动 `tests/mod.rs`,也不碰别的分组。 +//! 判据(M100 交付里正面回答的那条):给 java 加一条新断言 = **只改本文件**。 + +use super::super::java; +use unlucky_design::icons::IconKind; + +/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。 +/// +/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错); +/// ② 漏掉/多出一条行;③ 标签与值错位(`("Default memory", "4 GiB")` 被写成 `"8 threads"`)。 +/// 抓不住:值对不对(`Temurin 21.0.4` 是不是本机真检测到的运行时)——那是接线后真数据源的事。 +#[test] +fn the_java_group_shows_runtime_memory_and_jvm_arguments() { + let rows: Vec<(&str, &str)> = java::GROUP.rows.to_vec(); + assert_eq!( + rows, + vec![ + ("Runtime", "Temurin 21.0.4"), + ("Default memory", "4 GiB"), + ("JVM arguments", "Automatic"), + ] + ); +} + +/// 左列图标必须是 `IconKind::Folder`。 +/// +/// 抓得住:谁把它换回 `IconKind::Play`——那是视觉复审 P2-3 判过的(`Play` 是填充字形, +/// 和同屏其它描边图标语汇打架;Java 组讲的是运行时安装位置,目录图标语义才对得上)。 +/// 抓不住:图标画出来的大小/线宽(那是 tokens 层的事)。 +#[test] +fn the_java_group_keeps_the_folder_icon_from_the_visual_review() { + assert_eq!(java::GROUP.icon, IconKind::Folder); +} + +/// **绊线**:这一组还没接真控件,画的是演示行。 +/// +/// 抓得住:java 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。 +/// 抓不住:界面上真控件长什么样。 +/// +/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的 +/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。 +#[test] +fn the_java_group_is_still_demo_rows() { + assert!( + java::GROUP.body.is_none(), + "Java 还没接真控件:接了就改本断言" + ); +} 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..8d8e1f1 --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -0,0 +1,146 @@ +//! 设置页的**跨 section** 守门测试(M100 按 section 下放之后,留在 mod 层的那一份)。 +//! +//! ## 为什么这里只剩「跨 section」的断言 +//! +//! 设置页正文接下来由四条线并行接(java / download / language+appearance / ai+about)。每条线要能 +//! **只改自己的测试**就跑通,所以「某一组自己的行文案 / 图标 / body 现状」一律下放到 +//! `tests/
_section.rs`;本文件只留**必须四组一起看**的断言——下放到任何单组文件里, +//! 都会变成「某组替其它三组说话」,四条线并行时必然互相踩。 +//! +//! 判据(M100 报告里正面回答的那条):**给 java 加一条新断言,改几个文件?** —— 1 个 +//! (`tests/java_section.rs`)。本文件不动。 +//! +//! ## 站位 +//! +//! 本文件是 `pages::settings::tests`:`super` = `pages::settings`,`super::super` = `pages`。 +//! +//! ## 命名 +//! +//! 子模块带 `_section` 后缀,是为了不和 `use super::*` 带进来的分组模块(`settings::java` 等) +//! 撞名——撞名时显式 `mod` 会静默盖掉 glob,读代码的人会以为 `java` 指的是分组的那个。 + +mod appearance_section; +mod download_section; +mod general_section; +mod java_section; + +use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH}; +use super::*; +use crate::pages::appearance::{APPEARANCE_GROUP, GROUP_LABEL}; + +// --------------------------------------------------------------- 版面(不属于任何一组) + +/// 守的是**版面契约**:分组导航走 drawer 240(D33),不是 rail 的 88。 +/// +/// 不能下放到分组:它不是「某一组」的属性,而是整个 supporting-pane 的几何。 +#[test] +fn side_nav_reuses_the_drawer_width() { + assert_eq!(SIDE_NAV_WIDTH, 240.0); + let metrics = frame_metrics(PaneKit::Supporting, 1180.0); + assert_eq!(metrics.secondary, Some(240.0)); + assert_ne!(metrics.secondary, Some(layout::rail_width())); +} + +/// 守的是**行宽上限**(逐行 67 字符 ≤72)在所有宽度档下都成立。 +/// +/// 不能下放:它是四个分组共用的正文列,任何一组把列撑宽都会连带别人。 +#[test] +fn form_column_never_exceeds_the_measure_cap() { + for width in [720.0, 840.0, 1180.0, 1920.0] { + let metrics = frame_metrics(PaneKit::Supporting, width); + assert!( + metrics.column <= measure_cap_px() + f32::EPSILON, + "{width} 宽下表单列 {:?} 超过行宽上限", + metrics.column + ); + assert!(metrics.column >= layout::measure_floor_px()); + } +} + +/// 守的是**窄窗退化**:<720 时侧列消失、分组改为按组堆叠。 +/// +/// 不能下放:退化是整页行为,四组一起变(单组文件里没法验证「侧列没了」)。 +#[test] +fn narrow_window_stacks_the_groups_instead_of_the_side_nav() { + let narrow = frame_metrics(PaneKit::Supporting, 700.0); + assert_eq!(narrow.layout, PageLayout::SingleColumn); + assert!(narrow.secondary.is_none(), "窄窗不许留侧列"); +} + +// --------------------------------------------------------------- 注册表(四组一起看) + +/// 守的是**注册表的结构不变量**:每组都有名字、图标、演示行,且演示下标在范围内。 +/// +/// 下放不了:它遍历的是 `GROUPS`(四组拼出来的数组),单组文件拿不到其它三组, +/// 而「新增一组忘了填 rows」正是这条要抓的。 +#[test] +fn every_group_has_a_name_icon_and_rows() { + assert!(GROUPS.len() >= 3); + assert!(SELECTED < GROUPS.len()); + for group in &GROUPS { + assert!(!group.name.is_empty()); + assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name); + assert!(!group.rows.is_empty(), "{} 没有设置行", group.name); + for (label, value) in group.rows { + assert!(!label.is_empty() && !value.is_empty()); + } + } +} + +/// M93:设置页「外观」分组的下标与 `pages::appearance` 声明的常量必须一致。 +/// +/// 抓得住:谁把 `GROUPS` 的顺序调了却忘了改 `APPEARANCE_GROUP`(那样真控件会画到别的分组上, +/// 而支持列显示的分组名也对不上)。 +/// 抓不住:控件本身画得对不对(那由截图与 app 侧测试管)。 +/// +/// 下放不了:它同时读 `GROUPS`(注册表)与 `pages::appearance` 的常量,横跨两处; +/// 放进 `appearance_section.rs` 会让「外观那条线」替注册表顺序作证。 +#[test] +fn the_appearance_group_is_where_the_module_says_it_is() { + assert_eq!(GROUPS[APPEARANCE_GROUP].name, GROUP_LABEL); + assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内"); +} + +/// M99:拆分后**左列顺序**必须还是拆分前那四项(顺序变了就是观感变了,不是「纯搬移」)。 +/// +/// 抓得住:① 注册表顺序被调换(`GROUPS` 是四条线各自文件拼出来的,最容易在合并时错位); +/// ② 某组被误删/误加(长度不等)。 +/// 抓不住:组内那些行还在不在、译文对不对(各组自己的文件与 `catalog` 金表各管一段)。 +/// +/// 下放不了:它是一句「四组顺序 = 这四个名字」的断言,任何单组文件都只能看到自己那一个名字。 +#[test] +fn the_split_kept_the_left_column_order() { + let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect(); + assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]); +} + +/// M99:「外观」是**当前唯一**接了真控件的那一组。 +/// +/// 抓得住:拆分时把 `body: Some(…)` 搬丢 / 搬到了别的组(那会让真控件消失,退回演示行—— +/// 而截图对这种「只在有外观状态时出现」的差别不一定抓得住,见报告「没验的部分」); +/// 也抓「别组偷偷接了 body 却没人知道」。 +/// 抓不住:`body` 指向的那个渲染器画得对不对(那是 `pages::appearance` 的事)。 +/// +/// **为什么不能下放**(任务书点名要求写清):这条断言的全部内容是**四组的 body 现状对比** +/// ——「只有第 2 组是 Some,其余三组都是 None」。放进 `appearance_section.rs` 它就得替 java / +/// download / general 三组作证(那三条线一接真控件,本文件以外的地方无缘无故变红,且责任方 +/// 不是改代码的人);拆成四份「我这组是 None」又会丢掉「唯一」这个要点。 +/// 它是**绊线**:哪条线接上真控件,就必须来本文件手工改这一条(T1:绊线保持字面量, +/// 动态取活值等于把绊线剪了)。 +#[test] +fn only_the_appearance_group_carries_a_real_control_body() { + assert!( + GROUPS[APPEARANCE_GROUP].body.is_some(), + "外观分组必须是接了真控件的那一组" + ); + for (index, group) in GROUPS.iter().enumerate() { + if index == APPEARANCE_GROUP { + continue; + } + assert!( + group.body.is_none(), + "{} 现在应该还是演示行(真控件由它自己那条 section 线接)", + group.name + ); + } +} 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..5713a8e --- /dev/null +++ b/crates/app-iced/src/shell/tests.rs @@ -0,0 +1,126 @@ +//! `shell` 的断言(M100 从 `shell/mod.rs` 搬出来:那边在 HEAD 上已经 **399 行**, +//! 加一个 `ForcedState::Rest` 分支就必破 400 行硬线)。 +//! +//! 搬移**不改一行断言**:只去掉了 `mod tests { }` 那一层缩进,外加 M100 新增的两条 +//! 取证不变量(见文件末尾)。 +//! +//! 站位:本文件是 `crate::shell::tests`,`use super::*;` 就够。 +use super::*; +use unlucky_design::color::SchemeFlavor; +use unlucky_design::component_map::Component; +use unlucky_design::typography::Platform; + +use crate::theme::Families; + +fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform( + flavor, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) +} + +#[test] +fn a_screen_only_uses_four_text_sizes_at_most() { + let ui = ui(SchemeFlavor::Dark); + let card = Component::OutlinedCard.spec(); + let sizes = [ + ui.emphasized(TypeRole::TitleLarge).size_px, + ui.emphasized(TypeRole::LabelLarge).size_px, + ui.emphasized(card.text).size_px, + ui.style(card.support.unwrap_or(card.text)).size_px, + ui.style(TypeRole::LabelMedium).size_px, + ]; + let mut unique = sizes.to_vec(); + unique.sort_by(f32::total_cmp); + unique.dedup(); + assert!( + unique.len() <= 4, + "rubric D4:一屏字号档 ≤4(实际 {} 档 {unique:?})", + unique.len() + ); + assert!(unique[0] >= 12.0, "最小档不下 12px(caption ≥11)"); + assert!(unique[unique.len() - 1] <= 28.0, "列表页标题不得 >28px"); +} + +#[test] +fn flavor_slug_is_stable_for_filenames() { + assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Light)), "light"); + assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Dark)), "dark"); +} + +/// **交互模式不许被取证开关污染**(M100 的头号不变量)。 +/// +/// 抓得住:谁把 `--screenshot` 的默认值 `Some(Rest)` 顺手设成了 `Options::force_state` 的默认值 +/// (或者把默认往下挪一层,落到 `overridden` 的 `None` 分支上)——那会让**用户窗口里所有按钮 +/// 永远不显示 hover**,是产品事故,而不是取证问题。 +/// 抓不住:取证模式有没有真的压掉 hover(那是下一条)。 +#[test] +fn interactive_mode_still_hovers() { + use iced::widget::button::Status; + + for status in [Status::Hovered, Status::Pressed, Status::Active] { + assert_eq!( + overridden(None, status), + status, + "交互模式(None)必须跟控件自己的状态走,不许替用户钉死" + ); + } +} + +/// **取证图的常态钉死**:`Rest` 把控件压成 `Active`,不管它自己的运行期状态是什么。 +/// +/// 抓得住:谁把 `Rest` 接成「保留现状」(那等于没钉:系统指针 hover 照样漏进图——M99 复审实测 +/// 连跑 8 次翻盘 1 次就是这里没钉住)。 +/// 抓不住:① 不经过 `overridden` 的控件(标题栏以外的裸 `iced::button`,例如设置页「外观」区); +/// ② 窗口到底还收不收鼠标事件(那是 harness 的 passthrough,靠出图日志的 `指针事件=N` 自证)。 +#[test] +fn rest_suppresses_cursor_hover() { + use iced::widget::button::Status; + + for status in [Status::Hovered, Status::Pressed, Status::Active] { + assert_eq!( + overridden(Some(ForcedState::Rest), status), + Status::Active, + "Rest 必须压掉 {status:?}" + ); + } + // 明细态照旧(D30 的状态矩阵图靠它们) + assert_eq!( + overridden(Some(ForcedState::Hover), Status::Active), + Status::Hovered + ); + assert_eq!( + overridden(Some(ForcedState::Pressed), Status::Active), + Status::Pressed + ); +} + +/// M86:标题栏 caption 图标缩到 20 后,**容器一个数都没动**(46×48),图标也还在框里。 +#[test] +fn caption_icon_shrinks_without_touching_the_container() { + let ui = ui(SchemeFlavor::Light); + let metrics = ui.metrics(); + let caption = icons::CAPTION_PX; + assert_eq!(metrics.titlebar_button_width, 46.0, "容器宽不许跟着图标改"); + assert_eq!(metrics.titlebar_height, 48.0, "容器高不许跟着图标改"); + assert!( + caption < metrics.titlebar_button_width, + "图标 {caption} 必须小于按钮宽 {}", + metrics.titlebar_button_width + ); + assert!( + caption < metrics.titlebar_height, + "图标 {caption} 必须小于按钮高 {}", + metrics.titlebar_height + ); + // 官方口径 24 仍在令牌里(`IconSize::Toolbar`),偏离只发生在 caption 这一个入口。 + assert!( + caption < unlucky_design::icons::IconSize::Toolbar.px(), + "caption 图标必须小于官方 24(缩小是用户点名的)" + ); +}