M201: 接通 iced 设置页的数据通路(下载组:真值 + 真控件 + 写回闭环)

M193 的用户投诉「设置页里改了不生效」的结构性根因是 iced 侧没有设置数据通路:
`CoreEvent::SettingsChanged` 在 `crates/app-iced/src` 零命中、`pages::Model` 没有设置快照、
`download::State` 是空 struct。presenter 那套设置组早就是真值来源(`host::settings::groups`
由 `refresh_all` 与 `dispatch` 的 `SetSetting` 分支发成 `SettingsChanged`),只是没有接到 iced。

这条线现在通了:

    host::settings::groups → SettingsChanged → AppState::settings_groups
      → pages::Model.settings → settings::States.settings → 下载组控件
      → AppCommand::SetSetting → 写盘 + 重新发 SettingsChanged(闭环)

主要改动
- presenter `host/settings/download.rs`:并发/重试两行改成画**引擎生效值**(`DownloadTuning`),
  文件原值与生效值不一致时写明「原值 X 已按出厂 N 生效」(P3-5,两个数都现读);
  gh-proxy 行补「当前生效:跟随内置默认/已关闭/覆盖为 X」。
- app-iced `pages/settings/download.rs`:`GROUP.body = Some(body)`,按 `SettingControl`
  画 choice(filter chip)/ text(输入框 + 应用)/ action(按钮)/ toggle / readonly;写回走
  `app/update/settings/download.rs` → `AppCommand::SetSetting`(不做乐观回显)。
- `pages::Model.settings` + `settings::States.settings`:快照进页面的唯一通道。
- i18n:新增 `SettingsDownloadsApply`(金表锚点追加,`split_keeps_every_translation` 绿)。
- 取证:`ULL_SCREENSHOT_REAL_HOST=1` 让 `--screenshot` 接真 host(截图通道默认强制 mock,
  而 mock 不发 `SettingsChanged`,真值屏拍不出来)。

测试(新增 11 条)
- presenter `download_tests.rs`:P3-5 的越界回落 / 0→1 / 无差异不出提示 + P3-3 的
  gh-proxy 文件→三态端到端链。
- app-iced `app/update/settings/download_tests.rs`:假后端闭环 ×4 + **真 `CoreHost` 端到端**
  (点 16 ⇒ 磁盘上 `launcher.json` 真的有 16)。
- `pages/settings/tests/download_section.rs`:`body.is_none()` 绊线翻成 `is_some()` +
  控件形状 / 空快照不编值。
This commit is contained in:
AskaEth committed 2026-11-09 10:51:16 +08:00
1 parent 0cc1a35420
commit ab573838f3
18 files changed
+1257 -74

No files matched your search

