fix(m174): 复审 r2 —— 写入口收敛成一个(语言入口同缺陷)+ 只读文件上保存不再谎报成功
P2-1(同一类缺陷的第三个入口):`persist_ui_language` 与 `persist` 共用同一份长生命
store、`save()` 同样整份落盘,但语言入口没先 `reload()` ⇒ 保存 AI 配置后 1 秒内切界面语言,
`ai.*` 连密钥一起被 1 秒前的旧快照盖掉,而且函数返回 true。
修法选「收敛成唯一写入口」而不是「再补一次 reload()」:
- 新文件 `presenter/src/settings_bridge/write.rs` 提供 `SettingsBridge::write_with(|store| Edit)`
——现读 → 改 → 落盘 → 刷 mtime,`Edit::{Changed,Unchanged,Rejected}` 表达闭包的结果,
`WriteOutcome::{NoStorage,Unchanged,Saved,Skipped(reason),Failed}` 表达写的结果(只有 `Saved`
算成功)。两个入口都改成传闭包,**入口自己再也拿不到「不重读的 save」**——下一个写入口不会
再靠「记得」。r1 就是「补一次 reload」的做法,隔一个文件又漏了一次。
第二条(用户可见谎话):`ai::store::save` 注释写着「落盘后再读一遍才算数」,实际返回的是内存
doc;`SettingStore::save` 还有 `Skipped(ReadOnly/Unreadable/NotDirty)` 一档(`Ok`,不是 `Err`),
旧代码 `.is_ok()` 会把它当成功 ⇒ 在「配置文件被更新版本启动器写过」的机器上什么都没写,界面报
「已保存」,重启回旧值。现在按 `SaveOutcome` 分支(只有 `Saved` 算成功,`Skipped` 给具名原因),
并且真的 `SettingStore::load` 一次从磁盘回读。
回归用例(都做了变异验证):
- `settings_bridge_lang::tests::persisting_the_language_keeps_keys_another_writer_just_saved`
(去掉 `write_with` 的 `reload()` ⇒ 红,输出即「语言写回把刚保存的密钥盖掉了」+ 被覆盖的文件)
- `settings_bridge_lang::tests::persisting_the_language_reports_false_when_the_file_is_read_only`
(`persist_ui_language` 恒返回 true ⇒ 红)
- `ai::store::tests::read_only_config_is_reported_as_a_failure`(`save()` 恒返回 Ok ⇒ 红)
- `ai::store::tests::skip_reasons_are_named`
This commit is contained in:
1 parent
93d02ab12b
commit
6f78e0cf7e
5 files changed
+301
-53
No files matched your search
@@ -17,7 +17,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
|
||||
|
||||
use super::state::{ProviderId, Snapshot, State};
|
||||
|
||||
@@ -51,9 +51,31 @@ pub fn save(path: &Path, state: &State) -> Result<Snapshot, String> {
|
||||
.map_err(|error| format!("{error}"))?;
|
||||
}
|
||||
|
||||
store.save().map_err(|error| format!("{error}"))?;
|
||||
// 落盘后再读一遍:`set` 只证明「schema 收了」,`save` 之后文件里到底是什么才算数。
|
||||
Ok(snapshot(store.doc()))
|
||||
// **只有 `SaveOutcome::Saved` 算成功**(M174 r2 复审):`save()` 还有一档 `Skipped(reason)`
|
||||
// ——只读(`$schema` 认不出 / 文件被更新版本的启动器写过)、不可信文件、没有脏键。旧代码
|
||||
// 用 `.is_ok()` 把这一档也当成功,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。
|
||||
match store.save() {
|
||||
Ok(SaveOutcome::Saved(_)) => {}
|
||||
Ok(SaveOutcome::Skipped(reason)) => {
|
||||
return Err(format!("未写入磁盘({})", skip_reason(reason)));
|
||||
}
|
||||
Err(error) => return Err(format!("{error}")),
|
||||
}
|
||||
|
||||
// 落盘之后**真的从磁盘再读一份**:`store.doc()` 是这次改过的内存视图,它证明不了「文件里
|
||||
// 现在是什么」(上面那句注释在 r1 就有,代码却没照做——r2 复审按这句判的)。
|
||||
let reloaded = SettingStore::load(path, launcher_schema());
|
||||
Ok(snapshot(reloaded.doc()))
|
||||
}
|
||||
|
||||
/// `SkipReason` → 人话(`core` 的稳定名对用户没意义)。
|
||||
fn skip_reason(reason: unlucky_core::setting::store::SkipReason) -> &'static str {
|
||||
use unlucky_core::setting::store::SkipReason;
|
||||
match reason {
|
||||
SkipReason::NotDirty => "没有改动",
|
||||
SkipReason::ReadOnly => "配置文件只读:它被更新版本的启动器写过,或被标记为不可信",
|
||||
SkipReason::Unreadable => "配置文件不可信(加载时就降级成只读)",
|
||||
}
|
||||
}
|
||||
|
||||
/// 文档 → 快照(密钥只取长度,值不出本函数)。
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` |
|
||||
//! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 |
|
||||
//! | `appearance_write_back_keeps_the_ai_keys` | **用户场景**:存完 AI 再改主题,值还在 | 两处任一方不重读 |
|
||||
//! | `read_only_config_is_reported_as_a_failure` | 只读配置上保存**不许**报成功 | `save()` 恒返回 Ok |
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -161,3 +162,50 @@ fn appearance_write_back_keeps_the_ai_keys() {
|
||||
assert_eq!(load(&path).key_chars, "sk-must-survive".chars().count());
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// **P2-1(r2)第二条**:只读配置上的保存必须报**失败**,而不是「已保存」。
|
||||
///
|
||||
/// 现场:`$schema` 认不出(或文件被更新版本的启动器写过)⇒ `FileAccess::ReadOnly`。
|
||||
/// `SettingStore::save` 在这种文件上会 `Skipped(ReadOnly)`(**不是** Err),旧代码用 `.is_ok()`
|
||||
/// 判定,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。
|
||||
///
|
||||
/// 抓得住:①把 `save()` 的 `SaveOutcome` 分支去掉(恒返回 Ok);②把只读档当成功。
|
||||
/// 抓不住:`Skipped` 分支自身的文案(见 [`super::skip_reason`] 的单测)。
|
||||
#[test]
|
||||
fn read_only_config_is_reported_as_a_failure() {
|
||||
let path = temp_config("read-only");
|
||||
// `Invalid` 兼容档(`$schema` 前缀认不出)⇒ `FileAccess::ReadOnly`:值照读,不许改写。
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"$schema":"https://example.invalid/ull/launcher/9.9.9","ai":{"model":"keep-me"}}"#,
|
||||
)
|
||||
.expect("写配置");
|
||||
let before = std::fs::read_to_string(&path).expect("读配置");
|
||||
|
||||
let error = save(&path, &draft()).expect_err("只读配置上的保存必须报失败");
|
||||
assert!(
|
||||
error.contains("只读") || error.contains("只读文件") || error.contains("9.9.9"),
|
||||
"错误文案要说得清为什么没写:{error}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).expect("读配置"),
|
||||
before,
|
||||
"被只读闸门拦下的保存不许改动文件"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// `skip_reason` 的三档都有人话(`Skipped` 不许被静默当成成功)。
|
||||
#[test]
|
||||
fn skip_reasons_are_named() {
|
||||
use super::skip_reason;
|
||||
use unlucky_core::setting::store::SkipReason;
|
||||
|
||||
for reason in [
|
||||
SkipReason::NotDirty,
|
||||
SkipReason::ReadOnly,
|
||||
SkipReason::Unreadable,
|
||||
] {
|
||||
assert!(!skip_reason(reason).is_empty(), "{reason:?} 没有文案");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::prefs::{AppearanceBase, AppearancePrefs, ThemeMode};
|
||||
|
||||
#[path = "settings_bridge/write.rs"]
|
||||
mod write;
|
||||
|
||||
pub(crate) use write::Edit;
|
||||
|
||||
/// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
|
||||
const DATA_ROOT_KEYS: [&str; 4] = [
|
||||
"core.data_mode",
|
||||
@@ -119,36 +124,28 @@ impl SettingsBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。
|
||||
/// 落盘(失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。
|
||||
///
|
||||
/// **写前先 `reload()`**(M174 r1 复审 P2-4):本桥的 store 是长生命的(启动时 load 一次),
|
||||
/// 而 `SettingStore::save` 是**整份文档**落盘(`core/src/setting/store.rs` 的 `write_document`
|
||||
/// 序列化 `doc.to_value()`,不是只写脏键)。不先重读就会拿「≤1 秒前的旧快照」整体覆盖文件:
|
||||
/// 用户刚在设置页存下的 `ai.base_url` / `ai.model` / `ai.api_key` 会在下一秒被外观写回抹掉
|
||||
/// (R1 复审给的是这个现场:保存 AI 配置后 1 秒内改一次主题,密钥连影子都不剩)。
|
||||
///
|
||||
/// 与 `HostEnv::store()` 的口径对齐:**写之前现读**,不信任任何长生命快照。
|
||||
/// **写走 [`SettingsBridge::write_with`]**(M174 r1/r2 复审):本桥的 store 是长生命的
|
||||
/// (启动时 load 一次),而 `SettingStore::save` 是**整份文档**落盘。不先重读就会拿「≤1 秒前
|
||||
/// 的旧快照」整体覆盖文件:用户刚存下的 `ai.base_url` / `ai.model` / `ai.api_key` 会在下一秒
|
||||
/// 被外观写回抹掉。现在「现读」这一步收在唯一写入口里,本函数只管改哪些键。
|
||||
pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) {
|
||||
let Some(store) = self.store.as_mut() else {
|
||||
return;
|
||||
};
|
||||
store.reload();
|
||||
let mut snapshot = Appearance::from_store(store);
|
||||
snapshot.theme_mode = theme.as_str().to_owned();
|
||||
snapshot.bg_image_enabled = appearance.bg_image_enabled;
|
||||
snapshot.bg_image = appearance.bg_image.clone();
|
||||
snapshot.bg_fill = appearance.bg_fill;
|
||||
snapshot.bg_dim = f64::from(appearance.bg_dim);
|
||||
snapshot.panel_alpha = f64::from(appearance.panel_alpha);
|
||||
snapshot.glass = appearance.glass;
|
||||
snapshot.accent_color = appearance.accent_color.clone();
|
||||
if snapshot.write(store).is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = store.save();
|
||||
if let Some(path) = self.path() {
|
||||
self.last_mtime = mtime(path);
|
||||
}
|
||||
let _ = self.write_with(|store| {
|
||||
let mut snapshot = Appearance::from_store(store);
|
||||
snapshot.theme_mode = theme.as_str().to_owned();
|
||||
snapshot.bg_image_enabled = appearance.bg_image_enabled;
|
||||
snapshot.bg_image = appearance.bg_image.clone();
|
||||
snapshot.bg_fill = appearance.bg_fill;
|
||||
snapshot.bg_dim = f64::from(appearance.bg_dim);
|
||||
snapshot.panel_alpha = f64::from(appearance.panel_alpha);
|
||||
snapshot.glass = appearance.glass;
|
||||
snapshot.accent_color = appearance.accent_color.clone();
|
||||
if snapshot.write(store).is_err() {
|
||||
return Edit::Rejected;
|
||||
}
|
||||
Edit::Changed
|
||||
});
|
||||
}
|
||||
|
||||
/// 文件被外部(AI / `ull config set`)改过就重读;返回新的生效值。
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! 设置桥的**唯一写入口**(M174 r2 复审 P2-1)。
|
||||
//!
|
||||
//! ## 为什么要把「写」收敛成一处
|
||||
//!
|
||||
//! [`SettingsBridge`] 的 store 是**长生命**的(启动时 `SettingStore::load` 一次,之后只读),
|
||||
//! 而 `SettingStore::save` 写的是**整份文档**(`core::setting::store` 的 `write_document`
|
||||
//! 序列化 `doc.to_value()`,不是脏字段补丁)。于是任何「拿旧快照直接 save」的入口都会把别人
|
||||
//! 刚写进文件的键整片抹掉:
|
||||
//!
|
||||
//! | 轮次 | 入口 | 现象 |
|
||||
//! |---|---|---|
|
||||
//! | r1 | [`SettingsBridge::persist`](外观/主题) | 保存 AI 配置后 1 秒内改主题 ⇒ `ai.*` 连密钥一起消失 |
|
||||
//! | r2 | `persist_ui_language`(界面语言) | 保存 AI 配置后 1 秒内在同一页切成 English ⇒ 同样消失,而且函数**返回 true**(静默) |
|
||||
//!
|
||||
//! 两轮都是「同一类缺陷隔一个文件复发一次」。所以写这件事只留 [`SettingsBridge::write_with`]
|
||||
//! 一个出口:**现读 → 改 → 落盘 → 刷 mtime**,新入口只能传闭包,忘不了 `reload()`。
|
||||
//!
|
||||
//! ## 为什么返回 [`WriteOutcome`] 而不是 `bool`
|
||||
//!
|
||||
//! 「没写」有三种原因(内存态 / 没有改动 / 被 schema 只读闸门拦下 / 写盘失败),调用方要能分清:
|
||||
//! 语言入口要回答「真的落盘了吗」(旧版把 `Skipped` 也当成功),诊断要能说出原因。
|
||||
|
||||
use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason};
|
||||
|
||||
use super::{mtime, SettingsBridge};
|
||||
|
||||
/// 闭包对文档做的事。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Edit {
|
||||
/// 文档改过了(需要落盘)。
|
||||
Changed,
|
||||
/// 没有可改的(幂等路径):不落盘,也不算失败。
|
||||
Unchanged,
|
||||
/// 改不动(`store.set` 被 schema / 只读闸门拒绝)。
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// 一次写的结果(`Skipped` 的载荷给诊断用;界面只关心 [`WriteOutcome::is_saved`])。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum WriteOutcome {
|
||||
/// 数据根解析不出来(内存态桥):什么都没写。
|
||||
NoStorage,
|
||||
/// 闭包说没有改动:没落盘。
|
||||
Unchanged,
|
||||
/// 真的写进磁盘了。
|
||||
Saved,
|
||||
/// `save()` 跳过了(只读 / 不可信文件 / 没有脏键)。
|
||||
Skipped(SkipReason),
|
||||
/// 写盘报错(序列化 / 备份 / IO)。
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl WriteOutcome {
|
||||
/// 磁盘上真的有新内容吗(**只有这一档算成功**)。
|
||||
#[must_use]
|
||||
pub(crate) const fn is_saved(self) -> bool {
|
||||
matches!(self, Self::Saved)
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsBridge {
|
||||
/// 长生命 store 的**唯一写入口**:现读 → 改 → 落盘 → 刷 mtime。
|
||||
///
|
||||
/// `edit` 拿到的 `&mut SettingStore` **已经是刚从磁盘重读的那一份**(这一句就是 r1/r2 两个
|
||||
/// 缺陷的修法:任何入口都不许拿启动时的旧快照整份覆盖)。
|
||||
pub(crate) fn write_with<F>(&mut self, edit: F) -> WriteOutcome
|
||||
where
|
||||
F: FnOnce(&mut SettingStore) -> Edit,
|
||||
{
|
||||
let Some(store) = self.store.as_mut() else {
|
||||
return WriteOutcome::NoStorage;
|
||||
};
|
||||
// ← 这一行是两轮复审的修复本体。
|
||||
store.reload();
|
||||
match edit(store) {
|
||||
Edit::Changed => {}
|
||||
Edit::Unchanged => return WriteOutcome::Unchanged,
|
||||
Edit::Rejected => return WriteOutcome::Failed,
|
||||
}
|
||||
let outcome = match store.save() {
|
||||
Ok(SaveOutcome::Saved(_)) => WriteOutcome::Saved,
|
||||
Ok(SaveOutcome::Skipped(reason)) => WriteOutcome::Skipped(reason),
|
||||
Err(_) => WriteOutcome::Failed,
|
||||
};
|
||||
if let Some(path) = self.path() {
|
||||
self.last_mtime = mtime(path);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use unlucky_core::setting::document::SettingDocument;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::settings_bridge::SettingsBridge;
|
||||
use crate::settings_bridge::{Edit, SettingsBridge};
|
||||
|
||||
/// 界面语言键(M106):`zh_cn` / `en_us`(允许值见 `core::setting::keys::LANGUAGES`)。
|
||||
///
|
||||
@@ -44,27 +44,39 @@ impl SettingsBridge {
|
||||
|
||||
/// 写 `ui.language`:`Some("zh_cn")` / `Some("en_us")` 手选,`None` = **跟随系统 = 删键**。
|
||||
///
|
||||
/// 返回是否真的落盘(只读 / 内存态时是 `false`,配置写不进去不该把 UI 卡住)。
|
||||
/// 返回是否**真的落了盘**(内存态 / 只读 / 文件被更新版本写过 ⇒ `false`,配置写不进去不该把
|
||||
/// UI 卡住)。写法走 [`SettingsBridge::write_with`](M174 r2 复审 P2-1):与外观写回同一个
|
||||
/// 唯一出口,**入口自己不再碰 `store.save()`**——r1 修了外观那条、这一条漏着,于是「保存 AI
|
||||
/// 配置后 1 秒内切界面语言」照样把 `ai.*` 连密钥一起抹掉,而且本函数还返回 `true`。
|
||||
///
|
||||
/// 为什么不自己 `store.reload()` 了事:这是**同一类缺陷的第二个入口**(第一个是
|
||||
/// [`SettingsBridge::persist`])。收敛成 `write_with` 之后,下一个写入口在类型上就拿不到
|
||||
/// 「不重读的 save」——而不是靠每个人记得。
|
||||
pub fn persist_ui_language(&mut self, value: Option<&str>) -> bool {
|
||||
let Some(store) = self.store.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let changed = match value {
|
||||
Some(language) => store
|
||||
.set(UI_LANGUAGE, serde_json::Value::String(language.to_owned()))
|
||||
.is_ok(),
|
||||
None => store.doc().get(UI_LANGUAGE).is_some() && remove_ui_language(store),
|
||||
};
|
||||
if !changed {
|
||||
return false;
|
||||
}
|
||||
let saved = store.save().is_ok();
|
||||
if let Some(path) = self.path() {
|
||||
self.last_mtime = std::fs::metadata(path)
|
||||
.ok()
|
||||
.and_then(|meta| meta.modified().ok());
|
||||
}
|
||||
saved
|
||||
let outcome = self.write_with(|store| match value {
|
||||
Some(language) => {
|
||||
if store
|
||||
.set(UI_LANGUAGE, serde_json::Value::String(language.to_owned()))
|
||||
.is_err()
|
||||
{
|
||||
return Edit::Rejected;
|
||||
}
|
||||
Edit::Changed
|
||||
}
|
||||
// 「跟随系统」= 删键:`SettingDocument` 没有 remove API,而落盘写的是全量视图,所以
|
||||
// 把全量视图里的那个键去掉再 load 回一份文档就等价于删键(用户手写的未知键原样带回)。
|
||||
None => {
|
||||
if store.doc().get(UI_LANGUAGE).is_none() {
|
||||
return Edit::Unchanged;
|
||||
}
|
||||
if remove_ui_language(store) {
|
||||
Edit::Changed
|
||||
} else {
|
||||
Edit::Unchanged
|
||||
}
|
||||
}
|
||||
});
|
||||
outcome.is_saved()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +117,85 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
/// **P2-1(r2)的回归**:语言写回不许盖掉别人刚写的键。
|
||||
///
|
||||
/// 现场(复审探针 `rev2_ai_keys_survive_a_language_write`):保存 AI 配置后 1 秒内在同一页把
|
||||
/// 界面语言切成 English ⇒ `persist_ui_language` 拿旧快照整份覆盖 ⇒ 文件里只剩 `appearance` +
|
||||
/// `ui`,`ai` 整段消失,而本函数**返回 true**。
|
||||
///
|
||||
/// 抓得住:`write_with` 少了 `store.reload()`(或某个入口又自己 `store.save()` 绕过它)。
|
||||
#[test]
|
||||
fn persisting_the_language_keeps_keys_another_writer_just_saved() {
|
||||
let path = temp_config("lang-keeps-ai");
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
// 先让本桥真的加载过一次文件(长生命 store 的旧快照从这里来)。
|
||||
bridge.persist(
|
||||
&crate::prefs::AppearancePrefs::factory(),
|
||||
crate::prefs::ThemeMode::Light,
|
||||
);
|
||||
|
||||
// 另一个写者(M174 的助手设置页)写进三个 AI 键。
|
||||
{
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
let mut store = SettingStore::load(&path, launcher_schema());
|
||||
for (key, value) in [
|
||||
("ai.base_url", "https://api.deepseek.com/v1"),
|
||||
("ai.model", "deepseek-chat"),
|
||||
("ai.api_key", "sk-must-survive-lang"),
|
||||
] {
|
||||
store.set(key, value.to_string().into()).expect("写 AI 键");
|
||||
}
|
||||
store.save().expect("落盘");
|
||||
}
|
||||
|
||||
// 同一秒内切界面语言(语言入口写回它那份整文档)。
|
||||
assert!(
|
||||
bridge.persist_ui_language(Some("en_us")),
|
||||
"语言这一项该写进去"
|
||||
);
|
||||
|
||||
let text = std::fs::read_to_string(&path).expect("读文件");
|
||||
assert!(
|
||||
text.contains("sk-must-survive-lang"),
|
||||
"语言写回把刚保存的密钥盖掉了:{text}"
|
||||
);
|
||||
assert!(text.contains("deepseek-chat"), "模型名也被盖掉了:{text}");
|
||||
assert!(text.contains("\"en_us\""), "语言这一项该写进去:{text}");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// 「真的落盘了吗」不许撒谎:只读文件(`$schema` 认不出 ⇒ `FileAccess::ReadOnly`)上
|
||||
/// `persist_ui_language` 必须是 `false`。
|
||||
#[test]
|
||||
fn persisting_the_language_reports_false_when_the_file_is_read_only() {
|
||||
let path = temp_config("lang-read-only");
|
||||
// `Invalid` 兼容档 ⇒ 只读:值照读,但不许被我们改写。
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"$schema":"https://example.invalid/ull/launcher/9.9.9","ui":{"language":"zh_cn"}}"#,
|
||||
)
|
||||
.expect("写配置");
|
||||
let before = std::fs::read_to_string(&path).expect("读配置");
|
||||
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
assert_eq!(
|
||||
bridge.load_ui_language().as_deref(),
|
||||
Some("zh_cn"),
|
||||
"值照读"
|
||||
);
|
||||
assert!(
|
||||
!bridge.persist_ui_language(Some("en_us")),
|
||||
"只读文件上写成功是谎话"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).expect("读配置"),
|
||||
before,
|
||||
"被只读闸门拦下的写不许改动文件"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// 抓得住:①把 `effective_str` 当读法(英文机器没配过 → 被默认值 `zh_cn` 强行切成中文);
|
||||
/// ②「跟随系统」写成哨兵值(枚举校验会拒、下次启动会撒谎);③删键时连别的键一起删。
|
||||
/// 抓不住:UI 是否真的按这个值选目录(那是 `app-iced/src/ui/lang.rs` 的往返测试)。
|
||||
|
||||
Reference in new issue
Block a user