M201: 接通 iced 设置页的数据通路(下载组:真值 + 真控件 + 写回闭环)
M193 的用户投诉「设置页里改了不生效」的结构性根因是 iced 侧没有设置数据通路:
`CoreEvent::SettingsChanged` 在 `crates/app-iced/src` 零命中、`pages::Model` 没有设置快照、
`download::State` 是空 struct。presenter 那套设置组早就是真值来源(`host::settings::groups`
由 `refresh_all` 与 `dispatch` 的 `SetSetting` 分支发成 `SettingsChanged`),只是没有接到 iced。
这条线现在通了:
host::settings::groups → SettingsChanged → AppState::settings_groups
→ pages::Model.settings → settings::States.settings → 下载组控件
→ AppCommand::SetSetting → 写盘 + 重新发 SettingsChanged(闭环)
主要改动
- presenter `host/settings/download.rs`:并发/重试两行改成画**引擎生效值**(`DownloadTuning`),
文件原值与生效值不一致时写明「原值 X 已按出厂 N 生效」(P3-5,两个数都现读);
gh-proxy 行补「当前生效:跟随内置默认/已关闭/覆盖为 X」。
- app-iced `pages/settings/download.rs`:`GROUP.body = Some(body)`,按 `SettingControl`
画 choice(filter chip)/ text(输入框 + 应用)/ action(按钮)/ toggle / readonly;写回走
`app/update/settings/download.rs` → `AppCommand::SetSetting`(不做乐观回显)。
- `pages::Model.settings` + `settings::States.settings`:快照进页面的唯一通道。
- i18n:新增 `SettingsDownloadsApply`(金表锚点追加,`split_keeps_every_translation` 绿)。
- 取证:`ULL_SCREENSHOT_REAL_HOST=1` 让 `--screenshot` 接真 host(截图通道默认强制 mock,
而 mock 不发 `SettingsChanged`,真值屏拍不出来)。
测试(新增 11 条)
- presenter `download_tests.rs`:P3-5 的越界回落 / 0→1 / 无差异不出提示 + P3-3 的
gh-proxy 文件→三态端到端链。
- app-iced `app/update/settings/download_tests.rs`:假后端闭环 ×4 + **真 `CoreHost` 端到端**
(点 16 ⇒ 磁盘上 `launcher.json` 真的有 16)。
- `pages/settings/tests/download_section.rs`:`body.is_none()` 绊线翻成 `is_some()` +
控件形状 / 空快照不编值。
This commit is contained in:
1 parent
0cc1a35420
commit
ab573838f3
18 files changed
+1257
-74
No files matched your search
@@ -214,3 +214,19 @@ pub struct App {
|
||||
/// [`crate::platform::win32::corner::CornerApplied`]。
|
||||
pub(super) corner_applied: Option<crate::platform::win32::corner::CornerApplied>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// iced 侧的**设置组快照**(M201):presenter 的 `CoreEvent::SettingsChanged` 回流进来的那一份。
|
||||
///
|
||||
/// 真值只有一份——presenter 的 `AppState::settings_groups`(`apply_core` 是它唯一的写入口,
|
||||
/// 由 host 的 `refresh_all` / `SetSetting` 分支发出来)。这里**不另存一个字段**:那会变成第二份
|
||||
/// 真相,两份在 `pump` 之间必然有一步不一致(而「界面上是旧值、盘上是新值」正是本 mission 要
|
||||
/// 消灭的形状)。要画的那一份是 [`pages::Model::settings`](`refresh_view` 里从同一个字段克隆)。
|
||||
///
|
||||
/// `#[cfg(test)]`:产品路径不需要这个入口(页面拿的是 `Model.settings`,同一份数据的投影),
|
||||
/// 断言需要它——「App 认的值变了吗」必须能直接从 App 上问,不能去问页面模型(那是下游)。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn settings_snapshot(&self) -> &[unlucky_presenter::dto::SettingGroupView] {
|
||||
&self.state.settings_groups
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,30 @@ pub(super) fn app_options(options: &Options) -> AppOptions {
|
||||
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
|
||||
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
|
||||
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) {
|
||||
|
||||
@@ -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<Message> {
|
||||
match message {}
|
||||
use pages::settings::download::Message;
|
||||
|
||||
match message {
|
||||
Message::Set { key, value } => {
|
||||
self.dispatch(AppCommand::SetSetting { key, value });
|
||||
Task::none()
|
||||
}
|
||||
Message::Edit { key, value } => {
|
||||
// 只改草稿:`body` 每一帧都从 `Groups.download` 读它,所以这里不必重建视图模型。
|
||||
self.settings.download.edit(&key, value);
|
||||
Task::none()
|
||||
}
|
||||
Message::Commit { key } => {
|
||||
let value = self.pending_text(&key);
|
||||
self.settings.download.clear(&key);
|
||||
self.dispatch(AppCommand::SetSetting { key, value });
|
||||
Task::none()
|
||||
}
|
||||
// 动作行(「重新检测地区」):键名与动作 id 都由 presenter 的快照给,取值被
|
||||
// `settings::set` 忽略(那条分支先认键、再解析值)。
|
||||
Message::Action { key } => {
|
||||
self.dispatch(AppCommand::SetSetting {
|
||||
key,
|
||||
value: String::new(),
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 这一行要提交的文本:本地草稿优先,否则用快照里的当前值(= 文件里的值)。
|
||||
///
|
||||
/// 空串是合法提交(「留空 = 跟随默认」是这一组真有的语义),所以只有**两者都缺**才返回空串。
|
||||
fn pending_text(&self, key: &str) -> String {
|
||||
self.settings
|
||||
.download
|
||||
.pending(key)
|
||||
.or_else(|| find_row(&self.view.settings, key).map(|row| row.value_text.clone()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "download_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Downloads 组的**写回闭环**(M201):`控件 → Message → AppCommand::SetSetting → 真 host 写盘
|
||||
//! → CoreEvent::SettingsChanged → App 的快照与页面模型`。
|
||||
//!
|
||||
//! 为什么这条断言必须在 app 层:链子的两头都是「有状态的东西」——中间断在任何一环,页面层测试
|
||||
//! 都看不见(它只拿到一份已经画好的模型)。要问的是「用户点了以后,**盘上和 App 认的值**变了吗」,
|
||||
//! 而不是「按钮的 `on_press` 里有没有构造消息」。
|
||||
//!
|
||||
//! 两个后端都测,因为它们证明的事不同:
|
||||
//!
|
||||
//! | 用例 | 后端 | 证明 |
|
||||
//! |---|---|---|
|
||||
//! | 前四条 | 假后端(按 host 形状回快照) | 搬运:消息 → 命令、命令 → 快照、快照 → 模型 |
|
||||
//! | 最后一条 | **真 `CoreHost` + 临时数据根** | 整条链:命令 → `settings::set` → `launcher.json` 真的变了 |
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, AppEvent, Backend, CoreEvent, SettingControl, SettingGroupView, SettingRowView,
|
||||
};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::pages::settings::download;
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 这一组要断言的那个键(与 presenter 快照里的键同名)。
|
||||
const CONCURRENCY: &str = "download.concurrency";
|
||||
/// 一个文本键(代理地址)。
|
||||
const PROXY_URL: &str = "download.proxy_url";
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 一份只有下载组的快照:并发 = `concurrency`,代理 = `proxy`。
|
||||
///
|
||||
/// 行形状照 presenter 的 `host::settings::download::group` 抄(choice + 档位选项,text + 原文),
|
||||
/// 只保留本文件断言要用的那两行——这里守的是 app 侧的搬运,不是 presenter 的行文案。
|
||||
fn snapshot(concurrency: &str, proxy: &str) -> Vec<SettingGroupView> {
|
||||
let options = ["4", "8", "16"];
|
||||
let selected = i32::try_from(
|
||||
options
|
||||
.iter()
|
||||
.position(|item| *item == concurrency)
|
||||
.unwrap_or(1),
|
||||
)
|
||||
.expect("装得下");
|
||||
vec![SettingGroupView::new("download", "下载与镜像(D7)")
|
||||
.with(SettingRowView::choice(
|
||||
CONCURRENCY,
|
||||
"并发数",
|
||||
"同时下载的文件数",
|
||||
&options,
|
||||
selected,
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
PROXY_URL,
|
||||
"HTTP 代理",
|
||||
"manual 模式生效",
|
||||
proxy,
|
||||
))]
|
||||
}
|
||||
|
||||
/// 记下令、并按 host 的形状回一条 `SettingsChanged` 的后端。
|
||||
struct Hostish {
|
||||
commands: Rc<RefCell<Vec<AppCommand>>>,
|
||||
queued: Vec<AppEvent>,
|
||||
/// 回事件时用的快照(`SetSetting` 之后换成「值真的变了」的那一份)。
|
||||
state: RefCell<(String, String)>,
|
||||
}
|
||||
|
||||
impl Backend for Hostish {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
self.queued.drain(..).collect()
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.commands.borrow_mut().push(command.clone());
|
||||
if let AppCommand::SetSetting { key, value } = command {
|
||||
let mut state = self.state.borrow_mut();
|
||||
if key == CONCURRENCY {
|
||||
state.0 = value;
|
||||
} else if key == PROXY_URL {
|
||||
state.1 = value;
|
||||
}
|
||||
// host 写盘之后重新发一份快照(`dispatch.rs` 的 `SetSetting` 分支就这一行)。
|
||||
self.queued
|
||||
.push(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
|
||||
&state.0, &state.1,
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个装着 [`Hostish`] 的 App + 那本命令账。
|
||||
fn app() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
|
||||
let commands = Rc::new(RefCell::new(Vec::new()));
|
||||
let backend = Hostish {
|
||||
commands: Rc::clone(&commands),
|
||||
queued: Vec::new(),
|
||||
state: RefCell::new(("8".to_string(), "http://127.0.0.1:7890".to_string())),
|
||||
};
|
||||
let mut app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(backend),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
// 首屏:收一份 host 的快照(产品路径在 `on_boot` 的 `RefreshAll` 之后就是这样)。
|
||||
app.state
|
||||
.apply(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
|
||||
"8",
|
||||
"http://127.0.0.1:7890",
|
||||
))));
|
||||
app.refresh_view();
|
||||
(app, commands)
|
||||
}
|
||||
|
||||
/// 一条设置页消息走完漏斗。
|
||||
fn send(app: &mut App, message: pages::settings::download::Message) {
|
||||
let message = pages::Message::Settings(pages::settings::GroupMessage::Download(message));
|
||||
let _ = app.on_page(message);
|
||||
}
|
||||
|
||||
/// **主用例**:点一个档位 ⇒ 命令发出去了 ⇒ host 回的快照进了 App ⇒ 页面模型跟着换。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// 1. handler 不派发 `AppCommand::SetSetting`(写回那一行被删)——这时假后端收不到命令、
|
||||
/// 也回不出事件,快照停在旧值,红(变异验证见报告);
|
||||
/// 2. `pages::Model` 不从快照取设置(`view::model` 那行 `settings:` 被删)——`app.view.settings`
|
||||
/// 是空的,`current_value` 返回 `None`,红;
|
||||
/// 3. App 侧**乐观回显**(点完就地改本地值、不等事件)——把假后端的回事件删掉后第 2 段仍会绿,
|
||||
/// 所以本用例的「红」只证明闭合回路;它抓不住「有没有多存一份本地值」这一半。
|
||||
///
|
||||
/// 抓不住:真实点击(`Chip` 的 `on_press` 是否被真的按下)、host 是否真的落盘(那是最后一条的活)。
|
||||
#[test]
|
||||
fn changing_a_choice_value_lands_in_the_app_snapshot_and_the_page_model() {
|
||||
let (mut app, commands) = app();
|
||||
assert_eq!(
|
||||
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
|
||||
Some("8"),
|
||||
"开局认的是 host 给的那份"
|
||||
);
|
||||
|
||||
send(
|
||||
&mut app,
|
||||
pages::settings::download::Message::Set {
|
||||
key: CONCURRENCY.to_string(),
|
||||
value: "4".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(
|
||||
commands.borrow().iter().any(|command| matches!(
|
||||
command,
|
||||
AppCommand::SetSetting { key, value } if key == CONCURRENCY && value == "4"
|
||||
)),
|
||||
"写回必须走已有的 `AppCommand::SetSetting`(实际下发:{:?})",
|
||||
commands.borrow()
|
||||
);
|
||||
assert_eq!(
|
||||
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
|
||||
Some("4"),
|
||||
"App 的设置快照必须跟着 host 回来那份变(不是本地 State 变)"
|
||||
);
|
||||
assert_eq!(
|
||||
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
|
||||
Some("4"),
|
||||
"页面模型(这一帧要画的值)也必须是 4:控件画的是 `Model.settings`"
|
||||
);
|
||||
}
|
||||
|
||||
/// 文本行:`Edit` 只改草稿(**不发指令**),`Commit` 才带着草稿去写盘。
|
||||
///
|
||||
/// 抓得住:`Edit` 也派发(每敲一个键写一次盘)、`Commit` 用快照旧值而不是草稿(用户输入丢失)。
|
||||
/// 抓不住:输入框本身的按键路径(`text_field` 的 `on_input` 是组件既有实现)。
|
||||
#[test]
|
||||
fn typing_a_text_value_only_commits_on_demand() {
|
||||
let (mut app, commands) = app();
|
||||
|
||||
send(
|
||||
&mut app,
|
||||
pages::settings::download::Message::Edit {
|
||||
key: PROXY_URL.to_string(),
|
||||
value: "http://127.0.0.1:1080".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
commands.borrow().is_empty(),
|
||||
"输入过程中不许写盘(半截值会落进 launcher.json)"
|
||||
);
|
||||
|
||||
send(
|
||||
&mut app,
|
||||
pages::settings::download::Message::Commit {
|
||||
key: PROXY_URL.to_string(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
commands.borrow().iter().any(|command| matches!(
|
||||
command,
|
||||
AppCommand::SetSetting { key, value } if key == PROXY_URL && value == "http://127.0.0.1:1080"
|
||||
)),
|
||||
"提交时带的必须是**草稿**里的那一串(不是快照里的旧值):{:?}",
|
||||
commands.borrow()
|
||||
);
|
||||
assert_eq!(
|
||||
download::current_value(app.settings_snapshot(), PROXY_URL).as_deref(),
|
||||
Some("http://127.0.0.1:1080"),
|
||||
"提交后快照换成 host 回来的新值"
|
||||
);
|
||||
}
|
||||
|
||||
/// 动作行(「重新检测地区」):派发的是那一行的键,值留空(presenter 那条分支先认键、不看值)。
|
||||
///
|
||||
/// 抓得住:动作按钮被写成「什么都不发」、或者把键发成按钮文案。
|
||||
#[test]
|
||||
fn an_action_row_dispatches_its_key() {
|
||||
let (mut app, commands) = app();
|
||||
let key = "download.action.detect_region".to_string();
|
||||
|
||||
send(
|
||||
&mut app,
|
||||
pages::settings::download::Message::Action { key: key.clone() },
|
||||
);
|
||||
|
||||
assert!(
|
||||
commands.borrow().iter().any(|command| matches!(
|
||||
command,
|
||||
AppCommand::SetSetting { key: sent, .. } if sent == &key
|
||||
)),
|
||||
"动作行必须走「按 schema 键写设置」那一条既有通路:{:?}",
|
||||
commands.borrow()
|
||||
);
|
||||
}
|
||||
|
||||
/// 快照里没有本组时,页面**不许**自己编一行(`controls_of` 空 = 退回演示行)。
|
||||
///
|
||||
/// 抓得住:把演示行当成真值画(M193 用户的投诉形状)。
|
||||
/// 抓不住:`settings/mod.rs` 那条退回路径本身(那是 `demo_card` 的事)。
|
||||
#[test]
|
||||
fn an_empty_snapshot_yields_no_controls() {
|
||||
let (mut app, _commands) = app();
|
||||
app.state.settings_groups.clear();
|
||||
app.refresh_view();
|
||||
|
||||
assert!(download::controls_of(app.settings_snapshot()).is_empty());
|
||||
// 有快照时,本组的控件种类就是 presenter 给的那几行(这里两份:choice + text)。
|
||||
let kinds = download::controls_of(&snapshot("8", ""));
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![SettingControl::Choice, SettingControl::Text],
|
||||
"控件种类照快照的 `control` 字段画,不许自己另定"
|
||||
);
|
||||
}
|
||||
|
||||
/// **真 host 端到端**:点「16」⇒ 「设置页里改了就生效」这句话的正面证据。
|
||||
///
|
||||
/// 链子全长:`Message::Set` → `AppCommand::SetSetting` → 真 `CoreHost` 的 `dispatch.rs` 分支
|
||||
/// (`settings::set` 校验 + 落盘 + `apply_tuning` + 重新发 `SettingsChanged`)→ `AppState` →
|
||||
/// `pages::Model::settings`。断言落在**磁盘上那串字节**上,不是任何中间变量。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// 1. 只改本地、不派发(`Message::Set` 的分支被删)⇒ `launcher.json` 里没有 16,红;
|
||||
/// 2. 派发了但 handler 用了错误的键/值 ⇒ 文件里那个键还是出厂 8,红;
|
||||
/// 3. 快照没跟着 host 的回流走(`view::model` 那行 `settings:` 被删)⇒ 页面模型空,红;
|
||||
/// 4. 写盘校验被绕过(`settings::set` 不再 `save()`)⇒ 文件不存在/没变,红。
|
||||
///
|
||||
/// 抓不住:真实鼠标点击(本用例从 `Message` 起跑,`Chip::filter` 的 `on_press` 不在其中)、
|
||||
/// 引擎是否立刻吃到新值(`apply_tuning` 与 `concurrency_probe` 由 presenter 的
|
||||
/// `the_concurrency_setting_reaches_the_real_download_engine` 守)、以及界面像素(截图的事)。
|
||||
#[test]
|
||||
fn a_choice_change_reaches_the_real_settings_file() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-m201-writeback-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
|
||||
let config = dir.join(".ULL/config/launcher.json");
|
||||
|
||||
let env = unlucky_presenter::host::HostEnv::at_root(&dir);
|
||||
let mut app = App::with_presenter(
|
||||
Options {
|
||||
data_dir: Some(dir.clone()),
|
||||
..Options::default()
|
||||
},
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(unlucky_presenter::host::CoreHost::new(env)),
|
||||
// 设置桥也指向同一个根:不碰用户的真配置。
|
||||
SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)),
|
||||
);
|
||||
// 首屏:产品路径在 `on_boot` 里派的就是这一条(真 host 会发 `SettingsChanged`)。
|
||||
app.dispatch(AppCommand::RefreshAll);
|
||||
assert_eq!(
|
||||
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
|
||||
Some("8"),
|
||||
"临时根里没有这个键 ⇒ 快照给的是出厂 8(也是引擎的生效值)"
|
||||
);
|
||||
|
||||
send(
|
||||
&mut app,
|
||||
pages::settings::download::Message::Set {
|
||||
key: CONCURRENCY.to_string(),
|
||||
value: "16".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let written = std::fs::read_to_string(&config).expect("写回之后 launcher.json 必须存在");
|
||||
assert!(
|
||||
written.contains("\"concurrency\": 16"),
|
||||
"磁盘上必须真的有 16(这才是「设置页里改了就生效」):{written}"
|
||||
);
|
||||
assert_eq!(
|
||||
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
|
||||
Some("16"),
|
||||
"host 把新快照发回来了,App 认的也是 16"
|
||||
);
|
||||
assert_eq!(
|
||||
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
|
||||
Some("16"),
|
||||
"页面模型也换成了 16(下一帧控件画的就是它)"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -92,6 +92,8 @@ impl App {
|
||||
pages::settings::States {
|
||||
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` 就是静息态,不叠状态层。
|
||||
|
||||
Reference in new issue
Block a user