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)
}
}
@@ -0,0 +1,233 @@
//! harness 的**参数解析**(M100 从 `harness.rs` 搬出来:那边正好 400 行、零余量)。
//!
//! 搬移**不改行为**:同一份 argv 方言、同一份 slug、同一句报错。等价性判据是「同一 argv 出的 PNG
//! sha256 与拆分前一致」(M100 报告里有实测)。
//!
//! 参数方言与 `crates/app-iced/src/cli.rs` 同源(`--key value` 与 `--key=value` 都认),但**刻意不共用
//! 那个 parser**:harness 是取证通道,默认值与产品启动不同(`--screenshot` 默认开**指针穿透**,见
//! [`Request::passthrough`])。产品 bin 的密闭手段是另一套(`cli::ForcedState::Rest`)——两条通道的
//! 证据分开算,别互相冒充。
use std::path::PathBuf;
use unlucky_design::color::SchemeFlavor;
use crate::cli::Backend;
use crate::pages::{layout::WidthClass, Screen};
/// 验收窗口尺寸(D30:1180×760、100% DPI)。
const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0);
/// 取证窗口的落点请求值(`--screenshot` 时才用)。
///
/// 为什么要固定:密闭验证要把系统指针放进窗口里某个点(**故意压在控件上**),窗口位置每次都飘的话,
/// 「指针到底在不在窗口里」就变成了猜——那正是 U1 的形状。真实矩形由 harness 用
/// `window::position` / `window::size` 的**返回值**打日志报出,脚本按日志放指针,不以这个常量为准
/// (它只是请求值)。
pub const CAPTURE_POSITION: (f32, f32) = (80.0, 80.0);
/// 一次渲染请求。
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
/// 渲染哪一页。
pub screen: Screen,
/// 窗口宽度(**含外壳**:布局档位就是按它判的)。
pub width: f32,
/// 窗口高度。
pub height: f32,
/// 主题(`--screenshot-theme` > `--theme` > 浅色)。
pub flavor: SchemeFlavor,
/// 出图路径(`None` = 只开窗)。
pub screenshot: Option<PathBuf>,
/// 渲染后端请求。
pub backend: Backend,
/// 强制缩放因子(截图要 100% DPI)。
pub scale_factor: Option<f32>,
/// 设置页正文画**真控件**(`appearance: Some(..)`)还是演示行(`--appearance=real|demo`)。
///
/// 只对 `--screen=settings` 有意义:别的页没有外观分组,静默接受会造出「以为接了线」的假图(U1)。
pub real_appearance: bool,
/// 截图窗口开**指针穿透**(`window::enable_mouse_passthrough` ⇒ winit `set_cursor_hittest(false)`)。
///
/// 默认:给了 `--screenshot` 就开;**交互模式(没有截图)一律关**——交互窗口收不到鼠标是产品事故,
/// 所以 `--passthrough` 在交互模式下直接报错,不留「配一下就行」的口子。
///
/// `--passthrough=off` 只服务一件事:对照实验——证明「不开穿透时,同一指针位置会把 hover 漏进图」。
/// 没有这个反向证据的话,「穿透开着时 hash 不变」既可能是真密闭,也可能只是指针压根没进窗口。
pub passthrough: bool,
}
impl Default for Request {
fn default() -> Self {
Self {
screen: Screen::Instances,
width: DEFAULT_SIZE.0,
height: DEFAULT_SIZE.1,
flavor: SchemeFlavor::Light,
screenshot: None,
backend: Backend::Auto,
scale_factor: None,
real_appearance: false,
passthrough: false,
}
}
}
impl Request {
/// 从进程参数解析(`argv[1..]`)。未知参数 / 缺值 / 数字非法时返回人话。
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut request = Self::default();
let mut theme: Option<SchemeFlavor> = None;
let mut shot_theme: Option<SchemeFlavor> = None;
let mut passthrough: Option<bool> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let (key, inline) = match arg.split_once('=') {
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
None => (arg.clone(), None),
};
match key.as_str() {
"--screen" => {
let raw = take(&mut args, inline, "--screen")?;
request.screen = Screen::from_slug(&raw).ok_or_else(|| {
format!("--screen 只认 instances|download|settings|logs,收到 {raw:?}")
})?;
}
"--width" => {
request.width = number(&take(&mut args, inline, "--width")?, "--width")?
}
"--height" => {
request.height = number(&take(&mut args, inline, "--height")?, "--height")?;
}
"--theme" => theme = Some(flavor(&take(&mut args, inline, "--theme")?)?),
"--screenshot-theme" => {
shot_theme = Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
}
"--screenshot" => {
request.screenshot =
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
}
"--software-renderer" => request.backend = Backend::Cpu,
"--renderer" => {
request.backend = match take(&mut args, inline, "--renderer")?.as_str() {
"auto" => Backend::Auto,
"gpu" => Backend::Gpu,
"cpu" => Backend::Cpu,
other => {
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"))
}
};
}
"--scale-factor" => {
let raw = take(&mut args, inline, "--scale-factor")?;
let value = number(&raw, "--scale-factor")?;
if value <= 0.0 {
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
}
request.scale_factor = Some(value);
}
"--appearance" => {
request.real_appearance =
match take(&mut args, inline, "--appearance")?.as_str() {
"real" => true,
"demo" => false,
other => {
return Err(format!("--appearance 只认 real|demo,收到 {other:?}"))
}
};
}
"--passthrough" => {
passthrough = Some(match take(&mut args, inline, "--passthrough")?.as_str() {
"on" => true,
"off" => false,
other => return Err(format!("--passthrough 只认 on|off,收到 {other:?}")),
});
}
other => return Err(format!("未知参数 {other:?}")),
}
}
request.flavor = shot_theme.or(theme).unwrap_or(SchemeFlavor::Light);
// 指针穿透**只服务取证通道**:没有 `--screenshot` 时显式给值就是配置错误,不是「忽略」——
// 静默忽略会让人以为交互窗口也「密闭」了,而它其实照常收鼠标。
if passthrough.is_some() && request.screenshot.is_none() {
return Err(
"--passthrough 只服务 --screenshot 取证通道:交互模式的窗口必须收得到鼠标"
.to_owned(),
);
}
request.passthrough = match request.screenshot {
// 取证默认密闭:开窗口的那一刻就断开指针输入(见 `Harness::update` 的 `WindowReady`)
Some(_) => passthrough.unwrap_or(true),
None => false,
};
if request.real_appearance && request.screen != Screen::Settings {
return Err(format!(
"--appearance=real 只对 --screen=settings 生效(当前 --screen={}),别的页没有外观分组,\
静默接受会出一张假装接了线的图",
request.screen.slug()
));
}
if request.width < 320.0 || request.height < 320.0 {
return Err(format!(
"窗口太小:{}×{}(至少 320×320)",
request.width, request.height
));
}
Ok(request)
}
/// 出图文件名片段(`<screen>[-real]-<class>-<flavor>-w<宽>`,归档用)。
pub fn slug(&self) -> String {
let class = WidthClass::of(self.width);
let flavor = if self.flavor.is_dark() {
"dark"
} else {
"light"
};
let width = self.width as u32;
// 「演示行版」与「真控件版」必须能靠文件名分开,否则归档里两张图分不清谁是谁
let screen = if self.real_appearance {
format!("{}-real", self.screen.slug())
} else {
self.screen.slug().to_owned()
};
format!("{screen}-{}-{flavor}-w{width}", class.name())
}
}
fn take(
args: &mut impl Iterator<Item = String>,
inline: Option<String>,
flag: &str,
) -> Result<String, String> {
inline
.or_else(|| args.next())
.ok_or_else(|| format!("{flag} 需要一个值"))
}
fn number(raw: &str, flag: &str) -> Result<f32, String> {
raw.parse::<f32>()
.map_err(|_| format!("{flag} 需要数字,收到 {raw:?}"))
.and_then(|value| {
if value.is_finite() && value > 0.0 {
Ok(value)
} else {
Err(format!("{flag} 必须是正数,收到 {raw:?}"))
}
})
}
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
match raw {
"light" => Ok(SchemeFlavor::Light),
"dark" => Ok(SchemeFlavor::Dark),
other => Err(format!("主题只认 light|dark,收到 {other:?}")),
}
}
+64 -2
View File
@@ -1,8 +1,13 @@
//! `pages::harness` 的断言(M90 r2 从 `harness.rs` 外置:那边曾 402 行,guards 的 file-size 会判红)。
//!
//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时。
//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时——所以「指针穿透真的把窗口的鼠标输入关了」
//! 这类事这里证明不了,只能靠出图时日志里的 `指针事件=N`(见 `harness.rs` 的模块文档)。
use super::*;
use super::request::Request;
use crate::cli::Backend;
use crate::pages::Screen;
use unlucky_design::color::SchemeFlavor;
fn parse(args: &[&str]) -> Result<Request, String> {
Request::parse(args.iter().map(|arg| (*arg).to_owned()))
@@ -15,6 +20,7 @@ fn defaults_are_the_acceptance_frame() {
assert_eq!((request.width, request.height), (1180.0, 760.0));
assert_eq!(request.flavor, SchemeFlavor::Light);
assert!(request.screenshot.is_none());
assert!(!request.real_appearance, "默认画演示行");
}
#[test]
@@ -61,3 +67,59 @@ fn tiny_windows_are_rejected() {
assert!(parse(&["--width=200"]).is_err());
assert!(parse(&["--height=100"]).is_err());
}
/// **密闭不变量(M100)**:截图通道默认开指针穿透;交互模式(没有 `--screenshot`)一律关。
///
/// 抓得住:① 谁把取证默认值改成关(那会让取证图重新受系统指针影响);
/// ② 谁让交互模式也开穿透(用户窗口收不到鼠标 = 产品事故)。
/// 抓不住:穿透**真的**生效了没有——那要看出图日志的 `指针事件=N`,测试替不了。
#[test]
fn capture_is_sealed_by_default_and_interactive_never_is() {
assert!(
parse(&["--screenshot", "o.png"]).unwrap().passthrough,
"截图通道默认必须开指针穿透"
);
assert!(!parse(&[]).unwrap().passthrough, "交互模式不许开指针穿透");
assert!(
!parse(&["--screen=settings"]).unwrap().passthrough,
"交互模式(带页名)同样不许开"
);
// 显式给值在交互模式下是**配置错误**,不是「忽略」:静默忽略会让人以为交互窗口也密闭了
for flags in [
&["--passthrough=on"][..],
&["--passthrough", "off"],
&["--screen=settings", "--passthrough=on"],
] {
assert!(parse(flags).is_err(), "{flags:?} 应该报错");
}
}
/// 对照实验的口子(`--passthrough=off`):只服务「不开穿透时 hover 会不会漏进图」这一条。
#[test]
fn the_control_run_can_turn_the_passthrough_off() {
let shot = parse(&["--screenshot", "o.png", "--passthrough=off"]).unwrap();
assert!(!shot.passthrough, "显式 off 必须尊重");
assert!(shot.screenshot.is_some(), "off 只对截图通道有意义");
}
/// **外观真状态那一屏(M100)**:`--appearance=real` 只在设置页成立,且 slug 要与演示行版分开。
///
/// 抓得住:① 谁把 real 接到别的页(那会出一张「假装接了线」的图);
/// ② 两张图落成同名文件(归档后无法区分哪张是真控件版)。
/// 抓不住:那张图里到底有没有控件——那是像素的事,由两张图的 sha256 不同 + tower 亲自看图判。
#[test]
fn the_real_control_screen_is_settings_only_and_says_so_in_the_slug() {
let real = parse(&["--screen=settings", "--appearance=real"]).unwrap();
assert!(real.real_appearance);
assert_eq!(real.slug(), "settings-real-expanded-light-w1180");
assert_eq!(
parse(&["--screen=settings"]).unwrap().slug(),
"settings-expanded-light-w1180",
"演示行版的 slug 不许变(既有归档脚本按它找图)"
);
assert!(
parse(&["--screen=instances", "--appearance=real"]).is_err(),
"别的页没有外观分组,静默接受会出一张假图"
);
assert!(parse(&["--screen=settings", "--appearance=x"]).is_err());
}
+15 -1
View File
@@ -312,7 +312,21 @@ fn render<'a>(
/// `pages_demo` example 与单测:它们要一张「只有页面、不接 app 消息」的独立图,而 `shell` 的
/// 回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。
pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
let body = page(ui, model);
app_with_appearance(ui, model, None)
}
/// 同 [`app`],但可以带上**外观运行期状态**。
///
/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section.rs`);
/// `None` ⇒ 画演示行,与 [`app`] 逐像素等价。harness 的 `--appearance=real` 走的就是这一支:
/// M100 之前 harness 恒传 `None`,于是 D30 要的「带真实外观控件」那一屏**根本不存在**——
/// 报告上写着「设置页已接真控件」,图里却只有演示行(U1 的形状)。
pub fn app_with_appearance<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: Option<&'a appearance::State>,
) -> Element<'a, Message> {
let body = render(ui, model, appearance);
let shell = row![nav::rail(ui, model.active), body]
.width(Length::Fill)
+14
View File
@@ -30,6 +30,20 @@
//! | (页面上还没有这个分组) | [`ai`] | ai+about 线:先建分组再接线 |
//! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) |
//!
//! ## 测试也按 section 分(M100)
//!
//! 断言跟着它守的东西走,于是四条 section 线**各改各的测试**:
//!
//! | 测试文件 | 守什么 | 谁会动它 |
//! |---|---|---|
//! | `tests/general_section.rs` | General 自己的行 / 图标 / body 现状 | language+appearance 线 |
//! | `tests/appearance_section.rs` | Appearance 自己的行 / 图标 / body 现状 | language+appearance 线 |
//! | `tests/java_section.rs` | Java 自己的行 / 图标 / body 现状 | java 线 |
//! | `tests/download_section.rs` | Downloads 自己的行 / 图标 / body 现状 | download 线 |
//! | `tests/mod.rs` | **跨 section**:版面契约、行宽上限、窄窗退化、注册表顺序 / 分组下标 / 「只有一组有真控件」 | 谁都别随手改(每条都写了为什么不能下放) |
//!
//! 判据:**给某一组加一条新断言 = 只改它自己那个测试文件**。
//!
//! **加一个分组要动哪里**:① 在它自己的文件里写一个 `pub(super) const GROUP`(名字 / 图标 / 演示行 /
//! 可选的真控件渲染器 `body`);② 本文件的 [`GROUPS`] 加一行、数组长度 +1。**不需要**动 `frame.rs`、
//! 别的分组文件或 `pages/mod.rs`。
@@ -1,95 +0,0 @@
//! 设置页的守门测试(M99 拆目录时**原样搬出来**,用例与断言一字未动;断言强度只增不减)。
//!
//! 站位说明:本文件是 `pages::settings::tests`,所以 `super::super` 就是 `pages`。
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
use super::*;
use crate::pages::appearance::{APPEARANCE_GROUP, GROUP_LABEL};
#[test]
fn side_nav_reuses_the_drawer_width() {
// D33:分组导航走 drawer 240(同一容器 rail 88 ⇄ drawer 240),不是 rail 的 88
assert_eq!(SIDE_NAV_WIDTH, 240.0);
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
assert_eq!(metrics.secondary, Some(240.0));
assert_ne!(metrics.secondary, Some(layout::rail_width()));
}
#[test]
fn form_column_never_exceeds_the_measure_cap() {
for width in [720.0, 840.0, 1180.0, 1920.0] {
let metrics = frame_metrics(PaneKit::Supporting, width);
assert!(
metrics.column <= measure_cap_px() + f32::EPSILON,
"{width} 宽下表单列 {:?} 超过行宽上限",
metrics.column
);
assert!(metrics.column >= layout::measure_floor_px());
}
}
#[test]
fn every_group_has_a_name_icon_and_rows() {
assert!(GROUPS.len() >= 3);
assert!(SELECTED < GROUPS.len());
for group in &GROUPS {
assert!(!group.name.is_empty());
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name);
assert!(!group.rows.is_empty(), "{} 没有设置行", group.name);
for (label, value) in group.rows {
assert!(!label.is_empty() && !value.is_empty());
}
}
}
#[test]
fn narrow_window_stacks_the_groups_instead_of_the_side_nav() {
let narrow = frame_metrics(PaneKit::Supporting, 700.0);
assert_eq!(narrow.layout, PageLayout::SingleColumn);
assert!(narrow.secondary.is_none(), "窄窗不许留侧列");
}
/// M93:设置页「外观」分组的下标与 `pages::appearance` 声明的常量必须一致。
///
/// 抓得住:谁把 `GROUPS` 的顺序调了却忘了改 `APPEARANCE_GROUP`(那样真控件会画到别的分组上,
/// 而支持列显示的分组名也对不上)。
/// 抓不住:控件本身画得对不对(那由截图与 app 侧测试管)。
#[test]
fn the_appearance_group_is_where_the_module_says_it_is() {
assert_eq!(GROUPS[APPEARANCE_GROUP].name, GROUP_LABEL);
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}
/// M99:拆分后**左列顺序**必须还是拆分前那四项(顺序变了就是观感变了,不是「纯搬移」)。
///
/// 抓得住:① 注册表顺序被调换(`GROUPS` 是四条线各自文件拼出来的,最容易在合并时错位);
/// ② 某组被误删/误加(长度不等)。
/// 抓不住:组内那些行还在不在、译文对不对(各组自己的文件与 `catalog` 金表各管一段)。
#[test]
fn the_split_kept_the_left_column_order() {
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
}
/// M99:「外观」那一组之所以能画真控件,是因为**它自己的文件**挂上了 `body`。
///
/// 抓得住:拆分时把 `body: Some(…)` 搬丢 / 搬到了别的组(那会让真控件消失,退回演示行——
/// 而截图对这种「只在有外观状态时出现」的差别不一定抓得住,见报告「没验的部分」)。
/// 抓不住:`body` 指向的那个渲染器画得对不对(那是 `pages/appearance` 的事)。
#[test]
fn only_the_appearance_group_carries_a_real_control_body() {
assert!(
GROUPS[APPEARANCE_GROUP].body.is_some(),
"外观分组必须是接了真控件的那一组"
);
for (index, group) in GROUPS.iter().enumerate() {
if index == APPEARANCE_GROUP {
continue;
}
assert!(
group.body.is_none(),
"{} 现在应该还是演示行(真控件由它自己那条 section 线接)",
group.name
);
}
}
@@ -0,0 +1,48 @@
//! 「Appearance」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
//!
//! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在
//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。
use super::super::appearance;
use unlucky_design::icons::IconKind;
/// 这一组的**演示行**(没有外观运行期状态时才画)。
///
/// 抓得住:四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样);
/// 漏掉/多一条行;标签与值错位。
/// 抓不住:真控件那一屏(那是 `pages/appearance` 的事,靠出图判)。
#[test]
fn the_appearance_group_shows_density_corner_motion_and_text_scale() {
let rows: Vec<(&str, &str)> = appearance::GROUP.rows.to_vec();
assert_eq!(
rows,
vec![
("Density", "Comfortable"),
("Corner style", "Rounded"),
("Motion", "Full"),
("Text scale", "100%"),
]
);
}
/// 左列图标(`IconKind::Sidebar`)。
#[test]
fn the_appearance_group_keeps_its_icon() {
assert_eq!(appearance::GROUP.icon, IconKind::Sidebar);
}
/// **本组的绊线**:外观分组必须一直是「挂了真控件」的那一组。
///
/// 抓得住:language+appearance 线把 `body` 换回 `None`(真控件消失、退回演示行,而截图对这种
/// 「只在有外观状态时出现」的差别不一定抓得住)。
/// 抓不住:`body` 到底指向哪个渲染器——**函数指针相等性断言不了**(rustc
/// `unpredictable_function_pointer_comparisons`:fn 地址不保证唯一,比出来的结果没有意义),
/// 所以「指向 `pages::appearance::section`」这件事只能由
/// `settings/appearance.rs` 那一行源码 + 两张图的 sha256 不同来担保。
#[test]
fn the_appearance_group_keeps_a_real_control_body() {
assert!(
appearance::GROUP.body.is_some(),
"外观分组必须是挂了真控件的那一组(`body = Some(crate::pages::appearance::section)`)"
);
}
@@ -0,0 +1,47 @@
//! 「Downloads」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
//!
//! **归 download 线**:接真控件时改本文件,不必动 `tests/mod.rs`,也不碰别的分组。
//!
//! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。
use super::super::download;
use unlucky_design::icons::IconKind;
/// 这一组的行集合(标签 + 值)。
///
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错);
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Concurrency", "8 threads")` 被写成 `"Off"`)。
/// 抓不住:值对不对(`Automatic (region)` 是不是本机真选中的源)——那是接线后真数据源的事。
#[test]
fn the_downloads_group_shows_source_concurrency_and_checksums() {
let rows: Vec<(&str, &str)> = download::GROUP.rows.to_vec();
assert_eq!(
rows,
vec![
("Preferred source", "Automatic (region)"),
("Concurrency", "8 threads"),
("Verify checksums", "On"),
]
);
}
/// 左列图标(`IconKind::Download`)。与导航轨的「下载」同族,换掉就与 rail 语汇不一致。
#[test]
fn the_downloads_group_keeps_its_icon() {
assert_eq!(download::GROUP.icon, IconKind::Download);
}
/// **绊线**:这一组还没接真控件,画的是演示行。
///
/// 抓得住:download 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
#[test]
fn the_downloads_group_is_still_demo_rows() {
assert!(
download::GROUP.body.is_none(),
"Downloads 还没接真控件:接了就改本断言"
);
}
@@ -0,0 +1,47 @@
//! 「General」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
//!
//! **归 language+appearance 线**:接真控件时改本文件(`GROUP.body` 那条绊线要跟着翻),
//! 不必动 `tests/mod.rs`,也不碰别的分组。
use super::super::general;
use unlucky_design::icons::IconKind;
/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。
///
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错);
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Theme", "Follow system")` 被写成 `("Theme", "Instances")`)。
/// 抓不住:值对不对(`"Follow system"` 是不是当前系统主题的真相)——那是接线后真数据源的事。
#[test]
fn the_general_group_shows_language_theme_startup_and_close() {
let rows: Vec<(&str, &str)> = general::GROUP.rows.to_vec();
assert_eq!(
rows,
vec![
("Language", "English (System)"),
("Theme", "Follow system"),
("Startup page", "Instances"),
("Close button", "Exit to tray"),
]
);
}
/// 左列图标(`IconKind::Settings`)。分组图标是左列的语义标签,换掉就是换观感。
#[test]
fn the_general_group_keeps_its_icon() {
assert_eq!(general::GROUP.icon, IconKind::Settings);
}
/// **绊线**:这一组还没接真控件,画的是演示行。
///
/// 抓得住:language+appearance 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
#[test]
fn the_general_group_is_still_demo_rows() {
assert!(
general::GROUP.body.is_none(),
"General 还没接真控件:接了就改本断言"
);
}
@@ -0,0 +1,50 @@
//! 「Java」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
//!
//! **归 java 线**:接真控件时改本文件,不必动 `tests/mod.rs`,也不碰别的分组。
//! 判据(M100 交付里正面回答的那条):给 java 加一条新断言 = **只改本文件**。
use super::super::java;
use unlucky_design::icons::IconKind;
/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。
///
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错);
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Default memory", "4 GiB")` 被写成 `"8 threads"`)。
/// 抓不住:值对不对(`Temurin 21.0.4` 是不是本机真检测到的运行时)——那是接线后真数据源的事。
#[test]
fn the_java_group_shows_runtime_memory_and_jvm_arguments() {
let rows: Vec<(&str, &str)> = java::GROUP.rows.to_vec();
assert_eq!(
rows,
vec![
("Runtime", "Temurin 21.0.4"),
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
]
);
}
/// 左列图标必须是 `IconKind::Folder`。
///
/// 抓得住:谁把它换回 `IconKind::Play`——那是视觉复审 P2-3 判过的(`Play` 是填充字形,
/// 和同屏其它描边图标语汇打架;Java 组讲的是运行时安装位置,目录图标语义才对得上)。
/// 抓不住:图标画出来的大小/线宽(那是 tokens 层的事)。
#[test]
fn the_java_group_keeps_the_folder_icon_from_the_visual_review() {
assert_eq!(java::GROUP.icon, IconKind::Folder);
}
/// **绊线**:这一组还没接真控件,画的是演示行。
///
/// 抓得住:java 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
#[test]
fn the_java_group_is_still_demo_rows() {
assert!(
java::GROUP.body.is_none(),
"Java 还没接真控件:接了就改本断言"
);
}
@@ -0,0 +1,146 @@
//! 设置页的**跨 section** 守门测试(M100 按 section 下放之后,留在 mod 层的那一份)。
//!
//! ## 为什么这里只剩「跨 section」的断言
//!
//! 设置页正文接下来由四条线并行接(java / download / language+appearance / ai+about)。每条线要能
//! **只改自己的测试**就跑通,所以「某一组自己的行文案 / 图标 / body 现状」一律下放到
//! `tests/<section>_section.rs`;本文件只留**必须四组一起看**的断言——下放到任何单组文件里,
//! 都会变成「某组替其它三组说话」,四条线并行时必然互相踩。
//!
//! 判据(M100 报告里正面回答的那条):**给 java 加一条新断言,改几个文件?** —— 1 个
//! (`tests/java_section.rs`)。本文件不动。
//!
//! ## 站位
//!
//! 本文件是 `pages::settings::tests`:`super` = `pages::settings`,`super::super` = `pages`。
//!
//! ## 命名
//!
//! 子模块带 `_section` 后缀,是为了不和 `use super::*` 带进来的分组模块(`settings::java` 等)
//! 撞名——撞名时显式 `mod` 会静默盖掉 glob,读代码的人会以为 `java` 指的是分组的那个。
mod appearance_section;
mod download_section;
mod general_section;
mod java_section;
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
use super::*;
use crate::pages::appearance::{APPEARANCE_GROUP, GROUP_LABEL};
// --------------------------------------------------------------- 版面(不属于任何一组)
/// 守的是**版面契约**:分组导航走 drawer 240(D33),不是 rail 的 88。
///
/// 不能下放到分组:它不是「某一组」的属性,而是整个 supporting-pane 的几何。
#[test]
fn side_nav_reuses_the_drawer_width() {
assert_eq!(SIDE_NAV_WIDTH, 240.0);
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
assert_eq!(metrics.secondary, Some(240.0));
assert_ne!(metrics.secondary, Some(layout::rail_width()));
}
/// 守的是**行宽上限**(逐行 67 字符 ≤72)在所有宽度档下都成立。
///
/// 不能下放:它是四个分组共用的正文列,任何一组把列撑宽都会连带别人。
#[test]
fn form_column_never_exceeds_the_measure_cap() {
for width in [720.0, 840.0, 1180.0, 1920.0] {
let metrics = frame_metrics(PaneKit::Supporting, width);
assert!(
metrics.column <= measure_cap_px() + f32::EPSILON,
"{width} 宽下表单列 {:?} 超过行宽上限",
metrics.column
);
assert!(metrics.column >= layout::measure_floor_px());
}
}
/// 守的是**窄窗退化**:<720 时侧列消失、分组改为按组堆叠。
///
/// 不能下放:退化是整页行为,四组一起变(单组文件里没法验证「侧列没了」)。
#[test]
fn narrow_window_stacks_the_groups_instead_of_the_side_nav() {
let narrow = frame_metrics(PaneKit::Supporting, 700.0);
assert_eq!(narrow.layout, PageLayout::SingleColumn);
assert!(narrow.secondary.is_none(), "窄窗不许留侧列");
}
// --------------------------------------------------------------- 注册表(四组一起看)
/// 守的是**注册表的结构不变量**:每组都有名字、图标、演示行,且演示下标在范围内。
///
/// 下放不了:它遍历的是 `GROUPS`(四组拼出来的数组),单组文件拿不到其它三组,
/// 而「新增一组忘了填 rows」正是这条要抓的。
#[test]
fn every_group_has_a_name_icon_and_rows() {
assert!(GROUPS.len() >= 3);
assert!(SELECTED < GROUPS.len());
for group in &GROUPS {
assert!(!group.name.is_empty());
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name);
assert!(!group.rows.is_empty(), "{} 没有设置行", group.name);
for (label, value) in group.rows {
assert!(!label.is_empty() && !value.is_empty());
}
}
}
/// M93:设置页「外观」分组的下标与 `pages::appearance` 声明的常量必须一致。
///
/// 抓得住:谁把 `GROUPS` 的顺序调了却忘了改 `APPEARANCE_GROUP`(那样真控件会画到别的分组上,
/// 而支持列显示的分组名也对不上)。
/// 抓不住:控件本身画得对不对(那由截图与 app 侧测试管)。
///
/// 下放不了:它同时读 `GROUPS`(注册表)与 `pages::appearance` 的常量,横跨两处;
/// 放进 `appearance_section.rs` 会让「外观那条线」替注册表顺序作证。
#[test]
fn the_appearance_group_is_where_the_module_says_it_is() {
assert_eq!(GROUPS[APPEARANCE_GROUP].name, GROUP_LABEL);
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}
/// M99:拆分后**左列顺序**必须还是拆分前那四项(顺序变了就是观感变了,不是「纯搬移」)。
///
/// 抓得住:① 注册表顺序被调换(`GROUPS` 是四条线各自文件拼出来的,最容易在合并时错位);
/// ② 某组被误删/误加(长度不等)。
/// 抓不住:组内那些行还在不在、译文对不对(各组自己的文件与 `catalog` 金表各管一段)。
///
/// 下放不了:它是一句「四组顺序 = 这四个名字」的断言,任何单组文件都只能看到自己那一个名字。
#[test]
fn the_split_kept_the_left_column_order() {
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
}
/// M99:「外观」是**当前唯一**接了真控件的那一组。
///
/// 抓得住:拆分时把 `body: Some(…)` 搬丢 / 搬到了别的组(那会让真控件消失,退回演示行——
/// 而截图对这种「只在有外观状态时出现」的差别不一定抓得住,见报告「没验的部分」);
/// 也抓「别组偷偷接了 body 却没人知道」。
/// 抓不住:`body` 指向的那个渲染器画得对不对(那是 `pages::appearance` 的事)。
///
/// **为什么不能下放**(任务书点名要求写清):这条断言的全部内容是**四组的 body 现状对比**
/// ——「只有第 2 组是 Some,其余三组都是 None」。放进 `appearance_section.rs` 它就得替 java /
/// download / general 三组作证(那三条线一接真控件,本文件以外的地方无缘无故变红,且责任方
/// 不是改代码的人);拆成四份「我这组是 None」又会丢掉「唯一」这个要点。
/// 它是**绊线**:哪条线接上真控件,就必须来本文件手工改这一条(T1:绊线保持字面量,
/// 动态取活值等于把绊线剪了)。
#[test]
fn only_the_appearance_group_carries_a_real_control_body() {
assert!(
GROUPS[APPEARANCE_GROUP].body.is_some(),
"外观分组必须是接了真控件的那一组"
);
for (index, group) in GROUPS.iter().enumerate() {
if index == APPEARANCE_GROUP {
continue;
}
assert!(
group.body.is_none(),
"{} 现在应该还是演示行(真控件由它自己那条 section 线接)",
group.name
);
}
}