Files
wehub-resource-sync 64ed910d3f
CI / Backend Checks (push) Has been cancelled
CI / Frontend Checks (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:57:20 +08:00

815 lines
28 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 项目 Profile 快照/应用的端到端集成测试
//!
//! 全链路 apply 会写 live 配置文件——support.rs 已把 HOME 指向临时目录,安全。
use std::fs;
use serde_json::json;
use cc_switch_lib::{
AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileScope, ProfileService,
Prompt, PromptService, Provider, ProviderService, SkillApps, SkillService,
};
#[path = "support.rs"]
mod support;
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
fn claude_provider(id: &str, token: &str) -> Provider {
Provider::with_id(
id.to_string(),
id.to_uppercase(),
json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": token,
"ANTHROPIC_BASE_URL": "https://api.test"
}
}),
None,
)
}
/// Claude Desktop 供应商:无 meta 时默认 Direct 模式,只要求 env 里有 token + base_url
fn desktop_provider(id: &str, token: &str) -> Provider {
Provider::with_id(
id.to_string(),
id.to_uppercase(),
json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": token,
"ANTHROPIC_BASE_URL": "https://desktop.test"
}
}),
None,
)
}
fn mcp_server(id: &str, claude_enabled: bool) -> McpServer {
serde_json::from_value(json!({
"id": id,
"name": id,
"server": { "command": "echo", "args": [] },
"apps": { "claude": claude_enabled }
}))
.expect("construct mcp server")
}
fn prompt(id: &str, enabled: bool) -> Prompt {
Prompt {
id: id.to_string(),
name: id.to_uppercase(),
content: format!("# prompt {id}\n"),
description: None,
enabled,
created_at: Some(1_000),
updated_at: Some(1_000),
}
}
fn installed_skill(id: &str, directory: &str, claude_enabled: bool) -> InstalledSkill {
InstalledSkill {
id: id.to_string(),
name: id.to_string(),
description: None,
directory: directory.to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: claude_enabled,
..Default::default()
},
installed_at: 1_000,
content_hash: None,
updated_at: 0,
}
}
fn write_ssot_skill(directory: &str) {
let dir = SkillService::get_ssot_dir()
.expect("resolve skills SSOT dir")
.join(directory);
fs::create_dir_all(&dir).expect("create skill dir");
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {directory}\ndescription: Test skill\n---\n"),
)
.expect("write SKILL.md");
}
#[test]
fn profile_snapshot_apply_roundtrip_restores_configuration() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
// ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ----
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
.expect("save provider p1");
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
.expect("save provider p2");
state
.db
.set_current_provider(AppType::Claude.as_str(), "p1")
.expect("set current provider p1");
// Claude Desktop 只有供应商一个活跃维度(MCP/Skills/Prompt 对它不适用)
state
.db
.save_provider(
AppType::ClaudeDesktop.as_str(),
&desktop_provider("d1", "dk-1"),
)
.expect("save desktop provider d1");
state
.db
.save_provider(
AppType::ClaudeDesktop.as_str(),
&desktop_provider("d2", "dk-2"),
)
.expect("save desktop provider d2");
state
.db
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
.expect("set current desktop provider d1");
// 让 live settings.json 与 p1 一致(switch_normal 回填需要)
let claude_dir = home.join(".claude");
fs::create_dir_all(&claude_dir).expect("create .claude dir");
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config)
.expect("serialize p1 settings"),
)
.expect("seed live settings.json");
state
.db
.save_mcp_server(&mcp_server("m1", true))
.expect("save mcp m1");
state
.db
.save_mcp_server(&mcp_server("m2", false))
.expect("save mcp m2");
write_ssot_skill("test-skill");
state
.db
.save_skill(&installed_skill("local:test-skill", "test-skill", true))
.expect("save skill");
state
.db
.save_prompt(AppType::Claude.as_str(), &prompt("pr1", true))
.expect("save prompt pr1");
state
.db
.save_prompt(AppType::Claude.as_str(), &prompt("pr2", false))
.expect("save prompt pr2");
// ---- 保存项目 A(在 Claude 页新建:只拍 Claude 当前状态)----
let profile_a = ProfileService::create(&state, "Project A", ProfileScope::Claude)
.expect("create profile A");
let payload: ProfilePayload =
serde_json::from_str(&profile_a.payload).expect("parse profile A payload");
assert_eq!(payload.providers.claude.as_deref(), Some("p1"));
assert_eq!(payload.mcp.claude, Some(vec!["m1".to_string()]));
assert_eq!(
payload.skills.claude,
Some(vec!["local:test-skill".to_string()])
);
assert_eq!(payload.prompts.claude.as_deref(), Some("pr1"));
assert_eq!(
payload.providers.codex, None,
"codex side not captured when creating from the claude group"
);
assert_eq!(payload.mcp.codex, None, "uncaptured side stays None");
assert_eq!(
payload.providers.claude_desktop, None,
"claude desktop has its own profile scope"
);
// ---- 改动全部四类配置(走真实切换路径)----
ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2");
// Desktop 现在有自己的项目分组;Claude 分组 apply 不应再影响 Desktop
#[cfg(any(target_os = "macos", windows))]
ProviderService::switch(&state, AppType::ClaudeDesktop, "d2").expect("switch desktop to d2");
McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1");
McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2");
SkillService::toggle_app(&state.db, "local:test-skill", &AppType::Claude, false)
.expect("disable skill");
PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2");
// ---- 应用项目 AClaude 组):只复原 Claude 侧 ----
let (warnings, _) = ProfileService::apply(&state, &profile_a.id, ProfileScope::Claude)
.expect("apply profile A");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
let current = state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(current.as_deref(), Some("p1"), "provider restored to p1");
// Claude 分组不再管理 Desktopapply 后 Desktop 保持切换前的状态不变。
// macOS/Windows 上上面已切到 d2LinuxCI)不支持 Desktop 切换、那行被 cfg 门控
// 编译剔除,Desktop 仍是种子值 d1。两种情况都验证 claude-scope apply 不会动 Desktop。
let current_desktop = state
.db
.get_current_provider(AppType::ClaudeDesktop.as_str())
.expect("get current desktop provider");
#[cfg(any(target_os = "macos", windows))]
let expected_desktop = "d2";
#[cfg(not(any(target_os = "macos", windows)))]
let expected_desktop = "d1";
assert_eq!(
current_desktop.as_deref(),
Some(expected_desktop),
"desktop provider untouched by claude-scope apply"
);
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
assert!(servers.get("m1").expect("m1").apps.claude, "m1 re-enabled");
assert!(!servers.get("m2").expect("m2").apps.claude, "m2 disabled");
let skills = state.db.get_all_installed_skills().expect("get skills");
assert!(
skills.get("local:test-skill").expect("skill").apps.claude,
"skill re-enabled"
);
let prompts = state
.db
.get_prompts(AppType::Claude.as_str())
.expect("get prompts");
assert!(prompts.get("pr1").expect("pr1").enabled, "pr1 re-enabled");
assert!(!prompts.get("pr2").expect("pr2").enabled, "pr2 disabled");
let live_prompt = fs::read_to_string(claude_dir.join("CLAUDE.md")).expect("read CLAUDE.md");
assert_eq!(
live_prompt,
prompt("pr1", true).content,
"live memory file restored"
);
assert_eq!(
state
.db
.get_current_profile_id("claude")
.expect("get current profile id")
.as_deref(),
Some(profile_a.id.as_str()),
"profile A marked as current for claude scope"
);
assert_eq!(
state
.db
.get_current_profile_id("codex")
.expect("get codex current profile id"),
None,
"codex scope marker untouched by claude-group apply"
);
}
#[test]
fn shared_profile_sides_are_isolated_and_mergeable() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
// 种子:Claude 侧有当前供应商 + 启用的 MCP
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
.expect("save provider p1");
state
.db
.set_current_provider(AppType::Claude.as_str(), "p1")
.expect("set current provider p1");
let claude_dir = home.join(".claude");
fs::create_dir_all(&claude_dir).expect("create .claude dir");
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config)
.expect("serialize p1 settings"),
)
.expect("seed live settings.json");
state
.db
.save_mcp_server(&mcp_server("m1", true))
.expect("save mcp m1");
// 在 Codex 页新建项目:快照不应捕获 Claude 侧的任何状态
let project = ProfileService::create(&state, "Shared Project", ProfileScope::Codex)
.expect("create project from codex tab");
let payload: ProfilePayload =
serde_json::from_str(&project.payload).expect("parse project payload");
assert_eq!(
payload.providers.claude, None,
"claude slot not captured by codex-side snapshot"
);
assert_eq!(payload.mcp.claude, None);
assert_eq!(payload.providers.claude_desktop, None);
assert_eq!(payload.mcp.codex, Some(vec![]), "codex side captured");
// 按 Codex 组应用:只动 codex 组的 current 标记,Claude 侧原样不动
let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Codex)
.expect("apply project on codex side");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
assert_eq!(
state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get claude current provider")
.as_deref(),
Some("p1"),
"claude provider untouched"
);
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
assert!(
servers.get("m1").expect("m1").apps.claude,
"claude MCP untouched"
);
assert_eq!(
state
.db
.get_current_profile_id("codex")
.expect("get codex current profile id")
.as_deref(),
Some(project.id.as_str())
);
assert_eq!(
state
.db
.get_current_profile_id("claude")
.expect("get claude current profile id"),
None,
"claude scope marker untouched by codex-side apply"
);
// 同一共享项目在 Claude 页应用:该侧未拍过快照 → 不动配置、标记 current、返回提示
let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Claude)
.expect("apply project on claude side");
assert_eq!(warnings.len(), 1, "uncaptured side yields one hint");
assert!(warnings[0].contains("no claude configuration captured"));
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
assert!(
servers.get("m1").expect("m1").apps.claude,
"claude MCP still untouched by uncaptured apply"
);
assert_eq!(
state
.db
.get_current_profile_id("claude")
.expect("get claude current profile id")
.as_deref(),
Some(project.id.as_str()),
"claude side now bound to the shared project"
);
// 在 Claude 页"以当前状态更新":补拍 claude 侧,codex 侧快照原样保留
let updated =
ProfileService::update(&state, &project.id, None, true, Some(ProfileScope::Claude))
.expect("resnapshot claude side");
let payload: ProfilePayload =
serde_json::from_str(&updated.payload).expect("parse updated payload");
assert_eq!(payload.providers.claude.as_deref(), Some("p1"));
assert_eq!(payload.mcp.claude, Some(vec!["m1".to_string()]));
assert_eq!(
payload.mcp.codex,
Some(vec![]),
"codex side snapshot preserved by claude-side resnapshot"
);
}
#[test]
fn profile_apply_reports_dangling_references_and_continues() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
state
.db
.save_mcp_server(&mcp_server("m1", false))
.expect("save mcp m1");
// 手工构造引用了不存在资源的 payload
let payload = json!({
"providers": { "claude": "ghost-provider" },
"mcp": { "claude": ["m1", "ghost-mcp"] },
"skills": { "claude": ["ghost-skill"] },
"prompts": { "claude": "ghost-prompt" }
});
let profile = cc_switch_lib::Profile {
id: "dangling-test".to_string(),
name: "Dangling".to_string(),
payload: payload.to_string(),
sort_order: None,
created_at: Some(1_000),
updated_at: Some(1_000),
};
state.db.save_profile(&profile).expect("save profile");
let (warnings, _) = ProfileService::apply(&state, "dangling-test", ProfileScope::Claude)
.expect("apply succeeds");
assert_eq!(
warnings.len(),
4,
"each dangling reference yields one warning: {warnings:?}"
);
// 有效条目照常生效:m1 被启用
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
assert!(
servers.get("m1").expect("m1").apps.claude,
"m1 enabled despite warnings"
);
// best-effort 完成后仍标记为所属分组的当前项目
assert_eq!(
state
.db
.get_current_profile_id("claude")
.expect("get current profile id")
.as_deref(),
Some("dangling-test")
);
}
#[test]
fn clear_current_profile_only_clears_scoped_marker() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
state
.db
.set_current_profile_id("claude", Some("claude-profile"))
.expect("set claude current profile");
state
.db
.set_current_profile_id("codex", Some("codex-profile"))
.expect("set codex current profile");
// 清除 claude 组不影响 codex 组
state
.db
.set_current_profile_id("claude", None)
.expect("clear claude current profile");
assert_eq!(
state
.db
.get_current_profile_id("claude")
.expect("get claude current profile id"),
None
);
assert_eq!(
state
.db
.get_current_profile_id("codex")
.expect("get codex current profile id")
.as_deref(),
Some("codex-profile")
);
}
#[test]
fn switching_profile_autosaves_previous_profile_state() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
// ---- 种子:Claude 侧两套供应商 / MCP / Prompt ----
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
.expect("save provider p1");
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
.expect("save provider p2");
state
.db
.set_current_provider(AppType::Claude.as_str(), "p1")
.expect("set current provider p1");
let claude_dir = home.join(".claude");
fs::create_dir_all(&claude_dir).expect("create .claude dir");
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config)
.expect("serialize p1 settings"),
)
.expect("seed live settings.json");
state
.db
.save_mcp_server(&mcp_server("m1", true))
.expect("save mcp m1");
state
.db
.save_mcp_server(&mcp_server("m2", false))
.expect("save mcp m2");
state
.db
.save_prompt(AppType::Claude.as_str(), &prompt("pr1", true))
.expect("save prompt pr1");
state
.db
.save_prompt(AppType::Claude.as_str(), &prompt("pr2", false))
.expect("save prompt pr2");
// ---- Project A:状态 Xp1 / m1 / pr1----
let project_a = ProfileService::create(&state, "Project A", ProfileScope::Claude)
.expect("create project A");
let (warnings, _) = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude)
.expect("apply project A");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
// ---- 在 A 下改到状态 Yp2 / m2 / pr2),然后据此创建 Project B ----
ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2");
McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1");
McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2");
PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2");
let project_b = ProfileService::create(&state, "Project B", ProfileScope::Claude)
.expect("create project B");
// ---- 从 A 切换到 B:自动把当前状态 Y 保存到 A,再加载 B 的 Y ----
let (warnings, _) = ProfileService::apply(&state, &project_b.id, ProfileScope::Claude)
.expect("switch to project B");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
assert_eq!(
state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider")
.as_deref(),
Some("p2"),
"provider switched to p2"
);
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
assert!(!servers.get("m1").expect("m1").apps.claude, "m1 disabled");
assert!(servers.get("m2").expect("m2").apps.claude, "m2 enabled");
let prompts = state
.db
.get_prompts(AppType::Claude.as_str())
.expect("get prompts");
assert!(!prompts.get("pr1").expect("pr1").enabled, "pr1 disabled");
assert!(prompts.get("pr2").expect("pr2").enabled, "pr2 enabled");
// Project A 被自动保存为离开时的状态 Y
let saved_a = state
.db
.get_profile(&project_a.id)
.expect("get project A")
.expect("project A exists");
let payload_a: ProfilePayload =
serde_json::from_str(&saved_a.payload).expect("parse project A payload");
assert_eq!(payload_a.providers.claude.as_deref(), Some("p2"));
assert_eq!(payload_a.mcp.claude, Some(vec!["m2".to_string()]));
assert_eq!(payload_a.prompts.claude.as_deref(), Some("pr2"));
// ---- 在 B 下改回状态 X,再切换回 A ----
ProviderService::switch(&state, AppType::Claude, "p1").expect("switch to p1");
McpService::toggle_app(&state, "m1", AppType::Claude, true).expect("enable m1");
McpService::toggle_app(&state, "m2", AppType::Claude, false).expect("disable m2");
PromptService::enable_prompt(&state, AppType::Claude, "pr1").expect("enable pr1");
let (warnings, _) = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude)
.expect("switch back to project A");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
// 切回 A 时:先自动保存 B 为状态 X,再加载 A 的上次离开状态 Y
assert_eq!(
state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider")
.as_deref(),
Some("p2"),
"project A restored to the state when we left it (p2)"
);
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
assert!(
!servers.get("m1").expect("m1").apps.claude,
"m1 stays disabled"
);
assert!(
servers.get("m2").expect("m2").apps.claude,
"m2 stays enabled"
);
let prompts = state
.db
.get_prompts(AppType::Claude.as_str())
.expect("get prompts");
assert!(
!prompts.get("pr1").expect("pr1").enabled,
"pr1 stays disabled"
);
assert!(
prompts.get("pr2").expect("pr2").enabled,
"pr2 stays enabled"
);
// Project B 被自动保存为离开时的状态 X
let saved_b = state
.db
.get_profile(&project_b.id)
.expect("get project B")
.expect("project B exists");
let payload_b: ProfilePayload =
serde_json::from_str(&saved_b.payload).expect("parse project B payload");
assert_eq!(payload_b.providers.claude.as_deref(), Some("p1"));
assert_eq!(payload_b.mcp.claude, Some(vec!["m1".to_string()]));
assert_eq!(payload_b.prompts.claude.as_deref(), Some("pr1"));
}
#[test]
fn profile_switch_auto_disables_takeover_before_apply() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
// 使用临时端口,避免测试机器端口冲突
futures::executor::block_on(async {
let mut proxy_config = state.db.get_proxy_config().await.expect("get proxy config");
proxy_config.listen_port = 0;
state
.db
.update_proxy_config(proxy_config)
.await
.expect("set ephemeral proxy port");
});
// ---- 两个 Claude 供应商:custom1 与 custom2 ----
let mut custom1 = claude_provider("custom1", "custom-key-1");
custom1.category = Some("custom".to_string());
state
.db
.save_provider(AppType::Claude.as_str(), &custom1)
.expect("save custom1 provider");
let mut custom2 = claude_provider("custom2", "custom-key-2");
custom2.category = Some("custom".to_string());
state
.db
.save_provider(AppType::Claude.as_str(), &custom2)
.expect("save custom2 provider");
// 初始状态:custom1 + 代理接管
ProviderService::switch(&state, AppType::Claude, "custom1").expect("switch to custom1");
let rt = tokio::runtime::Runtime::new().expect("create tokio runtime");
rt.block_on(state.proxy_service.set_takeover_for_app("claude", true))
.expect("enable claude takeover");
let (proxy_enabled_before, _) = state.db.get_proxy_flags_sync("claude");
assert!(
proxy_enabled_before,
"takeover should be active before apply"
);
// ---- 构造一个目标为 custom2 的项目快照 ----
let project = ProfileService::create(&state, "Custom2 Project", ProfileScope::Claude)
.expect("create project");
let mut project = state
.db
.get_profile(&project.id)
.expect("get project")
.expect("project exists");
let mut payload: ProfilePayload =
serde_json::from_str(&project.payload).expect("parse project payload");
payload.providers.claude = Some("custom2".to_string());
project.payload = serde_json::to_string(&payload).expect("serialize payload");
state
.db
.save_profile(&project)
.expect("save updated project");
// ---- 应用项目:应无条件自动关闭接管,再切换到 custom2 ----
let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Claude)
.expect("apply custom2 project");
assert!(
warnings.is_empty(),
"switching project should not warn: {warnings:?}"
);
// 接管已关闭
let (proxy_enabled_after, _) = state.db.get_proxy_flags_sync("claude");
assert!(
!proxy_enabled_after,
"proxy takeover should be auto-disabled before applying profile"
);
// 当前供应商已切到 custom2
assert_eq!(
state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider")
.as_deref(),
Some("custom2"),
"current provider should be custom2"
);
// live 配置应指向 custom2 的真实 endpoint,而非代理地址
let settings_path = home.join(".claude/settings.json");
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).expect("read settings"))
.expect("parse settings");
let base_url = settings
.get("env")
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
.and_then(|v| v.as_str());
assert_eq!(
base_url,
Some("https://api.test"),
"live config should point to real endpoint after auto-disable"
);
}
#[cfg(any(target_os = "macos", windows))]
#[test]
fn claude_desktop_profile_scope_is_independent() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
state
.db
.save_provider(
AppType::ClaudeDesktop.as_str(),
&desktop_provider("d1", "dk-1"),
)
.expect("save desktop provider d1");
state
.db
.save_provider(
AppType::ClaudeDesktop.as_str(),
&desktop_provider("d2", "dk-2"),
)
.expect("save desktop provider d2");
state
.db
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
.expect("set current desktop provider d1");
// 在 Desktop 页新建项目:只拍 Desktop 供应商
let project = ProfileService::create(&state, "Desktop Project", ProfileScope::ClaudeDesktop)
.expect("create desktop profile");
let payload: ProfilePayload =
serde_json::from_str(&project.payload).expect("parse desktop payload");
assert_eq!(payload.providers.claude_desktop.as_deref(), Some("d1"));
assert_eq!(payload.providers.claude, None, "claude slot untouched");
assert_eq!(payload.providers.codex, None, "codex slot untouched");
// 切到 d2
ProviderService::switch(&state, AppType::ClaudeDesktop, "d2").expect("switch desktop to d2");
// 应用 Desktop 项目:恢复 d1
let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::ClaudeDesktop)
.expect("apply desktop profile");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
assert_eq!(
state
.db
.get_current_provider(AppType::ClaudeDesktop.as_str())
.expect("get current desktop provider")
.as_deref(),
Some("d1"),
"desktop provider restored by desktop-scope apply"
);
assert_eq!(
state
.db
.get_current_profile_id(ProfileScope::ClaudeDesktop.as_str())
.expect("get desktop current profile id")
.as_deref(),
Some(project.id.as_str()),
"desktop scope marker set"
);
}