use crate::lockorder::RankedMutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::usage::{ActivityStore, ProfileActivity, any_busy};
fn make_activity(entries: &[(&str, ProfileActivity)]) -> ActivityStore {
let mut map = HashMap::new();
for (name, activity) in entries {
map.insert(name.to_string(), *activity);
}
Arc::new(RankedMutex::new(map))
}
fn bootstrap_busy(flag: &Arc<AtomicBool>, activity: &ActivityStore) -> bool {
flag.load(Ordering::SeqCst) || any_busy(activity)
}
use super::{InputState, parse_threshold};
#[test]
fn delete_word_removes_run_left_of_caret() {
let mut input = InputState::new("foo bar");
input.delete_word();
assert_eq!(input.value, "foo ");
input.delete_word();
assert_eq!(input.value, "");
}
#[test]
fn delete_word_respects_caret_position() {
let mut input = InputState::new("foo bar");
input.home();
input.delete_word();
assert_eq!(input.value, "foo bar");
}
#[test]
fn parse_threshold_accepts_in_range_only() {
assert_eq!(parse_threshold("0"), Some(0.0));
assert_eq!(parse_threshold("100"), Some(100.0));
assert_eq!(parse_threshold("73.5"), Some(73.5));
assert!(parse_threshold("150").is_none());
assert!(parse_threshold("-1").is_none());
assert!(parse_threshold("abc").is_none());
assert!(parse_threshold("").is_none());
}
#[test]
fn bootstrap_active_true_reports_busy() {
let flag = Arc::new(AtomicBool::new(true));
let activity = make_activity(&[]);
assert!(bootstrap_busy(&flag, &activity));
}
#[test]
fn bootstrap_active_false_empty_store_reports_idle() {
let flag = Arc::new(AtomicBool::new(false));
let activity = make_activity(&[]);
assert!(!bootstrap_busy(&flag, &activity));
}
#[test]
fn bootstrap_active_true_with_refreshing_slot_reports_busy() {
let flag = Arc::new(AtomicBool::new(true));
let activity = make_activity(&[("alice", ProfileActivity::Refreshing)]);
assert!(bootstrap_busy(&flag, &activity));
}
#[test]
fn bootstrap_active_false_with_refreshing_slot_still_busy() {
let flag = Arc::new(AtomicBool::new(false));
let activity = make_activity(&[("alice", ProfileActivity::Refreshing)]);
assert!(bootstrap_busy(&flag, &activity));
}
use super::App;
fn bare_app() -> App {
use crate::profile::{AppConfig, AppState};
App::new(AppConfig {
state: AppState::default(),
profiles: Vec::new(),
})
}
fn write_plugin_install(scope: &str) {
let path = crate::profile::claude_dir()
.expect("claude dir")
.join("plugins")
.join("installed_plugins.json");
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
let body = serde_json::json!({
"plugins": { "clauth@clauth": [{ "scope": scope, "version": "0.1.0" }] }
});
std::fs::write(&path, serde_json::to_vec(&body).expect("serialize")).expect("write");
}
fn plugin_check(app: &App) -> &super::Check {
app.plugin
.checks
.iter()
.find(|c| c.label == "plugin")
.expect("plugin check present")
}
#[test]
fn plugin_check_ok_when_installed_globally() {
let _home = crate::testutil::HomeSandbox::new();
write_plugin_install("user");
let mut app = bare_app();
super::recompute_plugin_checks(&mut app, false);
let check = plugin_check(&app);
assert_eq!(check.health, super::Health::Ok);
assert!(
check
.detail
.iter()
.any(|line| line.starts_with("installed: yes")),
"global install should read installed, got {:?}",
check.detail
);
}
#[test]
fn plugin_check_warns_and_suggests_global_when_project_local() {
let _home = crate::testutil::HomeSandbox::new();
write_plugin_install("local");
let mut app = bare_app();
super::recompute_plugin_checks(&mut app, false);
let check = plugin_check(&app);
assert_eq!(check.health, super::Health::Warn);
assert!(
check.detail.iter().any(|line| line.contains("(local)")),
"the project-local scope should surface in the readout, got {:?}",
check.detail
);
assert!(
check
.detail
.iter()
.any(|line| line.contains("--scope user")),
"non-global install should suggest a user-scope install, got {:?}",
check.detail
);
}
fn mcp_check(app: &App) -> &super::Check {
app.plugin
.checks
.iter()
.find(|c| c.label == "mcp servers")
.expect("mcp servers check present")
}
#[test]
fn mcp_check_ok_when_globally_wired() {
let _home = crate::testutil::HomeSandbox::new();
crate::plugin_probe::wire_mcp_server().expect("wire ~/.claude.json");
let mut app = bare_app();
super::recompute_plugin_checks(&mut app, false);
let check = mcp_check(&app);
assert_eq!(check.health, super::Health::Ok);
assert!(
check.detail.iter().any(|line| line == "present: yes"),
"a globally wired server should read present, got {:?}",
check.detail
);
assert!(check.fix.is_none());
}
#[test]
fn mcp_check_warns_project_only_for_local_plugin() {
let _home = crate::testutil::HomeSandbox::new();
write_plugin_install("local");
let mut app = bare_app();
super::recompute_plugin_checks(&mut app, false);
let check = mcp_check(&app);
assert_eq!(check.health, super::Health::Warn);
assert!(
check
.detail
.iter()
.any(|line| line == "wired for this project only, not global"),
"project-only wiring should say so in the readout, got {:?}",
check.detail
);
assert!(check.fix.is_some(), "should offer the global write fix");
}
#[test]
fn runtime_check_summarizes_profiles() {
use crate::profile::{AppConfig, AppState, Profile};
let _home = crate::testutil::HomeSandbox::new();
let mut app = App::new(AppConfig {
state: AppState::default(),
profiles: vec![Profile::new("acct".to_string(), None, None)],
});
super::recompute_plugin_checks(&mut app, false);
let check = app
.plugin
.checks
.iter()
.find(|c| c.label == "runtime")
.expect("runtime check");
assert_eq!(check.health, super::Health::Idle);
assert!(check.fix.is_none());
assert!(check.detail.iter().any(|l| l == "profiles: 1"));
assert!(check.detail.iter().any(|l| l == "sessions: 0"));
assert!(check.detail.iter().any(|l| l == "active: \u{2014}"));
assert!(check.detail.iter().any(|l| l == "link: \u{2014}"));
}
#[test]
fn compact_entry_sets_flag_no_toast() {
let mut app = bare_app();
app.update_compact(13);
assert!(app.compact);
assert!(app.toasts.is_empty(), "compact must not fire a toast");
}
#[test]
fn compact_yields_warning_banner() {
use super::{BannerSeverity, update_banner};
let mut app = bare_app();
app.update_compact(13);
update_banner(&mut app);
let banner = app.banner.as_ref().expect("compact banner present");
assert_eq!(banner.severity, BannerSeverity::Warning);
assert_eq!(
banner.message,
"terminal too small · enlarge for full layout"
);
}
#[test]
fn compact_exit_clears_banner() {
use super::update_banner;
let mut app = bare_app();
app.update_compact(13);
update_banner(&mut app);
assert!(app.banner.is_some());
app.update_compact(14);
update_banner(&mut app);
assert!(!app.compact);
assert!(app.banner.is_none(), "banner self-clears on resize");
}
#[test]
fn compact_rearm_after_exit() {
use super::update_banner;
let mut app = bare_app();
app.update_compact(13);
app.update_compact(14);
app.update_compact(13);
update_banner(&mut app);
assert!(app.compact);
assert!(app.toasts.is_empty(), "compact must not fire a toast");
assert!(app.banner.is_some());
}
use super::theme::{self, Tier};
use super::{GLOBAL_CONFIG_ROWS, GlobalConfigRow, KeyCode, Tab};
use crate::testutil::key;
#[test]
fn theme_set_tier_round_trips() {
theme::set_tier(Tier::Full);
assert_eq!(theme::tier(), Tier::Full);
theme::set_tier(Tier::Compatible);
assert_eq!(theme::tier(), Tier::Compatible);
theme::set_tier(Tier::Full);
assert_eq!(theme::tier(), Tier::Full);
}
#[test]
fn global_config_cursor_wraps() {
let mut app = bare_app();
app.tab = Tab::Config;
let last = GLOBAL_CONFIG_ROWS.len() - 1;
assert_eq!(app.global_config_cursor, 0);
super::handle_global_config_key(&mut app, key(KeyCode::Up));
assert_eq!(
app.global_config_cursor, last,
"Up from first wraps to last"
);
super::handle_global_config_key(&mut app, key(KeyCode::Down));
assert_eq!(app.global_config_cursor, 0, "Down from last wraps to first");
}
use crate::profile::DivergenceChoice;
#[test]
fn next_divergence_default_cycles_round_trip() {
assert_eq!(
super::next_divergence_default(None),
Some(DivergenceChoice::Overwrite)
);
assert_eq!(
super::next_divergence_default(Some(DivergenceChoice::Overwrite)),
Some(DivergenceChoice::NewProfile)
);
assert_eq!(
super::next_divergence_default(Some(DivergenceChoice::NewProfile)),
Some(DivergenceChoice::Discard)
);
assert_eq!(
super::next_divergence_default(Some(DivergenceChoice::Discard)),
None
);
}
#[test]
fn divergence_default_row_is_reachable_by_cursor() {
let mut app = bare_app();
app.tab = Tab::Config;
let pos = GLOBAL_CONFIG_ROWS
.iter()
.position(|r| *r == GlobalConfigRow::DivergenceDefault)
.unwrap();
app.global_config_cursor = pos;
let from_up = if pos == 0 {
GLOBAL_CONFIG_ROWS.len() - 1
} else {
pos - 1
};
super::handle_global_config_key(&mut app, key(KeyCode::Up));
assert_eq!(app.global_config_cursor, from_up);
super::handle_global_config_key(&mut app, key(KeyCode::Down));
assert_eq!(app.global_config_cursor, pos);
}
use super::parse_refresh_secs;
fn on_refresh_row(app: &mut App) {
app.tab = Tab::Config;
app.global_config_cursor = GLOBAL_CONFIG_ROWS
.iter()
.position(|r| *r == GlobalConfigRow::RefreshInterval)
.unwrap();
}
#[test]
fn parse_refresh_secs_accepts_in_range_only() {
assert_eq!(parse_refresh_secs("10"), Some(10_000));
assert_eq!(parse_refresh_secs("90"), Some(90_000));
assert_eq!(parse_refresh_secs("3600"), Some(3_600_000));
assert!(parse_refresh_secs("9").is_none(), "below the 10s floor");
assert!(parse_refresh_secs("3601").is_none(), "above the 1h cap");
assert!(parse_refresh_secs("-5").is_none());
assert!(parse_refresh_secs("1.5").is_none());
assert!(parse_refresh_secs("abc").is_none());
assert!(parse_refresh_secs("").is_none());
}
#[test]
fn refresh_interval_enter_opens_editor_seeded_in_seconds() {
let mut app = bare_app();
on_refresh_row(&mut app);
assert!(app.refresh_interval_draft.is_none());
super::handle_global_config_key(&mut app, key(KeyCode::Enter));
let draft = app
.refresh_interval_draft
.as_ref()
.expect("⏎ opens the custom-value editor");
assert_eq!(draft.value, "90", "seeded with the default 90s in seconds");
}
#[test]
fn refresh_interval_space_cycles_without_opening_editor() {
let _home = crate::testutil::HomeSandbox::new();
let mut app = bare_app();
on_refresh_row(&mut app);
let before = app.refresh_interval.load(Ordering::Relaxed);
super::handle_global_config_key(&mut app, key(KeyCode::Char(' ')));
assert!(
app.refresh_interval_draft.is_none(),
"space cycles presets, never opens the editor"
);
assert_ne!(
app.refresh_interval.load(Ordering::Relaxed),
before,
"space steps to the next preset"
);
}
#[test]
fn refresh_interval_custom_value_commits_and_clears() {
let _home = crate::testutil::HomeSandbox::new();
let mut app = bare_app();
on_refresh_row(&mut app);
super::handle_global_config_key(&mut app, key(KeyCode::Enter));
super::handle_refresh_interval_edit_key(&mut app, key(KeyCode::Backspace));
super::handle_refresh_interval_edit_key(&mut app, key(KeyCode::Backspace));
for c in "45".chars() {
super::handle_refresh_interval_edit_key(&mut app, key(KeyCode::Char(c)));
}
super::handle_refresh_interval_edit_key(&mut app, key(KeyCode::Enter));
assert!(
app.refresh_interval_draft.is_none(),
"a valid commit clears the draft"
);
assert_eq!(app.refresh_interval.load(Ordering::Relaxed), 45_000);
}
#[test]
fn refresh_interval_out_of_range_keeps_editor_open() {
let _home = crate::testutil::HomeSandbox::new();
let mut app = bare_app();
on_refresh_row(&mut app);
let before = app.refresh_interval.load(Ordering::Relaxed);
super::handle_global_config_key(&mut app, key(KeyCode::Enter));
for c in "99999".chars() {
super::handle_refresh_interval_edit_key(&mut app, key(KeyCode::Char(c)));
}
super::handle_refresh_interval_edit_key(&mut app, key(KeyCode::Enter));
assert!(
app.refresh_interval_draft.is_some(),
"an out-of-range value keeps the editor open for correction"
);
assert_eq!(
app.refresh_interval.load(Ordering::Relaxed),
before,
"interval stays put while the typed value is invalid"
);
}
#[test]
fn refresh_interval_esc_discards_editor() {
let mut app = bare_app();
on_refresh_row(&mut app);
let before = app.refresh_interval.load(Ordering::Relaxed);
super::handle_global_config_key(&mut app, key(KeyCode::Enter));
for c in "30".chars() {
super::handle_refresh_interval_edit_key(&mut app, key(KeyCode::Char(c)));
}
super::handle_refresh_interval_edit_key(&mut app, key(KeyCode::Esc));
assert!(
app.refresh_interval_draft.is_none(),
"esc discards the editor"
);
assert_eq!(
app.refresh_interval.load(Ordering::Relaxed),
before,
"esc leaves the interval unchanged"
);
}
mod env_editor {
use super::super::{App, ConfigFocus, ConfigRow, InputState, Modal, Tab, config_rows};
use crate::profile::{AppConfig, AppState, Profile};
use crate::testutil::HomeSandbox;
use std::collections::BTreeMap;
fn app_with_env(env: BTreeMap<String, String>) -> App {
let mut profile = Profile::new("acct".to_string(), None, None);
profile.env = env;
App::new(AppConfig {
state: AppState::default(),
profiles: vec![profile],
})
}
fn enter_detail(app: &mut App) {
app.tab = Tab::Setup;
app.profile_cursor = 0;
super::super::enter_config_detail(app);
assert_eq!(app.config_focus, ConfigFocus::Actions);
}
#[test]
fn config_rows_insert_env_entries_then_add_row() {
let mut env = BTreeMap::new();
env.insert("ALPHA".to_string(), "1".to_string());
env.insert("ZED".to_string(), "2".to_string());
let app = app_with_env(env);
let rows = config_rows(&app);
let pos = |row: ConfigRow| rows.iter().position(|r| *r == row);
let e0 = pos(ConfigRow::EnvEntry(0)).expect("first env row");
let e1 = pos(ConfigRow::EnvEntry(1)).expect("second env row");
let add = pos(ConfigRow::EnvAdd).expect("add-env row");
assert!(e0 < e1 && e1 < add, "sorted entries precede the add row");
assert_eq!(
*rows.last().unwrap(),
ConfigRow::Delete,
"delete stays last"
);
}
fn app_with_profile(profile: Profile) -> App {
App::new(AppConfig {
state: AppState::default(),
profiles: vec![profile],
})
}
#[test]
fn oauth_account_hides_api_key_keeps_auto_start() {
let app = app_with_env(BTreeMap::new()); let rows = config_rows(&app);
assert!(
!rows.contains(&ConfigRow::ApiKey),
"api key is meaningless without a base url"
);
assert!(
rows.contains(&ConfigRow::AutoStart),
"auto-start is the OAuth-only row"
);
}
#[test]
fn api_account_shows_api_key_drops_auto_start() {
let app = app_with_profile(Profile::new(
"acct".to_string(),
Some("https://api.test".to_string()),
Some("sk-test".to_string()),
));
let rows = config_rows(&app);
assert!(
rows.contains(&ConfigRow::ApiKey),
"api key shows in API mode"
);
assert!(
!rows.contains(&ConfigRow::AutoStart),
"auto-start does not apply to API accounts"
);
}
#[test]
fn unset_overrides_collapse_behind_reveal_chip() {
let app = app_with_env(BTreeMap::new());
let rows = config_rows(&app);
assert!(
rows.contains(&ConfigRow::ModelOverrideAdd),
"the reveal chip stands in for the unset overrides"
);
for row in [
ConfigRow::OpusModel,
ConfigRow::SonnetModel,
ConfigRow::HaikuModel,
ConfigRow::SubagentModel,
] {
assert!(
!rows.contains(&row),
"unset override is hidden while collapsed"
);
}
}
#[test]
fn set_override_renders_others_stay_collapsed() {
let mut profile = Profile::new("acct".to_string(), None, None);
profile.models.opus = Some("claude-opus-4-8".to_string());
let rows = config_rows(&app_with_profile(profile));
assert!(
rows.contains(&ConfigRow::OpusModel),
"a set override always renders"
);
assert!(
!rows.contains(&ConfigRow::SonnetModel),
"an unset sibling stays hidden"
);
assert!(
rows.contains(&ConfigRow::ModelOverrideAdd),
"the chip remains while any override is still unset"
);
}
#[test]
fn reveal_chip_expands_all_overrides() {
let mut app = app_with_env(BTreeMap::new());
enter_detail(&mut app);
let chip = config_rows(&app)
.iter()
.position(|r| *r == ConfigRow::ModelOverrideAdd)
.expect("reveal chip present while collapsed");
app.config_action_cursor = chip;
super::super::run_config_row(&mut app, ConfigRow::ModelOverrideAdd);
assert!(
app.config_draft
.as_ref()
.is_some_and(|d| d.overrides_expanded),
"⏎ on the chip expands the override block"
);
let rows = config_rows(&app);
for row in [
ConfigRow::OpusModel,
ConfigRow::SonnetModel,
ConfigRow::HaikuModel,
ConfigRow::SubagentModel,
] {
assert!(rows.contains(&row), "every override shows once expanded");
}
assert!(
!rows.contains(&ConfigRow::ModelOverrideAdd),
"the chip is gone once expanded"
);
}
#[test]
fn add_field_with_managed_key_prompts_collision() {
let _home = HomeSandbox::new();
let mut app = app_with_env(BTreeMap::new());
enter_detail(&mut app);
if let Some(d) = app.config_draft.as_mut() {
d.env_new_key = InputState::new("ANTHROPIC_BASE_URL");
d.active = Some(ConfigRow::EnvAdd);
}
super::super::commit_env_new_key(&mut app);
assert!(
matches!(app.modals.last(), Some(Modal::EnvCollision(_))),
"a clauth-managed key clash raises the collision prompt"
);
}
#[test]
fn add_field_with_fresh_key_inserts_and_edits_value() {
let _home = HomeSandbox::new();
let mut app = app_with_env(BTreeMap::new());
enter_detail(&mut app);
if let Some(d) = app.config_draft.as_mut() {
d.env_new_key = InputState::new("CLAUDE_CODE_MAX_OUTPUT_TOKENS");
d.active = Some(ConfigRow::EnvAdd);
}
super::super::commit_env_new_key(&mut app);
assert!(app.modals.is_empty(), "a fresh key adds without prompting");
assert_eq!(
app.config()
.find("acct")
.and_then(|p| p.env.get("CLAUDE_CODE_MAX_OUTPUT_TOKENS").cloned()),
Some(String::new()),
"the key is added with an empty value"
);
assert!(
matches!(
app.config_draft.as_ref().and_then(|d| d.active),
Some(ConfigRow::EnvEntry(_))
),
"focus drops into the new entry's value editor"
);
}
}
fn app_with_unlinked_profiles(profiles: Vec<crate::profile::Profile>) -> App {
use crate::profile::{AppConfig, AppState};
let names: Vec<_> = profiles.iter().map(|p| p.name.clone()).collect();
App::new(AppConfig {
state: AppState {
profiles: names.clone(),
fallback_chain: names,
..AppState::default()
},
profiles,
})
}
#[test]
fn no_active_banner_without_spent_evidence() {
use super::update_banner;
let mut app = app_with_unlinked_profiles(vec![crate::testutil::blank_profile("a")]);
update_banner(&mut app);
assert_eq!(
app.banner.as_ref().expect("banner").message,
"no active profile · select one to resume"
);
}
#[test]
fn all_spent_banner_needs_live_spent_window() {
use super::update_banner;
use crate::usage::{UsageInfo, UsageWindow, epoch_secs_to_iso, now_epoch_secs};
let mut spent = crate::testutil::blank_profile("a");
spent.usage = Some(UsageInfo {
five_hour: Some(UsageWindow {
utilization: 100.0,
resets_at: Some(epoch_secs_to_iso(now_epoch_secs() + 3600)),
}),
..UsageInfo::default()
});
let mut app = app_with_unlinked_profiles(vec![spent]);
update_banner(&mut app);
assert_eq!(
app.banner.as_ref().expect("banner").message,
"all accounts spent · switch to a profile to resume"
);
}
#[test]
fn capture_refuses_empty_snapshot() {
use super::ToastKind;
let _home = crate::testutil::HomeSandbox::new();
let mut app = bare_app();
super::begin_capture(&mut app, false);
assert!(app.modals.is_empty(), "no name prompt on an empty snapshot");
assert!(
app.toasts
.iter()
.any(|t| t.kind == ToastKind::Danger && t.body.contains("nothing to capture")),
"danger toast names the problem"
);
}