Files
Unlucky-Launcher/crates/app-iced/src/app/update/settings/tests.rs
T
AskaEth c166944c8e docs(app-iced): M101 r3 —— 补「页外值 / 首帧取数」共享点,守门收窄,文档口径统一
r2 复审 P1:报告与代码文档的「仍然共享」清单只写了 3 项,漏了「读页外真值 / 首帧取数」这半边。
本轮按复审给的第一条路(文档/契约层面诚实交代,不动结构):

- `settings/mod.rs` 的「仍然共享」扩到 6 条,新增:
  4. 读页外真值 —— `States` 只有 appearance + groups,General 要显示 `Model.lang` 就得改
     `settings/state.rs` + `pages/mod.rs`(2 个共享文件)或在 `app/state.rs` 用真值初始化
     `Groups.language_appearance`(1 个);给出建议形状:一次性给 `States` 加 `model: &'a Model`。
  5. 首帧取数 / 异步发起 —— `Body` 返回不了 `Task`,组自己发不了首帧探测;两条路(`Boot` 分支派发
     1 个共享文件;或把 `Body` 返回类型加一条 `Task` 旁路,1 个文件一次性)与「不改的代价」都写明。
  6. 隐性代价:`Groups: Sync + Default`(`demo_groups` 的 `static LazyLock` 撑 harness),
     非 `Sync` 字段会让编译红在共享的 `settings/state.rs`;给了改法。
- `general.rs` 文档改成正确指引(消息加在本线的 `language_appearance.rs`,落点
  `app/update/settings/language_appearance.rs`,**不要**改 `pages/mod.rs` / `pages/message.rs` /
  `app/update/page_messages.rs`),并指向上面第 4、5 条。
- `tests/mod.rs` 的「接真控件改 3 个文件」订正为 2 个,并删掉对**已被删除**的
  `only_the_appearance_group_carries_a_real_control_body` 的点名;`appearance_section.rs` 的
  模块文档与断言消息同步订正(`body = Some(appearance::body)`)。
- 守门 `the_group_dispatch_has_no_catch_all_arm` 判据**结构化收窄**:用大括号深度计数只看
  `match message {` 本层(深度 1)的臂,嵌套 match 整段跳过、`|` 折行 or-pattern 续行放行;
  只有「本层里含 `=>` 且不以该组变体路径开头」才算兜底。新增两条守门自测:
  `the_guard_ignores_nested_matches_and_folded_or_patterns`(复审 P2-3 的假红探针)与
  `the_guard_still_catches_both_catch_all_shapes`(`_ =>` 与 `other =>` 仍要红);
  误伤类别如实写进测试 doc 的「抓不住什么」。
- P3:`java::Message::Ping` 的文档改成指向真实存在的绊线(`tests/java_section.rs` 的
  `the_java_group_is_still_demo_rows`);`demo_groups()` 文档写明 `Sync + Default` 的隐性要求。
2026-11-03 22:48:05 +08:00

