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

This commit is contained in:
AskaEth committed 2026-11-09 10:53:32 +08:00
commit 4c29cbac3e
18 files changed
+1399 -74

No files matched your search

+16
View File
@@ -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
}
}
+21 -1
View File
@@ -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();
}
+5
View File
@@ -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` 就是静息态,不叠状态层。
+6
View File
@@ -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)
} }
} }
+4
View File
@@ -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,
+13
View File
@@ -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,
} }
+352 -23
View File
@@ -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: &[],
} }
} }
+13 -1
View File
@@ -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\", ..)`)"
);
}
+2
View File
@@ -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", "应用"),
]; ];
+15 -3
View File
@@ -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", "应用"),
} }
}; };
} }
+1 -1
View File
@@ -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,
+108 -17
View File
@@ -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,8 +74,11 @@ 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(
"同时下载的文件数:出厂 {DEFAULT_MAX_CONCURRENCY},可调 1~{MAX_CONCURRENCY}(引擎硬上限,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效" format!(
"同时下载的文件数:出厂 {DEFAULT_MAX_CONCURRENCY},可调 1~{MAX_CONCURRENCY}(引擎硬上限,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效"
),
concurrency_note.as_deref(),
), ),
&concurrency_options, &concurrency_options,
selected(&concurrency_options, &concurrency), selected(&concurrency_options, &concurrency),
@@ -73,8 +86,11 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
.with(SettingRowView::choice( .with(SettingRowView::choice(
unlucky_core::download::config::KEY_RETRY_LIMIT, unlucky_core::download::config::KEY_RETRY_LIMIT,
"单源尝试次数", "单源尝试次数",
&format!( &with_note(
"同一个源最多试几次(含首次;出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE})。0 与 1 都等于「只试一次、不重试」——0 是旧版提供的档位,不代表「不下载」。换源不受此限" format!(
"同一个源最多试几次(含首次;出厂 {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();
}