From ab573838f337f233424111c9759f9c4431a1f1a0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 09:53:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?M201:=20=E6=8E=A5=E9=80=9A=20iced=20?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E7=9A=84=E6=95=B0=E6=8D=AE=E9=80=9A?= =?UTF-8?q?=E8=B7=AF=EF=BC=88=E4=B8=8B=E8=BD=BD=E7=BB=84=EF=BC=9A=E7=9C=9F?= =?UTF-8?q?=E5=80=BC=20+=20=E7=9C=9F=E6=8E=A7=E4=BB=B6=20+=20=E5=86=99?= =?UTF-8?q?=E5=9B=9E=E9=97=AD=E7=8E=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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()` + 控件形状 / 空快照不编值。 --- crates/app-iced/src/app/state.rs | 16 + crates/app-iced/src/app/state/boot_probe.rs | 22 +- .../src/app/update/settings/download.rs | 69 +++- .../src/app/update/settings/download_tests.rs | 332 ++++++++++++++++ crates/app-iced/src/app/view.rs | 5 + crates/app-iced/src/main.rs | 6 + crates/app-iced/src/pages/mod.rs | 4 + crates/app-iced/src/pages/model.rs | 13 + .../app-iced/src/pages/settings/download.rs | 363 ++++++++++++++++-- crates/app-iced/src/pages/settings/frame.rs | 1 + crates/app-iced/src/pages/settings/state.rs | 14 +- .../settings/tests/appearance_section.rs | 1 + .../pages/settings/tests/download_section.rs | 156 ++++++-- crates/i18n/src/catalog/golden/tail.rs | 2 + crates/i18n/src/catalog/settings/download.rs | 18 +- crates/presenter/src/dto/mod.rs | 2 +- .../presenter/src/host/settings/download.rs | 98 ++++- .../src/host/settings/download_tests.rs | 209 ++++++++++ 18 files changed, 1257 insertions(+), 74 deletions(-) create mode 100644 crates/app-iced/src/app/update/settings/download_tests.rs create mode 100644 crates/presenter/src/host/settings/download_tests.rs diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 7128c53..c575516 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -214,3 +214,19 @@ pub struct App { /// [`crate::platform::win32::corner::CornerApplied`]。 pub(super) corner_applied: Option, } + +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 + } +} diff --git a/crates/app-iced/src/app/state/boot_probe.rs b/crates/app-iced/src/app/state/boot_probe.rs index 94d6e0d..1cfcd75 100644 --- a/crates/app-iced/src/app/state/boot_probe.rs +++ b/crates/app-iced/src/app/state/boot_probe.rs @@ -23,10 +23,30 @@ pub(super) fn app_options(options: &Options) -> AppOptions { // `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。 // 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir ` 不会往那个目录写东西。 data_root: options.data_dir.clone(), - mock: options.screenshot.is_some(), + mock: options.screenshot.is_some() && !real_host_in_screenshot(), } } +/// 取证开关 `ULL_SCREENSHOT_REAL_HOST=1`:让**截图通道**接真 host(默认关)。 +/// +/// ## 为什么需要它(M201) +/// +/// 截图通道恒 `mock: true`(D30:同一份代码出同一张图),而 `MockBackend` **不发** +/// `CoreEvent::SettingsChanged` ⇒ 设置页拿不到任何真值,只能拍演示行。可「设置页里改了就生效」 +/// 这件事的正脸图**必须带真值**:M193 用户的投诉就是「图上有 8 threads,追下去是假的」。 +/// 这条开关把出图的**数据源**换成真 host(`--data-dir` 指一个临时根),渲染通道与落盘一字不改。 +/// +/// ## 边界(别把它当产品行为) +/// +/// - 默认关;开了它就不再满足 D30 的可复现(数据来自磁盘,两台机器会不同),所以**只用于取证**; +/// - 它只改 `mock` 这一位,不碰任何业务分支(`build_backend` 的其余两条路仍然照旧); +/// - 与 `ULL_START_ROUTE` / `ULL_SETTINGS_GROUP` 同一族:只影响这一次进程。 +fn real_host_in_screenshot() -> bool { + std::env::var("ULL_SCREENSHOT_REAL_HOST") + .ok() + .is_some_and(|raw| raw.trim() == "1") +} + /// 首帧耗时日志(只在第一次 `view()` 打一行)。 pub(crate) fn log_first_frame() { if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) { diff --git a/crates/app-iced/src/app/update/settings/download.rs b/crates/app-iced/src/app/update/settings/download.rs index 2bb52fb..79057e5 100644 --- a/crates/app-iced/src/app/update/settings/download.rs +++ b/crates/app-iced/src/app/update/settings/download.rs @@ -1,18 +1,75 @@ -//! Downloads 分组消息的落点(M101 `SETTINGSMSG`):**只归 download 线**。 +//! Downloads 分组消息的落点(M201):**设置写回**的那一段(M101 `SETTINGSMSG` 的漏斗不变)。 //! -//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。与下载页 -//! (`pages/download*` 与 `app/update/download.rs`)无关:那是「装哪个版本」,这一组是「怎么下」。 +//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。本文件只有一件事: +//! 把「用户改了哪个键」翻成 `AppCommand::SetSetting` —— 那是 presenter 已有的写回闭环 +//! (`host/dispatch.rs` 的 `SetSetting` 分支:`settings::set` 落盘 → `apply_tuning` → 重新发 +//! `CoreEvent::SettingsChanged`)。 //! -//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。 +//! ## 不做乐观回显 +//! +//! 这里**不**改任何「当前值」:界面上的值是 presenter 快照(`pages::Model.settings`)的投影, +//! 只有 host 把 `SettingsChanged` 发回来才会变。理由与下载页的安装/启动同一条纪律——本地改一份、 +//! host 回来再改一次,两边打架时界面就会各说一套(尤其是写盘失败:乐观回显会让用户以为改成了)。 +//! +//! ## 文本行为什么要「应用」才提交 +//! +//! 见 `pages/settings/download.rs` 的文件头:每敲一个键写一次盘会把半截值落进去。所以 +//! `Edit` 只改本地草稿(`view_dirty` 都不用翻:草稿由 `States` 直接读),`Commit` 才派发。 +//! 提交之后草稿当场丢掉——快照随即成为唯一真值,写失败时行上会回到文件里的值 + presenter 的 +//! 错误 Toast 说清原因(比留着一个没生效的草稿更诚实)。 use super::super::*; +use unlucky_presenter::dto::{find_row, AppCommand}; + impl App { - /// Downloads 分组的一条消息。首版没有可处理的东西(空枚举)。 + /// Downloads 分组的一条消息。 pub(super) fn on_settings_download( &mut self, message: pages::settings::download::Message, ) -> Task { - 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; diff --git a/crates/app-iced/src/app/update/settings/download_tests.rs b/crates/app-iced/src/app/update/settings/download_tests.rs new file mode 100644 index 0000000..24a0a6d --- /dev/null +++ b/crates/app-iced/src/app/update/settings/download_tests.rs @@ -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 { + 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>>, + queued: Vec, + /// 回事件时用的快照(`SetSetting` 之后换成「值真的变了」的那一份)。 + state: RefCell<(String, String)>, +} + +impl Backend for Hostish { + fn drain_events(&mut self) -> Vec { + 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>>) { + 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(); +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 997497f..da2d7fc 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -92,6 +92,8 @@ impl App { pages::settings::States { appearance: Some(&self.appearance), groups: &self.settings, + // M201:下载组画真控件要的那份快照(`Model` 里那份,`refresh_view` 刚重建)。 + settings: &self.view.settings, }, // M106:设置页 General 组要画「当前档位 + 生效语言」。 self.language(), @@ -190,6 +192,9 @@ pub(super) fn model( logs: logs_model(state).with_status(lang), // M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它) update: update_of(&state.update), + // M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按 + // schema 键取当前值画控件;写回后 host 重新发 `SettingsChanged` ⇒ 下一次重建换新值。 + settings: state.settings_groups.clone(), lang, forced: forced.map(|state| match state { // `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。 diff --git a/crates/app-iced/src/main.rs b/crates/app-iced/src/main.rs index 49ffbf8..e438ddd 100644 --- a/crates/app-iced/src/main.rs +++ b/crates/app-iced/src/main.rs @@ -82,6 +82,12 @@ fn main() -> ExitCode { "验收诊断:ULL_ACCEPTANCE=1([console]/[env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\ ,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸" ); + // M201:设置页要出「带真值」的验收图,而截图通道默认强制 mock(mock 不发 + // `CoreEvent::SettingsChanged`)。这一条把**数据源**换成真 host,其余一字不改。 + eprintln!( + "取证:ULL_SCREENSHOT_REAL_HOST=1 让 --screenshot 接真 host(默认 mock;\ + 出图将不再可复现,只用于验收真值屏)" + ); ExitCode::from(2) } } diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index ec4f1fe..351797c 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -167,6 +167,8 @@ pub fn page<'a>( settings::States { appearance: None, groups: settings::demo_groups(), + // harness 路径没有 presenter 快照:空切片 ⇒ 各组画演示行(`Model::settings` 此时是空的)。 + settings: &model.settings, }, Language::default(), form, @@ -193,6 +195,7 @@ pub fn page_with_appearance<'a>( settings::States { appearance: Some(appearance), groups: settings::demo_groups(), + settings: &model.settings, }, Language::default(), form, @@ -291,6 +294,7 @@ pub fn app_with_appearance<'a>( settings::States { appearance, groups: settings::demo_groups(), + settings: &model.settings, }, Language::default(), form, diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index 7669697..157c870 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -124,6 +124,16 @@ pub struct Model { pub content: Content, /// 自更新的展示状态(M159:设置页「更新」区 + 顶部提示条,见 [`super::update`])。 pub update: Update, + /// **设置组快照**(M201):presenter 的 `CoreEvent::SettingsChanged` 回流的那一份 + /// (`AppState::settings_groups`,由 `presenter::host::settings::groups` 产出)。 + /// + /// 谁填:`app/view.rs` 的 `model()`(`state.settings_groups.clone()`)。用途只有一处—— + /// 设置页各组的 `body` 按 schema 键从它里面 `dto::find_row` 取**当前值**,画成可改的控件; + /// 写回走 `AppCommand::SetSetting`,落盘后 host 重新发一次 `SettingsChanged` ⇒ 这里换新的一份。 + /// + /// 为什么放在 `Model` 而不是别处:它就是「这一帧要画的设置值」,与 `update` 同一条形状 + /// (页面只认 `Model`,presenter 的快照不直接进页面层)。 + pub settings: Vec, /// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。 /// /// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`), @@ -156,6 +166,9 @@ impl Model { // 演示模型也从这个空壳起步(`demo_model` 再按页面填数据) content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En), update: Update::default(), + // 空快照 = 这一帧没有设置数据(harness / `pages_demo`;`demo_model` 也不伪造一份, + // 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。 + settings: Vec::new(), lang: Lang::En, forced: None, } diff --git a/crates/app-iced/src/pages/settings/download.rs b/crates/app-iced/src/pages/settings/download.rs index 5dd288b..3e732ba 100644 --- a/crates/app-iced/src/pages/settings/download.rs +++ b/crates/app-iced/src/pages/settings/download.rs @@ -1,31 +1,76 @@ -//! 设置页「Downloads」分组:并发 / 单源尝试次数 / GitHub 加速前缀(M193)。 +//! 设置页「Downloads」分组:并发 / 单源尝试次数 / 地区 / 镜像 / 代理 / GitHub 加速前缀(M201)。 //! -//! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(download::body)`(渲染器也写在本文件), -//! `settings/mod.rs` 的注册表不用动。 +//! **这一组归 download 线**:正文渲染器在本文件(`GROUP.body = Some(body)`),`settings/mod.rs` +//! 的注册表不用动。 //! //! 注意与下载页(`pages/download*`)的区别:那是「装哪个版本」,这里是「怎么下」的默认值。 //! -//! ## 为什么这三行是「说语义」而不是「说当前值」(M193) +//! ## 数据从哪来、写到哪去(M201 接的就是这条通路) //! -//! 原来这三行是**写死的演示值**(`("Concurrency", "8 threads")` / `("Preferred source", -//! "Automatic (region)")` / `("Verify checksums", "On")`)。用户实测把它们当成真值投诉过 -//! (「它默认显示 8,可我改成 2 也没变」)——因为 iced 侧**根本没有设置数据通路**: -//! `pages::Model` 没有设置快照、`grep -rn "SettingsChanged" crates/app-iced/src` 零命中、 -//! [`State`] 是个空 struct 且无人填。而这三行里「8 threads」当时还是**假的**:引擎真值曾是 64 -//! (schema 说 8),「Verify checksums」更是没有任何校验开关(校验恒开)。 +//! ```text +//! presenter::host::settings::groups(&env) ← 真值(core schema 的键 + 生效值) +//! └─ CoreEvent::SettingsChanged(Vec) +//! └─ AppState::settings_groups ← presenter 的唯一写入口(apply_core) +//! └─ pages::Model.settings ← refresh_view:这一帧要画的设置值 +//! └─ settings::States.settings ← 本文件按 `id` 找到本组,逐行按 `control` 画 +//! └─ 控件(choice / text / action) +//! └─ Message → app/update/settings/download.rs +//! └─ AppCommand::SetSetting → host 写盘 + 重新发 SettingsChanged +//! ``` //! -//! 所以这一轮把假值换成**不会撒谎的语义行**:区间、默认值、「留空 / off / 前缀」三态各是什么。 -//! 真值(用户在 `launcher.json` 里填的那个数)不在这里显示——**宁可不说,也不说假的**。 -//! 真正的可改控件要等一条 presenter 快照 → app → `Groups.download` 的数据通路,另立 mission。 +//! **闭环**:控件不回显自己点的结果(不做乐观回显)。点了以后界面变,只能是因为 host 把 +//! `SettingsChanged` 发回来了——那也正是「设置页里改了就生效」这句话的证据链。 //! -//! **生效保证**:这三条设置真的会被引擎读走(`core::download::shared::set_tuning`,宿主侧 -//! 翻译在 `presenter::host::settings`),所以「行里的语义」与「引擎行为」是同一份契约。 +//! ## 三件事分开画(这一组是「设置页里改不了」的修复落点) +//! +//! 1. **当前值**(choice 的选中 chip / text 的输入框)——来自快照,不来自任何字面量; +//! 2. **这一行在讲什么**(标题 + 说明)——说明里带着 presenter 算出来的「引擎生效值」, +//! 文件原值与生效值不一致时那句「原值 X 已按出厂 N 生效」也在里面(P3-5); +//! 3. **怎么改**(点 chip / 输入 + 应用 / 点动作按钮)——写回走 `AppCommand::SetSetting`。 +//! +//! **没有快照**(harness / `pages_demo` / 首帧)时 `body` 返回 `None` ⇒ 退回去画 `GROUP.rows` +//! 那几行语义行(`frame::demo_card`)。那几行**说的不是值**(「出厂 8 · 可调 1~64」), +//! 所以不会再把演示值当成真值(M193 用户投诉的形状)。 +//! +//! ## 文本行为什么要点「应用」 +//! +//! 每敲一个键就写一次盘会把半截值落进 `launcher.json`(`off` 敲到 `of` 时 schema 会让它通过), +//! 而且 `store.set` 失败会弹一串 Toast。所以输入只改本地草稿([`State::edit`]),点「应用」 +//! 才走 [`Message::Commit`] → `AppCommand::SetSetting`。 +//! +//! **为什么不是回车提交**:本仓的 `text_field`(`app/components/inputs/field.rs`)只暴露 `on_input`, +//! 没有 `on_submit` 的口子;给它加一个口子要动组件库(组件库有自己的验收线),所以这一版只有 +//! 「应用」这一颗按钮。回车提交登记为后续(见报告「没验/没做」)。 +use std::collections::BTreeMap; + +use iced::widget::{column, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Key, Lang}; +use unlucky_presenter::dto::{SettingControl, SettingGroupView, SettingRowView}; +// `find_row` 只被 `#[cfg(test)]` 的探针用(产品路径按 `GROUP_ID` 取组、逐行画)。 +#[cfg(test)] +use unlucky_presenter::dto::find_row; -use super::Group; +use crate::app::components::buttons::{self, Button}; +use crate::app::components::inputs::{text_field, Field, FieldVariant}; +use crate::app::components::selection::{chip, switch, Chip, Switch}; +use crate::pages::frame::text_role_owned; +use crate::pages::Message as PageMessage; +use crate::theme::Ui; + +use super::frame::{card, divider}; +use super::{Group, States}; /// Downloads 分组。 +/// +/// `rows` 只在**没有快照**时画(harness / 首帧):它们是语义行(出厂值 / 区间 / 三态), +/// 不是当前值——M193 用户的投诉正是「把演示值当成了真值」。 pub(super) const GROUP: Group = Group { name: "Downloads", icon: IconKind::Download, @@ -36,20 +81,292 @@ pub(super) const GROUP: Group = Group { ("关掉它", "填 off = 关闭"), ("换掉它", "填 URL 前缀 = 覆盖"), ], - body: None, + body: Some(body), }; +/// 这一组在 presenter 快照里的 id(`setting::groups` 建组时给的那个)。 +const GROUP_ID: &str = "download"; + /// Downloads 分组的运行期状态(M101 r2:**只归 download 线**)。 /// -/// 加字段只改本文件:`Groups` 只认 `Groups.download` 这一个字段。 -/// 现在仍然是空的——真值的数据通路还没接(见文件头「为什么这三行是说语义」)。 +/// 只装「用户正在编辑、还没提交」的文本草稿。**当前值不在这里**——它来自 presenter 快照 +/// (`States::settings`),这里存一份就成了第二份真相。 #[derive(Debug, Default)] -pub struct State {} +pub struct State { + /// schema 键 → 正在输入但还没提交的文本。 + drafts: BTreeMap, +} + +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 { + self.drafts.get(key).cloned() + } + + /// 丢掉一行的草稿(提交之后用:快照重新成为唯一真值)。 + pub(crate) fn clear(&mut self, key: &str) { + self.drafts.remove(key); + } +} /// Downloads 分组的消息(M101:**只归 download 线**,加变体只改本文件)。 /// /// 落点是 `app/update/settings/download.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认 -/// [`GroupMessage`](super::GroupMessage))。异步结果(例如「测这个源可用」)在自己那条落点里构造 -/// `Message::Page(pages::Message::Settings(GroupMessage::Download(…)))` 回来即可。 +/// [`GroupMessage`](super::GroupMessage))。 #[derive(Debug, Clone)] -pub enum Message {} +pub enum Message { + /// `choice` 行:选了一个选项 —— 直接提交(选择本身就是意图,没有「半截」状态)。 + Set { + /// schema 键(`download.concurrency`) + key: String, + /// 选项原文(`8` / `cn` / `off` 这类,写回时按 schema 的类型转换) + value: String, + }, + /// `text` 行:正在输入 —— **只改本地草稿**,不派发。 + Edit { + /// schema 键 + key: String, + /// 输入框里的整段文本 + value: String, + }, + /// `text` 行:提交(「应用」按钮 / 回车)。 + Commit { + /// schema 键 + key: String, + }, + /// `action` 行:动作按钮(例如「重新检测地区」)。 + Action { + /// 快照里那一行的键(动作 id 由 host 在 `settings::set` 里认) + key: String, + }, +} + +/// 把本组消息包进设置页那条**唯一**投递口(`pages::Message::Settings(GroupMessage::Download)`)。 +/// +/// 为什么必须有它:本模块里 `Message` 是本组自己的枚举,而 `Element` 的消息类型是 +/// `crate::pages::Message`(别名 [`PageMessage`])。不包这一层,控件会带上本组的消息类型, +/// 于是 `settings/mod.rs` 那个 `and_then` 与页面树就对不上(编译期就红,不会静默丢消息)。 +fn msg(message: Message) -> PageMessage { + PageMessage::Settings(super::GroupMessage::Download(message)) +} + +// --------------------------------------------------------------- 正文 + +/// 整组正文:快照里按 [`GROUP_ID`] 找到本组,一行一个控件。 +/// +/// 返回 `None` = 这一帧没有本组的快照(harness / 首帧)⇒ `settings/mod.rs` 退回 `GROUP.rows`。 +fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option> { + let group = state_group(&states)?; + let drafts = &states.groups.download; + let catalog = lang.catalog(); + let mut stacked = column![].spacing(0.0).width(Length::Fill); + + for (index, item) in group.rows.iter().enumerate() { + if index > 0 { + stacked = stacked.push(divider(ui)); + } + stacked = stacked.push(match item.control { + SettingControl::Toggle => toggle_row(ui, item), + SettingControl::Choice => choice_row(ui, item), + SettingControl::Text => { + text_row(ui, item, drafts, catalog.text(Key::SettingsDownloadsApply)) + } + SettingControl::ReadOnly => read_only_row(ui, item), + SettingControl::Action => action_row(ui, item), + }); + } + + Some(card(ui, stacked)) +} + +/// 快照里的本组(`States::settings` 是 `Model.settings` 那一份)。 +fn state_group<'a>(states: &States<'a>) -> Option<&'a SettingGroupView> { + states.settings.iter().find(|group| group.id == GROUP_ID) +} + +/// 一行的标题(`BodyMedium`)。 +fn heading<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> { + text_role_owned( + ui, + TypeRole::BodyMedium, + false, + item.label.clone(), + ui.color(Role::OnSurface), + ) +} + +/// 一行的说明(`BodySmall`;P3-5 那句「原值 X 已按出厂 N 生效」就在这段文字里)。 +fn hint<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> { + text_role_owned( + ui, + TypeRole::BodySmall, + false, + item.description.clone(), + ui.color(Role::OnSurfaceVariant), + ) +} + +/// 标题 + 控件 + 说明(三行一组的常规排法)。 +fn block<'a>( + ui: &'a Ui, + item: &'a SettingRowView, + control: Element<'a, PageMessage>, +) -> Element<'a, PageMessage> { + column![heading(ui, item), control, hint(ui, item)] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +/// 开 / 关两态(本组暂时没有 Toggle 行,但 `SettingControl` 是穷尽枚举,画法在这里定下来)。 +fn toggle_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> { + let key = item.key.clone(); + let toggle = switch( + ui, + Switch::new( + &item.label, + item.flag, + msg(Message::Set { + key, + // schema 的 Bool 键认 `true` / `false`(`settings::parse_value` 也认 on/off/1/0)。 + value: (!item.flag).to_string(), + }), + ), + ); + column![toggle, hint(ui, item)] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +/// 分段单选:一个选项一颗 filter chip,选中的那颗是当前值。 +fn choice_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> { + let mut options = row![] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); + for (index, option) in item.options.iter().enumerate() { + let selected = i32::try_from(index) + .map(|index| index == item.selected) + .unwrap_or(false); + let key = item.key.clone(); + let value = option.clone(); + options = options.push(chip( + ui, + Chip::filter(option, selected, msg(Message::Set { key, value })), + )); + } + block(ui, item, options.into()) +} + +/// 单行文本 + 「应用」按钮(`on_input` 只改草稿,点按钮 / 回车才写盘)。 +fn text_row<'a>( + ui: &'a Ui, + item: &'a SettingRowView, + drafts: &'a State, + apply_label: &'a str, +) -> Element<'a, PageMessage> { + let key = item.key.clone(); + let field = Field::new(drafts.text_of(item)).variant(FieldVariant::Outlined); + let input = text_field(ui, field, move |value| { + msg(Message::Edit { + key: key.clone(), + value, + }) + }); + let apply = buttons::text( + ui, + Button::new( + apply_label, + msg(Message::Commit { + key: item.key.clone(), + }), + ), + ); + let controls = row![input, apply] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .width(Length::Fill); + block(ui, item, controls.into()) +} + +/// 只读一行(本组没有;`SettingControl` 是穷尽枚举)。 +fn read_only_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> { + block( + ui, + item, + text_role_owned( + ui, + TypeRole::BodySmall, + false, + item.value_text.clone(), + ui.color(Role::OnSurfaceVariant), + ), + ) +} + +/// 动作按钮(「重新检测地区」)。 +fn action_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> { + let button = buttons::tonal( + ui, + Button::new( + &item.label, + msg(Message::Action { + key: item.key.clone(), + }), + ), + ); + column![button, hint(ui, item)] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +// --------------------------------------------------------------- 测试探针 + +/// 这一帧本组要画的**控件种类**(顺序 = 快照里的行序)。 +/// +/// 存在的理由:`Element` 没有内省接口,「页面画了哪几种控件」只能从行模型上问。测试用它区分 +/// 「画了真控件」与「退回了演示行」——这是 `body.is_some()` 之外唯一能看的东西。 +/// +/// `#[cfg(test)]`:产品路径不需要它(`body` 自己就在画),它只为断言存在。 +#[cfg(test)] +pub(crate) fn controls_of(settings: &[SettingGroupView]) -> Vec { + 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 { + 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(), + }) +} diff --git a/crates/app-iced/src/pages/settings/frame.rs b/crates/app-iced/src/pages/settings/frame.rs index 005ea75..106b78b 100644 --- a/crates/app-iced/src/pages/settings/frame.rs +++ b/crates/app-iced/src/pages/settings/frame.rs @@ -303,6 +303,7 @@ mod no_stray_empty_state { States { appearance: None, groups: demo_groups(), + settings: &[], } } diff --git a/crates/app-iced/src/pages/settings/state.rs b/crates/app-iced/src/pages/settings/state.rs index 44113a8..13eeb72 100644 --- a/crates/app-iced/src/pages/settings/state.rs +++ b/crates/app-iced/src/pages/settings/state.rs @@ -36,6 +36,8 @@ use std::sync::LazyLock; +use unlucky_presenter::dto::SettingGroupView; + use crate::pages::appearance; use super::{ai_about, download, java, language_appearance}; @@ -55,7 +57,8 @@ pub struct Groups { pub ai_about: ai_about::State, } -/// 画一帧时**按值**递给各组渲染器的只读视图:组状态 + 外观运行期状态(两个引用,`Copy`)。 +/// 画一帧时**按值**递给各组渲染器的只读视图:组状态 + 外观运行期状态 + presenter 的设置快照 +/// (三个引用,`Copy`)。 /// /// 为什么按值而不是按引用:`Settings::view` 返回的 `Element<'a>` 会借走它,按引用传会逼着 /// harness 那条路径([`super::demo_groups`] 撑着的 `page()` / `page_with_appearance()`)为一份 @@ -67,6 +70,15 @@ pub struct States<'a> { pub appearance: Option<&'a appearance::State>, /// 各组自己的状态(见 [`Groups`])。 pub groups: &'a Groups, + /// **presenter 的设置组快照**(M201):`CoreEvent::SettingsChanged` 的回流结果, + /// `pages::Model` 那份(`Model::settings`)。空切片 = 这一帧没有快照(harness / 首帧), + /// 组自己的 `body` 据此退回演示行。 + /// + /// 为什么走这里而不是给 `settings::view` 加形参:`Group.body` 的槽位签名是 + /// `fn(&Ui, States, Lang) -> Option`,它**只看得到 `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` 用它)。 diff --git a/crates/app-iced/src/pages/settings/tests/appearance_section.rs b/crates/app-iced/src/pages/settings/tests/appearance_section.rs index 2fe858e..d628be1 100644 --- a/crates/app-iced/src/pages/settings/tests/appearance_section.rs +++ b/crates/app-iced/src/pages/settings/tests/appearance_section.rs @@ -154,6 +154,7 @@ fn appearance_texts(lang: Lang) -> Vec<(Key, &'static str)> { States { appearance: Some(&state), groups: demo_groups(), + settings: &[], }, Language { choice: LangChoice::System, diff --git a/crates/app-iced/src/pages/settings/tests/download_section.rs b/crates/app-iced/src/pages/settings/tests/download_section.rs index 1ddd0cb..b60720f 100644 --- a/crates/app-iced/src/pages/settings/tests/download_section.rs +++ b/crates/app-iced/src/pages/settings/tests/download_section.rs @@ -1,19 +1,65 @@ //! 「Downloads」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的, -//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。 +//! 一条 section 断言都没有;这几条是新增,不是搬移)。 //! -//! **归 download 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线): -//! `settings/download.rs` + 本文件。 +//! **归 download 线**。M201 之前这一组只有演示行;现在它接了真控件(`GROUP.body = Some(body)`), +//! 数据来自 presenter 的设置快照(`States::settings` ← `pages::Model::settings`)。 //! //! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。 +//! +//! ## 这一轮换了什么(M201) +//! +//! | 旧绊线 | 现在 | +//! |---|---| +//! | `body.is_none()`(「这一组还没接真控件」) | `body.is_some()`(「接了,别再退回去」) | +//! | 只有 `rows` 的语义行断言 | 保留 + 三条**控件形状**断言(按 `SettingControl` 分发) | +//! +//! 写回闭环(点了以后盘上真的变了)不在本文件:那是 `app/update/settings/download_tests.rs`, +//! 它要一个假后端 + `App`,页面层的测试拿不到。 use super::super::download; +use super::super::States; use unlucky_design::icons::IconKind; +use unlucky_presenter::dto::{SettingControl, SettingGroupView, SettingRowView}; -/// 这一组的行集合(标签 + 值)。 +/// 一份照 presenter 的 `host::settings::download::group` 形状搭的快照(键与控件种类同序)。 +/// +/// 为什么自己搭而不是问 presenter 要:那个函数要 `HostEnv`(读盘)且不在 `host` 的公开面上; +/// 本文件守的是**页面按 `control` 字段分发**这件事,不是 presenter 的行文案。 +fn snapshot() -> Vec { + vec![SettingGroupView::new("download", "下载与镜像(D7)") + .with(SettingRowView::choice( + "download.concurrency", + "并发数", + "同时下载的文件数", + &["4", "8", "16"], + 1, + )) + .with(SettingRowView::choice( + "download.retry_limit", + "单源尝试次数", + "同一个源最多试几次", + &["0", "1", "3"], + 2, + )) + .with(SettingRowView::text( + "download.proxy_url", + "HTTP 代理", + "manual 模式生效", + "", + )) + .with(SettingRowView::action( + "download.action.detect_region", + "重新检测地区", + "清掉 24h 地区缓存", + "detect_region", + ))] +} + +/// 这一组的行集合(标签 + 值)——**没有快照时**画的语义行。 /// /// 抓得住:① 四条线并行合并时把别人的行粘到本文件(六份 `GROUP` 长得几乎一样,最容易整块贴错); /// ② 漏掉/多出一条行;③ 标签与值错位。 -/// 抓不住:值对不对——这三行**本来就不是值**(M193 起改成语义行,见下一条用例)。 +/// 抓不住:值对不对(这几行本来就不是值,是语义),也抓不住有快照时画什么(下一条用例管)。 #[test] fn the_downloads_group_shows_concurrency_retries_and_the_gh_proxy_key() { let rows: Vec<(&str, &str)> = download::GROUP.rows.to_vec(); @@ -35,29 +81,76 @@ fn the_downloads_group_keeps_its_icon() { assert_eq!(download::GROUP.icon, IconKind::Download); } -/// **M193 换了绊线的内容,但没撤绊线**。 +/// **M201 翻了这条绊线**:这一组现在有真控件渲染器。 /// -/// 原绊线是 `the_downloads_group_is_still_demo_rows`:断言 `body.is_none()`(这一组还没接真控件)。 -/// 它存在的意义是「防止演示值回潮」。M193 撤掉的只是**演示值**(`("Concurrency", "8 threads")` -/// 这类):那个 8 与引擎真实的 64 对不上,用户按它调设置自然没效果。 +/// 旧绊线 `the_downloads_group_is_still_demo_rows`(`body.is_none()`)存在的意义是「防止演示值 +/// 回潮」。M201 之后演示值已经不是问题——有快照就画真控件,没快照才退回语义行——所以绊线翻面: /// -/// 现在改成两条更硬的断言: +/// 抓得住:① 谁把 `GROUP.body` 改回 `None`(真控件当场消失,页面又只剩静态语义行 —— +/// 那正是用户投诉的形态);② 谁把渲染器挪走却忘了同步这里。 +/// 抓不住:渲染器**画得对不对**(`Element` 没有内省接口,形状断言在下一条),也抓不住 +/// 「有快照时真的走了 body」(`settings/mod.rs` 的 `and_then` 那一层;那一层断了会退回语义行, +/// 界面上看得出来,但没有断言)。 +#[test] +fn the_downloads_group_carries_a_real_control_body() { + assert!( + download::GROUP.body.is_some(), + "Downloads 必须挂着真控件渲染器:`body = None` 就是「设置页里改不了」的回归" + ); +} + +/// **控件形状**:快照里有几种 `SettingControl`,页面就画几种(choice / text / action)。 /// -/// 1. **仍然没有真控件**(`body.is_none()`)——真值的通路还没接(`State` 是空 struct、 -/// iced 侧没有设置快照),所以这一组还是静态行;接上那天把这条改成 `is_some()` 并接数据。 -/// 2. **行里不许再出现「当前值」形态的字面量**:三行必须把「出厂值 / 区间 / 三态」讲清楚, -/// 且 gh-proxy 那一行必须**同时**说清「留空 = 跟随内置默认」与「填 off = 关闭」—— -/// 这两者最容易被混(一个「我没决定」,一个是「我明确不要」)。 +/// 抓得住:① 渲染器只认 choice(把 text / action 也画成chip 或干脆不画)——`controls_of` 是从 +/// 同一份行模型上读的,但**分发**发生在 `body` 里,改成 `match` 单臂会让别的行整段消失; +/// ② 谁把 `SettingControl::Text` 落到 `read_only_row` 上(那时输入框没了,用户改不了文本键)。 +/// 抓不住:画出来的像素(那是截图的事),也抓不住每种控件是否真的连了消息(那是 app 层的 +/// `download_tests.rs` 与真机截图)。 +#[test] +fn the_downloads_body_follows_the_control_kind() { + let groups = snapshot(); + assert_eq!( + download::controls_of(&groups), + vec![ + SettingControl::Choice, + SettingControl::Choice, + SettingControl::Text, + SettingControl::Action, + ], + "控件种类必须照快照的 `control` 字段画" + ); +} + +/// **没有快照就不许编值**:`controls_of` 空 ⇒ 页面退回 [`download::GROUP.rows`] 的语义行。 /// -/// 变异证明:把 `download.rs` 的 gh_proxy 行改回 `("Preferred source", "Automatic (region)")` -/// ⇒ 第 2 条红;把 `enabled` 语义写成「留空 = 关闭」⇒ 第 2 条红。 +/// 抓得住:谁为了「让页面好看」在渲染器里塞一份写死的默认快照(那就是 M193 被投诉的演示值, +/// 只不过换了个藏身处)。 +/// 抓不住:「空快照时确实走了 `demo_card`」这一步(`settings/mod.rs` 的退回逻辑,它有自己的 +/// `frame.rs` 守门)。 +#[test] +fn an_empty_snapshot_lists_no_controls() { + assert!(download::controls_of(&[]).is_empty()); + + // 别的组在场、本组缺席,同样一条控件都不该画(不许拿别人的行凑数)。 + let other = vec![ + SettingGroupView::new("java", "Java(D13)").with(SettingRowView::toggle( + "java.auto_install", + "缺 Java 时自动下载", + "", + true, + )), + ]; + assert!(download::controls_of(&other).is_empty()); +} + +/// 语义行里的「三态 / 区间 / 出厂值」必须还在——它们是**没有快照时**唯一说得清的说明。 +/// +/// 这一条是 M193 留下的,M201 原样保留:即便有了真控件,兜底那一屏也不许出现假的当前值。 +/// +/// 抓得住:把 gh_proxy 那两行合并成一句(「留空 = 关闭」这种混法)、或者把区间 / 0-1 语义删掉。 +/// 抓不住:有快照时行文案对不对(那时文案来自 presenter,见 `host/settings/download_tests.rs`)。 #[test] fn the_downloads_rows_state_semantics_and_never_fake_a_current_value() { - assert!( - download::GROUP.body.is_none(), - "Downloads 还没接真控件:接了就改成 is_some() 并接数据通路" - ); - let joined = download::GROUP .rows .iter() @@ -93,3 +186,22 @@ fn the_downloads_rows_state_semantics_and_never_fake_a_current_value() { ); } } + +/// 本组是**有快照就不退回演示行**的那一类:`States` 里带了快照时,`body` 一定拿得到组。 +/// +/// 站位提醒:`States` 是 `pages::settings::States`(三条线共享的只读视图),本文件只验证 +/// 「本组能从它里面找到自己」——找不到就等于退回演示行。 +#[test] +fn the_group_finds_itself_in_the_states_snapshot() { + let groups = snapshot(); + let groups_state = super::super::demo_groups(); + let states = States { + appearance: None, + groups: groups_state, + settings: &groups, + }; + assert!( + states.settings.iter().any(|group| group.id == "download"), + "本组的 id 必须在快照里:`download`(presenter 的 `SettingGroupView::new(\"download\", ..)`)" + ); +} diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index db8642d..0557508 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -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.", "已保存并从磁盘读回:三个键都进配置文件了。"), ("SettingsJavaPrefsUnsaved", "There are unsaved changes in this section.", "这一段有未保存的改动。"), ("SettingsJavaReasonInstallRootTooLong", "the folder path is too long", "目录路径太长"), + // M201:设置页 Downloads 组的文本行提交按钮(装配链里本域紧随 `settings_java_entries`)。 + ("SettingsDownloadsApply", "Apply", "应用"), ]; diff --git a/crates/i18n/src/catalog/settings/download.rs b/crates/i18n/src/catalog/settings/download.rs index 49163e3..b6e5e00 100644 --- a/crates/i18n/src/catalog/settings/download.rs +++ b/crates/i18n/src/catalog/settings/download.rs @@ -1,11 +1,23 @@ -//! 设置页「Downloads」分组文案:**预留**(页面侧的真控件还没接)。 +//! 设置页「Downloads」分组文案(M201 起有内容:这一组接了真控件)。 //! //! 加 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 { ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { - $next! { $($rest,)* { $($acc)* } } + $next! { $($rest,)* { + $($acc)* + // ── 文本行的提交按钮(`pages/settings/download.rs` 的 text 行) + SettingsDownloadsApply => ("Apply", "应用"), + } } }; } diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 961dafb..72ef41e 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -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, diff --git a/crates/presenter/src/host/settings/download.rs b/crates/presenter/src/host/settings/download.rs index 4e6be69..b96dec3 100644 --- a/crates/presenter/src/host/settings/download.rs +++ b/crates/presenter/src/host/settings/download.rs @@ -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 { options } +/// 文件里**显式写过**的原值(`None` = 文件没写这个键)。 +/// +/// 与 `super::int` 的区别:那个走 `effective()`(文件值 → schema 默认值),本函数只问文件本身。 +/// 「原值 X 已按出厂 N 生效」这句话的两个数必须现读(一个来自文件、一个来自 core 的翻译层), +/// **不许**拿常量或请求参数冒充——那是 U1 明令禁止的假证据。 +fn raw_int(env: &HostEnv, key: &str) -> Option { + env.store().doc().get(key).and_then(|value| value.as_i64()) +} + +/// 文件原值 ≠ 生效值时的说明;`None` = 没什么可说的(没写过,或写的就是生效值)。 +fn applied_note(raw: Option, effective: i64, factory: i64) -> Option { + 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; diff --git a/crates/presenter/src/host/settings/download_tests.rs b/crates/presenter/src/host/settings/download_tests.rs new file mode 100644 index 0000000..3d4d19f --- /dev/null +++ b/crates/presenter/src/host/settings/download_tests.rs @@ -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(); +} From 5dda9ac31ddebd28a5d995d33ac8f176a4449e4c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 10:36:27 +0800 Subject: [PATCH 2/2] =?UTF-8?q?M201=20r2:=20=E4=BF=AE=E8=A7=86=E8=A7=89=20?= =?UTF-8?q?r1=20=E7=9A=84=E4=B8=A4=E6=9D=A1=20P2=20+=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1=E7=9A=84=E5=9D=8F=E5=80=BC=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=20p2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视觉 r1(fix-then-merge): - P2-1 分隔线贴字:downloaded 组的行间距从 0 改成 `Space::X3`(12dp)。实测 说明末行→分隔线 1–3px → **12–13px**、分隔线→下一标题 4–5px → **12–17px** (同族页 9–18px;同一把尺子量演示回退卡 16–19px)。不动 `frame::divider` / `demo_card`;`spacing` 只作用于子节点之间,卡片首行顶距与末行底距仍是 20。 - P2-2 孤字「效」:`with_note` 把「原值 X 已按出厂 N 生效」从说明段落里挪到 **独立一行**(`\n` 分隔,渲染器按行断)。浅/深/窄三张复验:无 <20px 窄行。 两种措辞(出厂 8 / 已按 1)一字未改,presenter 侧新增 `ends_with("\n原值 …")` 断言把「独占一行」钉住。 代码复审 p2(非整数坏值被静默丢掉): - `raw_int`(`Value::as_i64`)→ `raw_text`:文件里显式写过的原值转**上屏文本** (字符串取原文不带 serde 引号、数字/布尔 `to_string`、`null` 当没写)。 于是 `"auto"` / `8.5` / `true` / `-4` 都能进那句提示,不再「画着 8 却说不出 用户写的 auto 被忽略」。 - 新用例 `every_bad_value_shape_says_what_the_raw_value_was`:负整数 / 字符串 / 浮点 / 布尔 / null 五种形态。 验证:fmt --all --check / check -p unlucky-app-iced --all-targets / test -p unlucky-app-iced(845+6) / test -p unlucky-presenter(316) / test -p unlucky-i18n(36),全部 EXIT=0。 --- .../app-iced/src/pages/settings/download.rs | 14 ++- .../presenter/src/host/settings/download.rs | 43 ++++++-- .../src/host/settings/download_tests.rs | 103 ++++++++++++++++++ 3 files changed, 151 insertions(+), 9 deletions(-) diff --git a/crates/app-iced/src/pages/settings/download.rs b/crates/app-iced/src/pages/settings/download.rs index 3e732ba..87454b6 100644 --- a/crates/app-iced/src/pages/settings/download.rs +++ b/crates/app-iced/src/pages/settings/download.rs @@ -167,11 +167,23 @@ fn msg(message: Message) -> PageMessage { /// 整组正文:快照里按 [`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> { let group = state_group(&states)?; let drafts = &states.groups.download; let catalog = lang.catalog(); - let mut stacked = column![].spacing(0.0).width(Length::Fill); + let mut stacked = column![].spacing(ui.space(Space::X3)).width(Length::Fill); for (index, item) in group.rows.iter().enumerate() { if index > 0 { diff --git a/crates/presenter/src/host/settings/download.rs b/crates/presenter/src/host/settings/download.rs index b96dec3..41977ee 100644 --- a/crates/presenter/src/host/settings/download.rs +++ b/crates/presenter/src/host/settings/download.rs @@ -51,12 +51,12 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView { 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), + raw_text(env, unlucky_core::download::config::KEY_CONCURRENCY), tuning.max_concurrency as i64, DEFAULT_MAX_CONCURRENCY as i64, ); let retry_note = applied_note( - raw_int(env, unlucky_core::download::config::KEY_RETRY_LIMIT), + raw_text(env, unlucky_core::download::config::KEY_RETRY_LIMIT), i64::from(tuning.max_attempts_per_source), i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE), ); @@ -174,19 +174,40 @@ fn ladder(ladder: &[&str], current: &str) -> Vec { options } -/// 文件里**显式写过**的原值(`None` = 文件没写这个键)。 +/// 文件里**显式写过**的原值,转成**上屏文本**(`None` = 文件没写这个键)。 /// /// 与 `super::int` 的区别:那个走 `effective()`(文件值 → schema 默认值),本函数只问文件本身。 /// 「原值 X 已按出厂 N 生效」这句话的两个数必须现读(一个来自文件、一个来自 core 的翻译层), /// **不许**拿常量或请求参数冒充——那是 U1 明令禁止的假证据。 -fn raw_int(env: &HostEnv, key: &str) -> Option { - env.store().doc().get(key).and_then(|value| value.as_i64()) +/// +/// ## 为什么返回字符串而不是 `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 { + 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` = 没什么可说的(没写过,或写的就是生效值)。 -fn applied_note(raw: Option, effective: i64, factory: i64) -> Option { +/// +/// `raw` 是**上屏文本**(见 [`raw_text`]),`effective` / `factory` 是 core 翻译层的结论。 +fn applied_note(raw: Option, effective: i64, factory: i64) -> Option { let raw = raw?; - if raw == effective { + // 比的是「上屏文本」:文件写 `8`、生效也是 8 ⇒ 没什么可说的(`8.0` 会照实提示,因为那是一次 + // 类型不一致的坏值)。 + if raw == effective.to_string() { return None; } Some(if effective == factory { @@ -197,9 +218,15 @@ fn applied_note(raw: Option, effective: i64, factory: i64) -> Option) -> String { match note { - Some(note) => format!("{base};{note}"), + Some(note) => format!("{base}\n{note}"), None => base, } } diff --git a/crates/presenter/src/host/settings/download_tests.rs b/crates/presenter/src/host/settings/download_tests.rs index 3d4d19f..3fd7b2a 100644 --- a/crates/presenter/src/host/settings/download_tests.rs +++ b/crates/presenter/src/host/settings/download_tests.rs @@ -70,6 +70,14 @@ fn an_out_of_range_file_value_shows_the_engine_value_and_names_the_raw_one() { "文件值与生效值不一致时必须写明原值:{}", 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, @@ -102,11 +110,106 @@ fn a_file_value_that_matches_the_engine_value_gets_no_note() { "没有差异就不该有那句话:{}", 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 —— 又一个「设置项说假话」);