use super::*;
use pretty_assertions::assert_eq;
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn span_style(line: &Line<'_>, needle: &str) -> Option<ratatui::style::Style> {
line.spans
.iter()
.find_map(|span| (span.content.as_ref() == needle).then_some(span.style))
}
fn test_info(cwd: PathBuf) -> RuntimeModelView {
let mut info = crate::tui::tests::test_bootstrap().runtime;
info.cwd = cwd;
info
}
fn fully_populated_statusline() -> StatusLine {
let mut statusline = StatusLine::new(&test_info(PathBuf::from("/tmp/project")));
statusline.state.reasoning_configurable = true;
statusline.state.reasoning = ReasoningLevel::Medium;
statusline.update_usage(
None,
Some(&ContextUsage::estimated(1_000, Some(10_000))),
12_500,
);
statusline.update_average_output_rate(Some(42));
statusline
}
#[test]
fn statusline_rows_use_display_width_for_alignment() {
let left = vec![field(
FieldKey::Context,
Side::Left,
RANK_CONTEXT,
0,
"项目",
Theme::dim(),
)];
let right = vec![field(
FieldKey::Model,
Side::Right,
RANK_MODEL,
0,
"模型",
Theme::dim(),
)];
let line = render_status_row(left, right, 10);
assert_eq!(display_width(&line_text(&line)), 10);
}
#[test]
fn context_usage_style_escalates_with_fill() {
assert_eq!(context_usage_style(0.0), Theme::dim());
assert_eq!(context_usage_style(74.9), Theme::dim());
assert_eq!(context_usage_style(75.0), Theme::warning());
assert_eq!(context_usage_style(89.9), Theme::warning());
assert_eq!(context_usage_style(90.0), Theme::error());
assert_eq!(context_usage_style(100.0), Theme::error());
}
#[test]
fn permission_style_marks_auto_as_warning() {
assert_eq!(permission_style(PermissionMode::Auto), Theme::warning());
assert_eq!(permission_style(PermissionMode::Plan), Theme::dim());
assert_eq!(permission_style(PermissionMode::Supervised), Theme::dim());
}
#[test]
fn auto_permission_and_high_context_use_warning_styles() {
let mut statusline = StatusLine::new(&test_info(PathBuf::from("/tmp/project")));
statusline.update_usage(None, Some(&ContextUsage::estimated(9_500, Some(10_000))), 0);
let line = statusline.lines(80, None)[1].clone();
assert_eq!(
span_style(&line, "Auto"),
Some(Theme::warning()),
"Auto permission must warn: {line:?}"
);
assert_eq!(
span_style(&line, "9.5K (95.0%)"),
Some(Theme::error()),
"critical context fill must error: {line:?}"
);
assert_eq!(
span_style(&line, "gpt-5.5"),
Some(Theme::dim()),
"model stays ambient: {line:?}"
);
}
#[test]
fn bottom_row_drops_fields_by_global_rank() {
let statusline = fully_populated_statusline();
let state = &statusline.state;
assert_eq!(
packed_keys(state, 100),
vec![
FieldKey::Context,
FieldKey::Cost,
FieldKey::Rate,
FieldKey::Permission,
FieldKey::Provider,
FieldKey::Model,
FieldKey::Reasoning,
],
"wide row keeps every present field"
);
let after_reasoning = packed_keys(state, 60);
assert!(
!after_reasoning.contains(&FieldKey::Reasoning),
"reasoning is the first drop: {after_reasoning:?}"
);
assert!(
after_reasoning.contains(&FieldKey::Rate),
"rate outranks reasoning: {after_reasoning:?}"
);
let after_rate = packed_keys(state, 45);
assert!(
!after_rate.contains(&FieldKey::Rate) && !after_rate.contains(&FieldKey::Reasoning),
"rate drops next: {after_rate:?}"
);
assert!(
after_rate.contains(&FieldKey::Provider),
"provider outranks rate: {after_rate:?}"
);
let after_provider = packed_keys(state, 36);
assert!(
!after_provider.contains(&FieldKey::Provider),
"provider drops before cost: {after_provider:?}"
);
assert!(
after_provider.contains(&FieldKey::Cost),
"cost outranks provider: {after_provider:?}"
);
let after_cost = packed_keys(state, 27);
assert!(
!after_cost.contains(&FieldKey::Cost),
"cost drops before context: {after_cost:?}"
);
assert!(
after_cost.contains(&FieldKey::Context) && after_cost.contains(&FieldKey::Model),
"context and model remain: {after_cost:?}"
);
let after_context = packed_keys(state, 20);
assert_eq!(
after_context,
vec![FieldKey::Permission, FieldKey::Model],
"context drops before model: {after_context:?}"
);
assert_eq!(
packed_keys(state, 12),
vec![FieldKey::Permission],
"permission is kept last"
);
}
#[test]
fn pack_prefers_model_over_cost_and_context() {
let statusline = fully_populated_statusline();
assert_eq!(
packed_keys(&statusline.state, 15),
vec![FieldKey::Permission, FieldKey::Model]
);
assert_eq!(
packed_keys(&statusline.state, 17),
vec![FieldKey::Permission, FieldKey::Model]
);
}
#[test]
fn provider_degrades_before_model_on_narrow_width() {
let mut statusline = StatusLine::new(&test_info(PathBuf::from("/tmp/project")));
statusline.state.reasoning_configurable = false;
let wide = statusline.lines(40, None)[1].clone();
assert!(
line_text(&wide).contains("OpenAI · gpt-5.5"),
"wide row should keep provider: {:?}",
line_text(&wide)
);
let narrow = statusline.lines(18, None)[1].clone();
assert!(
line_text(&narrow).contains("gpt-5.5"),
"narrow row should keep the model: {:?}",
line_text(&narrow)
);
assert!(
!line_text(&narrow).contains("OpenAI"),
"provider should drop before the model: {:?}",
line_text(&narrow)
);
}
#[test]
fn signed_out_keeps_not_signed_in_over_permission() {
let mut statusline = StatusLine::new(&test_info(PathBuf::from("/tmp/project")));
statusline.update_signed_in(false);
assert_eq!(
packed_keys(&statusline.state, 40),
vec![
FieldKey::Permission,
FieldKey::SignedOut,
FieldKey::LoginHint,
]
);
assert_eq!(
packed_keys(&statusline.state, 20),
vec![FieldKey::Permission, FieldKey::SignedOut]
);
assert_eq!(
packed_keys(&statusline.state, 10),
vec![FieldKey::SignedOut],
"signed-out copy outranks permission"
);
}
#[test]
fn permission_mode_update_invalidates_cache() {
let mut info = test_info(PathBuf::from("/tmp/project"));
let mut statusline = StatusLine::new(&info);
statusline.lines(18, None);
let initial_render_count = statusline.render_count();
info.permission_mode = crate::permission::PermissionMode::Plan;
statusline.update_model(&info);
let _ = statusline.lines(18, None);
assert_eq!(statusline.render_count(), initial_render_count + 1);
}
#[test]
fn unchanged_statusline_reuses_rendered_lines() {
let mut statusline = StatusLine::new(&test_info(PathBuf::from("/tmp/project")));
statusline.lines(80, None);
statusline.lines(80, None);
assert_eq!(statusline.render_count(), 1);
}
#[test]
fn git_branch_is_cached_until_explicit_refresh() {
use std::fs;
let temp = tempfile::tempdir().unwrap();
let git_dir = temp.path().join(".git");
fs::create_dir(&git_dir).unwrap();
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
let mut statusline = StatusLine::new(&test_info(temp.path().to_path_buf()));
let initial = statusline.lines(80, None).to_vec();
fs::write(git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
let cached = statusline.lines(80, None).to_vec();
statusline.refresh_git_branch();
let refreshed = statusline.lines(80, None).to_vec();
assert_eq!(cached, initial);
assert_ne!(refreshed, initial);
}
#[test]
fn shorten_path_keeps_trailing_segments() {
assert_eq!(
shorten_path_display("~/work/company/services/api-gateway", 24),
"~/…/services/api-gateway"
);
assert_eq!(
shorten_path_display("~/work/company/services/api-gateway", 18),
"~/…/api-gateway"
);
assert_eq!(
shorten_path_display("/tmp/claude-1000/home-emgym-herdr-work", 23),
"…/home-emgym-herdr-work"
);
assert_eq!(
shorten_path_display("/tmp/claude-1000/projects/api-gateway", 20),
"/…/api-gateway"
);
}
#[test]
fn shorten_path_keeps_end_when_last_segment_is_long() {
let shortened = shorten_path_display("~/work/company/very-long-service-name", 14);
assert!(shortened.starts_with('…'), "{shortened}");
assert!(
shortened.ends_with("service-name") || shortened.ends_with("name"),
"{shortened}"
);
assert!(display_width(&shortened) <= 14, "{shortened}");
assert!(!shortened.starts_with("~/work"), "{shortened}");
}
#[test]
fn fit_cwd_width_zero_is_empty() {
assert_eq!(fit_cwd("~/work/api-gateway", Some("main"), 0), "");
assert_eq!(fit_cwd("~/work/api-gateway", None, 0), "");
}
#[test]
fn fit_cwd_drops_branch_when_suffix_fills_width() {
let fitted = fit_cwd("~/work/company/services/api-gateway", Some("main"), 4);
assert!(!fitted.contains('('), "{fitted}");
assert!(display_width(&fitted) <= 4, "{fitted}");
}
#[test]
fn fit_cwd_keeps_branch_while_basename_fits() {
let fitted = fit_cwd("~/work/company/services/api-gateway", Some("main"), 28);
assert_eq!(fitted, "~/…/api-gateway (main)");
assert!(display_width(&fitted) <= 28, "{fitted}");
}
#[test]
fn fit_cwd_drops_branch_before_mangling_basename() {
let path = "~/work/company/services/api-gateway";
let fitted = fit_cwd(path, Some("very-long-feature-branch"), 18);
assert!(
fitted.contains("api-gateway"),
"basename must remain intact: {fitted}"
);
assert!(
!fitted.contains('('),
"branch should drop before basename is mangled: {fitted}"
);
assert!(display_width(&fitted) <= 18, "{fitted}");
}
#[test]
fn fit_cwd_handles_branch_names_with_parentheses() {
let fitted = fit_cwd("/tmp/project", Some("feat (wip)"), 40);
assert_eq!(fitted, "/tmp/project (feat (wip))");
let fitted = fit_cwd(
"~/work/company/services/api-gateway",
Some("feat (wip)"),
30,
);
assert!(fitted.contains("api-gateway"), "{fitted}");
assert!(
fitted.ends_with(" (feat (wip))") || !fitted.contains('('),
"must not re-parse branch from the joined string: {fitted}"
);
assert!(display_width(&fitted) <= 30, "{fitted}");
}