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:?} 没有文案");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user