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:
1 parent
0cc1a35420
commit
ab573838f3
18 files changed
+1257
-74
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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: &[],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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\", ..)`)"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user