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

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

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

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

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

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

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

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

No files matched your search

+135 -167
View File
@@ -3,7 +3,22 @@
//! **刻意不接 `cli.rs`**:等 `cli.rs` 具备 `--screen` / `--window-width` 之后,这个 harness 应当
//! 删掉,改由正式启动参数出图。参数:`--screen=instances|download|settings|logs`、`--width`、
//! `--height`、`--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
//! `--software-renderer`、`--scale-factor`。没给 `--screenshot` 就只是开窗看。
//! `--software-renderer`、`--scale-factor`、`--appearance=real|demo`、`--passthrough=on|off`。
//! 解析与默认值在 [`request`](M100 拆出去:本文件当时正好 400 行、零余量);没给 `--screenshot`
//! 就只是开窗看。
//!
//! ## 取证密闭(M100):取证图不许跟着系统指针变
//!
//! M99 复审实测过:同一个 exe 连跑 8 次,第 6 次给出**第三个 hash**,差异区是某个按钮被画成 Hovered
//! ——根因是 harness 开真窗口,`button::Status` 由**系统光标位置**决定,而「连跑两次一样」不是判据。
//!
//! 所以 `--screenshot` 有一条**通道级**钉死:开窗那一刻调 `window::enable_mouse_passthrough`
//! (winit 的 `set_cursor_hittest(false)`)⇒ 窗口从此收不到指针事件 ⇒ 全树控件恒为 `Active`。
//! **为什么不是给每个控件灌 forced**:设置页「外观」区的滑块 / 文本框 / 裸 `iced::button` 都不消费
//! forced,那样做要改十几个组件文件——为取证去动产品组件,代价与风险都不对,而且新组件随时会漏。
//!
//! 同时数指针事件(`iced::event::listen_with`)并打进日志:**「这张图收到几个鼠标事件」是可读数字**,
//! 于是「hash 稳」不再是「大概没漏」,而是「这张图压根没收到指针事件」。
//!
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- --screen=instances --width=1180 \
@@ -12,171 +27,29 @@
//!
//! 等 3 帧再截图与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,字体首次上屏会抖动。
use std::path::PathBuf;
use std::time::Instant;
use iced::{window, Element, Subscription, Task};
use iced::{window, Element, Point, Subscription, Task};
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs};
use crate::app::components::interaction::Interaction;
use crate::cli::Backend;
use crate::fonts;
use crate::pages::appearance;
use crate::probe::{self, Probe};
use crate::renderer;
use crate::shot;
use crate::theme::Ui;
use super::{app, demo_model, Model, Screen};
use super::{app_with_appearance, demo_model, Model};
/// 截图落盘的帧号(见模块文档);验收窗口尺寸(D30:1180×760、100% DPI)。
mod request;
pub use request::{Request, CAPTURE_POSITION};
/// 截图落盘的帧号(见模块文档)。
const CAPTURE_AT_FRAME: u32 = 3;
const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0);
/// 一次渲染请求。
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
/// 渲染哪一页。
pub screen: Screen,
/// 窗口宽度(**含外壳**:布局档位就是按它判的)。
pub width: f32,
/// 窗口高度。
pub height: f32,
/// 主题(`--screenshot-theme` > `--theme` > 浅色)。
pub flavor: SchemeFlavor,
/// 出图路径(`None` = 只开窗)。
pub screenshot: Option<PathBuf>,
/// 渲染后端请求。
pub backend: Backend,
/// 强制缩放因子(截图要 100% DPI)。
pub scale_factor: Option<f32>,
}
impl Default for Request {
fn default() -> Self {
Self {
screen: Screen::Instances,
width: DEFAULT_SIZE.0,
height: DEFAULT_SIZE.1,
flavor: SchemeFlavor::Light,
screenshot: None,
backend: Backend::Auto,
scale_factor: None,
}
}
}
impl Request {
/// 从进程参数解析(`argv[1..]`)。未知参数 / 缺值 / 数字非法时返回人话。
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut request = Self::default();
let mut theme: Option<SchemeFlavor> = None;
let mut shot_theme: Option<SchemeFlavor> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let (key, inline) = match arg.split_once('=') {
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
None => (arg.clone(), None),
};
match key.as_str() {
"--screen" => {
let raw = take(&mut args, inline, "--screen")?;
request.screen = Screen::from_slug(&raw).ok_or_else(|| {
format!("--screen 只认 instances|download|settings|logs,收到 {raw:?}")
})?;
}
"--width" => {
request.width = number(&take(&mut args, inline, "--width")?, "--width")?
}
"--height" => {
request.height = number(&take(&mut args, inline, "--height")?, "--height")?;
}
"--theme" => theme = Some(flavor(&take(&mut args, inline, "--theme")?)?),
"--screenshot-theme" => {
shot_theme = Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
}
"--screenshot" => {
request.screenshot =
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
}
"--software-renderer" => request.backend = Backend::Cpu,
"--renderer" => {
request.backend = match take(&mut args, inline, "--renderer")?.as_str() {
"auto" => Backend::Auto,
"gpu" => Backend::Gpu,
"cpu" => Backend::Cpu,
other => {
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"))
}
};
}
"--scale-factor" => {
let raw = take(&mut args, inline, "--scale-factor")?;
let value = number(&raw, "--scale-factor")?;
if value <= 0.0 {
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
}
request.scale_factor = Some(value);
}
other => return Err(format!("未知参数 {other:?}")),
}
}
request.flavor = shot_theme.or(theme).unwrap_or(SchemeFlavor::Light);
if request.width < 320.0 || request.height < 320.0 {
return Err(format!(
"窗口太小:{}×{}(至少 320×320)",
request.width, request.height
));
}
Ok(request)
}
/// 出图文件名片段(`<screen>-<class>-<flavor>-w<宽>`,归档用)。
pub fn slug(&self) -> String {
let class = super::layout::WidthClass::of(self.width);
let flavor = if self.flavor.is_dark() {
"dark"
} else {
"light"
};
let width = self.width as u32;
format!("{}-{}-{flavor}-w{width}", self.screen.slug(), class.name())
}
}
fn take(
args: &mut impl Iterator<Item = String>,
inline: Option<String>,
flag: &str,
) -> Result<String, String> {
inline
.or_else(|| args.next())
.ok_or_else(|| format!("{flag} 需要一个值"))
}
fn number(raw: &str, flag: &str) -> Result<f32, String> {
raw.parse::<f32>()
.map_err(|_| format!("{flag} 需要数字,收到 {raw:?}"))
.and_then(|value| {
if value.is_finite() && value > 0.0 {
Ok(value)
} else {
Err(format!("{flag} 必须是正数,收到 {raw:?}"))
}
})
}
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
match raw {
"light" => Ok(SchemeFlavor::Light),
"dark" => Ok(SchemeFlavor::Dark),
other => Err(format!("主题只认 light|dark,收到 {other:?}")),
}
}
/// 启动(阻塞到出图或关窗)。
///
@@ -213,6 +86,14 @@ pub fn run(request: Request) -> Result<(), String> {
size: iced::Size::new(request.width, request.height),
decorations: false,
resizable: true,
// 取证窗口钉在固定落点:这样「把指针放进窗口某个位置」是可复算的(真实矩形由
// `Harness` 用 `window::position` / `window::size` 的返回值打日志报出,见 `Message::Sized`)
position: match request.screenshot {
Some(_) => {
window::Position::Specific(Point::new(CAPTURE_POSITION.0, CAPTURE_POSITION.1))
}
None => window::Position::default(),
},
..window::Settings::default()
};
renderer::acceptance::report_window(
@@ -222,17 +103,53 @@ pub fn run(request: Request) -> Result<(), String> {
None,
);
// 外观**真状态**(`--appearance=real`):工厂态 + `ULL_APPEARANCE_*` 覆盖。
// 这不是给设置页塞常量:状态从 `State::new` / `apply_env` 来,打日志的是它算出来的生效值。
let appearance = request.real_appearance.then(|| {
let mut state = appearance::State::new(
&AppearancePrefs::factory(),
&AppearanceBase::factory(),
request.backend,
request.flavor,
);
state.apply_env();
eprintln!(
"[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \
配色来源={:?} 选中分组={}",
state.window_opacity,
state.panel_alpha,
state.effective_panel_alpha(),
state.bg_enabled,
state.bg_fill,
state.bg_dim,
state.palette_source,
state.selected_group,
);
state
});
let application = |backend: renderer::Kind| {
let boot = request.clone();
let appearance = appearance.clone();
iced::application(
move || {
let mut model = demo_model(boot.screen, boot.width);
// 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot`
// 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced),
// 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。
if boot.screenshot.is_some() {
model.forced = Some(Interaction::Enabled);
}
(
Harness {
ui: Ui::new(boot.flavor, families),
model: demo_model(boot.screen, boot.width),
model,
appearance: appearance.clone(),
request: boot.clone(),
window: None,
placed: None,
frames: 0,
pointer: 0,
started,
first_frame: None,
backend: backend.name(),
@@ -305,8 +222,14 @@ struct Harness {
ui: Ui,
/// 视图模型(演示数据;harness 不接 presenter,只出**单页**图)
model: Model,
/// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。
appearance: Option<appearance::State>,
window: Option<window::Id>,
/// 窗口落点(`window::position` 的**返回值**,不是请求值)。
placed: Option<Point>,
frames: u32,
/// 本张图收到过几个鼠标事件(`Message::Pointer` 的计数;密闭性靠它自证)。
pointer: u32,
/// 进程起点(`run()` 的开头)——`[perf]` 的首帧口径与产品路径一致:从进 `run()` 算。
started: Instant,
/// 第一帧到达的时刻(相对 `started`)。`None` = 一帧都没到过。
@@ -319,8 +242,14 @@ struct Harness {
enum Message {
Boot,
WindowReady(Option<window::Id>),
/// `window::position` 的回值。
Placed(Option<Point>),
/// `window::size` 的回值。
Sized(iced::Size),
Frame,
Captured(window::Screenshot),
/// 收到一个指针事件(只计数,不画;见 [`Harness::subscription`])。
Pointer,
/// 页面里的动作(骨架期只打印,接线后由 app 层处理)。
Page(super::Message),
}
@@ -331,6 +260,35 @@ impl Harness {
Message::Boot => window::latest().map(Message::WindowReady),
Message::WindowReady(id) => {
self.window = id;
// 取证窗口按住**那一刻**就断开指针输入(M100 模块文档「取证密闭」)。
// 交互模式走不到这里:`request.passthrough` 在没有 `--screenshot` 时恒为 false
// (`Request::parse` 里显式给值会直接报错,不给值就是 false)。
let Some(id) = id else {
return Task::none();
};
let mut tasks = Vec::new();
if self.request.passthrough {
eprintln!("[harness] 指针穿透已开:本窗口不再接收鼠标事件(取证专用)");
tasks.push(window::enable_mouse_passthrough(id));
}
tasks.push(window::position(id).map(Message::Placed));
Task::batch(tasks)
}
Message::Placed(position) => {
self.placed = position;
match self.window {
Some(id) => window::size(id).map(Message::Sized),
None => Task::none(),
}
}
Message::Sized(size) => {
match self.placed {
Some(Point { x, y }) => eprintln!(
"[harness] 窗口实测矩形:({x:.0}, {y:.0}) {:.0}×{:.0}(window::position/size 的回值)",
size.width, size.height
),
None => eprintln!("[harness] 窗口落点取不到(window::position 回 None)"),
}
Task::none()
}
Message::Frame => {
@@ -346,21 +304,24 @@ impl Harness {
None => Task::none(),
}
}
Message::Pointer => {
self.pointer += 1;
Task::none()
}
Message::Captured(screenshot) => {
let Some(path) = self.request.screenshot.clone() else {
return Task::none();
};
match shot::save(&screenshot, &path) {
Ok(summary) => {
eprintln!("{}", summary.describe());
eprintln!(
"[harness] slug={} backend={}(这张图的来源,自证用)",
self.request.slug(),
self.backend
);
}
Ok(summary) => eprintln!("{}", summary.describe()),
Err(error) => eprintln!("[harness] 截图失败:{error}"),
}
eprintln!(
"[harness] slug={} backend={} 指针事件={}(这张图的来源与密闭性,自证用)",
self.request.slug(),
self.backend,
self.pointer
);
// `[perf]` 两格都是实测:`first_frame` = 首帧事件的时间戳,`fps` = 帧数 / 时长
// (**取证窗口**的吞吐,不是长跑稳态帧率——那个看产品路径的 `[tick]`)。
renderer::acceptance::report_perf_scene(
@@ -379,11 +340,18 @@ impl Harness {
}
fn subscription(&self) -> Subscription<Message> {
if self.request.screenshot.is_some() {
window::frames().map(|_| Message::Frame)
} else {
Subscription::none()
if self.request.screenshot.is_none() {
return Subscription::none();
}
// 帧节拍 + **指针事件计数**两样都订阅。后者是 M100 加的:以前「取证密闭」靠
// 「同一场景连跑几次 hash 一样」证明,而进程压根收不到鼠标时那条恒真(U5 的同族假绿)。
Subscription::batch([
window::frames().map(|_| Message::Frame),
iced::event::listen_with(|event, _status, _window| match event {
iced::Event::Mouse(_) => Some(Message::Pointer),
_ => None,
}),
])
}
fn theme(&self) -> iced::Theme {
@@ -391,7 +359,7 @@ impl Harness {
}
fn view(&self) -> Element<'_, Message> {
app(&self.ui, &self.model).map(Message::Page)
app_with_appearance(&self.ui, &self.model, self.appearance.as_ref()).map(Message::Page)
}
}