feat(app-iced): M101 r2 设置页状态漏斗 + pages/mod.rs 拆分 + r1 复审订正

r1 复审的 P1:消息漏斗只解决了「加一支消息」,一组要画真值仍要改 `settings/mod.rs`
(`Group.body` 与 `view` 只认 `appearance::State`)。本 commit 补齐与消息漏斗同构的状态漏斗:

- 新增 `pages/settings/state.rs`:`Groups`(一个组一个字段,字段类型在组自己文件里)+
  `States`(按值传给渲染器的只读视图:外观状态 + 组状态)+ `demo_groups()`(harness 的
  `'static` 默认状态)。
- `Group.body` 签名改成 `for<'a> fn(&'a Ui, States<'a>) -> Option<Element>`:每组自己从
  `States` 取自己要的数据,「没有可画的状态」返回 `None` 由 `form` 退回演示行。
  `appearance` 组照旧接 `pages/appearance/section.rs`(M93 路径行为不变)。
- `App.settings: pages::settings::Groups`(`app/state.rs` 字段列表末尾)+ `app/view.rs` 构造
  `States` 走新入口 `pages::page_with_settings`。加/改某一组的字段只改那一组的文件。
- 四个组各自的 `State` 落在各自文件;`java::Message` 留一支只有测试构造的 `Ping`,配
  `app/update/settings/tests.rs` 的 `a_group_message_reaches_its_handler_through_the_funnel`
  ——消息漏斗第一次有**运行时**证据。

r1 其余条目:
- P2-1 删掉 `only_the_appearance_group_carries_a_real_control_body`(被四条 per-group 断言蕴含),
  并在同处写明删掉之后「唯一性」没人守;三份 section 文档从「改 3 个文件」订正为 2 个。
- P2-2 `pub mod settings` → `pub(crate) mod settings`(四个消息口模块 `pub(crate)`),
  并订正两处理由(lint 按类型自身可见性判,不是可达性)。
- P2-3 `pages/mod.rs` 397 行 → 拆出 `pages/message.rs` / `pages/screen.rs`(纯搬移,`pub use`
  保证 `pages::Message` / `Action` / `Screen` 路径不变),并删掉 M101 自己那 18 行重复文档。
- P3-1 订正共享点清单(`Key` 由宏从域文件累积生成,加 key 不改 `catalog.rs`)。
- P3-2 守门改成「除已知臂外任何含 `=>` 的行都算兜底」(`other =>` 也拦),并加「恰好四臂」断言。
- P3-3 `ai.rs` → `ai_about.rs` 单独一条 R100 改名提交,`git log --follow` 不断链。
- P3-4 `message.rs` 的「加一支消息改 1 个文件」订正为 2 个。
This commit is contained in:
AskaEth committed 2026-11-03 20:57:08 +08:00
1 parent 423ea77758
commit 029ece3f17
25 files changed
+1004 -240

No files matched your search

