Merge branch 'feat/m202-settingsflow-iced-settings-data-pat'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
4c29cbac3e
18 files changed
+1397
-72
No files matched your search
@@ -214,3 +214,19 @@ pub struct App {
|
|||||||
/// [`crate::platform::win32::corner::CornerApplied`]。
|
/// [`crate::platform::win32::corner::CornerApplied`]。
|
||||||
pub(super) corner_applied: Option<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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,10 +23,30 @@ pub(super) fn app_options(options: &Options) -> AppOptions {
|
|||||||
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
|
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
|
||||||
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
|
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
|
||||||
data_root: options.data_dir.clone(),
|
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()` 打一行)。
|
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
|
||||||
pub(crate) fn log_first_frame() {
|
pub(crate) fn log_first_frame() {
|
||||||
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
|
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/settings/download.rs`(`pages::settings::download::Message`)。本文件只有一件事:
|
||||||
//! (`pages/download*` 与 `app/update/download.rs`)无关:那是「装哪个版本」,这一组是「怎么下」。
|
//! 把「用户改了哪个键」翻成 `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 super::super::*;
|
||||||
|
|
||||||
|
use unlucky_presenter::dto::{find_row, AppCommand};
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
/// Downloads 分组的一条消息。首版没有可处理的东西(空枚举)。
|
/// Downloads 分组的一条消息。
|
||||||
pub(super) fn on_settings_download(
|
pub(super) fn on_settings_download(
|
||||||
&mut self,
|
&mut self,
|
||||||
message: pages::settings::download::Message,
|
message: pages::settings::download::Message,
|
||||||
) -> Task<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();
|
||||||
|
}
|
||||||
@@ -92,6 +92,8 @@ impl App {
|
|||||||
pages::settings::States {
|
pages::settings::States {
|
||||||
appearance: Some(&self.appearance),
|
appearance: Some(&self.appearance),
|
||||||
groups: &self.settings,
|
groups: &self.settings,
|
||||||
|
// M201:下载组画真控件要的那份快照(`Model` 里那份,`refresh_view` 刚重建)。
|
||||||
|
settings: &self.view.settings,
|
||||||
},
|
},
|
||||||
// M106:设置页 General 组要画「当前档位 + 生效语言」。
|
// M106:设置页 General 组要画「当前档位 + 生效语言」。
|
||||||
self.language(),
|
self.language(),
|
||||||
@@ -190,6 +192,9 @@ pub(super) fn model(
|
|||||||
logs: logs_model(state).with_status(lang),
|
logs: logs_model(state).with_status(lang),
|
||||||
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
|
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
|
||||||
update: update_of(&state.update),
|
update: update_of(&state.update),
|
||||||
|
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
|
||||||
|
// schema 键取当前值画控件;写回后 host 重新发 `SettingsChanged` ⇒ 下一次重建换新值。
|
||||||
|
settings: state.settings_groups.clone(),
|
||||||
lang,
|
lang,
|
||||||
forced: forced.map(|state| match state {
|
forced: forced.map(|state| match state {
|
||||||
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
|
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ fn main() -> ExitCode {
|
|||||||
"验收诊断:ULL_ACCEPTANCE=1([console]/[env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\
|
"验收诊断:ULL_ACCEPTANCE=1([console]/[env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\
|
||||||
,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸"
|
,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸"
|
||||||
);
|
);
|
||||||
|
// M201:设置页要出「带真值」的验收图,而截图通道默认强制 mock(mock 不发
|
||||||
|
// `CoreEvent::SettingsChanged`)。这一条把**数据源**换成真 host,其余一字不改。
|
||||||
|
eprintln!(
|
||||||
|
"取证:ULL_SCREENSHOT_REAL_HOST=1 让 --screenshot 接真 host(默认 mock;\
|
||||||
|
出图将不再可复现,只用于验收真值屏)"
|
||||||
|
);
|
||||||
ExitCode::from(2)
|
ExitCode::from(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ pub fn page<'a>(
|
|||||||
settings::States {
|
settings::States {
|
||||||
appearance: None,
|
appearance: None,
|
||||||
groups: settings::demo_groups(),
|
groups: settings::demo_groups(),
|
||||||
|
// harness 路径没有 presenter 快照:空切片 ⇒ 各组画演示行(`Model::settings` 此时是空的)。
|
||||||
|
settings: &model.settings,
|
||||||
},
|
},
|
||||||
Language::default(),
|
Language::default(),
|
||||||
form,
|
form,
|
||||||
@@ -193,6 +195,7 @@ pub fn page_with_appearance<'a>(
|
|||||||
settings::States {
|
settings::States {
|
||||||
appearance: Some(appearance),
|
appearance: Some(appearance),
|
||||||
groups: settings::demo_groups(),
|
groups: settings::demo_groups(),
|
||||||
|
settings: &model.settings,
|
||||||
},
|
},
|
||||||
Language::default(),
|
Language::default(),
|
||||||
form,
|
form,
|
||||||
@@ -291,6 +294,7 @@ pub fn app_with_appearance<'a>(
|
|||||||
settings::States {
|
settings::States {
|
||||||
appearance,
|
appearance,
|
||||||
groups: settings::demo_groups(),
|
groups: settings::demo_groups(),
|
||||||
|
settings: &model.settings,
|
||||||
},
|
},
|
||||||
Language::default(),
|
Language::default(),
|
||||||
form,
|
form,
|
||||||
|
|||||||
@@ -124,6 +124,16 @@ pub struct Model {
|
|||||||
pub content: Content,
|
pub content: Content,
|
||||||
/// 自更新的展示状态(M159:设置页「更新」区 + 顶部提示条,见 [`super::update`])。
|
/// 自更新的展示状态(M159:设置页「更新」区 + 顶部提示条,见 [`super::update`])。
|
||||||
pub update: 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 层决定后灌进来。
|
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
|
||||||
///
|
///
|
||||||
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
|
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
|
||||||
@@ -156,6 +166,9 @@ impl Model {
|
|||||||
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
|
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
|
||||||
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
|
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
|
||||||
update: Update::default(),
|
update: Update::default(),
|
||||||
|
// 空快照 = 这一帧没有设置数据(harness / `pages_demo`;`demo_model` 也不伪造一份,
|
||||||
|
// 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。
|
||||||
|
settings: Vec::new(),
|
||||||
lang: Lang::En,
|
lang: Lang::En,
|
||||||
forced: None,
|
forced: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,76 @@
|
|||||||
//! 设置页「Downloads」分组:并发 / 单源尝试次数 / GitHub 加速前缀(M193)。
|
//! 设置页「Downloads」分组:并发 / 单源尝试次数 / 地区 / 镜像 / 代理 / GitHub 加速前缀(M201)。
|
||||||
//!
|
//!
|
||||||
//! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(download::body)`(渲染器也写在本文件),
|
//! **这一组归 download 线**:正文渲染器在本文件(`GROUP.body = Some(body)`),`settings/mod.rs`
|
||||||
//! `settings/mod.rs` 的注册表不用动。
|
//! 的注册表不用动。
|
||||||
//!
|
//!
|
||||||
//! 注意与下载页(`pages/download*`)的区别:那是「装哪个版本」,这里是「怎么下」的默认值。
|
//! 注意与下载页(`pages/download*`)的区别:那是「装哪个版本」,这里是「怎么下」的默认值。
|
||||||
//!
|
//!
|
||||||
//! ## 为什么这三行是「说语义」而不是「说当前值」(M193)
|
//! ## 数据从哪来、写到哪去(M201 接的就是这条通路)
|
||||||
//!
|
//!
|
||||||
//! 原来这三行是**写死的演示值**(`("Concurrency", "8 threads")` / `("Preferred source",
|
//! ```text
|
||||||
//! "Automatic (region)")` / `("Verify checksums", "On")`)。用户实测把它们当成真值投诉过
|
//! presenter::host::settings::groups(&env) ← 真值(core schema 的键 + 生效值)
|
||||||
//! (「它默认显示 8,可我改成 2 也没变」)——因为 iced 侧**根本没有设置数据通路**:
|
//! └─ CoreEvent::SettingsChanged(Vec<SettingGroupView>)
|
||||||
//! `pages::Model` 没有设置快照、`grep -rn "SettingsChanged" crates/app-iced/src` 零命中、
|
//! └─ AppState::settings_groups ← presenter 的唯一写入口(apply_core)
|
||||||
//! [`State`] 是个空 struct 且无人填。而这三行里「8 threads」当时还是**假的**:引擎真值曾是 64
|
//! └─ pages::Model.settings ← refresh_view:这一帧要画的设置值
|
||||||
//! (schema 说 8),「Verify checksums」更是没有任何校验开关(校验恒开)。
|
//! └─ settings::States.settings ← 本文件按 `id` 找到本组,逐行按 `control` 画
|
||||||
|
//! └─ 控件(choice / text / action)
|
||||||
|
//! └─ Message → app/update/settings/download.rs
|
||||||
|
//! └─ AppCommand::SetSetting → host 写盘 + 重新发 SettingsChanged
|
||||||
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! 所以这一轮把假值换成**不会撒谎的语义行**:区间、默认值、「留空 / off / 前缀」三态各是什么。
|
//! **闭环**:控件不回显自己点的结果(不做乐观回显)。点了以后界面变,只能是因为 host 把
|
||||||
//! 真值(用户在 `launcher.json` 里填的那个数)不在这里显示——**宁可不说,也不说假的**。
|
//! `SettingsChanged` 发回来了——那也正是「设置页里改了就生效」这句话的证据链。
|
||||||
//! 真正的可改控件要等一条 presenter 快照 → app → `Groups.download` 的数据通路,另立 mission。
|
|
||||||
//!
|
//!
|
||||||
//! **生效保证**:这三条设置真的会被引擎读走(`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::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 分组。
|
/// Downloads 分组。
|
||||||
|
///
|
||||||
|
/// `rows` 只在**没有快照**时画(harness / 首帧):它们是语义行(出厂值 / 区间 / 三态),
|
||||||
|
/// 不是当前值——M193 用户的投诉正是「把演示值当成了真值」。
|
||||||
pub(super) const GROUP: Group = Group {
|
pub(super) const GROUP: Group = Group {
|
||||||
name: "Downloads",
|
name: "Downloads",
|
||||||
icon: IconKind::Download,
|
icon: IconKind::Download,
|
||||||
@@ -36,20 +81,304 @@ pub(super) const GROUP: Group = Group {
|
|||||||
("关掉它", "填 off = 关闭"),
|
("关掉它", "填 off = 关闭"),
|
||||||
("换掉它", "填 URL 前缀 = 覆盖"),
|
("换掉它", "填 URL 前缀 = 覆盖"),
|
||||||
],
|
],
|
||||||
body: None,
|
body: Some(body),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// 这一组在 presenter 快照里的 id(`setting::groups` 建组时给的那个)。
|
||||||
|
const GROUP_ID: &str = "download";
|
||||||
|
|
||||||
/// Downloads 分组的运行期状态(M101 r2:**只归 download 线**)。
|
/// Downloads 分组的运行期状态(M101 r2:**只归 download 线**)。
|
||||||
///
|
///
|
||||||
/// 加字段只改本文件:`Groups` 只认 `Groups.download` 这一个字段。
|
/// 只装「用户正在编辑、还没提交」的文本草稿。**当前值不在这里**——它来自 presenter 快照
|
||||||
/// 现在仍然是空的——真值的数据通路还没接(见文件头「为什么这三行是说语义」)。
|
/// (`States::settings`),这里存一份就成了第二份真相。
|
||||||
#[derive(Debug, Default)]
|
#[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 线**,加变体只改本文件)。
|
/// Downloads 分组的消息(M101:**只归 download 线**,加变体只改本文件)。
|
||||||
///
|
///
|
||||||
/// 落点是 `app/update/settings/download.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
|
/// 落点是 `app/update/settings/download.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
|
||||||
/// [`GroupMessage`](super::GroupMessage))。异步结果(例如「测这个源可用」)在自己那条落点里构造
|
/// [`GroupMessage`](super::GroupMessage))。
|
||||||
/// `Message::Page(pages::Message::Settings(GroupMessage::Download(…)))` 回来即可。
|
|
||||||
#[derive(Debug, Clone)]
|
#[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`。
|
||||||
|
///
|
||||||
|
/// **行间距 = `Space::X3`(M201 r1 视觉复审 P2-1)**:分隔线的对比度只有 1.55:1(`OutlineVariant`
|
||||||
|
/// 本色,正常),「这是新的一行」这件事全靠**间距**承担。r1 复审实测这一组原本是 0 间距:说明末行
|
||||||
|
/// 墨迹 → 分隔线只有 2–3px、分隔线 → 下一行标题 4–5px,100% 缩放下「地区判定」「镜像偏好」被读成
|
||||||
|
/// 上一段说明的续行。
|
||||||
|
///
|
||||||
|
/// 为什么是 X3(12)而不是兄弟组的 X2(8):本组每一行都是「标题 + 控件 + 多行说明」三段,比 Java
|
||||||
|
/// 组的行**密得多**,沿用 8 实测只到 8–9px(文本盒里 12–13px 的墨迹落在 16px 行盒中,盒间距 8 会被
|
||||||
|
/// 吃掉 1px);X3 把两侧都推到 12px 以上,落在「同族页 9–18px」的中段(实测见报告的表)。
|
||||||
|
///
|
||||||
|
/// 为什么不给 [`block`] 加 padding:那会把卡片的**第一行顶距**也撑开(卡内边距 20 + 12),与其它四组
|
||||||
|
/// 的第一行不再对齐;`spacing` 只作用在子节点之间,首行顶距与末行底距仍是卡片的 20。
|
||||||
|
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(ui.space(Space::X3)).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 {
|
States {
|
||||||
appearance: None,
|
appearance: None,
|
||||||
groups: demo_groups(),
|
groups: demo_groups(),
|
||||||
|
settings: &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@
|
|||||||
|
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use unlucky_presenter::dto::SettingGroupView;
|
||||||
|
|
||||||
use crate::pages::appearance;
|
use crate::pages::appearance;
|
||||||
|
|
||||||
use super::{ai_about, download, java, language_appearance};
|
use super::{ai_about, download, java, language_appearance};
|
||||||
@@ -55,7 +57,8 @@ pub struct Groups {
|
|||||||
pub ai_about: ai_about::State,
|
pub ai_about: ai_about::State,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 画一帧时**按值**递给各组渲染器的只读视图:组状态 + 外观运行期状态(两个引用,`Copy`)。
|
/// 画一帧时**按值**递给各组渲染器的只读视图:组状态 + 外观运行期状态 + presenter 的设置快照
|
||||||
|
/// (三个引用,`Copy`)。
|
||||||
///
|
///
|
||||||
/// 为什么按值而不是按引用:`Settings::view` 返回的 `Element<'a>` 会借走它,按引用传会逼着
|
/// 为什么按值而不是按引用:`Settings::view` 返回的 `Element<'a>` 会借走它,按引用传会逼着
|
||||||
/// harness 那条路径([`super::demo_groups`] 撑着的 `page()` / `page_with_appearance()`)为一份
|
/// harness 那条路径([`super::demo_groups`] 撑着的 `page()` / `page_with_appearance()`)为一份
|
||||||
@@ -67,6 +70,15 @@ pub struct States<'a> {
|
|||||||
pub appearance: Option<&'a appearance::State>,
|
pub appearance: Option<&'a appearance::State>,
|
||||||
/// 各组自己的状态(见 [`Groups`])。
|
/// 各组自己的状态(见 [`Groups`])。
|
||||||
pub groups: &'a 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` 用它)。
|
/// 演示 / harness 路径的默认组状态(`pages::page` 与 `pages::page_with_appearance` 用它)。
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ fn appearance_texts(lang: Lang) -> Vec<(Key, &'static str)> {
|
|||||||
States {
|
States {
|
||||||
appearance: Some(&state),
|
appearance: Some(&state),
|
||||||
groups: demo_groups(),
|
groups: demo_groups(),
|
||||||
|
settings: &[],
|
||||||
},
|
},
|
||||||
Language {
|
Language {
|
||||||
choice: LangChoice::System,
|
choice: LangChoice::System,
|
||||||
|
|||||||
@@ -1,19 +1,65 @@
|
|||||||
//! 「Downloads」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
|
//! 「Downloads」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
|
||||||
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
|
//! 一条 section 断言都没有;这几条是新增,不是搬移)。
|
||||||
//!
|
//!
|
||||||
//! **归 download 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线):
|
//! **归 download 线**。M201 之前这一组只有演示行;现在它接了真控件(`GROUP.body = Some(body)`),
|
||||||
//! `settings/download.rs` + 本文件。
|
//! 数据来自 presenter 的设置快照(`States::settings` ← `pages::Model::settings`)。
|
||||||
//!
|
//!
|
||||||
//! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。
|
//! 别把它和下载页(`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::download;
|
||||||
|
use super::super::States;
|
||||||
use unlucky_design::icons::IconKind;
|
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` 长得几乎一样,最容易整块贴错);
|
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(六份 `GROUP` 长得几乎一样,最容易整块贴错);
|
||||||
/// ② 漏掉/多出一条行;③ 标签与值错位。
|
/// ② 漏掉/多出一条行;③ 标签与值错位。
|
||||||
/// 抓不住:值对不对——这三行**本来就不是值**(M193 起改成语义行,见下一条用例)。
|
/// 抓不住:值对不对(这几行本来就不是值,是语义),也抓不住有快照时画什么(下一条用例管)。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_downloads_group_shows_concurrency_retries_and_the_gh_proxy_key() {
|
fn the_downloads_group_shows_concurrency_retries_and_the_gh_proxy_key() {
|
||||||
let rows: Vec<(&str, &str)> = download::GROUP.rows.to_vec();
|
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);
|
assert_eq!(download::GROUP.icon, IconKind::Download);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **M193 换了绊线的内容,但没撤绊线**。
|
/// **M201 翻了这条绊线**:这一组现在有真控件渲染器。
|
||||||
///
|
///
|
||||||
/// 原绊线是 `the_downloads_group_is_still_demo_rows`:断言 `body.is_none()`(这一组还没接真控件)。
|
/// 旧绊线 `the_downloads_group_is_still_demo_rows`(`body.is_none()`)存在的意义是「防止演示值
|
||||||
/// 它存在的意义是「防止演示值回潮」。M193 撤掉的只是**演示值**(`("Concurrency", "8 threads")`
|
/// 回潮」。M201 之后演示值已经不是问题——有快照就画真控件,没快照才退回语义行——所以绊线翻面:
|
||||||
/// 这类):那个 8 与引擎真实的 64 对不上,用户按它调设置自然没效果。
|
|
||||||
///
|
///
|
||||||
/// 现在改成两条更硬的断言:
|
/// 抓得住:① 谁把 `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、
|
/// 抓得住:① 渲染器只认 choice(把 text / action 也画成chip 或干脆不画)——`controls_of` 是从
|
||||||
/// iced 侧没有设置快照),所以这一组还是静态行;接上那天把这条改成 `is_some()` 并接数据。
|
/// 同一份行模型上读的,但**分发**发生在 `body` 里,改成 `match` 单臂会让别的行整段消失;
|
||||||
/// 2. **行里不许再出现「当前值」形态的字面量**:三行必须把「出厂值 / 区间 / 三态」讲清楚,
|
/// ② 谁把 `SettingControl::Text` 落到 `read_only_row` 上(那时输入框没了,用户改不了文本键)。
|
||||||
/// 且 gh-proxy 那一行必须**同时**说清「留空 = 跟随内置默认」与「填 off = 关闭」——
|
/// 抓不住:画出来的像素(那是截图的事),也抓不住每种控件是否真的连了消息(那是 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)")`
|
/// 抓得住:谁为了「让页面好看」在渲染器里塞一份写死的默认快照(那就是 M193 被投诉的演示值,
|
||||||
/// ⇒ 第 2 条红;把 `enabled` 语义写成「留空 = 关闭」⇒ 第 2 条红。
|
/// 只不过换了个藏身处)。
|
||||||
|
/// 抓不住:「空快照时确实走了 `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]
|
#[test]
|
||||||
fn the_downloads_rows_state_semantics_and_never_fake_a_current_value() {
|
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
|
let joined = download::GROUP
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.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\", ..)`)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -209,4 +209,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
|||||||
("SettingsJavaPrefsSaved", "Saved and read back from disk: all three keys are in the settings file.", "已保存并从磁盘读回:三个键都进配置文件了。"),
|
("SettingsJavaPrefsSaved", "Saved and read back from disk: all three keys are in the settings file.", "已保存并从磁盘读回:三个键都进配置文件了。"),
|
||||||
("SettingsJavaPrefsUnsaved", "There are unsaved changes in this section.", "这一段有未保存的改动。"),
|
("SettingsJavaPrefsUnsaved", "There are unsaved changes in this section.", "这一段有未保存的改动。"),
|
||||||
("SettingsJavaReasonInstallRootTooLong", "the folder path is too long", "目录路径太长"),
|
("SettingsJavaReasonInstallRootTooLong", "the folder path is too long", "目录路径太长"),
|
||||||
|
// M201:设置页 Downloads 组的文本行提交按钮(装配链里本域紧随 `settings_java_entries`)。
|
||||||
|
("SettingsDownloadsApply", "Apply", "应用"),
|
||||||
];
|
];
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
//! 设置页「Downloads」分组文案:**预留**(页面侧的真控件还没接)。
|
//! 设置页「Downloads」分组文案(M201 起有内容:这一组接了真控件)。
|
||||||
//!
|
//!
|
||||||
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
|
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
|
||||||
//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。
|
//! 本域在装配链里排在 `settings_java_entries` 之后、`settings_ai_entries` 之前,所以金表那几行
|
||||||
|
//! 也落在 `SettingsJavaReasonInstallRootTooLong` 与 `SettingsGroupAssistant` 之间。
|
||||||
|
//!
|
||||||
|
//! ## 只有一条 key 的理由
|
||||||
|
//!
|
||||||
|
//! 下载组的**行文案**(`并发数` / `单源尝试次数` / 「原值 9999 已按出厂 8 生效」这些)不在这里:
|
||||||
|
//! 它们由 presenter 按 schema 键现算(`dto::SettingRowView::label` / `description`),因为那些句子
|
||||||
|
//! 里带着**生效值**——把数字搬进静态目录只会再长出一份会说谎的文案。这一域只放「页面自己的
|
||||||
|
//! 按钮词」:提交文本框那一下的「应用」。
|
||||||
|
|
||||||
macro_rules! settings_download_entries {
|
macro_rules! settings_download_entries {
|
||||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||||
$next! { $($rest,)* { $($acc)* } }
|
$next! { $($rest,)* {
|
||||||
|
$($acc)*
|
||||||
|
// ── 文本行的提交按钮(`pages/settings/download.rs` 的 text 行)
|
||||||
|
SettingsDownloadsApply => ("Apply", "应用"),
|
||||||
|
} }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ pub use pack::{
|
|||||||
ImportState, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
ImportState, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
||||||
PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
|
PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
|
||||||
};
|
};
|
||||||
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
pub use settings::{find_row, SettingControl, SettingGroupView, SettingRowView};
|
||||||
// M159: 自更新
|
// M159: 自更新
|
||||||
pub use update::{
|
pub use update::{
|
||||||
ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView,
|
ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView,
|
||||||
|
|||||||
@@ -22,14 +22,15 @@
|
|||||||
//! 「读起来是纯读、实际改全局状态」正是本仓吃过亏的那类形状。
|
//! 「读起来是纯读、实际改全局状态」正是本仓吃过亏的那类形状。
|
||||||
|
|
||||||
use unlucky_core::download::config::{
|
use unlucky_core::download::config::{
|
||||||
DownloadTuning, DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY, MAX_CONCURRENCY,
|
DownloadTuning, GhProxyOverride, DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY,
|
||||||
|
MAX_CONCURRENCY,
|
||||||
};
|
};
|
||||||
use unlucky_core::download::shared;
|
use unlucky_core::download::shared;
|
||||||
|
|
||||||
use crate::dto::{SettingGroupView, SettingRowView};
|
use crate::dto::{SettingGroupView, SettingRowView};
|
||||||
|
|
||||||
use super::super::env::HostEnv;
|
use super::super::env::HostEnv;
|
||||||
use super::{int, selected, text};
|
use super::{selected, text};
|
||||||
|
|
||||||
/// 并发数的推荐档位(`selected()` 找不到时回 0;手改出来的非档位值会被插进选项表,见 [`group`])。
|
/// 并发数的推荐档位(`selected()` 找不到时回 0;手改出来的非档位值会被插进选项表,见 [`group`])。
|
||||||
const CONCURRENCY_LADDER: [&str; 5] = ["4", "8", "16", "32", "64"];
|
const CONCURRENCY_LADDER: [&str; 5] = ["4", "8", "16", "32", "64"];
|
||||||
@@ -37,19 +38,28 @@ const CONCURRENCY_LADDER: [&str; 5] = ["4", "8", "16", "32", "64"];
|
|||||||
const RETRY_LADDER: [&str; 6] = ["0", "1", "2", "3", "5", "10"];
|
const RETRY_LADDER: [&str; 6] = ["0", "1", "2", "3", "5", "10"];
|
||||||
|
|
||||||
/// 下载(D7):并发/重试/地区/镜像偏好/代理/GitHub 加速前缀。
|
/// 下载(D7):并发/重试/地区/镜像偏好/代理/GitHub 加速前缀。
|
||||||
|
///
|
||||||
|
/// ## P3-5:这两行显示的是**引擎生效值**,不是文件原值
|
||||||
|
///
|
||||||
|
/// `download.concurrency` / `download.retry_limit` 的生效判定在 core([`DownloadTuning`]):
|
||||||
|
/// 越界、负数、手改坏值一律**回落出厂值**(不夹到边界——「想 9999 给 64」仍然是界面说谎)。
|
||||||
|
/// 所以这一组必须画 [`tuning_of`] 的结论;文件里的原值只在**两者不一致**时作为一句解释出现
|
||||||
|
/// (「原值 9999 已按出厂 8 生效」)。判定的两个输入都是**现读**的:文件原值取自
|
||||||
|
/// `doc().get(..)`(显式写过的值),生效值取自 core 的翻译层 —— 没有一个数字来自常量或参数。
|
||||||
pub(super) fn group(env: &HostEnv) -> SettingGroupView {
|
pub(super) fn group(env: &HostEnv) -> SettingGroupView {
|
||||||
let concurrency = int(
|
let tuning = tuning_of(env);
|
||||||
env,
|
let concurrency = (tuning.max_concurrency as i64).to_string();
|
||||||
unlucky_core::download::config::KEY_CONCURRENCY,
|
let retry = i64::from(tuning.max_attempts_per_source).to_string();
|
||||||
|
let concurrency_note = applied_note(
|
||||||
|
raw_text(env, unlucky_core::download::config::KEY_CONCURRENCY),
|
||||||
|
tuning.max_concurrency as i64,
|
||||||
DEFAULT_MAX_CONCURRENCY as i64,
|
DEFAULT_MAX_CONCURRENCY as i64,
|
||||||
)
|
);
|
||||||
.to_string();
|
let retry_note = applied_note(
|
||||||
let retry = int(
|
raw_text(env, unlucky_core::download::config::KEY_RETRY_LIMIT),
|
||||||
env,
|
i64::from(tuning.max_attempts_per_source),
|
||||||
unlucky_core::download::config::KEY_RETRY_LIMIT,
|
|
||||||
i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE),
|
i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE),
|
||||||
)
|
);
|
||||||
.to_string();
|
|
||||||
let concurrency_options = ladder(&CONCURRENCY_LADDER, &concurrency);
|
let concurrency_options = ladder(&CONCURRENCY_LADDER, &concurrency);
|
||||||
let retry_options = ladder(&RETRY_LADDER, &retry);
|
let retry_options = ladder(&RETRY_LADDER, &retry);
|
||||||
let concurrency_options: Vec<&str> = concurrency_options.iter().map(String::as_str).collect();
|
let concurrency_options: Vec<&str> = concurrency_options.iter().map(String::as_str).collect();
|
||||||
@@ -64,18 +74,24 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
|
|||||||
.with(SettingRowView::choice(
|
.with(SettingRowView::choice(
|
||||||
unlucky_core::download::config::KEY_CONCURRENCY,
|
unlucky_core::download::config::KEY_CONCURRENCY,
|
||||||
"并发数",
|
"并发数",
|
||||||
&format!(
|
&with_note(
|
||||||
|
format!(
|
||||||
"同时下载的文件数:出厂 {DEFAULT_MAX_CONCURRENCY},可调 1~{MAX_CONCURRENCY}(引擎硬上限,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效"
|
"同时下载的文件数:出厂 {DEFAULT_MAX_CONCURRENCY},可调 1~{MAX_CONCURRENCY}(引擎硬上限,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效"
|
||||||
),
|
),
|
||||||
|
concurrency_note.as_deref(),
|
||||||
|
),
|
||||||
&concurrency_options,
|
&concurrency_options,
|
||||||
selected(&concurrency_options, &concurrency),
|
selected(&concurrency_options, &concurrency),
|
||||||
))
|
))
|
||||||
.with(SettingRowView::choice(
|
.with(SettingRowView::choice(
|
||||||
unlucky_core::download::config::KEY_RETRY_LIMIT,
|
unlucky_core::download::config::KEY_RETRY_LIMIT,
|
||||||
"单源尝试次数",
|
"单源尝试次数",
|
||||||
&format!(
|
&with_note(
|
||||||
|
format!(
|
||||||
"同一个源最多试几次(含首次;出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE})。0 与 1 都等于「只试一次、不重试」——0 是旧版提供的档位,不代表「不下载」。换源不受此限"
|
"同一个源最多试几次(含首次;出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE})。0 与 1 都等于「只试一次、不重试」——0 是旧版提供的档位,不代表「不下载」。换源不受此限"
|
||||||
),
|
),
|
||||||
|
retry_note.as_deref(),
|
||||||
|
),
|
||||||
&retry_options,
|
&retry_options,
|
||||||
selected(&retry_options, &retry),
|
selected(&retry_options, &retry),
|
||||||
))
|
))
|
||||||
@@ -129,10 +145,15 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
|
|||||||
))
|
))
|
||||||
// M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把
|
// M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把
|
||||||
// 「我还没决定」当成「我不要加速」,于是以为自己关掉了、实际还在套前缀。
|
// 「我还没决定」当成「我不要加速」,于是以为自己关掉了、实际还在套前缀。
|
||||||
|
//
|
||||||
|
// P3-5:末尾那半句是**引擎生效档位**(`tuning.gh_proxy` 的翻译结论),不是文件原文。
|
||||||
.with(SettingRowView::text(
|
.with(SettingRowView::text(
|
||||||
unlucky_core::download::config::KEY_GH_PROXY,
|
unlucky_core::download::config::KEY_GH_PROXY,
|
||||||
"GitHub 加速前缀(gh-proxy)",
|
"GitHub 加速前缀(gh-proxy)",
|
||||||
"留空 = 跟随内置默认(镜像优先/国内判定时自动套 gh-proxy.com);填 off = 关闭(不生成任何 gh-proxy 候选);填一个 URL 前缀 = 覆盖它",
|
&format!(
|
||||||
|
"留空 = 跟随内置默认(镜像优先/国内判定时自动套 gh-proxy.com);填 off = 关闭(不生成任何 gh-proxy 候选);填一个 URL 前缀 = 覆盖它;当前生效:{}",
|
||||||
|
gh_proxy_state(&tuning.gh_proxy)
|
||||||
|
),
|
||||||
&text(env, unlucky_core::download::config::KEY_GH_PROXY),
|
&text(env, unlucky_core::download::config::KEY_GH_PROXY),
|
||||||
))
|
))
|
||||||
.with(SettingRowView::action(
|
.with(SettingRowView::action(
|
||||||
@@ -153,6 +174,72 @@ fn ladder(ladder: &[&str], current: &str) -> Vec<String> {
|
|||||||
options
|
options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 文件里**显式写过**的原值,转成**上屏文本**(`None` = 文件没写这个键)。
|
||||||
|
///
|
||||||
|
/// 与 `super::int` 的区别:那个走 `effective()`(文件值 → schema 默认值),本函数只问文件本身。
|
||||||
|
/// 「原值 X 已按出厂 N 生效」这句话的两个数必须现读(一个来自文件、一个来自 core 的翻译层),
|
||||||
|
/// **不许**拿常量或请求参数冒充——那是 U1 明令禁止的假证据。
|
||||||
|
///
|
||||||
|
/// ## 为什么返回字符串而不是 `i64`(r1 代码复审 p2)
|
||||||
|
///
|
||||||
|
/// 手改文件可以把一个整数键写成 `"auto"` / `8.5` / `true`:`document::load` 对坏值只记
|
||||||
|
/// `LoadProblem`、原样留在 `values` 里,所以 `doc().get(key)` 真的能拿到 `Some(String("auto"))`。
|
||||||
|
/// 早先这里用 `Value::as_i64`,非整数一律 `None` ⇒ 行上画着生效值 8,却**不告诉用户他写的
|
||||||
|
/// `auto` 被忽略了**——「设置项说假话」的最后一个洞。改成上屏文本后,越界整数(`9999` / `-4`)、
|
||||||
|
/// 非整数 JSON(`8.5`)、别的类型(`true`)、字符串(`auto`)**都能进那句提示**;
|
||||||
|
/// 数字 `to_string()` 出来仍是 `9999`,所以旧断言与旧措辞一字未变。
|
||||||
|
fn raw_text(env: &HostEnv, key: &str) -> Option<String> {
|
||||||
|
use serde_json::Value;
|
||||||
|
let store = env.store();
|
||||||
|
match store.doc().get(key)? {
|
||||||
|
// `null` 等于「没写」:`effective()` 也是这么当的(`filter(|value| !value.is_null())`)。
|
||||||
|
Value::Null => None,
|
||||||
|
// 字符串照原文上屏(不要 serde 的引号):用户写的是 `auto`,提示里就该是 `auto`。
|
||||||
|
Value::String(text) => Some(text.clone()),
|
||||||
|
other => Some(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文件原值 ≠ 生效值时的说明;`None` = 没什么可说的(没写过,或写的就是生效值)。
|
||||||
|
///
|
||||||
|
/// `raw` 是**上屏文本**(见 [`raw_text`]),`effective` / `factory` 是 core 翻译层的结论。
|
||||||
|
fn applied_note(raw: Option<String>, effective: i64, factory: i64) -> Option<String> {
|
||||||
|
let raw = raw?;
|
||||||
|
// 比的是「上屏文本」:文件写 `8`、生效也是 8 ⇒ 没什么可说的(`8.0` 会照实提示,因为那是一次
|
||||||
|
// 类型不一致的坏值)。
|
||||||
|
if raw == effective.to_string() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(if effective == factory {
|
||||||
|
format!("原值 {raw} 已按出厂 {factory} 生效")
|
||||||
|
} else {
|
||||||
|
format!("原值 {raw} 已按 {effective} 生效")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 说明 +(有差异时)那一句「原值 … 生效」。
|
||||||
|
///
|
||||||
|
/// **那句结论独占一行(M201 r1 视觉复审 P2-2)**:原先用「;」接在说明末尾,于是在设置页 424 列的
|
||||||
|
/// 表单宽里,越界那句的最后一个「效」被挤到第三行、单字成行(浅色 1180 / 深色 1180 / 窄窗 880
|
||||||
|
/// 三张都是同一处孤字,x 396–407)。那句话恰好只在本 mission 的主场景(手改坏值/越界)出现,
|
||||||
|
/// 让「用户最需要看懂的那一句」排得最难看。改法**不动排版**(换行由渲染器按 `\n` 分行),
|
||||||
|
/// 只把结论从段落里挪出来——两个数(文件原值 / 生效值)与出厂/非出厂的两种措辞一个字都没丢。
|
||||||
|
fn with_note(base: String, note: Option<&str>) -> String {
|
||||||
|
match note {
|
||||||
|
Some(note) => format!("{base}\n{note}"),
|
||||||
|
None => base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gh-proxy 三态的上屏短句(与 [`GhProxyOverride`] 一一对应)。
|
||||||
|
fn gh_proxy_state(state: &GhProxyOverride) -> String {
|
||||||
|
match state {
|
||||||
|
GhProxyOverride::Builtin => "跟随内置默认".to_string(),
|
||||||
|
GhProxyOverride::Off => "已关闭(off)".to_string(),
|
||||||
|
GhProxyOverride::Prefix(prefix) => format!("覆盖为 {prefix}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 从 `launcher.json` 读出下载调优(默认值与夹取口径在 core,本函数只做「取原始值」)。
|
/// 从 `launcher.json` 读出下载调优(默认值与夹取口径在 core,本函数只做「取原始值」)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
|
pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
|
||||||
@@ -176,3 +263,7 @@ pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
|
|||||||
pub(crate) fn apply_tuning(env: &HostEnv) -> bool {
|
pub(crate) fn apply_tuning(env: &HostEnv) -> bool {
|
||||||
shared::set_tuning(env.layout.cache(), tuning_of(env))
|
shared::set_tuning(env.layout.cache(), tuning_of(env))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "download_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
//! 下载设置组的「**生效值**」守门(P3-5 / P3-3)。
|
||||||
|
//!
|
||||||
|
//! 这一组画的是引擎真的会用的那个数,不是文件里那串字节。两者的判定在 core
|
||||||
|
//! (`DownloadTuning::from_settings`:越界/坏值回落出厂,`0` 落成「只试一次」),这里守的是
|
||||||
|
//! 「宿主把哪个数交给页面」以及「文件原值与生效值不一致时那句话有没有出现」。
|
||||||
|
//!
|
||||||
|
//! 构造真文件:手改坏值绕不过 `store.set` 的 schema 校验,所以测试**直接写 `launcher.json`**
|
||||||
|
//! ——这正是用户手改文件的现场(`document::load` 对坏值只记 `LoadProblem`、原样保留)。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::dto::find_row;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// 一个装好「手写 `launcher.json`」的数据根。`json` 是整个文件的顶层对象。
|
||||||
|
fn env_with(tag: &str, json: serde_json::Value) -> (PathBuf, HostEnv) {
|
||||||
|
let dir = std::env::temp_dir().join(format!("ull-download-group-{}-{tag}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
|
||||||
|
std::fs::write(
|
||||||
|
dir.join(".ULL/config/launcher.json"),
|
||||||
|
serde_json::to_string_pretty(&json).expect("序列化"),
|
||||||
|
)
|
||||||
|
.expect("写 launcher.json");
|
||||||
|
let env = HostEnv::at_root(&dir);
|
||||||
|
(dir, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某一行(走页面同一条 `find_row`)。
|
||||||
|
fn row<'a>(group: &'a SettingGroupView, key: &str) -> &'a SettingRowView {
|
||||||
|
find_row(std::slice::from_ref(group), key).unwrap_or_else(|| panic!("{key} 这一行不在组里"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选项表里被选中的那一项(页面正是按 `selected` 下标画选中的 chip)。
|
||||||
|
fn chosen<'a>(row: &'a SettingRowView) -> &'a str {
|
||||||
|
row.options
|
||||||
|
.get(usize::try_from(row.selected).expect("下标非负"))
|
||||||
|
.map_or("<越界下标>", String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P3-5 主用例**:文件里是坏值 9999,行上画的是引擎真在用的 8,并把原值写明白。
|
||||||
|
///
|
||||||
|
/// 抓得住:① 宿主把文件值当生效值画(改回 `int(env, ..)`)⇒ 选中的 chip 变成 `9999`,红;
|
||||||
|
/// ② 那句「原值 9999 已按出厂 8 生效」被删掉或写成常量 ⇒ 文案断言红;
|
||||||
|
/// ③ core 的回落口径被改成「夹到边界 64」⇒ 选中项变成 64,红。
|
||||||
|
/// 抓不住:界面上这行是否真的画出来(那是 `pages/settings/download.rs` 的 body 与截图的事),
|
||||||
|
/// 也抓不住用户是否看得懂那句话。
|
||||||
|
#[test]
|
||||||
|
fn an_out_of_range_file_value_shows_the_engine_value_and_names_the_raw_one() {
|
||||||
|
let (dir, env) = env_with(
|
||||||
|
"concurrency",
|
||||||
|
serde_json::json!({ "download": { "concurrency": 9999 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let group = group(&env);
|
||||||
|
let concurrency = row(&group, unlucky_core::download::config::KEY_CONCURRENCY);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
group.id, "download",
|
||||||
|
"设置页按这个 id 找组(app-iced `pages/settings/download.rs` 的 GROUP_ID);改名会静默退回演示行"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
chosen(concurrency),
|
||||||
|
"8",
|
||||||
|
"行上画的必须是引擎生效值(出厂 8),不是文件里的 9999"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
concurrency
|
||||||
|
.description
|
||||||
|
.contains("原值 9999 已按出厂 8 生效"),
|
||||||
|
"文件值与生效值不一致时必须写明原值:{}",
|
||||||
|
concurrency.description
|
||||||
|
);
|
||||||
|
// r1 视觉复审 P2-2:那句结论必须**独占一行**(接在说明末尾时,最后一个「效」会被挤成孤字)。
|
||||||
|
assert!(
|
||||||
|
concurrency
|
||||||
|
.description
|
||||||
|
.ends_with("\n原值 9999 已按出厂 8 生效"),
|
||||||
|
"结论必须自成一行(`\\n` 分隔),否则 424 列的说明会把它折出单字孤行:{:?}",
|
||||||
|
concurrency.description
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tuning_of(&env).max_concurrency,
|
||||||
|
8,
|
||||||
|
"同一份文件经 core 的翻译层也必须是 8(两处口径不许漂)"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文件里写的就是生效值 ⇒ **不许**冒出「原值 …」那句话(否则它是常量而不是判决)。
|
||||||
|
///
|
||||||
|
/// 抓得住:`applied_note` 不管三七二十一都返回 `Some`(把那句话变成装饰)。
|
||||||
|
/// 抓不住:句子出现的**位置**(埋在哪一段文案里)。
|
||||||
|
#[test]
|
||||||
|
fn a_file_value_that_matches_the_engine_value_gets_no_note() {
|
||||||
|
let (dir, env) = env_with(
|
||||||
|
"in-range",
|
||||||
|
serde_json::json!({ "download": { "concurrency": 2, "retry_limit": 5 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let group = group(&env);
|
||||||
|
let concurrency = row(&group, unlucky_core::download::config::KEY_CONCURRENCY);
|
||||||
|
let retry = row(&group, unlucky_core::download::config::KEY_RETRY_LIMIT);
|
||||||
|
|
||||||
|
assert_eq!(chosen(concurrency), "2");
|
||||||
|
assert_eq!(chosen(retry), "5");
|
||||||
|
for row in [concurrency, retry] {
|
||||||
|
assert!(
|
||||||
|
!row.description.contains("原值"),
|
||||||
|
"没有差异就不该有那句话:{}",
|
||||||
|
row.description
|
||||||
|
);
|
||||||
|
// 没有结论行 ⇒ 说明里也不该有换行(那会让页面多出一行空白)。
|
||||||
|
assert!(
|
||||||
|
!row.description.contains('\n'),
|
||||||
|
"没有差异就不该有第二段:{:?}",
|
||||||
|
row.description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **每一种坏值形态都要说清原值**(r1 代码复审 p2:非整数坏值曾被静默丢掉)。
|
||||||
|
///
|
||||||
|
/// 任务书 P3-5 是无条件的「文件里的值与生效值不一致时,行上要写明原值 X 已按出厂 N 生效」,
|
||||||
|
/// 而「手改坏值」不止越界整数这一种。四种形态各验一遍:
|
||||||
|
///
|
||||||
|
/// | 文件里写的 | 生效值 | 行上必须出现 |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | `9999`(越界整数) | 8 | `原值 9999 已按出厂 8 生效`(上面那条用例) |
|
||||||
|
/// | `-4`(负整数) | 8 | `原值 -4 已按出厂 8 生效` |
|
||||||
|
/// | `"auto"`(类型不对) | 8 | `原值 auto 已按出厂 8 生效` ← **本用例补的洞** |
|
||||||
|
/// | `8.5` / `true`(类型不对) | 3 | `原值 8.5 / true 已按出厂 3 生效` |
|
||||||
|
/// | `0`(retry 特例) | 1 | `原值 0 已按 1 生效`(上面那条用例) |
|
||||||
|
///
|
||||||
|
/// 抓得住:① 原值退回 `Value::as_i64`(非整数 → `None` → `let raw = raw?` 短路 ⇒ 一个字都不说);
|
||||||
|
/// ② 字符串原值被 serde 加上引号(上屏成 `"auto"`);③ `null` 被当成「写过」(会冒出
|
||||||
|
/// `原值 null …`)。
|
||||||
|
/// 抓不住:别的键(region / mirror / proxy_mode)同族缺口——那三条不在 P3-5 点名范围,
|
||||||
|
/// 已由 tower 登记为另一个 follow-up。
|
||||||
|
#[test]
|
||||||
|
fn every_bad_value_shape_says_what_the_raw_value_was() {
|
||||||
|
// 负整数
|
||||||
|
let (dir, env) = env_with(
|
||||||
|
"bad-negative",
|
||||||
|
serde_json::json!({ "download": { "concurrency": -4 } }),
|
||||||
|
);
|
||||||
|
let group_view = group(&env);
|
||||||
|
let concurrency = row(&group_view, unlucky_core::download::config::KEY_CONCURRENCY);
|
||||||
|
assert_eq!(chosen(concurrency), "8", "-4 越界 ⇒ 生效值是出厂 8");
|
||||||
|
assert!(
|
||||||
|
concurrency.description.contains("原值 -4 已按出厂 8 生效"),
|
||||||
|
"负整数也要说清原值:{}",
|
||||||
|
concurrency.description
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
|
||||||
|
// 类型不对:字符串 + 浮点(一次覆盖两个)
|
||||||
|
let (dir, env) = env_with(
|
||||||
|
"bad-types",
|
||||||
|
serde_json::json!({ "download": { "concurrency": "auto", "retry_limit": 8.5 } }),
|
||||||
|
);
|
||||||
|
let group_view = group(&env);
|
||||||
|
let concurrency = row(&group_view, unlucky_core::download::config::KEY_CONCURRENCY);
|
||||||
|
let retry = row(&group_view, unlucky_core::download::config::KEY_RETRY_LIMIT);
|
||||||
|
assert_eq!(
|
||||||
|
chosen(concurrency),
|
||||||
|
"8",
|
||||||
|
"\"auto\" 不是整数 ⇒ 生效值是出厂 8"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
concurrency
|
||||||
|
.description
|
||||||
|
.contains("原值 auto 已按出厂 8 生效"),
|
||||||
|
"字符串坏值必须原样上屏(不带引号)并说清它被忽略了:{}",
|
||||||
|
concurrency.description
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!concurrency.description.contains("\"auto\""),
|
||||||
|
"上屏的是用户写的那个词,不是 serde 的转义形式:{:?}",
|
||||||
|
concurrency.description
|
||||||
|
);
|
||||||
|
assert_eq!(chosen(retry), "3", "8.5 不是整数 ⇒ 生效值是出厂 3");
|
||||||
|
assert!(
|
||||||
|
retry.description.contains("原值 8.5 已按出厂 3 生效"),
|
||||||
|
"浮点坏值也要说清:{}",
|
||||||
|
retry.description
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
|
||||||
|
// 布尔 / null:`true` 要说;`null` 等于没写(不该冒出「原值 null …」)
|
||||||
|
let (dir, env) = env_with(
|
||||||
|
"bad-bool-null",
|
||||||
|
serde_json::json!({ "download": { "retry_limit": true, "concurrency": null } }),
|
||||||
|
);
|
||||||
|
let group_view = group(&env);
|
||||||
|
let retry = row(&group_view, unlucky_core::download::config::KEY_RETRY_LIMIT);
|
||||||
|
let concurrency = row(&group_view, unlucky_core::download::config::KEY_CONCURRENCY);
|
||||||
|
assert!(
|
||||||
|
retry.description.contains("原值 true 已按出厂 3 生效"),
|
||||||
|
"布尔坏值也要说清:{}",
|
||||||
|
retry.description
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!concurrency.description.contains("原值"),
|
||||||
|
"`null` 等于没写,不该有提示:{}",
|
||||||
|
concurrency.description
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `retry_limit = 0`(旧版档位)落成「只试一次」:行上画 1,并说清原值是 0。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `0` 原样画上屏(那时引擎按 1 跑,界面说 0 —— 又一个「设置项说假话」);
|
||||||
|
/// 或者把这句话写成「按出厂 3 生效」(实际生效值是 1,不是出厂 3)。
|
||||||
|
/// 抓不住:用户是否理解 0/1 同义(那是文案的事,本行 base 段已有说明)。
|
||||||
|
#[test]
|
||||||
|
fn a_zero_retry_in_the_file_is_shown_as_one_and_says_so() {
|
||||||
|
let (dir, env) = env_with(
|
||||||
|
"retry-zero",
|
||||||
|
serde_json::json!({ "download": { "retry_limit": 0 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let group = group(&env);
|
||||||
|
let retry = row(&group, unlucky_core::download::config::KEY_RETRY_LIMIT);
|
||||||
|
|
||||||
|
assert_eq!(chosen(retry), "1", "0 生效为 1(只试一次)");
|
||||||
|
assert!(
|
||||||
|
retry.description.contains("原值 0 已按 1 生效"),
|
||||||
|
"必须写明 0 落成了 1(不是出厂 3):{}",
|
||||||
|
retry.description
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!retry.description.contains("原值 0 已按出厂"),
|
||||||
|
"1 不是出厂值(出厂 3),措辞不许混"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P3-3(选了「在下载设置页补一条 gh_proxy 端到端链」这一支)**:
|
||||||
|
/// 文件里的 `download.gh_proxy` → `store` → [`tuning_of`] → core 的三态 → 行上的值与那句
|
||||||
|
/// 「当前生效:…」。三档各走一遍。
|
||||||
|
///
|
||||||
|
/// 抓得住:① 键名读错(例如 `download.gh-proxy`)⇒ 三档全部退回 `Builtin`,`off`/`prefix` 两档红;
|
||||||
|
/// ② 三态翻译被拍平(`off` 也当 `Builtin`)⇒ 第二档红;③ 行上只画文件原文、不画生效档位
|
||||||
|
/// ⇒ 那一句文案红(页面上「填了 off 却没关」的那种谎正是它兜的)。
|
||||||
|
/// 抓不住:候选链里到底生成了什么(那是 `shared.rs` 的 `expand()`,core 侧已有用例),
|
||||||
|
/// 也抓不住真下载时是否走那条链(要网络)。
|
||||||
|
///
|
||||||
|
/// 为什么选这一支而不是拆 core 的 `gh_proxy_override_reaches_the_profile_and_the_chain`:
|
||||||
|
/// 那个文件不在本 mission 的 scope(`crates/core/**`),拆它得由 core 线另立 mission;
|
||||||
|
/// 而「设置文件 → 宿主 → 三态」这一段的缺口正是本 mission 要接的路,补在这里能证明整条链
|
||||||
|
/// 从用户手写的那串字节开始就是通的。
|
||||||
|
#[test]
|
||||||
|
fn the_gh_proxy_key_travels_from_the_file_to_the_core_tri_state() {
|
||||||
|
use unlucky_core::download::config::KEY_GH_PROXY;
|
||||||
|
|
||||||
|
// ① 留空 = 跟随内置默认(空串不是「关」)。
|
||||||
|
let (empty_dir, empty_env) = env_with(
|
||||||
|
"gh-empty",
|
||||||
|
serde_json::json!({ "download": { "gh_proxy": "" } }),
|
||||||
|
);
|
||||||
|
let empty_group = group(&empty_env);
|
||||||
|
let empty_row = row(&empty_group, KEY_GH_PROXY);
|
||||||
|
assert_eq!(tuning_of(&empty_env).gh_proxy, GhProxyOverride::Builtin);
|
||||||
|
assert_eq!(empty_row.value_text, "", "文件里的原文照旧回填");
|
||||||
|
assert!(
|
||||||
|
empty_row.description.contains("当前生效:跟随内置默认"),
|
||||||
|
"空串必须说成「跟随内置默认」而不是「关闭」:{}",
|
||||||
|
empty_row.description
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&empty_dir).ok();
|
||||||
|
|
||||||
|
// ② 填 off = 关(不生成任何 gh-proxy 候选)。
|
||||||
|
let (off_dir, off_env) = env_with(
|
||||||
|
"gh-off",
|
||||||
|
serde_json::json!({ "download": { "gh_proxy": "off" } }),
|
||||||
|
);
|
||||||
|
let off_group = group(&off_env);
|
||||||
|
let off_row = row(&off_group, KEY_GH_PROXY);
|
||||||
|
assert_eq!(tuning_of(&off_env).gh_proxy, GhProxyOverride::Off);
|
||||||
|
assert_eq!(off_row.value_text, "off");
|
||||||
|
assert!(
|
||||||
|
off_row.description.contains("当前生效:已关闭(off)"),
|
||||||
|
"填了 off 就必须说「已关闭」:{}",
|
||||||
|
off_row.description
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&off_dir).ok();
|
||||||
|
|
||||||
|
// ③ 填 URL 前缀 = 覆盖它。
|
||||||
|
let (prefix_dir, prefix_env) = env_with(
|
||||||
|
"gh-prefix",
|
||||||
|
serde_json::json!({ "download": { "gh_proxy": "https://gh.example/" } }),
|
||||||
|
);
|
||||||
|
let prefix_group = group(&prefix_env);
|
||||||
|
let prefix_row = row(&prefix_group, KEY_GH_PROXY);
|
||||||
|
assert_eq!(
|
||||||
|
tuning_of(&prefix_env).gh_proxy,
|
||||||
|
GhProxyOverride::Prefix("https://gh.example/".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(prefix_row.value_text, "https://gh.example/");
|
||||||
|
assert!(
|
||||||
|
prefix_row
|
||||||
|
.description
|
||||||
|
.contains("当前生效:覆盖为 https://gh.example/"),
|
||||||
|
"覆盖档必须把生效的前缀写出来:{}",
|
||||||
|
prefix_row.description
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&prefix_dir).ok();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user