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`(纯搬移)。
108 lines
3.9 KiB
Rust
108 lines
3.9 KiB
Rust
//! [`super`] 的用例(M174 r1 复审 P2-5 拆出来:`provider.rs` 撞了 400 行硬线)。
|
||
//!
|
||
//! 拆的是**纯搬移**:一条断言都没改。分类那半边的新用例在 `failure` 的模块文档里指路,
|
||
//! 结构化分类本身由 `brain/rig.rs` 的 `map_error` 产生,用例在 `probe` 的真服务器那几条。
|
||
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn local_defaults_match_research() {
|
||
assert_eq!(
|
||
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
|
||
LLAMACPP_DEFAULT_BASE_URL
|
||
);
|
||
assert_eq!(
|
||
ProviderConfig::ollama("llama3").resolved_base_url(),
|
||
OLLAMA_DEFAULT_BASE_URL
|
||
);
|
||
assert_eq!(
|
||
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
|
||
ANTHROPIC_DEFAULT_BASE_URL
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn setting_vocabulary_round_trips() {
|
||
// 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的
|
||
// `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」
|
||
// agent 侧读出来是 None(M174 的根因)。
|
||
for kind in [
|
||
ProviderKind::OpenAiCompatible,
|
||
ProviderKind::Anthropic,
|
||
ProviderKind::Ollama,
|
||
ProviderKind::Disabled,
|
||
] {
|
||
assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind));
|
||
}
|
||
assert_eq!(
|
||
ProviderKind::from_setting(" ollama "),
|
||
Some(ProviderKind::Ollama)
|
||
);
|
||
assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None);
|
||
assert_eq!(ProviderKind::from_setting(""), None);
|
||
// schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。
|
||
assert_eq!(
|
||
unlucky_core::setting::keys::AI_PROVIDERS,
|
||
[
|
||
ProviderKind::OpenAiCompatible.setting_value(),
|
||
ProviderKind::Anthropic.setting_value(),
|
||
ProviderKind::Ollama.setting_value(),
|
||
ProviderKind::Disabled.setting_value(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn validate_requires_model_base_url_and_key() {
|
||
assert!(!ProviderConfig::disabled().is_enabled());
|
||
assert!(ProviderConfig::disabled().validate().is_err());
|
||
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
|
||
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
|
||
// OpenAI 兼容必须显式给端点
|
||
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
|
||
.validate()
|
||
.is_err());
|
||
assert!(
|
||
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||
.validate()
|
||
.is_ok()
|
||
);
|
||
assert!(ProviderConfig::anthropic("claude").validate().is_err());
|
||
assert!(ProviderConfig::anthropic("claude")
|
||
.with_api_key("sk-ant")
|
||
.validate()
|
||
.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn redacted_debug_never_leaks_key() {
|
||
let config = ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||
.with_api_key("sk-super-secret");
|
||
let text = config.redacted_debug();
|
||
assert!(!text.contains("sk-super-secret"), "{text}");
|
||
assert!(text.contains(crate::redact::MASK));
|
||
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
|
||
let debug = format!("{config:?}");
|
||
assert!(!debug.contains("sk-super-secret"), "{debug}");
|
||
assert!(debug.contains(crate::redact::MASK), "{debug}");
|
||
}
|
||
|
||
#[test]
|
||
fn empty_base_url_falls_back_to_default() {
|
||
let mut config = ProviderConfig::ollama("llama3");
|
||
config.base_url = String::new();
|
||
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
|
||
}
|
||
|
||
#[test]
|
||
fn four_states_serialize_as_snake_case() {
|
||
assert_eq!(
|
||
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
|
||
"\"open_ai_compatible\""
|
||
);
|
||
assert_eq!(
|
||
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
|
||
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
|
||
);
|
||
}
|