+16
View File
@@ -214,3 +214,19 @@ pub struct App {
/// [`crate::platform::win32::corner::CornerApplied`]。
pub(super) corner_applied: Option<crate::platform::win32::corner::CornerApplied>,
}
impl App {
/// iced 侧的**设置组快照**(M201):presenter 的 `CoreEvent::SettingsChanged` 回流进来的那一份。
///
/// 真值只有一份——presenter 的 `AppState::settings_groups`(`apply_core` 是它唯一的写入口,
/// 由 host 的 `refresh_all` / `SetSetting` 分支发出来)。这里**不另存一个字段**:那会变成第二份
/// 真相,两份在 `pump` 之间必然有一步不一致(而「界面上是旧值、盘上是新值」正是本 mission 要
/// 消灭的形状)。要画的那一份是 [`pages::Model::settings`](`refresh_view` 里从同一个字段克隆)。
///
/// `#[cfg(test)]`:产品路径不需要这个入口(页面拿的是 `Model.settings`,同一份数据的投影),
/// 断言需要它——「App 认的值变了吗」必须能直接从 App 上问,不能去问页面模型(那是下游)。
#[cfg(test)]
pub(crate) fn settings_snapshot(&self) -> &[unlucky_presenter::dto::SettingGroupView] {
&self.state.settings_groups
}
}
+21 -1
View File
@@ -23,10 +23,30 @@ pub(super) fn app_options(options: &Options) -> AppOptions {
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
data_root: options.data_dir.clone(),
mock: options.screenshot.is_some(),
mock: options.screenshot.is_some() && !real_host_in_screenshot(),
}
}
/// 取证开关 `ULL_SCREENSHOT_REAL_HOST=1`:让**截图通道**接真 host(默认关)。
///
/// ## 为什么需要它(M201)
///
/// 截图通道恒 `mock: true`(D30:同一份代码出同一张图),而 `MockBackend` **不发**
/// `CoreEvent::SettingsChanged` ⇒ 设置页拿不到任何真值,只能拍演示行。可「设置页里改了就生效」
/// 这件事的正脸图**必须带真值**:M193 用户的投诉就是「图上有 8 threads,追下去是假的」。
/// 这条开关把出图的**数据源**换成真 host(`--data-dir` 指一个临时根),渲染通道与落盘一字不改。
///
/// ## 边界(别把它当产品行为)
///
/// - 默认关;开了它就不再满足 D30 的可复现(数据来自磁盘,两台机器会不同),所以**只用于取证**;
/// - 它只改 `mock` 这一位,不碰任何业务分支(`build_backend` 的其余两条路仍然照旧);
/// - 与 `ULL_START_ROUTE` / `ULL_SETTINGS_GROUP` 同一族:只影响这一次进程。
fn real_host_in_screenshot() -> bool {
std::env::var("ULL_SCREENSHOT_REAL_HOST")
.ok()
.is_some_and(|raw| raw.trim() == "1")
}
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
pub(crate) fn log_first_frame() {
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
@@ -1,18 +1,75 @@
//! Downloads 分组消息的落点(M101 `SETTINGSMSG`):**只归 download 线**。
//! Downloads 分组消息的落点(M201):**设置写回**的那一段(M101 `SETTINGSMSG` 的漏斗不变)。
//!
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。与下载页
//! (`pages/download*` 与 `app/update/download.rs`)无关:那是「装哪个版本」,这一组是「怎么下」。
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。本文件只有一件事:
//! 把「用户改了哪个键」翻成 `AppCommand::SetSetting` —— 那是 presenter 已有的写回闭环
//! (`host/dispatch.rs` 的 `SetSetting` 分支:`settings::set` 落盘 → `apply_tuning` → 重新发
//! `CoreEvent::SettingsChanged`)。
//!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
//! ## 不做乐观回显
//!
//! 这里**不**改任何「当前值」:界面上的值是 presenter 快照(`pages::Model.settings`)的投影,
//! 只有 host 把 `SettingsChanged` 发回来才会变。理由与下载页的安装/启动同一条纪律——本地改一份、
//! host 回来再改一次,两边打架时界面就会各说一套(尤其是写盘失败:乐观回显会让用户以为改成了)。
//!
//! ## 文本行为什么要「应用」才提交
//!
//! 见 `pages/settings/download.rs` 的文件头:每敲一个键写一次盘会把半截值落进去。所以
//! `Edit` 只改本地草稿(`view_dirty` 都不用翻:草稿由 `States` 直接读),`Commit` 才派发。
//! 提交之后草稿当场丢掉——快照随即成为唯一真值,写失败时行上会回到文件里的值 + presenter 的
//! 错误 Toast 说清原因(比留着一个没生效的草稿更诚实)。
use super::super::*;
use unlucky_presenter::dto::{find_row, AppCommand};
impl App {
/// Downloads 分组的一条消息。首版没有可处理的东西(空枚举)。
/// Downloads 分组的一条消息。
pub(super) fn on_settings_download(
&mut self,
message: pages::settings::download::Message,
) -> Task<Message> {
match message {}
use pages::settings::download::Message;
match message {
Message::Set { key, value } => {
self.dispatch(AppCommand::SetSetting { key, value });
Task::none()
}
Message::Edit { key, value } => {
// 只改草稿:`body` 每一帧都从 `Groups.download` 读它,所以这里不必重建视图模型。
self.settings.download.edit(&key, value);
Task::none()
}
Message::Commit { key } => {
let value = self.pending_text(&key);
self.settings.download.clear(&key);
self.dispatch(AppCommand::SetSetting { key, value });
Task::none()
}
// 动作行(「重新检测地区」):键名与动作 id 都由 presenter 的快照给,取值被
// `settings::set` 忽略(那条分支先认键、再解析值)。
Message::Action { key } => {
self.dispatch(AppCommand::SetSetting {
key,
value: String::new(),
});
Task::none()
}
}
}
/// 这一行要提交的文本:本地草稿优先,否则用快照里的当前值(= 文件里的值)。
///
/// 空串是合法提交(「留空 = 跟随默认」是这一组真有的语义),所以只有**两者都缺**才返回空串。
fn pending_text(&self, key: &str) -> String {
self.settings
.download
.pending(key)
.or_else(|| find_row(&self.view.settings, key).map(|row| row.value_text.clone()))
.unwrap_or_default()
}
}
#[cfg(test)]
#[path = "download_tests.rs"]
mod tests;
@@ -0,0 +1,332 @@
//! Downloads 组的**写回闭环**(M201):`控件 → Message → AppCommand::SetSetting → 真 host 写盘
//! → CoreEvent::SettingsChanged → App 的快照与页面模型`。
//!
//! 为什么这条断言必须在 app 层:链子的两头都是「有状态的东西」——中间断在任何一环,页面层测试
//! 都看不见(它只拿到一份已经画好的模型)。要问的是「用户点了以后,**盘上和 App 认的值**变了吗」,
//! 而不是「按钮的 `on_press` 里有没有构造消息」。
//!
//! 两个后端都测,因为它们证明的事不同:
//!
//! | 用例 | 后端 | 证明 |
//! |---|---|---|
//! | 前四条 | 假后端(按 host 形状回快照) | 搬运:消息 → 命令、命令 → 快照、快照 → 模型 |
//! | 最后一条 | **真 `CoreHost` + 临时数据根** | 整条链:命令 → `settings::set` → `launcher.json` 真的变了 |
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, SettingControl, SettingGroupView, SettingRowView,
};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::cli::Options;
use crate::pages;
use crate::pages::settings::download;
use crate::theme::Families;
use super::*;
/// 这一组要断言的那个键(与 presenter 快照里的键同名)。
const CONCURRENCY: &str = "download.concurrency";
/// 一个文本键(代理地址)。
const PROXY_URL: &str = "download.proxy_url";
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 一份只有下载组的快照:并发 = `concurrency`,代理 = `proxy`。
///
/// 行形状照 presenter 的 `host::settings::download::group` 抄(choice + 档位选项,text + 原文),
/// 只保留本文件断言要用的那两行——这里守的是 app 侧的搬运,不是 presenter 的行文案。
fn snapshot(concurrency: &str, proxy: &str) -> Vec<SettingGroupView> {
let options = ["4", "8", "16"];
let selected = i32::try_from(
options
.iter()
.position(|item| *item == concurrency)
.unwrap_or(1),
)
.expect("装得下");
vec![SettingGroupView::new("download", "下载与镜像(D7)")
.with(SettingRowView::choice(
CONCURRENCY,
"并发数",
"同时下载的文件数",
&options,
selected,
))
.with(SettingRowView::text(
PROXY_URL,
"HTTP 代理",
"manual 模式生效",
proxy,
))]
}
/// 记下令、并按 host 的形状回一条 `SettingsChanged` 的后端。
struct Hostish {
commands: Rc<RefCell<Vec<AppCommand>>>,
queued: Vec<AppEvent>,
/// 回事件时用的快照(`SetSetting` 之后换成「值真的变了」的那一份)。
state: RefCell<(String, String)>,
}
impl Backend for Hostish {
fn drain_events(&mut self) -> Vec<AppEvent> {
self.queued.drain(..).collect()
}
fn dispatch(&mut self, command: AppCommand) {
self.commands.borrow_mut().push(command.clone());
if let AppCommand::SetSetting { key, value } = command {
let mut state = self.state.borrow_mut();
if key == CONCURRENCY {
state.0 = value;
} else if key == PROXY_URL {
state.1 = value;
}
// host 写盘之后重新发一份快照(`dispatch.rs` 的 `SetSetting` 分支就这一行)。
self.queued
.push(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
&state.0, &state.1,
))));
}
}
}
/// 一个装着 [`Hostish`] 的 App + 那本命令账。
fn app() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let commands = Rc::new(RefCell::new(Vec::new()));
let backend = Hostish {
commands: Rc::clone(&commands),
queued: Vec::new(),
state: RefCell::new(("8".to_string(), "http://127.0.0.1:7890".to_string())),
};
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
);
// 首屏:收一份 host 的快照(产品路径在 `on_boot` 的 `RefreshAll` 之后就是这样)。
app.state
.apply(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
"8",
"http://127.0.0.1:7890",
))));
app.refresh_view();
(app, commands)
}
/// 一条设置页消息走完漏斗。
fn send(app: &mut App, message: pages::settings::download::Message) {
let message = pages::Message::Settings(pages::settings::GroupMessage::Download(message));
let _ = app.on_page(message);
}
/// **主用例**:点一个档位 ⇒ 命令发出去了 ⇒ host 回的快照进了 App ⇒ 页面模型跟着换。
///
/// 抓得住:
/// 1. handler 不派发 `AppCommand::SetSetting`(写回那一行被删)——这时假后端收不到命令、
/// 也回不出事件,快照停在旧值,红(变异验证见报告);
/// 2. `pages::Model` 不从快照取设置(`view::model` 那行 `settings:` 被删)——`app.view.settings`
/// 是空的,`current_value` 返回 `None`,红;
/// 3. App 侧**乐观回显**(点完就地改本地值、不等事件)——把假后端的回事件删掉后第 2 段仍会绿,
/// 所以本用例的「红」只证明闭合回路;它抓不住「有没有多存一份本地值」这一半。
///
/// 抓不住:真实点击(`Chip` 的 `on_press` 是否被真的按下)、host 是否真的落盘(那是最后一条的活)。
#[test]
fn changing_a_choice_value_lands_in_the_app_snapshot_and_the_page_model() {
let (mut app, commands) = app();
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("8"),
"开局认的是 host 给的那份"
);
send(
&mut app,
pages::settings::download::Message::Set {
key: CONCURRENCY.to_string(),
value: "4".to_string(),
},
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, value } if key == CONCURRENCY && value == "4"
)),
"写回必须走已有的 `AppCommand::SetSetting`(实际下发:{:?})",
commands.borrow()
);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("4"),
"App 的设置快照必须跟着 host 回来那份变(不是本地 State 变)"
);
assert_eq!(
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
Some("4"),
"页面模型(这一帧要画的值)也必须是 4:控件画的是 `Model.settings`"
);
}
/// 文本行:`Edit` 只改草稿(**不发指令**),`Commit` 才带着草稿去写盘。
///
/// 抓得住:`Edit` 也派发(每敲一个键写一次盘)、`Commit` 用快照旧值而不是草稿(用户输入丢失)。
/// 抓不住:输入框本身的按键路径(`text_field` 的 `on_input` 是组件既有实现)。
#[test]
fn typing_a_text_value_only_commits_on_demand() {
let (mut app, commands) = app();
send(
&mut app,
pages::settings::download::Message::Edit {
key: PROXY_URL.to_string(),
value: "http://127.0.0.1:1080".to_string(),
},
);
assert!(
commands.borrow().is_empty(),
"输入过程中不许写盘(半截值会落进 launcher.json)"
);
send(
&mut app,
pages::settings::download::Message::Commit {
key: PROXY_URL.to_string(),
},
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, value } if key == PROXY_URL && value == "http://127.0.0.1:1080"
)),
"提交时带的必须是**草稿**里的那一串(不是快照里的旧值):{:?}",
commands.borrow()
);
assert_eq!(
download::current_value(app.settings_snapshot(), PROXY_URL).as_deref(),
Some("http://127.0.0.1:1080"),
"提交后快照换成 host 回来的新值"
);
}
/// 动作行(「重新检测地区」):派发的是那一行的键,值留空(presenter 那条分支先认键、不看值)。
///
/// 抓得住:动作按钮被写成「什么都不发」、或者把键发成按钮文案。
#[test]
fn an_action_row_dispatches_its_key() {
let (mut app, commands) = app();
let key = "download.action.detect_region".to_string();
send(
&mut app,
pages::settings::download::Message::Action { key: key.clone() },
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key: sent, .. } if sent == &key
)),
"动作行必须走「按 schema 键写设置」那一条既有通路:{:?}",
commands.borrow()
);
}
/// 快照里没有本组时,页面**不许**自己编一行(`controls_of` 空 = 退回演示行)。
///
/// 抓得住:把演示行当成真值画(M193 用户的投诉形状)。
/// 抓不住:`settings/mod.rs` 那条退回路径本身(那是 `demo_card` 的事)。
#[test]
fn an_empty_snapshot_yields_no_controls() {
let (mut app, _commands) = app();
app.state.settings_groups.clear();
app.refresh_view();
assert!(download::controls_of(app.settings_snapshot()).is_empty());
// 有快照时,本组的控件种类就是 presenter 给的那几行(这里两份:choice + text)。
let kinds = download::controls_of(&snapshot("8", ""));
assert_eq!(
kinds,
vec![SettingControl::Choice, SettingControl::Text],
"控件种类照快照的 `control` 字段画,不许自己另定"
);
}
/// **真 host 端到端**:点「16」⇒ 「设置页里改了就生效」这句话的正面证据。
///
/// 链子全长:`Message::Set` → `AppCommand::SetSetting` → 真 `CoreHost` 的 `dispatch.rs` 分支
/// (`settings::set` 校验 + 落盘 + `apply_tuning` + 重新发 `SettingsChanged`)→ `AppState` →
/// `pages::Model::settings`。断言落在**磁盘上那串字节**上,不是任何中间变量。
///
/// 抓得住:
/// 1. 只改本地、不派发(`Message::Set` 的分支被删)⇒ `launcher.json` 里没有 16,红;
/// 2. 派发了但 handler 用了错误的键/值 ⇒ 文件里那个键还是出厂 8,红;
/// 3. 快照没跟着 host 的回流走(`view::model` 那行 `settings:` 被删)⇒ 页面模型空,红;
/// 4. 写盘校验被绕过(`settings::set` 不再 `save()`)⇒ 文件不存在/没变,红。
///
/// 抓不住:真实鼠标点击(本用例从 `Message` 起跑,`Chip::filter` 的 `on_press` 不在其中)、
/// 引擎是否立刻吃到新值(`apply_tuning` 与 `concurrency_probe` 由 presenter 的
/// `the_concurrency_setting_reaches_the_real_download_engine` 守)、以及界面像素(截图的事)。
#[test]
fn a_choice_change_reaches_the_real_settings_file() {
let dir = std::env::temp_dir().join(format!("ull-m201-writeback-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
let config = dir.join(".ULL/config/launcher.json");
let env = unlucky_presenter::host::HostEnv::at_root(&dir);
let mut app = App::with_presenter(
Options {
data_dir: Some(dir.clone()),
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::host::CoreHost::new(env)),
// 设置桥也指向同一个根:不碰用户的真配置。
SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)),
);
// 首屏:产品路径在 `on_boot` 里派的就是这一条(真 host 会发 `SettingsChanged`)。
app.dispatch(AppCommand::RefreshAll);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("8"),
"临时根里没有这个键 ⇒ 快照给的是出厂 8(也是引擎的生效值)"
);
send(
&mut app,
pages::settings::download::Message::Set {
key: CONCURRENCY.to_string(),
value: "16".to_string(),
},
);
let written = std::fs::read_to_string(&config).expect("写回之后 launcher.json 必须存在");
assert!(
written.contains("\"concurrency\": 16"),
"磁盘上必须真的有 16(这才是「设置页里改了就生效」):{written}"
);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("16"),
"host 把新快照发回来了,App 认的也是 16"
);
assert_eq!(
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
Some("16"),
"页面模型也换成了 16(下一帧控件画的就是它)"
);
std::fs::remove_dir_all(&dir).ok();
}
+5
View File
@@ -92,6 +92,8 @@ impl App {
pages::settings::States {
appearance: Some(&self.appearance),
groups: &self.settings,
// M201:下载组画真控件要的那份快照(`Model` 里那份,`refresh_view` 刚重建)。
settings: &self.view.settings,
},
// M106:设置页 General 组要画「当前档位 + 生效语言」。
self.language(),
@@ -190,6 +192,9 @@ pub(super) fn model(
logs: logs_model(state).with_status(lang),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update),
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
// schema 键取当前值画控件;写回后 host 重新发 `SettingsChanged` ⇒ 下一次重建换新值。
settings: state.settings_groups.clone(),
lang,
forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
+6
View File
@@ -82,6 +82,12 @@ fn main() -> ExitCode {
"验收诊断:ULL_ACCEPTANCE=1([console]/[env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\
,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸"
);
// M201:设置页要出「带真值」的验收图,而截图通道默认强制 mock(mock 不发
// `CoreEvent::SettingsChanged`)。这一条把**数据源**换成真 host,其余一字不改。
eprintln!(
"取证:ULL_SCREENSHOT_REAL_HOST=1 让 --screenshot 接真 host(默认 mock;\
出图将不再可复现,只用于验收真值屏)"
);
ExitCode::from(2)
}
}
+4
View File
@@ -167,6 +167,8 @@ pub fn page<'a>(
settings::States {
appearance: None,
groups: settings::demo_groups(),
// harness 路径没有 presenter 快照:空切片 ⇒ 各组画演示行(`Model::settings` 此时是空的)。
settings: &model.settings,
},
Language::default(),
form,
@@ -193,6 +195,7 @@ pub fn page_with_appearance<'a>(
settings::States {
appearance: Some(appearance),
groups: settings::demo_groups(),
settings: &model.settings,
},
Language::default(),
form,
@@ -291,6 +294,7 @@ pub fn app_with_appearance<'a>(
settings::States {
appearance,
groups: settings::demo_groups(),
settings: &model.settings,
},
Language::default(),
form,
+13
View File
@@ -124,6 +124,16 @@ pub struct Model {
pub content: Content,
/// 自更新的展示状态(M159:设置页「更新」区 + 顶部提示条,见 [`super::update`])。
pub update: Update,
/// **设置组快照**(M201):presenter 的 `CoreEvent::SettingsChanged` 回流的那一份
/// (`AppState::settings_groups`,由 `presenter::host::settings::groups` 产出)。
///
/// 谁填:`app/view.rs` 的 `model()`(`state.settings_groups.clone()`)。用途只有一处——
/// 设置页各组的 `body` 按 schema 键从它里面 `dto::find_row` 取**当前值**,画成可改的控件;
/// 写回走 `AppCommand::SetSetting`,落盘后 host 重新发一次 `SettingsChanged` ⇒ 这里换新的一份。
///
/// 为什么放在 `Model` 而不是别处:它就是「这一帧要画的设置值」,与 `update` 同一条形状
/// (页面只认 `Model`,presenter 的快照不直接进页面层)。
pub settings: Vec<unlucky_presenter::dto::SettingGroupView>,
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
///
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
@@ -156,6 +166,9 @@ impl Model {
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
update: Update::default(),
// 空快照 = 这一帧没有设置数据(harness / `pages_demo`;`demo_model` 也不伪造一份,
// 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。
settings: Vec::new(),
lang: Lang::En,
forced: None,
}
+340 -23
View File
@@ -1,31 +1,76 @@
//! 设置页「Downloads」分组:并发 / 单源尝试次数 / GitHub 加速前缀(M193)。
//! 设置页「Downloads」分组:并发 / 单源尝试次数 / 地区 / 镜像 / 代理 / GitHub 加速前缀(M201)。
//!
//! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(download::body)`(渲染器也写在本文件),
//! `settings/mod.rs` 的注册表不用动。
//! **这一组归 download 线**:正文渲染器在本文件(`GROUP.body = Some(body)`),`settings/mod.rs`
//! 的注册表不用动。
//!
//! 注意与下载页(`pages/download*`)的区别:那是「装哪个版本」,这里是「怎么下」的默认值。
//!
//! ## 为什么这三行是「说语义」而不是「说当前值」(M193)
//! ## 数据从哪来、写到哪去(M201 接的就是这条通路)
//!
//! 原来这三行是**写死的演示值**(`("Concurrency", "8 threads")` / `("Preferred source",
//! "Automatic (region)")` / `("Verify checksums", "On")`)。用户实测把它们当成真值投诉过
//! (「它默认显示 8,可我改成 2 也没变」)——因为 iced 侧**根本没有设置数据通路**:
//! `pages::Model` 没有设置快照、`grep -rn "SettingsChanged" crates/app-iced/src` 零命中、
//! [`State`] 是个空 struct 且无人填。而这三行里「8 threads」当时还是**假的**:引擎真值曾是 64
//! (schema 说 8),「Verify checksums」更是没有任何校验开关(校验恒开)。
//! ```text
//! presenter::host::settings::groups(&env) ← 真值(core schema 的键 + 生效值)
//! └─ CoreEvent::SettingsChanged(Vec<SettingGroupView>)
//! └─ AppState::settings_groups ← presenter 的唯一写入口(apply_core)
//! └─ pages::Model.settings ← refresh_view:这一帧要画的设置值
//! └─ settings::States.settings ← 本文件按 `id` 找到本组,逐行按 `control` 画
//! └─ 控件(choice / text / action)
//! └─ Message → app/update/settings/download.rs
//! └─ AppCommand::SetSetting → host 写盘 + 重新发 SettingsChanged
//! ```
//!
//! 所以这一轮把假值换成**不会撒谎的语义行**:区间、默认值、「留空 / off / 前缀」三态各是什么。
//! 真值(用户在 `launcher.json` 里填的那个数)不在这里显示——**宁可不说,也不说假的**。
//! 真正的可改控件要等一条 presenter 快照 → app → `Groups.download` 的数据通路,另立 mission。
//! **闭环**:控件不回显自己点的结果(不做乐观回显)。点了以后界面变,只能是因为 host 把
//! `SettingsChanged` 发回来了——那也正是「设置页里改了就生效」这句话的证据链。
//!
//! **生效保证**:这三条设置真的会被引擎读走(`core::download::shared::set_tuning`,宿主侧
//! 翻译在 `presenter::host::settings`),所以「行里的语义」与「引擎行为」是同一份契约。
//! ## 三件事分开画(这一组是「设置页里改不了」的修复落点)
//!
//! 1. **当前值**(choice 的选中 chip / text 的输入框)——来自快照,不来自任何字面量;
//! 2. **这一行在讲什么**(标题 + 说明)——说明里带着 presenter 算出来的「引擎生效值」,
//! 文件原值与生效值不一致时那句「原值 X 已按出厂 N 生效」也在里面(P3-5);
//! 3. **怎么改**(点 chip / 输入 + 应用 / 点动作按钮)——写回走 `AppCommand::SetSetting`。
//!
//! **没有快照**(harness / `pages_demo` / 首帧)时 `body` 返回 `None` ⇒ 退回去画 `GROUP.rows`
//! 那几行语义行(`frame::demo_card`)。那几行**说的不是值**(「出厂 8 · 可调 1~64」),
//! 所以不会再把演示值当成真值(M193 用户投诉的形状)。
//!
//! ## 文本行为什么要点「应用」
//!
//! 每敲一个键就写一次盘会把半截值落进 `launcher.json`(`off` 敲到 `of` 时 schema 会让它通过),
//! 而且 `store.set` 失败会弹一串 Toast。所以输入只改本地草稿([`State::edit`]),点「应用」
//! 才走 [`Message::Commit`] → `AppCommand::SetSetting`。
//!
//! **为什么不是回车提交**:本仓的 `text_field`(`app/components/inputs/field.rs`)只暴露 `on_input`,
//! 没有 `on_submit` 的口子;给它加一个口子要动组件库(组件库有自己的验收线),所以这一版只有
//! 「应用」这一颗按钮。回车提交登记为后续(见报告「没验/没做」)。
use std::collections::BTreeMap;
use iced::widget::{column, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{SettingControl, SettingGroupView, SettingRowView};
// `find_row` 只被 `#[cfg(test)]` 的探针用(产品路径按 `GROUP_ID` 取组、逐行画)。
#[cfg(test)]
use unlucky_presenter::dto::find_row;
use super::Group;
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::selection::{chip, switch, Chip, Switch};
use crate::pages::frame::text_role_owned;
use crate::pages::Message as PageMessage;
use crate::theme::Ui;
use super::frame::{card, divider};
use super::{Group, States};
/// Downloads 分组。
///
/// `rows` 只在**没有快照**时画(harness / 首帧):它们是语义行(出厂值 / 区间 / 三态),
/// 不是当前值——M193 用户的投诉正是「把演示值当成了真值」。
pub(super) const GROUP: Group = Group {
name: "Downloads",
icon: IconKind::Download,
@@ -36,20 +81,292 @@ pub(super) const GROUP: Group = Group {
("关掉它", "填 off = 关闭"),
("换掉它", "填 URL 前缀 = 覆盖"),
],
body: None,
body: Some(body),
};
/// 这一组在 presenter 快照里的 id(`setting::groups` 建组时给的那个)。
const GROUP_ID: &str = "download";
/// Downloads 分组的运行期状态(M101 r2:**只归 download 线**)。
///
/// 加字段只改本文件:`Groups` 只认 `Groups.download` 这一个字段。
/// 现在仍然是空的——真值的数据通路还没接(见文件头「为什么这三行是说语义」)。
/// 只装「用户正在编辑、还没提交」的文本草稿。**当前值不在这里**——它来自 presenter 快照
/// (`States::settings`),这里存一份就成了第二份真相。
#[derive(Debug, Default)]
pub struct State {}
pub struct State {
/// schema 键 → 正在输入但还没提交的文本。
drafts: BTreeMap<String, String>,
}
impl State {
/// 这一行此刻要显示的文本:有草稿用草稿,否则用快照里那个值(= 文件里的值)。
pub(crate) fn text_of<'a>(&'a self, row: &'a SettingRowView) -> &'a str {
self.drafts
.get(&row.key)
.map_or(row.value_text.as_str(), String::as_str)
}
/// 记一条草稿(`text_field` 的 `on_input` 落点;**不派发**任何指令)。
pub(crate) fn edit(&mut self, key: &str, value: String) {
self.drafts.insert(key.to_string(), value);
}
/// 这一行要提交的值(草稿优先)。`None` = 没草稿,调用方回落到快照里的值。
pub(crate) fn pending(&self, key: &str) -> Option<String> {
self.drafts.get(key).cloned()
}
/// 丢掉一行的草稿(提交之后用:快照重新成为唯一真值)。
pub(crate) fn clear(&mut self, key: &str) {
self.drafts.remove(key);
}
}
/// Downloads 分组的消息(M101:**只归 download 线**,加变体只改本文件)。
///
/// 落点是 `app/update/settings/download.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
/// [`GroupMessage`](super::GroupMessage))。异步结果(例如「测这个源可用」)在自己那条落点里构造
/// `Message::Page(pages::Message::Settings(GroupMessage::Download(…)))` 回来即可。
/// [`GroupMessage`](super::GroupMessage))。
#[derive(Debug, Clone)]
pub enum Message {}
pub enum Message {
/// `choice` 行:选了一个选项 —— 直接提交(选择本身就是意图,没有「半截」状态)。
Set {
/// schema 键(`download.concurrency`)
key: String,
/// 选项原文(`8` / `cn` / `off` 这类,写回时按 schema 的类型转换)
value: String,
},
/// `text` 行:正在输入 —— **只改本地草稿**,不派发。
Edit {
/// schema 键
key: String,
/// 输入框里的整段文本
value: String,
},
/// `text` 行:提交(「应用」按钮 / 回车)。
Commit {
/// schema 键
key: String,
},
/// `action` 行:动作按钮(例如「重新检测地区」)。
Action {
/// 快照里那一行的键(动作 id 由 host 在 `settings::set` 里认)
key: String,
},
}
/// 把本组消息包进设置页那条**唯一**投递口(`pages::Message::Settings(GroupMessage::Download)`)。
///
/// 为什么必须有它:本模块里 `Message` 是本组自己的枚举,而 `Element` 的消息类型是
/// `crate::pages::Message`(别名 [`PageMessage`])。不包这一层,控件会带上本组的消息类型,
/// 于是 `settings/mod.rs` 那个 `and_then` 与页面树就对不上(编译期就红,不会静默丢消息)。
fn msg(message: Message) -> PageMessage {
PageMessage::Settings(super::GroupMessage::Download(message))
}
// --------------------------------------------------------------- 正文
/// 整组正文:快照里按 [`GROUP_ID`] 找到本组,一行一个控件。
///
/// 返回 `None` = 这一帧没有本组的快照(harness / 首帧)⇒ `settings/mod.rs` 退回 `GROUP.rows`。
fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option<Element<'a, PageMessage>> {
let group = state_group(&states)?;
let drafts = &states.groups.download;
let catalog = lang.catalog();
let mut stacked = column![].spacing(0.0).width(Length::Fill);
for (index, item) in group.rows.iter().enumerate() {
if index > 0 {
stacked = stacked.push(divider(ui));
}
stacked = stacked.push(match item.control {
SettingControl::Toggle => toggle_row(ui, item),
SettingControl::Choice => choice_row(ui, item),
SettingControl::Text => {
text_row(ui, item, drafts, catalog.text(Key::SettingsDownloadsApply))
}
SettingControl::ReadOnly => read_only_row(ui, item),
SettingControl::Action => action_row(ui, item),
});
}
Some(card(ui, stacked))
}
/// 快照里的本组(`States::settings` 是 `Model.settings` 那一份)。
fn state_group<'a>(states: &States<'a>) -> Option<&'a SettingGroupView> {
states.settings.iter().find(|group| group.id == GROUP_ID)
}
/// 一行的标题(`BodyMedium`)。
fn heading<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
text_role_owned(
ui,
TypeRole::BodyMedium,
false,
item.label.clone(),
ui.color(Role::OnSurface),
)
}
/// 一行的说明(`BodySmall`;P3-5 那句「原值 X 已按出厂 N 生效」就在这段文字里)。
fn hint<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
text_role_owned(
ui,
TypeRole::BodySmall,
false,
item.description.clone(),
ui.color(Role::OnSurfaceVariant),
)
}
/// 标题 + 控件 + 说明(三行一组的常规排法)。
fn block<'a>(
ui: &'a Ui,
item: &'a SettingRowView,
control: Element<'a, PageMessage>,
) -> Element<'a, PageMessage> {
column![heading(ui, item), control, hint(ui, item)]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 开 / 关两态(本组暂时没有 Toggle 行,但 `SettingControl` 是穷尽枚举,画法在这里定下来)。
fn toggle_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
let key = item.key.clone();
let toggle = switch(
ui,
Switch::new(
&item.label,
item.flag,
msg(Message::Set {
key,
// schema 的 Bool 键认 `true` / `false`(`settings::parse_value` 也认 on/off/1/0)。
value: (!item.flag).to_string(),
}),
),
);
column![toggle, hint(ui, item)]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 分段单选:一个选项一颗 filter chip,选中的那颗是当前值。
fn choice_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
let mut options = row![]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
for (index, option) in item.options.iter().enumerate() {
let selected = i32::try_from(index)
.map(|index| index == item.selected)
.unwrap_or(false);
let key = item.key.clone();
let value = option.clone();
options = options.push(chip(
ui,
Chip::filter(option, selected, msg(Message::Set { key, value })),
));
}
block(ui, item, options.into())
}
/// 单行文本 + 「应用」按钮(`on_input` 只改草稿,点按钮 / 回车才写盘)。
fn text_row<'a>(
ui: &'a Ui,
item: &'a SettingRowView,
drafts: &'a State,
apply_label: &'a str,
) -> Element<'a, PageMessage> {
let key = item.key.clone();
let field = Field::new(drafts.text_of(item)).variant(FieldVariant::Outlined);
let input = text_field(ui, field, move |value| {
msg(Message::Edit {
key: key.clone(),
value,
})
});
let apply = buttons::text(
ui,
Button::new(
apply_label,
msg(Message::Commit {
key: item.key.clone(),
}),
),
);
let controls = row![input, apply]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill);
block(ui, item, controls.into())
}
/// 只读一行(本组没有;`SettingControl` 是穷尽枚举)。
fn read_only_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
block(
ui,
item,
text_role_owned(
ui,
TypeRole::BodySmall,
false,
item.value_text.clone(),
ui.color(Role::OnSurfaceVariant),
),
)
}
/// 动作按钮(「重新检测地区」)。
fn action_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
let button = buttons::tonal(
ui,
Button::new(
&item.label,
msg(Message::Action {
key: item.key.clone(),
}),
),
);
column![button, hint(ui, item)]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
// --------------------------------------------------------------- 测试探针
/// 这一帧本组要画的**控件种类**(顺序 = 快照里的行序)。
///
/// 存在的理由:`Element` 没有内省接口,「页面画了哪几种控件」只能从行模型上问。测试用它区分
/// 「画了真控件」与「退回了演示行」——这是 `body.is_some()` 之外唯一能看的东西。
///
/// `#[cfg(test)]`:产品路径不需要它(`body` 自己就在画),它只为断言存在。
#[cfg(test)]
pub(crate) fn controls_of(settings: &[SettingGroupView]) -> Vec<SettingControl> {
settings
.iter()
.find(|group| group.id == GROUP_ID)
.map(|group| group.rows.iter().map(|item| item.control).collect())
.unwrap_or_default()
}
/// 快照里本组某一行的**当前值**(choice 取选中的选项、text 取 `value_text`)。
///
/// 与 [`controls_of`] 同一条理由:页面把控件画出来之后,「它认为当前值是几」只能从这个入口问,
/// 不能从 `Element` 里读。
#[cfg(test)]
pub(crate) fn current_value(settings: &[SettingGroupView], key: &str) -> Option<String> {
let row = find_row(settings, key)?;
Some(match row.control {
SettingControl::Choice => row
.options
.get(usize::try_from(row.selected).ok()?)
.cloned()
.unwrap_or_default(),
// 其余三种控件的「当前值」就是 `value_text`(开关那一种用它表示 true/false)。
SettingControl::Toggle
| SettingControl::Text
| SettingControl::ReadOnly
| SettingControl::Action => row.value_text.clone(),
})
}
@@ -303,6 +303,7 @@ mod no_stray_empty_state {
States {
appearance: None,
groups: demo_groups(),
settings: &[],
}
}
+13 -1
View File
@@ -36,6 +36,8 @@
use std::sync::LazyLock;
use unlucky_presenter::dto::SettingGroupView;
use crate::pages::appearance;
use super::{ai_about, download, java, language_appearance};
@@ -55,7 +57,8 @@ pub struct Groups {
pub ai_about: ai_about::State,
}
/// 画一帧时**按值**递给各组渲染器的只读视图:组状态 + 外观运行期状态(两个引用,`Copy`)。
/// 画一帧时**按值**递给各组渲染器的只读视图:组状态 + 外观运行期状态 + presenter 的设置快照
/// (三个引用,`Copy`)。
///
/// 为什么按值而不是按引用:`Settings::view` 返回的 `Element<'a>` 会借走它,按引用传会逼着
/// harness 那条路径([`super::demo_groups`] 撑着的 `page()` / `page_with_appearance()`)为一份
@@ -67,6 +70,15 @@ pub struct States<'a> {
pub appearance: Option<&'a appearance::State>,
/// 各组自己的状态(见 [`Groups`])。
pub groups: &'a Groups,
/// **presenter 的设置组快照**(M201):`CoreEvent::SettingsChanged` 的回流结果,
/// `pages::Model` 那份(`Model::settings`)。空切片 = 这一帧没有快照(harness / 首帧),
/// 组自己的 `body` 据此退回演示行。
///
/// 为什么走这里而不是给 `settings::view` 加形参:`Group.body` 的槽位签名是
/// `fn(&Ui, States, Lang) -> Option<Element>`,它**只看得到 `States`**——下载组要画真控件
/// (`GROUP.body = Some(..)`,见 `settings/download.rs`)就必须从这里拿到快照。加形参那条路
/// 与 `GROUP.body` 互斥,而且要把 `settings/mod.rs`(399 行)再撑出 10 行。
pub settings: &'a [SettingGroupView],
}
/// 演示 / harness 路径的默认组状态(`pages::page` 与 `pages::page_with_appearance` 用它)。
@@ -154,6 +154,7 @@ fn appearance_texts(lang: Lang) -> Vec<(Key, &'static str)> {
States {
appearance: Some(&state),
groups: demo_groups(),
settings: &[],
},
Language {
choice: LangChoice::System,
@@ -1,19 +1,65 @@
//! 「Downloads」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
//! 一条 section 断言都没有;这几条是新增,不是搬移)。
//!
//! **归 download 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线):
//! `settings/download.rs` + 本文件。
//! **归 download 线**。M201 之前这一组只有演示行;现在它接了真控件(`GROUP.body = Some(body)`),
//! 数据来自 presenter 的设置快照(`States::settings` ← `pages::Model::settings`)。
//!
//! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。
//!
//! ## 这一轮换了什么(M201)
//!
//! | 旧绊线 | 现在 |
//! |---|---|
//! | `body.is_none()`(「这一组还没接真控件」) | `body.is_some()`(「接了,别再退回去」) |
//! | 只有 `rows` 的语义行断言 | 保留 + 三条**控件形状**断言(按 `SettingControl` 分发) |
//!
//! 写回闭环(点了以后盘上真的变了)不在本文件:那是 `app/update/settings/download_tests.rs`,
//! 它要一个假后端 + `App`,页面层的测试拿不到。
use super::super::download;
use super::super::States;
use unlucky_design::icons::IconKind;
use unlucky_presenter::dto::{SettingControl, SettingGroupView, SettingRowView};
/// 这一组的行集合(标签 + 值)。
/// 一份照 presenter 的 `host::settings::download::group` 形状搭的快照(键与控件种类同序)。
///
/// 为什么自己搭而不是问 presenter 要:那个函数要 `HostEnv`(读盘)且不在 `host` 的公开面上;
/// 本文件守的是**页面按 `control` 字段分发**这件事,不是 presenter 的行文案。
fn snapshot() -> Vec<SettingGroupView> {
vec![SettingGroupView::new("download", "下载与镜像(D7)")
.with(SettingRowView::choice(
"download.concurrency",
"并发数",
"同时下载的文件数",
&["4", "8", "16"],
1,
))
.with(SettingRowView::choice(
"download.retry_limit",
"单源尝试次数",
"同一个源最多试几次",
&["0", "1", "3"],
2,
))
.with(SettingRowView::text(
"download.proxy_url",
"HTTP 代理",
"manual 模式生效",
"",
))
.with(SettingRowView::action(
"download.action.detect_region",
"重新检测地区",
"清掉 24h 地区缓存",
"detect_region",
))]
}
/// 这一组的行集合(标签 + 值)——**没有快照时**画的语义行。
///
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(六份 `GROUP` 长得几乎一样,最容易整块贴错);
/// ② 漏掉/多出一条行;③ 标签与值错位。
/// 抓不住:值对不对——这三行**本来就不是值**(M193 起改成语义行,见下一条用例)。
/// 抓不住:值对不对(这几行本来就不是值,是语义),也抓不住有快照时画什么(下一条用例管)。
#[test]
fn the_downloads_group_shows_concurrency_retries_and_the_gh_proxy_key() {
let rows: Vec<(&str, &str)> = download::GROUP.rows.to_vec();
@@ -35,29 +81,76 @@ fn the_downloads_group_keeps_its_icon() {
assert_eq!(download::GROUP.icon, IconKind::Download);
}
/// **M193 换了绊线的内容,但没撤绊线**。
/// **M201 翻了这条绊线**:这一组现在有真控件渲染器。
///
/// 原绊线是 `the_downloads_group_is_still_demo_rows`:断言 `body.is_none()`(这一组还没接真控件)。
/// 它存在的意义是「防止演示值回潮」。M193 撤掉的只是**演示值**(`("Concurrency", "8 threads")`
/// 这类):那个 8 与引擎真实的 64 对不上,用户按它调设置自然没效果。
/// 旧绊线 `the_downloads_group_is_still_demo_rows`(`body.is_none()`)存在的意义是「防止演示值
/// 回潮」。M201 之后演示值已经不是问题——有快照就画真控件,没快照才退回语义行——所以绊线翻面:
///
/// 现在改成两条更硬的断言:
/// 抓得住:① 谁把 `GROUP.body` 改回 `None`(真控件当场消失,页面又只剩静态语义行 ——
/// 那正是用户投诉的形态);② 谁把渲染器挪走却忘了同步这里。
/// 抓不住:渲染器**画得对不对**(`Element` 没有内省接口,形状断言在下一条),也抓不住
/// 「有快照时真的走了 body」(`settings/mod.rs` 的 `and_then` 那一层;那一层断了会退回语义行,
/// 界面上看得出来,但没有断言)。
#[test]
fn the_downloads_group_carries_a_real_control_body() {
assert!(
download::GROUP.body.is_some(),
"Downloads 必须挂着真控件渲染器:`body = None` 就是「设置页里改不了」的回归"
);
}
/// **控件形状**:快照里有几种 `SettingControl`,页面就画几种(choice / text / action)。
///
/// 1. **仍然没有真控件**(`body.is_none()`)——真值的通路还没接(`State` 是空 struct、
/// iced 侧没有设置快照),所以这一组还是静态行;接上那天把这条改成 `is_some()` 并接数据。
/// 2. **行里不许再出现「当前值」形态的字面量**:三行必须把「出厂值 / 区间 / 三态」讲清楚,
/// 且 gh-proxy 那一行必须**同时**说清「留空 = 跟随内置默认」与「填 off = 关闭」——
/// 这两者最容易被混(一个「我没决定」,一个是「我明确不要」)。
/// 抓得住:① 渲染器只认 choice(把 text / action 也画成chip 或干脆不画)——`controls_of` 是从
/// 同一份行模型上读的,但**分发**发生在 `body` 里,改成 `match` 单臂会让别的行整段消失;
/// ② 谁把 `SettingControl::Text` 落到 `read_only_row` 上(那时输入框没了,用户改不了文本键)。
/// 抓不住:画出来的像素(那是截图的事),也抓不住每种控件是否真的连了消息(那是 app 层的
/// `download_tests.rs` 与真机截图)。
#[test]
fn the_downloads_body_follows_the_control_kind() {
let groups = snapshot();
assert_eq!(
download::controls_of(&groups),
vec![
SettingControl::Choice,
SettingControl::Choice,
SettingControl::Text,
SettingControl::Action,
],
"控件种类必须照快照的 `control` 字段画"
);
}
/// **没有快照就不许编值**:`controls_of` 空 ⇒ 页面退回 [`download::GROUP.rows`] 的语义行。
///
/// 变异证明:把 `download.rs` 的 gh_proxy 行改回 `("Preferred source", "Automatic (region)")`
/// ⇒ 第 2 条红;把 `enabled` 语义写成「留空 = 关闭」⇒ 第 2 条红。
/// 抓得住:谁为了「让页面好看」在渲染器里塞一份写死的默认快照(那就是 M193 被投诉的演示值,
/// 只不过换了个藏身处)。
/// 抓不住:「空快照时确实走了 `demo_card`」这一步(`settings/mod.rs` 的退回逻辑,它有自己的
/// `frame.rs` 守门)。
#[test]
fn an_empty_snapshot_lists_no_controls() {
assert!(download::controls_of(&[]).is_empty());
// 别的组在场、本组缺席,同样一条控件都不该画(不许拿别人的行凑数)。
let other = vec![
SettingGroupView::new("java", "Java(D13)").with(SettingRowView::toggle(
"java.auto_install",
"缺 Java 时自动下载",
"",
true,
)),
];
assert!(download::controls_of(&other).is_empty());
}
/// 语义行里的「三态 / 区间 / 出厂值」必须还在——它们是**没有快照时**唯一说得清的说明。
///
/// 这一条是 M193 留下的,M201 原样保留:即便有了真控件,兜底那一屏也不许出现假的当前值。
///
/// 抓得住:把 gh_proxy 那两行合并成一句(「留空 = 关闭」这种混法)、或者把区间 / 0-1 语义删掉。
/// 抓不住:有快照时行文案对不对(那时文案来自 presenter,见 `host/settings/download_tests.rs`)。
#[test]
fn the_downloads_rows_state_semantics_and_never_fake_a_current_value() {
assert!(
download::GROUP.body.is_none(),
"Downloads 还没接真控件:接了就改成 is_some() 并接数据通路"
);
let joined = download::GROUP
.rows
.iter()
@@ -93,3 +186,22 @@ fn the_downloads_rows_state_semantics_and_never_fake_a_current_value() {
);
}
}
/// 本组是**有快照就不退回演示行**的那一类:`States` 里带了快照时,`body` 一定拿得到组。
///
/// 站位提醒:`States` 是 `pages::settings::States`(三条线共享的只读视图),本文件只验证
/// 「本组能从它里面找到自己」——找不到就等于退回演示行。
#[test]
fn the_group_finds_itself_in_the_states_snapshot() {
let groups = snapshot();
let groups_state = super::super::demo_groups();
let states = States {
appearance: None,
groups: groups_state,
settings: &groups,
};
assert!(
states.settings.iter().any(|group| group.id == "download"),
"本组的 id 必须在快照里:`download`(presenter 的 `SettingGroupView::new(\"download\", ..)`)"
);
}