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
@@ -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