fix(m174): 复审 r1 五项 —— 失败带 target、密钥消息掩码、请求体断言、桥写前重读、结构化分类

P2-1(假绿截图):「测试连接」失败类也带上被请求的 target。`ProbeOutcome` 的五个失败变体
改成 `{ target, detail }`,`ProbeResult::from_outcome` 不再把端点/模型填成空串;view 另加一条
「目标为空就整行不画」的保险。**截图种子改走 `from_outcome`**(新增
`ProbeResult::screenshot_seed`):种子只伪造「结论是哪一档」,字段形状由产品路径决定——上一版
手填字段,于是「失败态目标为空」只在产品上出现、截图永远看不见。

P2-2(明文进日志):`Message::EditKey` 的载荷从 `String` 换成 `ai::Secret`(`Debug` 掩码)。
用例 `the_key_message_never_prints_the_key` 同时钉住裸消息与整条 `pages::Message` 漏斗。

P2-3(模型没被证上线):假服务器按 `Content-Length` 读请求正文,新用例
`the_request_carries_the_configured_model` 断言正文里 `"model":"gpt-test"`。变异实测暴露出
一处真相:`ChatRequest.model` 被 `RigProvider::build_request` 静默忽略,线上模型来自
`ProviderConfig.model`(已另开 finding,并写进 probe 的注释)。

P2-4(数据丢失):`SettingsBridge::persist` 写前 `store.reload()`。`SettingStore::save` 是整份
文档落盘,不重读就会拿 ≤1 秒前的旧快照盖掉刚写的 `ai.*`。两条回归用例:
`presenter::settings_bridge::persist_keeps_keys_another_writer_just_saved` 与 app-iced 侧的
`ai::store::appearance_write_back_keeps_the_ai_keys`(保存 AI → 改主题 → 值还在)。

P2-5(子串匹配):删掉 `classify()`。分类改由 `brain::rig::map_error` 从 rig 0.45 的结构化
报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成新的 `ProviderFailure`
(`brain/provider/failure.rs`),`ProviderError` 因此带上 `kind`。`closed_port_is_unreachable`
不再依赖错误文本:连接被拒恒为 `ErrorKind::Http` ⇒ unreachable。防退化用例
`factory_error_carries_the_structured_classification` 断言「纯文本 401 不许被猜成 auth」。

顺带(400 行硬线):`provider.rs` / `rig.rs` 的用例各自拆到 `provider/tests.rs` /
`rig/tests.rs`(纯搬移)。
This commit is contained in:
AskaEth committed 2026-11-08 16:20:34 +08:00
1 parent 9cb605f316
commit 93d02ab12b
18 files changed
+840 -412

No files matched your search

+45
View File
@@ -120,10 +120,19 @@ impl SettingsBridge {
}
/// 落盘(只写出现过的键;失败静默——配置写不进去不该把 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()` 的口径对齐:**写之前现读**,不信任任何长生命快照。
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;
@@ -288,6 +297,42 @@ mod tests {
panic!("外部修改没有被 mtime 热切捕获");
}
/// **P2-4 回归**:外观写回不许盖掉别人刚写的键(`ai.api_key` 最贵)。修之前必红。
#[test]
fn persist_keeps_keys_another_writer_just_saved() {
let path = temp_config("persist-keeps-ai");
let mut bridge = SettingsBridge::with_path(&path);
// 先让本桥真的加载过一份文件(旧快照从这里来)。
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
{
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
// 另一个写者(M174 的助手设置页 / `ull config set`)写进三个 AI 键。
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"),
] {
store.set(key, value.to_string().into()).expect("写 AI 键");
}
store.save().expect("落盘");
}
// 同一秒内改主题:外观写回。
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark);
let text = std::fs::read_to_string(&path).expect("读文件");
assert!(
text.contains("sk-must-survive"),
"外观写回把刚保存的密钥盖掉了:{text}"
);
assert!(text.contains("deepseek-chat"), "model 也被盖掉了:{text}");
assert!(text.contains("\"dark\""), "主题这一项该写进去:{text}");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn accent_preset_and_validation() {
let mut appearance = AppearancePrefs::factory();