+7
View File
@@ -119,6 +119,12 @@ pub struct App {
pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs,
/// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。
pub(super) hwnd: Option<isize>,
/// 设置页各组自己的运行期状态(M101 r2 的**状态漏斗**)。
///
/// 所有者在 `pages/settings/state.rs` 的 `Groups`:加/改某一组的字段只改那一组自己的文件,
/// 本字段与 `app/view.rs` 都不用动(`settings::States` 只看 `&Groups`,不看任何一组的字段)。
/// 放在字段列表**末尾**:后续 mission 若也要往这里加字段,冲突面最小。
pub(super) settings: pages::settings::Groups,
}
impl App {
@@ -202,6 +208,7 @@ impl App {
appearance,
appearance_prefs,
hwnd: None,
settings: pages::settings::Groups::default(),
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
+1
View File
@@ -44,6 +44,7 @@ use super::CAPTURE_AT_FRAME;
mod download;
mod page_messages;
mod settings;
impl App {
/// 一条消息进来,改状态并返回后续任务。
@@ -38,6 +38,9 @@ impl App {
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change),
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
// (那一层没有 `_ =>`,见本文件末尾与 `update/settings/tests.rs` 的两条守门)
pages::Message::Settings(group) => return self.on_settings(group),
// M95:摘要落剪贴板(文本由页面造,这里只搬)
pages::Message::CopySummary(text) => return iced::clipboard::write(text),
// 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档
@@ -0,0 +1,55 @@
//! 设置页分组消息的**二级分发**(M101 `SETTINGSMSG`):
//! `pages::Message::Settings(GroupMessage)` → 四条填页线各自的 handler。
//!
//! ## 为什么分两级
//!
//! `app/update/page_messages.rs` 对 [`pages::Message`] 做穷尽匹配(没有 `_ =>`,有
//! `the_page_match_has_no_wildcard_fallback` 守门)。设置页的四条填页线若各自往 `pages::Message`
//! 里加变体,就都得改那个文件——四路并行必然冲突。M101 把它们收进一个投递口
//! `pages::Message::Settings(GroupMessage)`,本文件再把 `GroupMessage` 分给四条线:
//!
//! ```text
//! GroupMessage::Java(m) → settings/java.rs
//! GroupMessage::Download(m) → settings/download.rs
//! GroupMessage::LanguageAppearance(m) → settings/language_appearance.rs
//! GroupMessage::AiAbout(m) → settings/ai_about.rs
//! ```
//!
//! ## 穷尽性:抓得住什么 / 抓不住什么
//!
//! - **抓得住**:某条线给 `GroupMessage` 加了载荷变体(= 在自己文件里加了一支消息)却忘了在本
//! match 里接线 → **E0004**;四个 handler 里对空枚举的 `match message {}` 一旦有人写成
//! `_ => {}`,`the_group_dispatch_has_no_wildcard_fallback` 会红(兜底会把「漏接线」从编译
//! 错误降级成「点了没反应」,`page_messages.rs` 的同类守卫是这么写的,这里照抄)。
//! - **抓不住**:handler 忽略自己的消息(`let _ = message; Task::none()`)、两个 handler 身体写反
//! 或写成一样——类型对得上就编得过,只有人读能看出来。也因此「接错了组」的一半是编译期兜住的
//! (载荷类型不同 ⇒ 编不过),另一半(接了个空壳)不是。
//!
//! ## 加一支设置页消息要改哪
//!
//! 自己那条线的两个文件:消息口(`pages/settings/<线>.rs`)+ 落点(本目录 `<线>.rs`)。
//! 本文件的 match 只在 `GroupMessage` **加变体**时才动,而那意味着新增一条线,不是加一支消息。
use super::*;
impl App {
/// 设置页的分组消息:分给四条线各自的文件,本文件不认识任何一支具体消息。
pub(super) fn on_settings(&mut self, message: pages::settings::GroupMessage) -> Task<Message> {
match message {
pages::settings::GroupMessage::Java(message) => self.on_settings_java(message),
pages::settings::GroupMessage::Download(message) => self.on_settings_download(message),
pages::settings::GroupMessage::LanguageAppearance(message) => {
self.on_settings_language_appearance(message)
}
pages::settings::GroupMessage::AiAbout(message) => self.on_settings_ai_about(message),
}
}
}
mod ai_about;
mod download;
mod java;
mod language_appearance;
#[cfg(test)]
mod tests;
@@ -0,0 +1,17 @@
//! AI / About 分组消息的落点(M101 `SETTINGSMSG`):**只归 ai+about 线**。
//!
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`)。
//!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
use super::super::*;
impl App {
/// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_ai_about(
&mut self,
message: pages::settings::ai_about::Message,
) -> Task<Message> {
match message {}
}
}
@@ -0,0 +1,18 @@
//! Downloads 分组消息的落点(M101 `SETTINGSMSG`):**只归 download 线**。
//!
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。与下载页
//! (`pages/download*` 与 `app/update/download.rs`)无关:那是「装哪个版本」,这一组是「怎么下」。
//!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
use super::super::*;
impl App {
/// Downloads 分组的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_download(
&mut self,
message: pages::settings::download::Message,
) -> Task<Message> {
match message {}
}
}
@@ -0,0 +1,33 @@
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。
//!
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。
//!
//! 消息进来的通道有两条,都不经过共享文件:
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`;
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))`
//! (`app::Message::Page` 是既有通道,不用加新变体)。
//!
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的
//! `the_group_dispatch_has_no_catch_all_arm` 会红。
use super::super::*;
impl App {
/// Java 分组的一条消息。
pub(super) fn on_settings_java(
&mut self,
message: pages::settings::java::Message,
) -> Task<Message> {
match message {
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。
#[cfg(test)]
pages::settings::java::Message::Ping => {
self.view_dirty = true;
Task::none()
}
}
}
}
@@ -0,0 +1,19 @@
//! 语言 + 外观两组消息的落点(M101 `SETTINGSMSG`):**只归 language+appearance 线**。
//!
//! 消息口在 `pages/settings/language_appearance.rs`(`pages::settings::language_appearance::Message`)。
//! 为什么两组共用一个消息口:它俩本就是同一条线的两个分组(General 的语言 / 主题 / 启动页,
//! Appearance 的密度 / 圆角 / 动效 / 文字缩放),`GroupMessage` 只给这条线留一个变体。
//!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
use super::super::*;
impl App {
/// 语言 / 外观线的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_language_appearance(
&mut self,
message: pages::settings::language_appearance::Message,
) -> Task<Message> {
match message {}
}
}
@@ -0,0 +1,149 @@
//! 设置页分组消息分发的**守门测试**(M101 `SETTINGSMSG`)。
//!
//! 判据与 `app/update/page_messages.rs` 里那条 `the_page_match_has_no_wildcard_fallback` 同源:
//! 兜底会把「加了变体忘了接线」从**编译错误**降级成「点了没反应」。两级 dispatch 各守一条:
//! 那一层守 `pages::Message`,本文件守 `GroupMessage` 与四个 handler。
//!
//! r1 复审的 P3-2 指出上一版有洞:只拦 `_ =>` 的话,`other => { … }` 这种 **binding 兜底**
//! 照样取消穷尽性、照样静默。本版判据反过来写——**除已知臂外,任何含 `=>` 的行都算兜底**:
//! `settings.rs` 里每条臂都必须以 `pages::settings::GroupMessage::` 开头且恰好 4 条;handler 里
//! 每条臂都必须以该组自己的变体路径开头;三个还是空枚举的 handler 一行 `=>` 都不许有。
//!
//! 站位:本文件是 `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;
/// 不碰磁盘的 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(),
)
}
/// 收集「不该存在的臂」:非注释行里出现 `=>`,但不以该文件允许的前缀开头。
fn offending_arms(path: &str, source: &str, allowed: Option<&str>) -> Vec<String> {
source
.lines()
.enumerate()
.filter_map(|(index, line)| {
let trimmed = line.trim();
if trimmed.starts_with("//") || !trimmed.contains("=>") {
return None;
}
match allowed {
Some(prefix) if trimmed.starts_with(prefix) => None,
_ => Some(format!("{path}:{}: {trimmed}", index + 1)),
}
})
.collect()
}
/// `on_settings` 与四个 handler 里都**不许**出现任何形式的兜底臂(`_ =>` 或 `other =>`)。
///
/// 抓得住:① 给 `on_settings` 加 `_ =>` 或 `other =>`(`GroupMessage` 不再穷尽);
/// ② 四个 handler 把 `match message {}` 改成 `_ => Task::none()` / `other => { … }`(同样取消穷尽性);
/// ③ 给 settings.rs 多接一条不认识的臂(前缀不对)。
/// 抓不住:handler 忽略消息(`let _ = message; Task::none()`)、两个 handler 身体写反或写成一样
/// —— 类型对得上就编得过;也抓不住「新变体接了个空壳」。那两条只有人读能看得出来。
#[test]
fn the_group_dispatch_has_no_catch_all_arm() {
let sources: [(&str, &str, Option<&str>); 5] = [
(
"app/update/settings.rs",
include_str!("../settings.rs"),
Some("pages::settings::GroupMessage::"),
),
(
"app/update/settings/java.rs",
include_str!("java.rs"),
Some("pages::settings::java::Message::"),
),
(
"app/update/settings/download.rs",
include_str!("download.rs"),
None,
),
(
"app/update/settings/language_appearance.rs",
include_str!("language_appearance.rs"),
None,
),
(
"app/update/settings/ai_about.rs",
include_str!("ai_about.rs"),
None,
),
];
let mut offending = Vec::new();
for (path, source, allowed) in sources {
offending.extend(offending_arms(path, source, allowed));
}
assert!(
offending.is_empty(),
"设置页分组消息的 dispatch 里出现了兜底臂({offending:?}):\
任何 catch-all / binding 臂都会取消 `GroupMessage` 的穷尽性,\
新变体漏接线时编译器不再报 E0004"
);
}
/// `on_settings` 必须**恰好四条**臂,一条一条显式写全(不是「至少」)。
///
/// 抓得住:有人删掉一条臂、或者把两条合到一起(`A(..) | B(..) =>` 这种也过不了上面那条前缀判据,
/// 这里是第二条独立判据)。
/// 抓不住:臂的顺序(顺序不影响语义)。
#[test]
fn the_group_dispatch_has_exactly_four_arms() {
let source = include_str!("../settings.rs");
let arms = source
.lines()
.filter(|line| line.trim().starts_with("pages::settings::GroupMessage::"))
.count();
assert_eq!(
arms, 4,
"`on_settings` 应该恰好四条臂(java / download / language_appearance / ai_about),实际 {arms} 条"
);
}
/// **活的端到端证据**:一条组消息真的从外壳那条既有通道走到该组的 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`:消息漏斗在某一环断了"
);
}
+13 -3
View File
@@ -49,7 +49,8 @@ impl App {
// `Close` 五个消息只在外壳里构造 ⇒ 产品页「拖不动、缩不了、点不到关闭」。
//
// M93:三样东西从外观状态进来——①背景层(垫在最底下)②面板 α(外壳层的底色)③设置页要读
// 的那份状态本身(`page_with_appearance`)。
// 的那份状态本身。M101 r2 起 ③ 走 `page_with_settings`:外观状态 + 各组自己的状态
// (`App.settings`)一起递进去,四条填页线加字段不用动这一行。
match pages::Screen::of(self.state.route()) {
Some(_) => {
let background =
@@ -61,8 +62,17 @@ impl App {
nav_item_of(self.state.route()),
APP_TITLE,
background,
pages::page_with_appearance(&self.ui, &self.view, &self.appearance)
.map(Message::Page),
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
// `settings` 的所有者是 `App`;这里只借一帧,加/改某一组的字段不用动这行。
pages::page_with_settings(
&self.ui,
&self.view,
pages::settings::States {
appearance: Some(&self.appearance),
groups: &self.settings,
},
)
.map(Message::Page),
self.options.force_state,
)
}
+94
View File
@@ -0,0 +1,94 @@
//! 页面消息与动作表(M101 r2 从 `pages/mod.rs` **原样搬出**)。
//!
//! 为什么拆:合并 M100/M101 之后 `pages/mod.rs` 是 397 行,只剩 3 行余量,下一条线往枚举里加
//! 一个变体(含文档)就顶破 400 硬线。拆法与 M99 的 `pages/nav.rs` 同一种范式:**纯搬移**、
//! 一行不改、路径靠 `pages/mod.rs` 的 `pub use` 再导出保持不变(`pages::Message` / `pages::Action`)。
//!
//! `pages::Message` 是页面层的自有消息类型(**不** import `crate::app::Message`,组件契约 §2 的
//! 同一条纪律);app 层只做一次 `.map`。
use unlucky_presenter::router::Route;
use super::appearance;
use super::download::Category;
use super::settings;
/// 页面消息。
///
/// **故意不用 `crate::app::Message`**(组件库契约 §2 的同一条理由):页面不绑 app 的状态层,
/// 接线时由 app 层把这里的三类意图翻成 presenter 的 `AppCommand` / `Route`。
#[derive(Debug, Clone)]
pub enum Message {
/// 骨架期动作(三页的占位按钮 + 实例页上还没接后端的两个页头动作)。
Action(Action),
/// 路由跳转:导航轨、列表行、窄窗「返回」都走这一条(栈语义归 `presenter::router`)。
Navigate(Route),
/// 启动某实例(app 层翻成 `AppCommand::LaunchInstance`)。
Launch(String),
/// 停止某实例(app 层翻成 `AppCommand::StopInstance`)。
Stop(String),
/// 外观项改动(设置页「外观」分组 → `App` 的状态与落盘)。
///
/// 载荷是**用户请求值**(未钳位):钳位与 CPU 档降级在 `appearance::alpha` 里做,
/// 页面只表达意图(与组件库「不持有业务状态」同一条纪律)。
Appearance(appearance::Change),
/// 选中下载页的一个版本(M94):**页面内选中**,app 层只改视图模型,不 dispatch。
SelectVersion(String),
/// 安装指定版本(M94;app 层翻成 `AppCommand::InstallVersion`)。
InstallVersion(String),
/// 取消正在跑的安装(M94;`AppCommand::CancelInstall`)。
CancelInstall,
/// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。
SetCategory(Category),
/// 把现成文本塞进剪贴板(**M95**:诊断卡片的一行摘要,文本由页面造,app 只搬)。
CopySummary(String),
/// 设置页的一组消息(**M101 `SETTINGSMSG`**):设置页的**唯一**投递口。
///
/// 四条填页线(java / download / language+appearance / ai+about)各自在自己的文件里定义
/// 消息枚举,本枚举**不再**为设置页增加第二个变体——「加一支设置页消息」从此不碰本文件,
/// 也不碰 `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。加变体与落点见
/// `pages/settings/message.rs`(`app/update/settings.rs` 对它做穷尽分发,漏接线报 E0004)。
Settings(settings::GroupMessage),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// 新建实例(**还没有后端**,见模块文档偏离 #8)。
NewInstance,
/// 导入(同上)。
Import,
/// 返回实例列表(窄窗整页切换)。**已由 `Message::Navigate(Route::Instances)` 取代**,
/// 这里保留枚举项是为了别把「返回」这件意图从动作表里抹掉(外壳回退路径仍可能用到)。
Back,
/// 启动(占位动作表里的「启动」;真启动走 `Message::Launch`)。
Play,
/// 更多。
More,
/// 复制实例。
Duplicate,
/// 打开实例目录。
OpenFolder,
/// 删除实例。
Delete,
/// 重新探测下载源。
RefreshSources,
/// 浏览版本列表。
BrowseVersions,
/// 导入整合包。
ImportModpack,
/// 检查下载源。
CheckSources,
/// 打开日志目录。
OpenLogs,
/// 复制诊断信息。
CopyDiagnostics,
/// 清空日志。
ClearLogs,
/// 重置当前设置分组。
ResetSection,
/// 保存设置。
SaveSettings,
/// 新增逐实例覆盖。
AddOverride,
}
+59 -152
View File
@@ -43,6 +43,7 @@
//! | [`model`] | **视图模型**:presenter 快照 → 页面要画的东西(页面层不认识 presenter 类型) |
//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线 |
//! | [`nav`] | 导航轨的 `NAV` 表 + 部件版 `rail()`(harness / 单测;产品走 `shell/nav.rs`) |
//! | [`message`] / [`screen`] | `pages::Message` / `Action` / `Screen`(M101 r2 从本文件原样搬出) |
//! | `instances` | 实例页(list-detail)——**M90 唯一打通到真组件 + 真数据真命令的页** |
//! | `settings` | 设置页(supporting-pane,占位视觉):分组注册表 + 每个分组一个文件 |
//! | `download` / `logs` | 下载 / 日志页(单列,占位视觉) |
@@ -55,6 +56,14 @@
//! 于是「页面里点一行」= 「压一层栈」,语义与 presenter 的 `Router` 天然一致,
//! 中间不再有一层「页面自定义的选中项」需要同步。
//!
//! ### 设置页只有一个投递口(M101 `SETTINGSMSG`)
//!
//! 设置页的四条填页线各自在自己的文件里定义消息与状态,本文件的 [`Message`] 只留一个
//! [`Message::Settings`](详见 `pages/settings/mod.rs` 的「消息 / 状态」两节);`Message` 与
//! `Screen` 本身也在 M101 r2 搬到 [`message`] / [`screen`](`pages/mod.rs` 合并后 397 行,只剩
//! 3 行余量),这里的 `pub use` 保证了 `pages::Message` / `pages::Action` / `pages::Screen`
//! 三条路径一字未变。
//!
//! ## 偏离登记(D30 要求逐条写清;M84 六条 + M90 三条)
//!
//! | # | 偏离 | 为什么 | 什么时候收口 |
@@ -103,179 +112,50 @@ pub mod model;
mod download;
mod instances;
mod logs;
mod message;
mod nav;
mod settings;
mod screen;
// M101:设置页的两条漏斗(消息 / 状态)都从这个模块树里出——app 层要构造
// `settings::GroupMessage`、借用 `settings::Groups`,所以最小暴露面是 `pub(crate)`。
// **不是** `pub`:`private_interfaces` 按**类型自身的声明可见性**判(四个 `Message` 枚举与
// `GroupMessage` 声明成 `pub` 就够),把整个设置页模块树暴露成 crate 公开 API 没有回报。
pub(crate) mod settings;
#[cfg(test)]
mod tests;
pub use download::{Category, Download};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message};
pub use model::{Detail, Instances, Meta, Model, Row};
pub use screen::Screen;
use iced::widget::{column, container, row, space};
use iced::{Element, Length};
use unlucky_design::elevation::MaterialLayer;
use unlucky_presenter::router::{NavSection, Route};
use crate::theme::Ui;
use frame::filled;
/// 页面消息。
///
/// **故意不用 `crate::app::Message`**(组件库契约 §2 的同一条理由):页面不绑 app 的状态层,
/// 接线时由 app 层把这里的三类意图翻成 presenter 的 `AppCommand` / `Route`。
#[derive(Debug, Clone)]
pub enum Message {
/// 骨架期动作(三页的占位按钮 + 实例页上还没接后端的两个页头动作)。
Action(Action),
/// 路由跳转:导航轨、列表行、窄窗「返回」都走这一条(栈语义归 `presenter::router`)。
Navigate(Route),
/// 启动某实例(app 层翻成 `AppCommand::LaunchInstance`)。
Launch(String),
/// 停止某实例(app 层翻成 `AppCommand::StopInstance`)。
Stop(String),
/// 外观项改动(设置页「外观」分组 → `App` 的状态与落盘)。
///
/// 载荷是**用户请求值**(未钳位):钳位与 CPU 档降级在 `appearance::alpha` 里做,
/// 页面只表达意图(与组件库「不持有业务状态」同一条纪律)。
Appearance(appearance::Change),
/// 选中下载页的一个版本(M94):**页面内选中**,app 层只改视图模型,不 dispatch。
SelectVersion(String),
/// 安装指定版本(M94;app 层翻成 `AppCommand::InstallVersion`)。
InstallVersion(String),
/// 取消正在跑的安装(M94;`AppCommand::CancelInstall`)。
CancelInstall,
/// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。
SetCategory(Category),
/// 把现成文本塞进剪贴板(**M95**:诊断卡片的一行摘要,文本由页面造,app 只搬)。
CopySummary(String),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// 新建实例(**还没有后端**,见模块文档偏离 #8)。
NewInstance,
/// 导入(同上)。
Import,
/// 返回实例列表(窄窗整页切换)。**已由 `Message::Navigate(Route::Instances)` 取代**,
/// 这里保留枚举项是为了别把「返回」这件意图从动作表里抹掉(外壳回退路径仍可能用到)。
Back,
/// 启动(占位动作表里的「启动」;真启动走 `Message::Launch`)。
Play,
/// 更多。
More,
/// 复制实例。
Duplicate,
/// 打开实例目录。
OpenFolder,
/// 删除实例。
Delete,
/// 重新探测下载源。
RefreshSources,
/// 浏览版本列表。
BrowseVersions,
/// 导入整合包。
ImportModpack,
/// 检查下载源。
CheckSources,
/// 打开日志目录。
OpenLogs,
/// 复制诊断信息。
CopyDiagnostics,
/// 清空日志。
ClearLogs,
/// 重置当前设置分组。
ResetSection,
/// 保存设置。
SaveSettings,
/// 新增逐实例覆盖。
AddOverride,
}
/// 四个页面。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
/// 实例(list-detail)。
Instances,
/// 下载(单列)。
Download,
/// 设置(supporting-pane)。
Settings,
/// 日志(单列,两行页头)。
Logs,
}
impl Screen {
/// 全部页面(截图矩阵按这个顺序铺)。
pub const ALL: [Screen; 4] = [
Screen::Instances,
Screen::Download,
Screen::Settings,
Screen::Logs,
];
/// 稳定 slug(命令行参数与文件名用)。
pub const fn slug(self) -> &'static str {
match self {
Screen::Instances => "instances",
Screen::Download => "download",
Screen::Settings => "settings",
Screen::Logs => "logs",
}
}
/// 反向解析(`--screen=instances`)。
pub fn from_slug(slug: &str) -> Option<Self> {
Self::ALL.into_iter().find(|screen| screen.slug() == slug)
}
/// 页面声明的**最宽**版面;实际用哪个由 [`layout::resolve`] 按窗口宽度决定。
pub const fn kit(self) -> PaneKit {
match self {
Screen::Instances => PaneKit::ListDetail,
Screen::Settings => PaneKit::Supporting,
Screen::Download | Screen::Logs => PaneKit::Single,
}
}
/// 路由落在哪一页上(`None` = 这一页还没有实现,由 app 层决定回退)。
///
/// `Route::Detail(_)` 仍属实例页(窄窗时是「整页切换」的那一屏)。
pub fn of(route: &Route) -> Option<Self> {
Some(match route {
Route::Instances | Route::Detail(_) => Screen::Instances,
Route::Download => Screen::Download,
Route::Logs => Screen::Logs,
Route::Settings => Screen::Settings,
Route::Agent => return None,
})
}
/// 该页高亮的导航项(与 `Route::nav` 同源,但这里是「按页问」的口径)。
pub const fn nav(self) -> NavSection {
match self {
Screen::Instances => NavSection::Instances,
Screen::Download => NavSection::Download,
Screen::Logs => NavSection::Logs,
Screen::Settings => NavSection::Settings,
}
}
}
/// **单页内容**(不含外壳):交给 `shell::view_with_content` 塞进真标题栏与导航轨之间。
///
/// 产品路径(`app/view.rs`)走 [`page_with_appearance`]:外壳由 `shell` 画(拖动/缩放/三个
/// 产品路径(`app/view.rs`)走 [`page_with_settings`]:外壳由 `shell` 画(拖动/缩放/三个
/// caption 键、rail 88),这里只画「页头 + 内容列」。列宽仍按**整个窗口宽**算
/// (`layout::frame_metrics` 内部会扣掉 rail 88 与页边距),所以外壳换了不影响版面判据。
///
/// 视图模型由**调用方持有**(`App` 在状态变化时重建一次 `Model`),这里只借用:
/// `view(&self)` 不能返回一棵借用局部变量的树。
pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
render(ui, model, None)
render(
ui,
model,
settings::States {
appearance: None,
groups: settings::demo_groups(),
},
)
}
/// 同 [`page`],但带上**外观状态**(设置页的「外观」分组要读当前值与诊断)。
@@ -283,25 +163,44 @@ pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
/// 为什么另开一个入口而不是给 `Model` 加字段:`pages/model.rs` 是页面与 presenter 之间的展示
/// 契约(`Model` 只装「要画什么」),外观是**运行时状态**(滑块请求值、已解码的背景图、诊断),
/// 塞进去会让演示数据(`pages::demo`)也得伪造一份外观状态。
///
/// 这条入口拿的是**演示组状态**([`settings::demo_groups`]):页面层没有真组状态可用,
/// 产品路径走 [`page_with_settings`]。
pub fn page_with_appearance<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: &'a appearance::State,
) -> Element<'a, Message> {
render(ui, model, Some(appearance))
render(
ui,
model,
settings::States {
appearance: Some(appearance),
groups: settings::demo_groups(),
},
)
}
fn render<'a>(
/// 产品路径:带上**设置页那份只读状态**(外观运行期状态 + 各组自己的状态)。
///
/// 为什么要单独一个入口:组状态的所有者是 `App`(`app/state.rs` 的 `settings` 字段),页面层
/// 只借用一帧。加/改某一组的字段不改变这条签名(`States` 里只有 `&Groups`),所以四条填页线
/// 都不必来这里动一行。
pub fn page_with_settings<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: Option<&'a appearance::State>,
states: settings::States<'a>,
) -> Element<'a, Message> {
render(ui, model, states)
}
fn render<'a>(ui: &'a Ui, model: &'a Model, states: settings::States<'a>) -> Element<'a, Message> {
let metrics = layout::frame_metrics(model.screen.kit(), model.window_width);
match model.screen {
Screen::Instances => instances::view(ui, metrics, model),
Screen::Download => download::view(ui, metrics, &model.download, unlucky_i18n::Lang::En),
Screen::Settings => settings::view(ui, metrics, appearance),
Screen::Settings => settings::view(ui, metrics, states),
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
}
}
@@ -326,7 +225,15 @@ pub fn app_with_appearance<'a>(
model: &'a Model,
appearance: Option<&'a appearance::State>,
) -> Element<'a, Message> {
let body = render(ui, model, appearance);
// harness 路径没有真组状态:用演示状态([`settings::demo_groups`]),外观状态照传。
let body = render(
ui,
model,
settings::States {
appearance,
groups: settings::demo_groups(),
},
);
let shell = row![nav::rail(ui, model.active), body]
.width(Length::Fill)
+78
View File
@@ -0,0 +1,78 @@
//! 页面枚举 `Screen` 与它的三张对照表(版面 / 导航项 / 路由;M101 r2 从 `pages/mod.rs` **原样搬出**)。
//!
//! 为什么拆:合并 M100/M101 之后 `pages/mod.rs` 是 397 行,只剩 3 行余量。纯搬移,路径靠
//! `pages/mod.rs` 的 `pub use screen::Screen;` 保持不变(`pages::Screen`)。
use unlucky_presenter::router::{NavSection, Route};
use super::layout::PaneKit;
/// 四个页面。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
/// 实例(list-detail)。
Instances,
/// 下载(单列)。
Download,
/// 设置(supporting-pane)。
Settings,
/// 日志(单列,两行页头)。
Logs,
}
impl Screen {
/// 全部页面(截图矩阵按这个顺序铺)。
pub const ALL: [Screen; 4] = [
Screen::Instances,
Screen::Download,
Screen::Settings,
Screen::Logs,
];
/// 稳定 slug(命令行参数与文件名用)。
pub const fn slug(self) -> &'static str {
match self {
Screen::Instances => "instances",
Screen::Download => "download",
Screen::Settings => "settings",
Screen::Logs => "logs",
}
}
/// 反向解析(`--screen=instances`)。
pub fn from_slug(slug: &str) -> Option<Self> {
Self::ALL.into_iter().find(|screen| screen.slug() == slug)
}
/// 页面声明的**最宽**版面;实际用哪个由 [`layout::resolve`](super::layout) 按窗口宽度决定。
pub const fn kit(self) -> PaneKit {
match self {
Screen::Instances => PaneKit::ListDetail,
Screen::Settings => PaneKit::Supporting,
Screen::Download | Screen::Logs => PaneKit::Single,
}
}
/// 路由落在哪一页上(`None` = 这一页还没有实现,由 app 层决定回退)。
///
/// `Route::Detail(_)` 仍属实例页(窄窗时是「整页切换」的那一屏)。
pub fn of(route: &Route) -> Option<Self> {
Some(match route {
Route::Instances | Route::Detail(_) => Screen::Instances,
Route::Download => Screen::Download,
Route::Logs => Screen::Logs,
Route::Settings => Screen::Settings,
Route::Agent => return None,
})
}
/// 该页高亮的导航项(与 `Route::nav` 同源,但这里是「按页问」的口径)。
pub const fn nav(self) -> NavSection {
match self {
Screen::Instances => NavSection::Instances,
Screen::Download => NavSection::Download,
Screen::Logs => NavSection::Logs,
Screen::Settings => NavSection::Settings,
}
}
}
+27 -1
View File
@@ -7,7 +7,33 @@
//! ## 接线步骤(ai+about 线照做)
//!
//! 1. 在本文件写 `pub(super) const GROUP: Group = Group { … }`,形状抄 `java.rs`;
//! 2. 到 `settings/mod.rs` 的 `GROUPS` 里加一行 `ai::GROUP,`,并把数组长度 `4` 改成 `5`;
//! 2. 到 `settings/mod.rs` 的 `GROUPS` 里加一行 `ai_about::GROUP,`,并把数组长度 `4` 改成 `5`;
//! 3. 文案 key 写进 `crates/i18n/src/catalog/settings/ai.rs`(那边已经接进装配链,同样只改一个文件)。
//!
//! 界面上「多了一组」这件事本身是行为变化,所以**只在真有那一组内容时才做**,不要为占位先加。
//!
//! ## M101 起本文件还装本线的状态与消息口
//!
//! - 状态:[`State`](`Groups.ai_about` 的载荷,加字段只改本文件);
//! - 消息:[`Message`](落点 `app/update/settings/ai_about.rs`)。
//!
//! 上第 5 组时**额外**要动的共享文件(按上面的步骤 2 + 测试装配):
//! - `settings/mod.rs` 的 `GROUPS`(加一行,长度 +1);
//! - `settings/tests/mod.rs` 顶部加一行 `mod ai_section;`(新测试文件不挂 `mod` 就不编译),
//! 并且左列顺序那条字面量断言会红——那是刻意的(页面上多一组就是观感变了,要人确认)。
//!
//! 注意 i18n 那边是按**分组**命名的,文案文件叫 `ai.rs` 而不是 `ai_about.rs`。
/// AI / About 分组的运行期状态(M101 r2:**只归 ai+about 线**)。
///
/// 加字段只改本文件:`Groups` 只认 `Groups.ai_about` 这一个字段。
#[derive(Debug, Default)]
pub struct State {}
/// AI / About 线的消息(**只归本线**)。
///
/// 首版是空枚举:这一条线的分组还没上页面。空枚举的好处是
/// `app/update/settings/ai_about.rs` 里的 `match message {}` 天然穷尽——加了变体不接线就报
/// E0004,不会静默。
#[derive(Debug, Clone)]
pub enum Message {}
@@ -1,19 +1,25 @@
//! 设置页「Appearance」分组:演示行 + **真控件的接线**(M93 / APPEARWIRE)。
//!
//! 这一组是设置页唯一接了真控件的:`body = Some(crate::pages::appearance::section)`。
//! `settings/mod.rs` 只按 `group.body` 分发,**不认识「Appearance」这个名字**;分组在左列的下标仍由
//! `crate::pages::appearance::APPEARANCE_GROUP` 声明,`settings/tests.rs` 拿它和本文件比对(顺序被
//! 调换会红)。
//! 这一组是设置页唯一接了真控件的:`body = Some(body)`,`body` 直接调
//! `crate::pages::appearance::section`。`settings/mod.rs` 只按 `group.body` 分发,**不认识
//! 「Appearance」这个名字**;分组在左列的下标仍由 `crate::pages::appearance::APPEARANCE_GROUP`
//! 声明,`settings/tests/mod.rs` 拿它和本文件比对(顺序被调换会红)。
//!
//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section.rs`(本文件只负责
//! 把它挂进注册表),所以改控件时不用碰设置页目录。
//!
//! M101 r2:本组的渲染器签名改成收 [`States`](组状态 + 外观状态),于是「这一组要读哪些状态」
//! 由本文件决定——`settings/mod.rs` 不再认识 `appearance::State`。
use iced::Element;
use unlucky_design::icons::IconKind;
use super::Group;
use crate::theme::Ui;
/// Appearance 分组。`body` 指着 `pages/appearance/section.rs` 的真控件;没有外观运行期状态时
/// (harness / `pages_demo`)退回下面的演示行。
use super::{Group, Message, States};
/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section.rs` 的真控件);
/// 没有外观运行期状态时(harness / `pages_demo`)返回 `None`,由 `form` 退回下面的演示行。
pub(super) const GROUP: Group = Group {
name: "Appearance",
icon: IconKind::Sidebar,
@@ -23,5 +29,12 @@ pub(super) const GROUP: Group = Group {
("Motion", "Full"),
("Text scale", "100%"),
],
body: Some(crate::pages::appearance::section),
body: Some(body),
};
/// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。
fn body<'a>(ui: &'a Ui, states: States<'a>) -> Option<Element<'a, Message>> {
states
.appearance
.map(|state| crate::pages::appearance::section(ui, state))
}
+15 -1
View File
@@ -1,6 +1,6 @@
//! 设置页「Downloads」分组:首选源 / 并发数 / 校验(演示行)。
//!
//! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(...)`(渲染器也写在本文件),
//! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(download::body)`(渲染器也写在本文件),
//! `settings/mod.rs` 的注册表不用动。
//!
//! 注意与下载页(`pages/download*`)的区别:那是「装哪个版本」,这里是「怎么下」的默认值。
@@ -20,3 +20,17 @@ pub(super) const GROUP: Group = Group {
],
body: None,
};
/// Downloads 分组的运行期状态(M101 r2:**只归 download 线**)。
///
/// 加字段只改本文件:`Groups` 只认 `Groups.download` 这一个字段。
#[derive(Debug, Default)]
pub struct State {}
/// Downloads 分组的消息(M101:**只归 download 线**,加变体只改本文件)。
///
/// 落点是 `app/update/settings/download.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
/// [`GroupMessage`](super::GroupMessage))。异步结果(例如「测这个源可用」)在自己那条落点里构造
/// `Message::Page(pages::Message::Settings(GroupMessage::Download(…)))` 回来即可。
#[derive(Debug, Clone)]
pub enum Message {}
+21 -1
View File
@@ -1,6 +1,6 @@
//! 设置页「Java」分组:运行时 / 默认内存 / JVM 参数(演示行)。
//!
//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(...)`(渲染器也写在本文件),
//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(java::body)`(渲染器也写在本文件),
//! 再把 `rows` 换成真数据源给的文本;`settings/mod.rs` 的注册表不用动。
//!
//! 图标说明(视觉复审 P2-3):令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架;
@@ -21,3 +21,23 @@ pub(super) const GROUP: Group = Group {
],
body: None,
};
/// Java 分组的运行期状态(M101 r2:**只归 java 线**)。
///
/// 加字段只改本文件:`Groups` 只认 `Groups.java`(一个字段),不认这个类型里的任何字段,
/// 所以 `settings/mod.rs` / `app/state.rs` / `app/view.rs` 都不用动。
#[derive(Debug, Default)]
pub struct State {}
/// Java 分组的消息(M101:**只归 java 线**,加变体只改本文件)。
///
/// 落点是 `app/update/settings/java.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
/// [`GroupMessage`](super::GroupMessage))。
#[derive(Debug, Clone)]
pub enum Message {
/// **只有测试构造它**:M101 r2 留的「活的端到端证据」——外壳消息 →
/// `on_page` → `on_settings` → `on_settings_java` 这条路在运行时真的跑通,不是只靠编译期穷尽。
/// java 线接第一个真控件时把这一支换成真变体(`tests/java_section.rs` 会提醒)。
#[cfg(test)]
Ping,
}
@@ -0,0 +1,35 @@
//! 设置页 language+appearance 线的**消息口 + 状态口**(M101 `SETTINGSMSG`)。
//!
//! 为什么另开一个文件而不是塞进 `general.rs`:这一条线管**两个分组**(General 的语言 / 主题 /
//! 启动页 / 关闭按钮,Appearance 的密度 / 圆角 / 动效 / 文字缩放)。两个分组各有自己的正文文件
//! (`settings/general.rs`、`settings/appearance.rs`),消息与状态却只能有一个类型挂到
//! [`GroupMessage::LanguageAppearance`](super::GroupMessage::LanguageAppearance) /
//! [`Groups::language_appearance`](super::Groups) 上——否则 `GroupMessage` 与 `Groups` 会多出第五个
//! 槽位,本线就要改 `settings/message.rs` / `settings/state.rs`(共享文件)。
//!
//! ## 本线的文件集合(只归本线,四条线两两不相交)
//!
//! | 文件 | 作用 |
//! |---|---|
//! | 本文件 | 消息枚举 + 状态结构(加一支消息 / 一个字段只改这里) |
//! | `settings/general.rs` / `settings/appearance.rs` | 两个分组各自的 `GROUP`(名字 / 图标 / 行 / body) |
//! | `settings/tests/general_section.rs` / `settings/tests/appearance_section.rs` | 两组的独立断言 |
//! | `crates/i18n/src/catalog/settings/general.rs` / `appearance.rs` | 两组的文案 key |
//! | `crates/app-iced/src/app/update/settings/language_appearance.rs` | 消息的落点 |
//!
//! 真控件那半边在 `pages/appearance/section.rs`(`appearance.rs` 只负责把 `body` 挂上去)。
/// 语言 + 外观线的状态(**只归本线**)。
///
/// 首版是空结构:这一条线还没有自己的运行期状态(外观那份状态是 `pages::appearance::State`,
/// 归 M93,走 `States::appearance` 那条既有通道,不在这里)。加字段只改本文件。
#[derive(Debug, Default)]
pub struct State {}
/// 语言 + 外观线的消息(**只归本线**)。
///
/// 首版是空枚举:这一条线还没有接真下拉 / 真控件之前,没有可点的东西。空枚举的好处是
/// `app/update/settings/language_appearance.rs` 里的 `match message {}` 天然穷尽——加了变体
/// 不接线就报 E0004,不会静默。
#[derive(Debug, Clone)]
pub enum Message {}
@@ -0,0 +1,105 @@
//! 设置页的**分组消息投递口**(M101 `SETTINGSMSG`):四条填页线各写各的变体,不再抢
//! `pages/mod.rs` 的枚举尾部与 `app/update/page_messages.rs` 那条不许兜底的 match。
//!
//! ## 这一层为什么存在
//!
//! M100 把设置页的**文件**按分区拆开了,但四条线真正的交汇处仍是同一处追加锚点:
//! `pages::Message` 的枚举尾部 + `page_messages.rs` 的 match 臂(该 match 有
//! `the_page_match_has_no_wildcard_fallback` 守门,不许 `_ =>`)。四个变体、四条线、两个共享
//! 文件 ⇒ 四路并行必然四路冲突。M101 只做一件事:把「加消息」从共享文件搬进各组自己的文件——
//! `pages::Message` 从此只有**这一个**设置页变体。
//!
//! ```text
//! pages::Message::Settings(GroupMessage)
//! ├─ Java(java::Message) → settings/java.rs
//! ├─ Download(download::Message) → settings/download.rs
//! ├─ LanguageAppearance(language_appearance::Message) → settings/language_appearance.rs
//! └─ AiAbout(ai_about::Message) → settings/ai_about.rs
//! ```
//!
//! 四个载荷**必须**是四个不同类型、分别定义在四个文件里;否则「两条线往同一个枚举里加变体」的
//! 冲突只是从 `pages/mod.rs` 搬到了这里。`each_group_owns_its_own_message_type` 用 `type_name`
//! 里的模块路径守这一条。
//!
//! ## 加一支设置页消息要改几个文件(M101 r2 之后)
//!
//! **2 个**:你自己那条线的消息口文件(`settings/<线>.rs`)加一个变体,加上你那条落点
//! (`app/update/settings/<线>.rs`)接一条臂——两处都只归你。`pages/mod.rs`、
//! `page_messages.rs`、`settings/message.rs`、`settings/state.rs`、`app/update/settings.rs` 都不用动。
//!
//! ## 穷尽性:抓得住什么 / 抓不住什么
//!
//! - **抓得住**:某条线加了变体却忘了接线 → 编译器在 `app/update/settings.rs` 的 match 上报
//! **E0004**(那里对 [`GroupMessage`] 做穷尽匹配,没有 `_ =>`)。注意 `page_messages.rs`
//! 那条守卫只认 `pages::Message`,**它抓不住组内变体**——把守卫下放到 `settings.rs` 正是本层
//! 的第二个目的,两个 dispatch 各守一层。
//! - **抓不住**:handler 忽略消息(`let _ = message; Task::none()`)或两个 handler 的**身体**被
//! 写成一样——类型对得上就编得过。那要人读,或者等真接业务后由「点了没反应」的用例抓。
//!
//! 类型还顺手挡掉了另一种错:把 `Java(m)` 转给 `on_settings_download` 编不过(载荷类型不同),
//! 所以「接错组」这一半是编译期兜住的,「接了个空壳」那一半不是。
//!
//! ## 测试
//!
//! 本文件的 [`tests::each_group_owns_its_own_message_type`];dispatch 侧的
//! `the_group_dispatch_has_no_wildcard_fallback` 在 `app/update/settings.rs`。
use super::{ai_about, download, java, language_appearance};
/// 设置页分组消息:**唯一**一个进 `pages::Message` 的投递口。
///
/// 每个变体只归一条线,载荷类型定义在那条线自己的文件里(见模块文档的四个箭头)。
/// 加变体 = 改自己那个文件 + 改 `app/update/settings/<线>.rs`,两处都只归你。
#[derive(Debug, Clone)]
pub enum GroupMessage {
/// Java 分组(`settings/java.rs`)。
Java(java::Message),
/// Downloads 分组(`settings/download.rs`)。
Download(download::Message),
/// 语言 + 外观两组的共同消息口(`settings/language_appearance.rs`)。
LanguageAppearance(language_appearance::Message),
/// AI / About 分组(`settings/ai_about.rs`)。
AiAbout(ai_about::Message),
}
#[cfg(test)]
mod tests {
use super::*;
/// 四条线的载荷**必须**住在四个不同文件里。
///
/// 抓得住:① 为了少建一个文件,把两个组的 `Message` 塞进同一个文件(四条线又会撞同一个
/// 追加锚点,本 mission 的目的当场作废);② 谁把某个分组文件改名后忘了同步(`type_name`
/// 里带模块路径,改名就红)。
/// 抓不住:变体装得对不对(那是 `app/update/settings.rs` 的 E0004 与其守卫的事),也抓不住
/// handler 身体写空/写反(类型对得上就编得过)。
#[test]
fn each_group_owns_its_own_message_type() {
let homes = [
(
std::any::type_name::<java::Message>(),
"settings::java::Message",
),
(
std::any::type_name::<download::Message>(),
"settings::download::Message",
),
(
std::any::type_name::<language_appearance::Message>(),
"settings::language_appearance::Message",
),
(
std::any::type_name::<ai_about::Message>(),
"settings::ai_about::Message",
),
];
for (actual, expected) in homes {
assert!(
actual.ends_with(expected),
"载荷类型 {actual} 不在它自己的文件里(应为 {expected}):\
两个组共用一个消息类型,四条线又会撞同一个追加锚点"
);
}
}
}
+87 -34
View File
@@ -18,16 +18,17 @@
//!
//! ## 文件地图(M99 `SETTINGSFRAME`:**纯搬移**,行为与视觉零变化)
//!
//! 本文件只做两件事:**分组注册表**([`GROUPS`])+ **内容分发**([`form`] 按分组挑渲染器)。
//! 一组的正文、文案、演示行都在它自己的文件里,于是设置页接下来的四条 section 线各占一个文件:
//! 本文件只做三件事:**分组注册表**([`GROUPS`])、**内容分发**([`form`] 按分组挑渲染器)、
//! 以及 M101 加的两条漏斗的**转发**(消息 → [`message`],状态 → [`state`])。
//! 一组的正文、文案、演示行、状态、消息都在它自己的文件里:
//!
//! | 分组 | 文件 | 谁会动它 |
//! | 分组 | 文件(正文 / 状态 / 消息) | 谁会动它 |
//! |---|---|---|
//! | General | [`general`] | language+appearance 线(语言下拉 + 主题 / 启动页) |
//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section.rs`) |
//! | Java | [`java`] | java 线 |
//! | Downloads | [`download`] | download 线 |
//! | (页面上还没有这个分组) | [`ai`] | ai+about 线:先建分组再接线 |
//! | (页面上还没有这个分组) | [`ai_about`] | ai+about 线:先建分组再接线 |
//! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) |
//!
//! ## 测试也按 section 分(M100)
@@ -40,24 +41,75 @@
//! | `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**:版面契约、行宽上限、窄窗退化、注册表顺序 / 分组下标 / 「只有一组有真控件」 | 谁都别随手改(每条都写了为什么不能下放) |
//! | `tests/mod.rs` | **跨 section**:版面契约、行宽上限、窄窗退化、注册表顺序 / 分组下标 / 每条线的漏斗安装情况 | 谁都别随手改(每条都写了为什么不能下放) |
//!
//! 判据:**给某一组加一条新断言 = 只改它自己那个测试文件**。
//!
//! **加一个分组要动哪里**:① 在它自己的文件里写一个 `pub(super) const GROUP`(名字 / 图标 / 演示行 /
//! 可选的真控件渲染器 `body`);② 本文件的 [`GROUPS`] 加一行、数组长度 +1。**不需要**动 `frame.rs`、
//! 可选的真控件渲染器 `body`);② 本文件的 [`GROUPS`] 加一行、数组长度 +1;③ [`state::Groups`] 加一个
//! 字段、[`message::GroupMessage`] 加一个变体、`app/update/settings.rs` 加一条臂。**不需要**动 `frame.rs`、
//! 别的分组文件或 `pages/mod.rs`。
//!
//! ## 消息:一个投递口,四条线各写各的(M101 `SETTINGSMSG`)
//!
//! 四条填页线都要往 `pages::Message` 里加变体,而那是**一个**枚举加上
//! `app/update/page_messages.rs` 里一条不许 `_ =>` 的 match —— 四路并行必然四路冲突。
//! M101 把这件事收成一个投递口:
//!
//! ```text
//! pages::Message::Settings(GroupMessage) ← 全 crate 只有这一个设置页变体
//! ├─ Java(java::Message) → settings/java.rs
//! ├─ Download(download::Message) → settings/download.rs
//! ├─ LanguageAppearance(language_appearance::Message) → settings/language_appearance.rs
//! └─ AiAbout(ai_about::Message) → settings/ai_about.rs
//! ```
//!
//! 加一支消息 = 改自己那个消息口文件 + 自己那条落点(`app/update/settings/<线>.rs`),2 个文件、
//! 0 个共享文件。四个消息口枚举声明成 `pub` 是为了过 `private_interfaces`:那条 lint 按**类型自身
//! 的声明可见性**判,所以模块用最小的 `pub(crate)`(`pages/mod.rs` 的 `pub(crate) mod settings;`)
//! 就够,不必把整个设置页模块树暴露成 crate 公开 API。
//!
//! ## 状态:与消息同构的状态漏斗(M101 r2,r1 复审 P1)
//!
//! ```text
//! App.settings: state::Groups ← app/state.rs 一行(M101 加一次,四条线都不碰)
//! ├─ java / download / language_appearance / ai_about:各自的 State 在各自文件里
//! States { appearance, groups } ← 只读视图,按值传给 view;每组的 GROUP.body 自己取自己要的那份
//! ```
//!
//! **加/改某一组的字段**只改 `pages/settings/<组>.rs`;**异步结果**在自己那条落点里构造
//! `Message::Page(pages::Message::Settings(GroupMessage::<线>(…)))` 回来——`Page` 这条既有通道就是
//! 异步结果的入口,`app::Message` / `app/update.rs` 都不用动。
//!
//! ### 仍然共享、本 mission 无法消除的点(四条线都可能碰到,只能靠 tower 串行合并)
//!
//! 1. `crates/i18n/src/catalog/golden.rs` —— 每加一条文案 key 都要按声明顺序往金表里插一行
//! (`split_keeps_every_translation` 逐条比 key + 英/中译文)。**注意**:`catalog.rs` 的 `Key`
//! 枚举由 `catalog!` 宏从各域文件累积生成,加 key **不用**改 `catalog.rs`,也不用改
//! `catalog/settings/mod.rs`(五个域已全部接进装配链)。
//! 2. 本文件的 [`GROUPS`] —— 只有 ai+about 线**上第 5 组**时加一行(页面上多一组本来就是整页的变化)。
//! 3. `tests/mod.rs` —— ai+about 线新建 `tests/ai_section.rs` 时要加一行 `mod ai_section;`
//! (测试文件不挂 `mod` 就不编译),以及「左列顺序」那条字面量断言会连带红,需要人确认。
mod ai;
// 四条填页线的文件要能被**各自那条落点**(`app/update/settings/<线>.rs`)与状态架子
// (`state::Groups`)命名,所以是 `pub(crate)`:最小面,不是 crate 公开 API。
// 分组正文文件 `appearance` / `frame` / `general` 只在设置页内部用,保持私有。
pub(crate) mod ai_about;
mod appearance;
mod download;
pub(crate) mod download;
mod frame;
mod general;
mod java;
pub(crate) mod java;
pub(crate) mod language_appearance;
mod message;
mod state;
#[cfg(test)]
mod tests;
pub use message::GroupMessage;
pub use state::{demo_groups, Groups, States};
use iced::widget::column;
use iced::{Element, Length};
@@ -66,18 +118,23 @@ use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::pages::appearance::State;
use crate::theme::Ui;
use super::layout::{FrameMetrics, PageLayout};
use super::Message;
/// 一个分组正文的**真控件渲染器**:需要外观的运行期状态(现在只有「外观」分组用得上)。
type Body = for<'a> fn(&'a Ui, &'a State) -> Element<'a, Message>;
/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态),
/// 返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下没有外观状态),
/// 由 [`form`] 退回演示行。
///
/// 各组自己的渲染器**定义在自己文件里**,签名是这一条:于是「这一组要读哪些状态」由它自己决定,
/// 本文件不需要认识任何一组的字段(r1 复审 P1 要的正是这一点)。
type Body = for<'a> fn(&'a Ui, States<'a>) -> Option<Element<'a, Message>>;
/// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。
///
/// 除 `name`/`icon`/`rows` 外没有别的字段:**分组自己的状态归分组自己**,这里只装「怎么画」。
/// 除 `name`/`icon`/`rows`/`body` 外没有别的字段:**分组自己的状态归分组自己**(见 [`state`]),
/// 这里只装「怎么画」。
struct Group {
/// 左列显示名(也是表单列的分组标题)。
name: &'static str,
@@ -104,22 +161,21 @@ const SELECTED: usize = 0;
/// 整页。
///
/// `appearance` 是 M93 接进来的外观运行期状态(`None` = harness / 演示路径,设置页仍画占位行)。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
appearance: Option<&'a State>,
) -> Element<'a, Message> {
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
/// (`None` = harness / 演示路径,设置页仍画占位行),[`States::groups`] 是各组自己的状态。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, states: States<'a>) -> Element<'a, Message> {
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
let selected = appearance.map_or(SELECTED, |state| state.selected_group);
let selected = states
.appearance
.map_or(SELECTED, |state| state.selected_group);
let body = match metrics.layout {
PageLayout::SupportingPane => frame::panes(
metrics,
frame::group_nav(ui, selected),
frame::left_aligned(metrics.column, form(ui, Some(selected), appearance)),
frame::left_aligned(metrics.column, form(ui, Some(selected), states)),
),
_ => frame::single_column(metrics, form(ui, None, appearance)),
_ => frame::single_column(metrics, form(ui, None, states)),
};
frame::page(ui, metrics, frame::header(ui), body)
@@ -130,13 +186,10 @@ pub fn view<'a>(
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
///
/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some` 画真控件(现在只有「外观」),
/// `None` 画演示行([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标。
fn form<'a>(
ui: &'a Ui,
only: Option<usize>,
appearance: Option<&'a State>,
) -> Element<'a, Message> {
/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去
/// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行
/// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。
fn form<'a>(ui: &'a Ui, only: Option<usize>, states: States<'a>) -> Element<'a, Message> {
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
for (index, group) in GROUPS.iter().enumerate() {
@@ -145,11 +198,11 @@ fn form<'a>(
}
// 「外观」分组:有真实外观状态时画真控件(M93),否则退回演示行(harness)。
// 这两条的差别现在由 `GROUP.body` 表达,而不是「下标 == APPEARANCE_GROUP」的硬比较。
let card = match (group.body, appearance) {
(Some(body), Some(state)) => body(ui, state),
_ => frame::demo_card(ui, group.rows),
};
// 这两条的差别由 `GROUP.body` 的返回值表达,而不是「下标 == APPEARANCE_GROUP」的硬比较。
let card = group
.body
.and_then(|body| body(ui, states))
.unwrap_or_else(|| frame::demo_card(ui, group.rows));
stack = stack.push(
column![
@@ -0,0 +1,79 @@
//! 设置页的**状态漏斗**(M101 r2):与 [`super::message`] 的消息漏斗同构——一个组的状态归它自己的
//! 文件,[`Groups`] 只是一个按组排开的架子。
//!
//! ## 为什么要这一层(r1 复审的 P1)
//!
//! 消息漏斗解决了「加一支消息要改哪些文件」,但一组要画**真值**就得有数据进渲染器:r1 之前
//! `settings::view` 只收 `Option<&appearance::State>`,于是 java / download / language+appearance
//! 三条线要显示「当前运行时 / 当前并发数 / 当前语言」时,**都得改 `settings/mod.rs`**;带运行期状态
//! 的还要改 `pages/mod.rs` / `app/view.rs` / `app/state.rs`。四条线一铺开就是第二处四路撞车。
//!
//! 现在的形状:
//!
//! ```text
//! App.settings: pages::settings::Groups ← 唯一的所有者(app/state.rs 一行)
//! ├─ java: java::State → pages/settings/java.rs
//! ├─ download: download::State → pages/settings/download.rs
//! ├─ language_appearance: language_appearance::State → pages/settings/language_appearance.rs
//! └─ ai_about: ai_about::State → pages/settings/ai_about.rs
//!
//! settings::view(ui, metrics, States { appearance, groups }) ← 只读视图,按值传
//! └─ 每组的 GROUP.body(ui, states) 自己从 states 里取自己要的那份
//! ```
//!
//! **加/改某一组的字段** = 只改 `pages/settings/<组>.rs`(状态类型在它自己文件里)+ 用它自己的
//! handler 更新(`app/update/settings/<线>.rs`)。本文件、`pages/mod.rs`、`app/state.rs`、
//! `app/view.rs` **都不用动**——本 mission 的这半边判据就是这么验的(见报告「变异证据」)。
//!
//! **加一整条新线**(多一个组)才需要:本文件加一个字段、`message.rs` 加一个变体、
//! `app/update/settings.rs` 加一条臂。那是「加一条线」,不是「加一个字段」。
//!
//! ## 外观那条真控件路径(M93)没有被挤掉
//!
//! `appearance::State` 仍是 `App` 的字段(不复用、不搬走),[`States::appearance`] 原样借用它;
//! 「没有外观状态(harness)⇒ 画演示行」这条判据现在由 `appearance` 组自己的 `GROUP.body`
//! 表达(返回 `None` ⇒ [`super::frame::demo_card`]),与 r1 之前逐像素等价。
use std::sync::LazyLock;
use crate::pages::appearance;
use super::{ai_about, download, java, language_appearance};
/// 各组自己拥有的运行期状态:**一个组一个字段**,字段类型定义在那条线自己的文件里。
///
/// 字段的**内部**结构归各组(加字段只改它自己的文件);架子(有哪几个组)归这里。
#[derive(Debug, Default)]
pub struct Groups {
/// Java 分组的状态(`pages/settings/java.rs`)。
pub java: java::State,
/// Downloads 分组的状态(`pages/settings/download.rs`)。
pub download: download::State,
/// 语言 + 外观两组的状态(`pages/settings/language_appearance.rs`)。
pub language_appearance: language_appearance::State,
/// AI / About 分组的状态(`pages/settings/ai_about.rs`)。
pub ai_about: ai_about::State,
}
/// 画一帧时**按值**递给各组渲染器的只读视图:组状态 + 外观运行期状态(两个引用,`Copy`)。
///
/// 为什么按值而不是按引用:`Settings::view` 返回的 `Element<'a>` 会借走它,按引用传会逼着
/// harness 那条路径([`super::demo_groups`] 撑着的 `page()` / `page_with_appearance()`)为一份
/// **临时构造**的 `States` 造 `'static`,或者把默认状态塞进 `Model`(两份真相)。按值传没有
/// 这层借用纠缠,调用方写一行就够。
#[derive(Debug, Clone, Copy)]
pub struct States<'a> {
/// 外观运行期状态(M93):`None` = harness / 演示路径(设置页画演示行)。
pub appearance: Option<&'a appearance::State>,
/// 各组自己的状态(见 [`Groups`])。
pub groups: &'a Groups,
}
/// 演示 / harness 路径的默认组状态(`pages::page` 与 `pages::page_with_appearance` 用它)。
///
/// `'static` 是有意的:那两条入口不持有任何组状态,而返回的 `Element<'a>` 会借走这份 `Groups`;
/// 让它活得比调用方长,借用就不会把返回值钉在函数栈上。
pub fn demo_groups() -> &'static Groups {
static DEMO: LazyLock<Groups> = LazyLock::new(Groups::default);
&DEMO
}
@@ -1,8 +1,8 @@
//! 「Downloads」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
//!
//! **归 download 线**。接真控件那天要改**三个**文件:本文件 `the_downloads_group_is_still_demo_rows`
//! (`body.is_none()` 绊线)+ `settings/download.rs` + `tests/mod.rs` 的跨 section 绊线。
//! **归 download 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线):
//! `settings/download.rs` + 本文件 `the_downloads_group_is_still_demo_rows`。
//!
//! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。
@@ -38,8 +38,8 @@ fn the_downloads_group_keeps_its_icon() {
/// 抓得住:download 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删)。
#[test]
fn the_downloads_group_is_still_demo_rows() {
assert!(
@@ -1,9 +1,8 @@
//! 「General」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
//!
//! **归 language+appearance 线**。接真控件那天要改**三个**文件:本文件
//! `the_general_group_is_still_demo_rows`(`body.is_none()` 绊线)+ `settings/general.rs` +
//! `tests/mod.rs` 的跨 section 绊线。
//! **归 language+appearance 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨
//! section 绊线):`settings/general.rs` + 本文件 `the_general_group_is_still_demo_rows`。
use super::super::general;
use unlucky_design::icons::IconKind;
@@ -38,8 +37,8 @@ fn the_general_group_keeps_its_icon() {
/// 抓得住:language+appearance 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删)。
#[test]
fn the_general_group_is_still_demo_rows() {
assert!(
@@ -1,10 +1,10 @@
//! 「Java」分组自己的断言(M100 **新增**:`settings/tests.rs` 里一条 section 断言都没有,
//! 那 7 条全是跨 section 的;所以这 3 条是新增,不是搬移)。
//!
//! **归 java 线**。接真控件那天要改**三个**文件:
//! 1. 本文件 `the_java_group_is_still_demo_rows`(它断言 `body.is_none()`,接上就红);
//! 2. `settings/java.rs`(写 `body = Some(..)`);
//! 3. `tests/mod.rs` 的 `only_the_appearance_group_carries_a_real_control_body`(跨 section 绊线)。
//! **归 java 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线,
//! 见 `tests/mod.rs` 的 `every_line_can_be_wired_alone` 说明):
//! 1. `settings/java.rs`(`body = Some(java::body)` + 渲染器);
//! 2. 本文件 `the_java_group_is_still_demo_rows`(把 `is_none()` 改成 `is_some()`)。
//!
//! download / general 同型。
//!
@@ -46,8 +46,8 @@ fn the_java_group_keeps_the_folder_icon_from_the_visual_review() {
/// 抓得住:java 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删,见那里的 `every_line_can_be_wired_alone`)。
#[test]
fn the_java_group_is_still_demo_rows() {
assert!(
+54 -25
View File
@@ -130,33 +130,62 @@ fn the_split_kept_the_left_column_order() {
assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
}
/// M99:「外观」是**当前唯一**接了真控件的那一组。
/// M101 r2:`settings/mod.rs` 里**不许出现任何一组的 `State` 类型**(r1 复审 P1 的判据钉在这里)。
///
/// 抓得住:拆分时把 `body: Some(…)` 搬丢 / 搬到了别的组(那会让真控件消失,退回演示行——
/// 而截图对这种「只在有外观状态时出现」的差别不一定抓得住,见报告「没验的部分」);
/// 也抓「别组偷偷接了 body 却没人知道」。
/// 抓不住:`body` 指向的那个渲染器画得对不对(那是 `pages::appearance` 的事)。
///
/// **为什么不能下放**(任务书点名要求写清):这条断言的全部内容是**四组的 body 现状对比**
/// ——「只有第 2 组是 Some,其余三组都是 None」。放进 `appearance_section.rs` 它就得替 java /
/// download / general 三组作证(那三条线一接真控件,本文件以外的地方无缘无故变红,且责任方
/// 不是改代码的人);拆成四份「我这组是 None」又会丢掉「唯一」这个要点。
/// 它是**绊线**:哪条线接上真控件,就必须来本文件手工改这一条(T1:绊线保持字面量,
/// 动态取活值等于把绊线剪了)。
/// 抓得住:谁把状态漏斗拆回去——让 `view` / `form` 直接收 `appearance::State` 或某一组的 `State`
/// (那正是四条线「加一个字段就要改本文件」的形状,本次复审要求修掉的就是它)。
/// 抓不住:各组的**字段**(那些活在各组自己的文件里,本文件本来就不该知道,也就没有可断言的东西)。
#[test]
fn only_the_appearance_group_carries_a_real_control_body() {
fn the_settings_module_does_not_name_any_group_state() {
let source = include_str!("../mod.rs");
let forbidden = [
"appearance::State",
"java::State",
"download::State",
"language_appearance::State",
"ai_about::State",
];
let hits: Vec<&str> = forbidden
.into_iter()
.filter(|needle| source.contains(needle))
.collect();
assert!(
GROUPS[APPEARANCE_GROUP].body.is_some(),
"外观分组必须是接了真控件的那一组"
hits.is_empty(),
"`settings/mod.rs` 里出现了具体的组状态类型 {hits:?}:状态漏斗被打回去了——\
四条填页线又要改本文件。状态应该只在 `settings/state.rs` 的 `Groups` 与各组自己的\
`State` 定义里出现,本文件只转发 `States`"
);
for (index, group) in GROUPS.iter().enumerate() {
if index == APPEARANCE_GROUP {
continue;
}
assert!(
group.body.is_none(),
"{} 现在应该还是演示行(真控件由它自己那条 section 线接)",
group.name
);
}
}
///
/// 为什么能删:`tests/appearance_section.rs` 断言外观组 `body.is_some()`,
/// `tests/{general,java,download}_section.rs` 各自断言本组 `body.is_none()`——四条合起来与
/// 原来那条聚合断言等价(成员与顺序另有 [`the_split_kept_the_left_column_order`] 钉住)。
/// 删掉之后每条线「接真控件」只需改自己那 2 个文件,不必来本文件报到。
///
/// ## 删掉之后**抓不住什么**(tower 要求写明)
///
/// 那四条都只看**本组**,没有任何一条再断言「唯一性」——「四组里恰好一组挂了真控件」这件事
/// **现在没有守卫**。具体漏法:
///
/// | 情形 | 还红吗 |
/// |---|---|
/// | 某条线接上 `body` 却忘了翻自己那条 `is_none()` | **红**(本组断言还在) |
/// | 两条线的断言被同时改成 `is_some()`,页面上两组同时长真控件 | **绿着过去**(没人指认「唯一」) |
/// | 谁把 `body: Some(..)` 挂到一个不该有的组、又顺手改了它那条断言 | **绿着过去** |
///
/// 接受这个损失的理由:接真控件是**每组各自的一次性动作**,谁做谁翻自己那一条;而「唯一」在
/// 四条线并行的阶段是最容易假红的约束(一条线接上就替另外三条红)。想要回这条守卫,等四条线
/// 都落地后在这里加一条**计数**断言(`GROUPS.iter().filter(|g| g.body.is_some()).count()`),
/// 而不是现在钉死某个下标。
///
/// 这条 `#[test]` 本体已删;保留这段文档是为了让后来的人知道「这里原本有一条绊线」。
#[test]
fn every_line_can_be_wired_alone() {
// 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事——
// ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。
assert_eq!(GROUPS.len(), 4, "四条线各一个分组");
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}