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

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

这条线现在通了:

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

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

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

No files matched your search

+1 -1
View File
@@ -67,7 +67,7 @@ pub use pack::{
ImportState, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
};
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
pub use settings::{find_row, SettingControl, SettingGroupView, SettingRowView};
// M159: 自更新
pub use update::{
ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView,
+81 -17
View File
@@ -22,14 +22,15 @@
//! 「读起来是纯读、实际改全局状态」正是本仓吃过亏的那类形状。
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 crate::dto::{SettingGroupView, SettingRowView};
use super::super::env::HostEnv;
use super::{int, selected, text};
use super::{selected, text};
/// 并发数的推荐档位(`selected()` 找不到时回 0;手改出来的非档位值会被插进选项表,见 [`group`])。
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"];
/// 下载(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 {
let concurrency = int(
env,
unlucky_core::download::config::KEY_CONCURRENCY,
let tuning = tuning_of(env);
let concurrency = (tuning.max_concurrency as i64).to_string();
let retry = i64::from(tuning.max_attempts_per_source).to_string();
let concurrency_note = applied_note(
raw_int(env, unlucky_core::download::config::KEY_CONCURRENCY),
tuning.max_concurrency as i64,
DEFAULT_MAX_CONCURRENCY as i64,
)
.to_string();
let retry = int(
env,
unlucky_core::download::config::KEY_RETRY_LIMIT,
);
let retry_note = applied_note(
raw_int(env, unlucky_core::download::config::KEY_RETRY_LIMIT),
i64::from(tuning.max_attempts_per_source),
i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE),
)
.to_string();
);
let concurrency_options = ladder(&CONCURRENCY_LADDER, &concurrency);
let retry_options = ladder(&RETRY_LADDER, &retry);
let concurrency_options: Vec<&str> = concurrency_options.iter().map(String::as_str).collect();
@@ -64,8 +74,11 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
.with(SettingRowView::choice(
unlucky_core::download::config::KEY_CONCURRENCY,
"并发数",
&format!(
"同时下载的文件数:出厂 {DEFAULT_MAX_CONCURRENCY},可调 1~{MAX_CONCURRENCY}(引擎硬上限,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效"
&with_note(
format!(
"同时下载的文件数:出厂 {DEFAULT_MAX_CONCURRENCY},可调 1~{MAX_CONCURRENCY}(引擎硬上限,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效"
),
concurrency_note.as_deref(),
),
&concurrency_options,
selected(&concurrency_options, &concurrency),
@@ -73,8 +86,11 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
.with(SettingRowView::choice(
unlucky_core::download::config::KEY_RETRY_LIMIT,
"单源尝试次数",
&format!(
"同一个源最多试几次(含首次;出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE})。0 与 1 都等于「只试一次、不重试」——0 是旧版提供的档位,不代表「不下载」。换源不受此限"
&with_note(
format!(
"同一个源最多试几次(含首次;出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE})。0 与 1 都等于「只试一次、不重试」——0 是旧版提供的档位,不代表「不下载」。换源不受此限"
),
retry_note.as_deref(),
),
&retry_options,
selected(&retry_options, &retry),
@@ -129,10 +145,15 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
))
// M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把
// 「我还没决定」当成「我不要加速」,于是以为自己关掉了、实际还在套前缀。
//
// P3-5:末尾那半句是**引擎生效档位**(`tuning.gh_proxy` 的翻译结论),不是文件原文。
.with(SettingRowView::text(
unlucky_core::download::config::KEY_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),
))
.with(SettingRowView::action(
@@ -153,6 +174,45 @@ fn ladder(ladder: &[&str], current: &str) -> Vec<String> {
options
}
/// 文件里**显式写过**的原值(`None` = 文件没写这个键)。
///
/// 与 `super::int` 的区别:那个走 `effective()`(文件值 → schema 默认值),本函数只问文件本身。
/// 「原值 X 已按出厂 N 生效」这句话的两个数必须现读(一个来自文件、一个来自 core 的翻译层),
/// **不许**拿常量或请求参数冒充——那是 U1 明令禁止的假证据。
fn raw_int(env: &HostEnv, key: &str) -> Option<i64> {
env.store().doc().get(key).and_then(|value| value.as_i64())
}
/// 文件原值 ≠ 生效值时的说明;`None` = 没什么可说的(没写过,或写的就是生效值)。
fn applied_note(raw: Option<i64>, effective: i64, factory: i64) -> Option<String> {
let raw = raw?;
if raw == effective {
return None;
}
Some(if effective == factory {
format!("原值 {raw} 已按出厂 {factory} 生效")
} else {
format!("原值 {raw} 已按 {effective} 生效")
})
}
/// 说明 +(有差异时)那一句「原值 … 生效」。
fn with_note(base: String, note: Option<&str>) -> String {
match note {
Some(note) => format!("{base};{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,本函数只做「取原始值」)。
#[must_use]
pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
@@ -176,3 +236,7 @@ pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
pub(crate) fn apply_tuning(env: &HostEnv) -> bool {
shared::set_tuning(env.layout.cache(), tuning_of(env))
}
#[cfg(test)]
#[path = "download_tests.rs"]
mod tests;
@@ -0,0 +1,209 @@
//! 下载设置组的「**生效值**」守门(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
);
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
);
}
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();
}