use std::sync::mpsc;
use std::{ffi::OsString, path::Path};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::{buffer::Buffer, layout::Rect};
use serde_json::json;
use serial_test::serial;
use tempfile::TempDir;
use super::app::{Action, App, LoadingKind, Overlay, ToastKind};
use super::data::UiData;
use super::form::ProviderAddField;
use super::*;
use crate::cli::i18n::texts;
use crate::test_support::{
lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock,
};
use crate::{AppError, AppType};
struct EnvGuard {
_lock: TestHomeSettingsLock,
old_home: Option<OsString>,
old_userprofile: Option<OsString>,
}
impl EnvGuard {
fn set_home(home: &Path) -> Self {
let lock = lock_test_home_and_settings();
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
unsafe {
std::env::set_var("HOME", home);
std::env::set_var("USERPROFILE", home);
}
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
_lock: lock,
old_home,
old_userprofile,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_home {
Some(value) => unsafe { std::env::set_var("HOME", value) },
None => unsafe { std::env::remove_var("HOME") },
}
match &self.old_userprofile {
Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
None => unsafe { std::env::remove_var("USERPROFILE") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
}
}
#[test]
fn mcp_import_uses_info_toast_kind() {
let mut app = App::new(Some(AppType::OpenCode));
let mut data = UiData::default();
import_mcp_for_current_app_with(
&mut app,
&mut data,
|_app_type| Ok(0),
|_app_type| Ok(UiData::default()),
)
.expect("mcp import should work");
let toast = app.toast.as_ref().expect("mcp import should show toast");
assert_eq!(toast.kind, ToastKind::Info);
assert_eq!(toast.message, texts::tui_toast_mcp_imported(0));
}
#[test]
fn tui_tick_rate_returns_to_200ms() {
assert_eq!(TUI_TICK_RATE, std::time::Duration::from_millis(200));
}
#[test]
fn initialize_codex_opens_current_provider_mismatch_picker() {
let (app, _data) = initialize_app_state_for_test(Some(AppType::Codex), |_| {
let mut data = UiData::default();
data.providers.codex_current_mismatch =
Some(crate::services::provider::CodexCurrentProviderMismatch {
stored_provider_id: "stored-current".to_string(),
stored_provider_name: "zhima-cx".to_string(),
live_provider_id: "live-current".to_string(),
live_provider_name: "zhima-fuli".to_string(),
live_model_provider_key: "zhima-fuli".to_string(),
});
Ok(data)
})
.expect("initialize app state");
assert!(matches!(
app.overlay,
Overlay::CodexCurrentProviderMismatch { selected: 0, .. }
));
}
#[test]
fn switching_to_codex_opens_current_provider_mismatch_picker() {
let mut app = App::new(Some(AppType::Claude));
let mut data = UiData::default();
let mut next_data = UiData::default();
next_data.providers.codex_current_mismatch =
Some(crate::services::provider::CodexCurrentProviderMismatch {
stored_provider_id: "stored-current".to_string(),
stored_provider_name: "zhima-cx".to_string(),
live_provider_id: "live-current".to_string(),
live_provider_name: "zhima-fuli".to_string(),
live_model_provider_key: "zhima-fuli".to_string(),
});
apply_preloaded_app_switch(&mut app, &mut data, AppType::Codex, next_data);
assert!(matches!(
app.overlay,
Overlay::CodexCurrentProviderMismatch { selected: 0, .. }
));
}
#[test]
fn codex_current_mismatch_picker_enter_accepts_live_by_default() {
let mut app = App::new(Some(AppType::Codex));
app.overlay = Overlay::CodexCurrentProviderMismatch {
selected: 0,
mismatch: crate::services::provider::CodexCurrentProviderMismatch {
stored_provider_id: "stored-current".to_string(),
stored_provider_name: "zhima-cx".to_string(),
live_provider_id: "live-current".to_string(),
live_provider_name: "zhima-fuli".to_string(),
live_model_provider_key: "zhima-fuli".to_string(),
},
};
let action = app.on_key(
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
&UiData::default(),
);
assert!(matches!(
action,
Action::CodexAcceptLiveCurrent { id } if id == "live-current"
));
}
#[test]
fn skills_scan_unmanaged_uses_info_toast_kind() {
let mut app = App::new(Some(AppType::OpenCode));
scan_unmanaged_skills_with(&mut app, || Ok(Vec::new())).expect("skills scan should work");
let toast = app.toast.as_ref().expect("skills scan should show toast");
assert_eq!(toast.kind, ToastKind::Info);
assert_eq!(toast.message, texts::tui_toast_unmanaged_scanned(0));
}
#[test]
fn opening_skills_import_picker_selects_all_by_default() {
let mut app = App::new(Some(AppType::Claude));
open_skills_import_picker_with(&mut app, || {
Ok(vec![crate::services::skill::UnmanagedSkill {
directory: "hello-skill".to_string(),
name: "Hello Skill".to_string(),
description: Some("A local skill".to_string()),
found_in: vec!["claude".to_string()],
}])
})
.expect("import picker should open");
assert!(matches!(
&app.overlay,
Overlay::SkillsImportPicker {
skills,
selected_idx: 0,
selected,
} if skills.len() == 1
&& skills[0].directory == "hello-skill"
&& selected.contains("hello-skill")
));
}
#[test]
fn opening_agent_skills_import_picker_selects_all_by_default() {
let mut app = App::new(Some(AppType::Claude));
open_agent_skills_import_picker_with(&mut app, || {
Ok(vec![crate::services::skill::UnmanagedSkill {
directory: "agent-skill".to_string(),
name: "Agent Skill".to_string(),
description: Some("A local agent skill".to_string()),
found_in: vec!["agents".to_string()],
}])
})
.expect("agent import picker should open");
assert!(matches!(
&app.overlay,
Overlay::SkillsAgentImportPicker {
skills,
selected_idx: 0,
selected,
} if skills.len() == 1
&& skills[0].directory == "agent-skill"
&& selected.contains("agent-skill")
));
}
#[test]
fn skills_import_from_apps_uses_info_toast_kind() {
let mut app = App::new(Some(AppType::OpenCode));
let mut data = UiData::default();
finish_skills_import_with(
&mut app,
&mut data,
|| Ok(vec![]),
|_app_type| Ok(UiData::default()),
)
.expect("skills import should work");
let toast = app.toast.as_ref().expect("skills import should show toast");
assert_eq!(toast.kind, ToastKind::Info);
assert_eq!(toast.message, texts::tui_toast_unmanaged_imported(0));
}
#[test]
fn proxy_help_overlay_uses_on_demand_proxy_config() {
let mut app = App::new(Some(AppType::Claude));
let data = UiData::default();
open_proxy_help_overlay_with(&mut app, &data, || {
Ok(Some(crate::proxy::ProxyConfig {
listen_address: "127.0.0.1".to_string(),
listen_port: 3456,
..crate::proxy::ProxyConfig::default()
}))
})
.expect("proxy help overlay should open");
let Overlay::TextView(view) = &app.overlay else {
panic!("expected proxy help overlay");
};
let joined = view.lines.join("\n");
assert!(joined.contains("cc-switch proxy serve --listen-address 127.0.0.1 --listen-port 3456"));
assert!(joined.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:3456"));
}
#[test]
fn managed_proxy_action_enqueues_background_request_and_shows_loading_overlay() {
let mut app = App::new(Some(AppType::Claude));
let mut loading = RequestTracker::default();
let (tx, rx) = mpsc::channel();
queue_managed_proxy_action(&mut app, Some(&tx), &mut loading, AppType::Claude, true)
.expect("queue proxy action should succeed");
let req = rx.recv().expect("proxy request should be queued");
assert!(matches!(
req,
ProxyReq::SetManagedSessionForCurrentApp {
request_id: 1,
app_type: AppType::Claude,
enabled: true,
}
));
assert_eq!(loading.active, Some(1));
assert!(matches!(
app.overlay,
Overlay::Loading {
kind: LoadingKind::Proxy,
..
}
));
}
#[test]
fn proxy_open_flash_runner_persists_effect_across_frames() {
let mut flash = ProxyOpenFlash::default();
let mut app = App::new(Some(AppType::Claude));
app.proxy_visual_transition = Some(super::app::ProxyVisualTransition {
from_on: false,
to_on: true,
started_tick: 10,
});
let area = Rect::new(0, 0, 20, 2);
flash.sync(&app, area);
assert!(flash.active());
let mut first = Buffer::empty(area);
flash.process(std::time::Duration::from_millis(500), &mut first, area);
assert!(flash.active(), "flash should still be active at peak frame");
let mut second = Buffer::empty(area);
flash.process(std::time::Duration::from_millis(100), &mut second, area);
assert!(
flash.active(),
"flash should still be active during return phase"
);
}
#[test]
fn managed_proxy_action_warns_when_worker_is_unavailable() {
let mut app = App::new(Some(AppType::Claude));
let mut loading = RequestTracker::default();
queue_managed_proxy_action(&mut app, None, &mut loading, AppType::Claude, true)
.expect("missing worker should not crash");
let toast = app.toast.as_ref().expect("warning toast should be shown");
assert_eq!(toast.kind, ToastKind::Warning);
assert_eq!(
toast.message,
texts::tui_toast_proxy_request_failed(texts::tui_error_proxy_worker_unavailable())
);
assert!(matches!(app.overlay, Overlay::None));
assert_eq!(loading.active, None);
}
#[test]
fn normalize_ctrl_h_becomes_backspace() {
let key = KeyEvent::new_with_kind(
KeyCode::Char('h'),
KeyModifiers::CONTROL,
KeyEventKind::Press,
);
let normalized = normalize_key_event(key);
assert_eq!(normalized.code, KeyCode::Backspace);
assert!(!normalized.modifiers.contains(KeyModifiers::CONTROL));
}
#[test]
fn normalize_plain_h_unchanged() {
let key = KeyEvent::new_with_kind(KeyCode::Char('h'), KeyModifiers::NONE, KeyEventKind::Press);
let normalized = normalize_key_event(key);
assert_eq!(normalized.code, KeyCode::Char('h'));
assert_eq!(normalized.modifiers, KeyModifiers::NONE);
}
#[test]
fn normalize_real_backspace_unchanged() {
let key = KeyEvent::new_with_kind(KeyCode::Backspace, KeyModifiers::NONE, KeyEventKind::Press);
let normalized = normalize_key_event(key);
assert_eq!(normalized.code, KeyCode::Backspace);
}
#[test]
fn quick_setup_helper_saves_preset_and_runs_connection_check() {
let mut captured = None;
let mut checked = false;
apply_webdav_jianguoyun_quick_setup(
" demo@nutstore.com ",
" app-password ",
|cfg| {
captured = Some(cfg);
Ok(())
},
|| {
checked = true;
Ok(())
},
)
.expect("quick setup helper should succeed");
let saved = captured.expect("settings should be saved");
assert!(saved.enabled);
assert_eq!(saved.base_url, "https://dav.jianguoyun.com/dav");
assert_eq!(saved.remote_root, "cc-switch-sync");
assert_eq!(saved.profile, "default");
assert_eq!(saved.username, "demo@nutstore.com");
assert_eq!(saved.password, "app-password");
assert!(checked, "connection check should be called");
}
#[test]
fn quick_setup_helper_stops_when_save_fails() {
let mut checked = false;
let err = apply_webdav_jianguoyun_quick_setup(
"u",
"p",
|_cfg| Err(AppError::Message("save failed".to_string())),
|| {
checked = true;
Ok(())
},
)
.expect_err("save failure should be returned");
assert!(err.to_string().contains("save failed"));
assert!(!checked, "connection check should not run when save fails");
}
#[test]
fn stream_check_result_lines_include_core_fields() {
let result = crate::services::stream_check::StreamCheckResult {
status: crate::services::stream_check::HealthStatus::Degraded,
success: true,
message: "slow but working".to_string(),
response_time_ms: Some(6789),
http_status: Some(200),
model_used: "gpt-5.1-codex".to_string(),
tested_at: 1_700_000_000,
retry_count: 1,
};
let lines = build_stream_check_result_lines("Provider One", &result);
let joined = lines.join("\n");
assert!(joined.contains("Provider One"));
assert!(joined.contains("gpt-5.1-codex"));
assert!(joined.contains("200"));
assert!(joined.contains("6789"));
assert!(joined.contains("slow but working"));
}
#[test]
fn external_editor_helper_replaces_editor_buffer_and_keeps_initial_text() {
let mut app = App::new(Some(crate::AppType::Claude));
app.open_editor(
"Prompt",
super::app::EditorKind::Plain,
"hello",
super::app::EditorSubmit::PromptEdit {
id: "pr1".to_string(),
},
);
run_external_editor_for_current_editor(&mut app, |current| {
assert_eq!(current, "hello");
Ok("hello from external\neditor".to_string())
})
.expect("external editor helper should succeed");
let editor = app.editor.as_ref().expect("editor should stay open");
assert_eq!(editor.text(), "hello from external\neditor");
assert_eq!(editor.initial_text, "hello");
assert!(editor.is_dirty(), "updated buffer should remain unsaved");
}
#[test]
fn external_editor_helper_preserves_buffer_on_error() {
let mut app = App::new(Some(crate::AppType::Claude));
app.open_editor(
"Prompt",
super::app::EditorKind::Plain,
"hello",
super::app::EditorSubmit::PromptEdit {
id: "pr1".to_string(),
},
);
let err = run_external_editor_for_current_editor(&mut app, |_current| {
Err(AppError::Message("boom".to_string()))
})
.expect_err("external editor helper should surface the edit error");
assert!(err.to_string().contains("boom"));
let editor = app.editor.as_ref().expect("editor should stay open");
assert_eq!(editor.text(), "hello");
assert_eq!(editor.initial_text, "hello");
assert!(
!editor.is_dirty(),
"failed external edit must not dirty the buffer"
);
}
#[test]
fn drain_latest_webdav_req_prefers_last_enqueued_request() {
let (tx, rx) = mpsc::channel();
tx.send(WebDavReq {
request_id: 1,
kind: WebDavReqKind::CheckConnection,
})
.expect("send check request");
tx.send(WebDavReq {
request_id: 2,
kind: WebDavReqKind::Upload,
})
.expect("send upload request");
tx.send(WebDavReq {
request_id: 3,
kind: WebDavReqKind::JianguoyunQuickSetup {
username: "u@example.com".to_string(),
password: "p".to_string(),
},
})
.expect("send quick setup request");
let first = rx.recv().expect("receive first request");
let latest = drain_latest_webdav_req(first, &rx);
assert!(matches!(
latest,
WebDavReq {
request_id: 3,
kind: WebDavReqKind::JianguoyunQuickSetup { username, password }
}
if username == "u@example.com" && password == "p"
));
}
#[test]
fn update_webdav_last_error_with_updates_status_when_present() {
let mut captured = None;
update_webdav_last_error_with(
Some("network timeout".to_string()),
|| Some(crate::settings::WebDavSyncSettings::default()),
|cfg| {
captured = Some(cfg);
Ok(())
},
);
let saved = captured.expect("expected settings to be saved");
assert_eq!(saved.status.last_error.as_deref(), Some("network timeout"));
}
#[test]
fn update_webdav_last_error_with_skips_when_settings_absent() {
let mut saved = false;
update_webdav_last_error_with(
Some("network timeout".to_string()),
|| None,
|_cfg| {
saved = true;
Ok(())
},
);
assert!(
!saved,
"set callback should not run when webdav settings are missing"
);
}
#[test]
fn update_success_does_not_force_exit_when_overlay_hidden() {
let mut app = App::new(None);
app.overlay = Overlay::None;
let mut update_check = RequestTracker::default();
handle_update_msg(
&mut app,
&mut update_check,
UpdateMsg::DownloadFinished(Ok("v9.9.9".to_string())),
);
assert!(
!app.should_quit,
"successful update should not force exit without user confirmation"
);
assert!(
matches!(app.overlay, Overlay::UpdateResult { success: true, .. }),
"successful update should show result overlay even when progress overlay was hidden"
);
}
#[test]
fn update_check_finished_is_ignored_when_canceled() {
let mut app = App::new(None);
app.overlay = Overlay::None;
let mut update_check = RequestTracker::default();
let info = crate::cli::commands::update::UpdateCheckInfo {
current_version: "4.7.0".to_string(),
target_tag: "v9.9.9".to_string(),
is_already_latest: false,
is_downgrade: false,
};
handle_update_msg(
&mut app,
&mut update_check,
UpdateMsg::CheckFinished {
request_id: 1,
result: Ok(info),
},
);
assert!(
matches!(app.overlay, Overlay::None),
"update check result should be ignored after cancel/hide"
);
}
#[test]
fn update_check_finished_is_processed_when_request_id_matches() {
let mut app = App::new(None);
app.overlay = Overlay::Loading {
kind: LoadingKind::UpdateCheck,
title: texts::tui_update_checking_title().to_string(),
message: texts::tui_loading().to_string(),
};
let mut update_check = RequestTracker::default();
update_check.active = Some(7);
let info = crate::cli::commands::update::UpdateCheckInfo {
current_version: "4.7.0".to_string(),
target_tag: "v9.9.9".to_string(),
is_already_latest: false,
is_downgrade: false,
};
handle_update_msg(
&mut app,
&mut update_check,
UpdateMsg::CheckFinished {
request_id: 7,
result: Ok(info),
},
);
assert_eq!(update_check.active, None);
assert!(matches!(
app.overlay,
Overlay::UpdateAvailable {
latest,
selected: 0,
..
} if latest == "v9.9.9"
));
}
#[test]
fn update_check_finished_is_ignored_when_request_id_mismatch() {
let mut app = App::new(None);
app.overlay = Overlay::None;
let mut update_check = RequestTracker::default();
update_check.active = Some(2);
let stale = crate::cli::commands::update::UpdateCheckInfo {
current_version: "4.7.0".to_string(),
target_tag: "v1.0.0".to_string(),
is_already_latest: false,
is_downgrade: false,
};
handle_update_msg(
&mut app,
&mut update_check,
UpdateMsg::CheckFinished {
request_id: 1,
result: Ok(stale),
},
);
assert_eq!(update_check.active, Some(2));
assert!(matches!(app.overlay, Overlay::None));
let latest = crate::cli::commands::update::UpdateCheckInfo {
current_version: "4.7.0".to_string(),
target_tag: "v9.9.9".to_string(),
is_already_latest: false,
is_downgrade: false,
};
handle_update_msg(
&mut app,
&mut update_check,
UpdateMsg::CheckFinished {
request_id: 2,
result: Ok(latest),
},
);
assert_eq!(update_check.active, None);
assert!(matches!(app.overlay, Overlay::UpdateAvailable { .. }));
}
#[test]
fn model_fetch_strategy_matches_provider_field() {
assert_eq!(
model_fetch_strategy_for_field(ProviderAddField::CodexModel),
ModelFetchStrategy::Bearer
);
assert_eq!(
model_fetch_strategy_for_field(ProviderAddField::GeminiModel),
ModelFetchStrategy::GoogleApiKey
);
assert_eq!(
model_fetch_strategy_for_field(ProviderAddField::ClaudeModelConfig),
ModelFetchStrategy::Anthropic
);
}
#[test]
fn model_fetch_candidate_urls_prefers_v1_for_anthropic_base() {
let urls = build_model_fetch_candidate_urls(
"https://api.anthropic.com",
ModelFetchStrategy::Anthropic,
);
assert_eq!(
urls,
vec![
"https://api.anthropic.com/v1/models".to_string(),
"https://api.anthropic.com/models".to_string()
]
);
}
#[test]
fn model_fetch_candidate_urls_strip_anthropic_compat_suffix() {
let urls = build_model_fetch_candidate_urls(
"https://api.deepseek.com/anthropic",
ModelFetchStrategy::Anthropic,
);
assert_eq!(
urls,
vec![
"https://api.deepseek.com/anthropic/v1/models".to_string(),
"https://api.deepseek.com/v1/models".to_string(),
"https://api.deepseek.com/models".to_string(),
]
);
}
#[test]
fn model_fetch_candidate_urls_for_gemini_v1beta_keeps_models_endpoint() {
let urls = build_model_fetch_candidate_urls(
"https://generativelanguage.googleapis.com/v1beta",
ModelFetchStrategy::GoogleApiKey,
);
assert_eq!(
urls,
vec!["https://generativelanguage.googleapis.com/v1beta/models".to_string()]
);
}
#[test]
#[serial(home_settings)]
fn startup_hidden_requested_app_bootstrap_uses_visible_app_normalization_before_loading_data() {
let temp_home = TempDir::new().expect("create temp home");
let _env = EnvGuard::set_home(temp_home.path());
crate::settings::set_visible_apps(crate::settings::VisibleApps {
claude: true,
codex: true,
gemini: false,
opencode: true,
openclaw: true,
hermes: false,
})
.expect("save visible apps");
let mut loaded_app_type = None;
let (app, _data) = initialize_app_state_for_test(Some(AppType::Gemini), |app_type| {
loaded_app_type = Some(app_type.clone());
Ok(UiData::default())
})
.expect("bootstrap app state");
assert_eq!(loaded_app_type, Some(AppType::OpenCode));
assert_eq!(app.app_type, AppType::OpenCode);
}
#[test]
fn parse_model_ids_supports_multiple_shapes_and_dedups_stably() {
let data_payload = json!({
"data": [
{"id": "gpt-4o"},
{"id": "gpt-4o-mini"},
{"id": "gpt-4o"},
{"id": "o3"}
]
});
assert_eq!(
parse_model_ids_from_response(&data_payload),
vec!["gpt-4o", "gpt-4o-mini", "o3"]
);
let gemini_payload = json!({
"models": [
{"name": "models/gemini-2.0-pro"},
{"name": "models/gemini-2.0-flash"}
]
});
assert_eq!(
parse_model_ids_from_response(&gemini_payload),
vec!["gemini-2.0-pro", "gemini-2.0-flash"]
);
}