266 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 设置页分组消息分发的**守门测试**(M101 `SETTINGSMSG`)。
//!
//! 判据与 `app/update/page_messages.rs` 里那条 `the_page_match_has_no_wildcard_fallback` 同源:
//! 兜底会把「加了变体忘了接线」从**编译错误**降级成「点了没反应」。两级 dispatch 各守一条:
//! 那一层守 `pages::Message`,本文件守 `GroupMessage` 与四个 handler。
//!
//! ## 判据(r2 复审 P2-3 之后收窄过一次)
//!
//! 上一版的判据是「除已知前缀外,任何含 `=>` 的行都算兜底」——**过宽**:往 handler 里加一个
//! **没有兜底臂**的嵌套 match(`match true { true => …, false => … }`),或者 rustfmt 把长
//! or-pattern 折行成 `| X =>`,都会让它假红。四条填页线一写真实逻辑就撞,守门反而推高成本。
//!
//! 现在的判据是**结构化的**:只找 `match message {` 这一层(用大括号深度计数把嵌套 match 排除
//! 在外),**只看深度 1 的臂**——
//!
//! - `app/update/settings.rs` 的 `on_settings`:恰好 **4 条**臂,每条以 `pages::settings::GroupMessage::`
//! (或短写 `GroupMessage::`)开头;
//! - 四个 handler:每条臂以该组自己的 `pages::settings::<线>::Message::`(或短写 `Message::`)开头,
//! 还没有臂的(空枚举 `match message {}`)一条臂都没有;
//! - 以 `|` 开头的折行 or-pattern 续行放行(它不可能是兜底)。
//!
//! ## 抓得住什么 / 抓不住什么
//!
//! 抓得住:
//! 1. `on_settings` 里加 `_ =>` 或 binding 臂 `other =>`(`GroupMessage` 不再穷尽);
//! 2. handler 把 `match message {}` 改成 `_ => Task::none()` / `other => { … }`;
//! 3. `on_settings` 的臂数不是 4(删了一条、或把两条合并);
//! 4. handler 的臂写成别的组的变体路径(前缀不对)。
//!
//! 抓不住(老实列出来):
//! 1. **嵌套 match 里的兜底**——深度 ≥2 的臂不看。这是刻意的:嵌套 match 的穷尽性由它自己的类型
//! 保证,跟 `GroupMessage` 漏接线无关;代价是没人守「嵌套 match 自己写了个 `_ =>`」;
//! 2. **跨行 pattern**(除 `|` 续行)——把 struct 字段拆成多行、`=>` 落在第 3 行那种写法会被
//! 当成「前缀不对」而误判兜底。当前代码风格不出现;真出现时放宽规则,别把守门删掉;
//! 3. **字符串/字符字面量里的 `{` `}`** 会扰乱深度计数(本文件与四个 handler 都没有这种写法);
//! 4. handler 忽略消息(`let _ = message; Task::none()`)、两个 handler 身体写反或写成一样
//! ——类型对得上就编得过,只有人读能看出来;
//! 5. `match message` 被改名或换变量名时找不到匹配,臂数为 0 —— 这时
//! `the_group_dispatch_has_exactly_four_arms` 会红(不是静默通过)。
//!
//! 站位:本文件是 `app::update::settings::tests`,`include_str!` 的路径相对本文件所在目录。
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::message::Message;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
/// `on_settings` 的臂允许的前缀。
const GROUP_ARMS: &[&str] = &["pages::settings::GroupMessage::", "GroupMessage::"];
/// 一行的净大括号增量(`{` 加、`}` 减)。
fn brace_delta(line: &str) -> i32 {
let open = line.matches('{').count() as i32;
let close = line.matches('}').count() as i32;
open - close
}
/// 扫一条 `match message { … }`,只看**本层**(深度 1)的臂。
///
/// 返回 `(本层的合法臂数, 违规行)`:违规 = 本层里含 `=>`、又不是「已知前缀开头」的行
/// (`|` 开头的折行 or-pattern 续行放行)。嵌套 match 的臂在深度 ≥2,整段跳过。
fn dispatch_arms(path: &str, source: &str, allowed: &[&str]) -> (usize, Vec<String>) {
let mut arms = 0usize;
let mut offending = Vec::new();
let mut depth = 0i32;
let mut inside = false;
for (index, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
if !inside {
if trimmed.contains("match message {") {
inside = true;
depth = 1;
}
continue;
}
if depth == 1 && trimmed.contains("=>") {
let pattern = trimmed.trim_start_matches('|').trim_start();
if allowed.iter().any(|prefix| pattern.starts_with(prefix)) {
arms += 1;
} else if !trimmed.starts_with('|') {
offending.push(format!("{path}:{}: {trimmed}", index + 1));
}
}
depth += brace_delta(trimmed);
if depth <= 0 {
break;
}
}
(arms, offending)
}
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同一副骨架。
fn headless() -> App {
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
// --------------------------------------------------------------- 守门本体
/// `on_settings` 与四个 handler 的**本层**臂里不许出现任何形式的兜底(`_ =>` 或 `other =>`)。
#[test]
fn the_group_dispatch_has_no_catch_all_arm() {
let sources: [(&str, &str, &[&str]); 5] = [
(
"app/update/settings.rs",
include_str!("../settings.rs"),
GROUP_ARMS,
),
(
"app/update/settings/java.rs",
include_str!("java.rs"),
&["pages::settings::java::Message::", "Message::"],
),
(
"app/update/settings/download.rs",
include_str!("download.rs"),
&["pages::settings::download::Message::", "Message::"],
),
(
"app/update/settings/language_appearance.rs",
include_str!("language_appearance.rs"),
&[
"pages::settings::language_appearance::Message::",
"Message::",
],
),
(
"app/update/settings/ai_about.rs",
include_str!("ai_about.rs"),
&["pages::settings::ai_about::Message::", "Message::"],
),
];
let mut offending = Vec::new();
for (path, source, allowed) in sources {
let (_, bad) = dispatch_arms(path, source, allowed);
offending.extend(bad);
}
assert!(
offending.is_empty(),
"设置页分组消息的 dispatch 本层出现了兜底臂({offending:?}):\
任何 catch-all / binding 臂都会取消 `GroupMessage` 的穷尽性,\
新变体漏接线时编译器不再报 E0004"
);
}
/// `on_settings` 必须**恰好四条**臂,一条一条显式写全(不是「至少」)。
///
/// 抓得住:删掉一条臂、把两条合并、或者把 `match message` 改名(改名后扫不到臂 ⇒ 0 ≠ 4)。
/// 抓不住:臂的顺序(顺序不影响语义)、每条臂接到了哪个 handler(类型对得上就编得过)。
#[test]
fn the_group_dispatch_has_exactly_four_arms() {
let (arms, _) = dispatch_arms(
"app/update/settings.rs",
include_str!("../settings.rs"),
GROUP_ARMS,
);
assert_eq!(
arms, 4,
"`on_settings` 应该恰好四条臂(java / download / language_appearance / ai_about),实际 {arms} 条"
);
}
/// **守门自身不误伤**:嵌套 match 与折行的 or-pattern 都不算兜底(r2 复审 P2-3 的探针)。
///
/// 这条是上一版判据假红的回归测试:复审往 handler 里加 `match true { true => …, false => … }`
/// 就让它 FAILED;现在这类写法必须放行。
#[test]
fn the_guard_ignores_nested_matches_and_folded_or_patterns() {
let synthetic = "\
impl App {
fn on_settings_java(&mut self, message: pages::settings::java::Message) -> Task<Message> {
match message {
pages::settings::java::Message::Probe => {
let nested = match true { true => 10u8, false => 11u8 };
let _ = nested;
Task::none()
}
| pages::settings::java::Message::Retry => Task::none(),
}
}
}
";
let (arms, offending) = dispatch_arms(
"synthetic",
synthetic,
&["pages::settings::java::Message::"],
);
assert!(
offending.is_empty(),
"正常代码被误判成兜底(这正是 r2 复审 P2-3 的假红):{offending:?}"
);
assert_eq!(arms, 2, "嵌套 match 里的两条臂不算本层臂");
}
/// **守门仍然有牙**:`_ =>` 与 binding 臂 `other =>` 都要被本层判成兜底。
#[test]
fn the_guard_still_catches_both_catch_all_shapes() {
for arm in [
"_ => Task::none(),",
"other => { let _ = other; Task::none() }",
] {
let synthetic = format!(
"impl App {{\n fn on_settings_java(&mut self, message: M) -> Task<Message> {{\n match message {{\n {arm}\n }}\n }}\n}}\n"
);
let (_, offending) = dispatch_arms("synthetic", &synthetic, GROUP_ARMS);
assert_eq!(
offending.len(),
1,
"`{arm}` 是兜底,必须被判红(实际 {offending:?})"
);
}
}
// --------------------------------------------------------------- 活的端到端证据
/// **活的端到端证据**:一条组消息真的从外壳那条既有通道走到该组的 handler(r1 复审要求)。
///
/// 路径:`app::Message::Page` → `on_page` → `pages::Message::Settings` → `on_settings` →
/// `on_settings_java`,最后改到 `App` 的状态。r1 之前这条链只有编译期证据(`GroupMessage` 是
/// uninhabited,运行时是死代码);现在至少有一支真的跑得起来。
///
/// 抓得住:漏斗某一环接断了(`on_page` 没转交、`on_settings` 转错组、handler 忽略消息)——那时
/// `view_dirty` 不会翻。抓不住:真实用户点击(本条没有 UI 控件,构造点只有测试)。
#[test]
fn a_group_message_reaches_its_handler_through_the_funnel() {
let mut app = headless();
app.view_dirty = false;
let _ = app.update(Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping),
)));
assert!(
app.view_dirty,
"java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了"
);
}