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
}
#[test]
fn reasoning_capabilities_refresh_on_catalog_and_metadata_changes() {
use rho_providers::model::models_dev::{
with_models_dev_cache_dir_for_tests, write_cached_model_metadata_for_tests,
};
let dir = tempfile::tempdir().unwrap();
with_models_dev_cache_dir_for_tests(dir.path().to_path_buf(), || {
let mut info = test_info(dir.path().to_path_buf());
info.provider = "openai".into();
info.model = "gpt-4o".into();
write_cached_model_metadata_for_tests(
&info.provider,
&info.model,
&ModelMetadata {
reasoning_capabilities_known: true,
reasoning_metadata_complete: true,
..ModelMetadata::default()
},
);
let mut app = crate::tui::tests::test_app();
app.info.runtime = info;
app.statusline = StatusLine::new(&app.info.runtime);
for levels in [Some(vec![ReasoningLevel::High]), None] {
let metadata = ModelMetadata {
supported_reasoning_levels: levels.clone(),
reasoning_capabilities_known: true,
reasoning_metadata_complete: true,
..ModelMetadata::default()
};
let before = app.statusline.state.reasoning_configurable;
write_cached_model_metadata_for_tests(
&app.info.runtime.provider,
&app.info.runtime.model,
&metadata,
);
app.statusline.update_model(&app.info.runtime);
app.statusline
.update_model_metadata(app.statusline.state.model_metadata.clone().as_ref());
assert_eq!(app.statusline.state.reasoning_configurable, before);
if levels.is_some() {
app.apply_model_list_refresh(
rho_providers::model::provider_models::ProviderModelRefresh {
provider: app.info.runtime.provider.clone(),
models: vec![],
},
);
} else {
app.statusline.update_model_metadata(Some(&metadata));
}
assert_eq!(
app.statusline.state.reasoning_configurable,
levels.is_some()
);
}
});
}
#[tokio::test(flavor = "current_thread")]
async fn startup_catalog_completion_preserves_mcp_picker() {
use crate::{
app::interactive_runtime::test_startup_runtime,
tools::mcp::{
report::{ConnectedServerReport, McpLiveServerState},
McpServerReport, McpSessionReport, McpTransportSummary,
},
tui::ComposerMode,
};
use rho_providers::model::models_dev::{
age_catalog_snapshot_for_tests, write_cached_model_metadata_for_tests,
ModelsDevCacheDirGuard,
};
let home = tempfile::tempdir().unwrap();
let _cache_dir = ModelsDevCacheDirGuard::new(home.path().to_path_buf());
let mut app = crate::tui::tests::test_app();
let mut metadata = ModelMetadata {
reasoning_capabilities_known: true,
reasoning_metadata_complete: true,
..ModelMetadata::default()
};
write_cached_model_metadata_for_tests(
&app.info.runtime.provider,
&app.info.runtime.model,
&metadata,
);
age_catalog_snapshot_for_tests();
app.statusline = StatusLine::new(&app.info.runtime);
assert!(!app.statusline.state.reasoning_configurable);
app.mcp_report = McpSessionReport {
servers: ["docs", "search"]
.map(|identity| {
McpServerReport::connected(ConnectedServerReport {
identity: identity.into(),
transport: McpTransportSummary::Stdio {
command: "fixture".into(),
args: Vec::new(),
},
tools: Vec::new(),
instructions: None,
live: McpLiveServerState::default(),
filtered_out_count: 0,
collision_skipped_count: 0,
})
})
.to_vec(),
..Default::default()
};
app.execute_mcp_command().unwrap();
let ComposerMode::Picker(picker) = app.input_ui.composer_mut() else {
panic!("expected MCP picker");
};
picker.filter = "search".into();
picker.selected = 2;
let cursor = (picker.filter.clone(), picker.selected);
let stored = crate::session::Session::create_in_root(home.path(), home.path()).unwrap();
let (release, catalog) = tokio::sync::oneshot::channel();
let (finished, completion) = tokio::sync::oneshot::channel();
let mut agent = test_startup_runtime(home.path(), Some(stored), async move {
catalog.await.unwrap();
finished.send(()).unwrap();
0
})
.await;
app.start_model_metadata_fetch(&mut agent);
assert!(!app
.tasks
.contains(|id| { *id == crate::tui::background_tasks::TaskId::ModelMetadata }));
assert!(!app.poll_startup_hydrates(&mut agent).await.unwrap());
metadata.supported_reasoning_levels = Some(vec![ReasoningLevel::High]);
write_cached_model_metadata_for_tests(
&app.info.runtime.provider,
&app.info.runtime.model,
&metadata,
);
release.send(()).unwrap();
completion.await.unwrap();
assert!(app.poll_startup_hydrates(&mut agent).await.unwrap());
assert!(app.statusline.state.reasoning_configurable);
let ComposerMode::Picker(picker) = app.input_ui.composer() else {
panic!("expected MCP picker");
};
assert!(picker.is_mcp_inventory());
assert_eq!((picker.filter.clone(), picker.selected), cursor);
assert!(!app.poll_startup_hydrates(&mut agent).await.unwrap());
agent.shutdown().await;
}
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.state.zen_mode = true;
statusline.update_usage(
None,
Some(&ContextUsage::estimated(1_000, Some(10_000))),
12_500,
);
statusline.update_average_generation_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, _) = status_fields_line(&left, &right, 10, None);
assert_eq!(display_width(&line_text(&line)), 10);
}
fn painted_field_columns(
line: &Line<'_>,
fields: &[StatusField],
width: usize,
) -> Vec<(FieldKey, std::ops::Range<usize>)> {
let mut column = 0;
let mut painted = Vec::new();
let mut remaining = fields.iter().filter(|field| field.action.is_some());
let mut next = remaining.next();
for span in &line.spans {
let span_width = display_width(span.content.as_ref());
if let Some(field) = next.filter(|field| field.text == span.content.as_ref()) {
let columns = column.min(width)..(column + span_width).min(width);
if !columns.is_empty() {
painted.push((field.key, columns));
}
next = remaining.next();
}
column += span_width;
}
painted
}
#[test]
fn field_hits_match_painted_columns_across_widths() {
let statusline = fully_populated_statusline();
for width in [100, 66, 51, 45, 36, 27, 20, 12, 6] {
let (left, right) = pack_bottom_status(&statusline.state, width);
let fields = left.iter().chain(&right).cloned().collect::<Vec<_>>();
let (line, hits) = status_fields_line(&left, &right, width, None);
let recorded = hits
.into_iter()
.map(|hit| (hit.key, hit.columns))
.collect::<Vec<_>>();
assert_eq!(
recorded,
painted_field_columns(&line, &fields, width),
"width {width}"
);
}
}
#[test]
fn hover_lifts_only_the_hovered_clickable_field() {
let _guard = crate::tui::theme::theme_test_lock();
let mut statusline = fully_populated_statusline();
let before = statusline.lines(100, None)[FIELDS_ROW].clone();
let model = statusline
.cache
.hits
.iter()
.find(|hit| hit.key == FieldKey::Model)
.cloned()
.expect("model field is clickable at width 100");
statusline.set_hovered_column(Some(model.columns.start));
let after = statusline.lines(100, None)[FIELDS_ROW].clone();
let changed = before
.spans
.iter()
.zip(&after.spans)
.filter(|(before, after)| before.style != after.style)
.map(|(_, after)| after.content.to_string())
.collect::<Vec<_>>();
assert_eq!(changed.len(), 1, "{changed:?}");
assert_eq!(display_width(&changed[0]), model.columns.len());
assert_eq!(line_text(&before), line_text(&after));
let text = line_text(&after);
let zen_byte = text.find(" zen ").expect("zen is painted") + 1;
let zen_column = display_width(&text[..zen_byte]);
statusline.set_hovered_column(Some(zen_column));
assert_eq!(statusline.state.hovered, None);
}
#[test]
fn context_usage_style_escalates_with_fill() {
let _guard = crate::tui::theme::theme_test_lock();
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_bypass_as_warning() {
let _guard = crate::tui::theme::theme_test_lock();
assert_eq!(permission_style(PermissionMode::Bypass), Theme::warning());
assert_eq!(permission_style(PermissionMode::Auto), Theme::dim());
assert_eq!(permission_style(PermissionMode::Plan), Theme::dim());
assert_eq!(permission_style(PermissionMode::Supervised), Theme::dim());
}
#[test]
fn bypass_permission_and_high_context_use_warning_styles() {
let _guard = crate::tui::theme::theme_test_lock();
let mut statusline = StatusLine::new(&test_info(PathBuf::from("/tmp/project")));
statusline.state.permission_mode = PermissionMode::Bypass;
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, "Bypass"),
Some(Theme::warning()),
"Bypass 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 context_without_known_window_still_shows_tokens() {
let _guard = crate::tui::theme::theme_test_lock();
let mut statusline = StatusLine::new(&test_info(PathBuf::from("/tmp/project")));
statusline.update_usage(None, Some(&ContextUsage::estimated(1_234, None)), 0);
let line = statusline.lines(80, None)[1].clone();
assert_eq!(
span_style(&line, "1.2K"),
Some(Theme::dim()),
"token count must render dim without a fill percent: {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::Zen,
FieldKey::Provider,
FieldKey::Model,
FieldKey::Reasoning,
],
"wide row keeps every present field"
);
let after_reasoning = packed_keys(state, 66);
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, 51);
assert!(
!after_rate.contains(&FieldKey::Rate) && !after_rate.contains(&FieldKey::Reasoning),
"rate drops next: {after_rate:?}"
);
assert!(
after_rate.contains(&FieldKey::Zen),
"zen outranks rate: {after_rate:?}"
);
let after_zen = packed_keys(state, 45);
assert!(
!after_zen.contains(&FieldKey::Zen),
"zen drops before provider: {after_zen:?}"
);
assert!(
after_zen.contains(&FieldKey::Provider),
"provider outranks zen: {after_zen:?}"
);
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],
"ambient context drops before model: {after_context:?}"
);
let mut at_warning = fully_populated_statusline();
at_warning.update_usage(
None,
Some(&ContextUsage::estimated(7_500, Some(10_000))),
12_500,
);
assert_eq!(
packed_keys(&at_warning.state, 20),
vec![FieldKey::Context, FieldKey::Permission],
"context at warning threshold outranks model at narrow width"
);
let mut below_warning = fully_populated_statusline();
below_warning.update_usage(
None,
Some(&ContextUsage::estimated(7_499, Some(10_000))),
12_500,
);
assert_eq!(
packed_keys(&below_warning.state, 20),
vec![FieldKey::Permission, FieldKey::Model],
"context below warning threshold still drops before model"
);
let mut unknown_after_compaction = fully_populated_statusline();
unknown_after_compaction.update_usage(
None,
Some(&ContextUsage::unknown_after_compaction(Some(10_000))),
12_500,
);
assert_eq!(
packed_keys(&unknown_after_compaction.state, 15),
vec![FieldKey::Context, FieldKey::Permission],
"unknown-after-compaction warning keeps context above model"
);
assert_eq!(
packed_keys(state, 12),
vec![FieldKey::Permission],
"permission is kept last"
);
let mut connected = fully_populated_statusline();
connected.state.permission_mode = PermissionMode::Bypass;
connected.update_computer(ComputerUseStatus::Connected);
for (width, expected) in [
(20, vec![FieldKey::Permission, FieldKey::Computer]),
(18, vec![FieldKey::Computer]),
(11, vec![FieldKey::Computer]),
] {
assert_eq!(
packed_keys(&connected.state, width),
expected,
"width {width}"
);
}
}
#[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 _guard = crate::tui::theme::theme_test_lock();
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 _guard = crate::tui::theme::theme_test_lock();
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 fit_cwd_row_drops_extra_before_branch() {
let path = "proj";
let branch = Some("main");
let extra = Some(" #12");
let cases = [
(15, "proj (main)", true),
(14, "proj (main)", false),
(10, "proj", false),
(0, "", false),
];
for (width, left, show_extra) in cases {
assert_eq!(
fit_cwd_row(path, branch, extra, width),
(left.to_string(), show_extra),
"width {width}"
);
}
}
#[test]
fn git_branch_is_cached_until_explicit_refresh() {
use std::fs;
let _guard = crate::tui::theme::theme_test_lock();
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() {
for (case, path, width, expected) in [
(
"rooted two segments",
"~/work/company/services/api-gateway",
24,
"~/…/services/api-gateway",
),
(
"rooted last segment",
"~/work/company/services/api-gateway",
18,
"~/…/api-gateway",
),
(
"unrooted last segment",
"/tmp/claude-1000/home-emgym-herdr-work",
23,
"…/home-emgym-herdr-work",
),
(
"absolute last segment",
"/tmp/claude-1000/projects/api-gateway",
20,
"/…/api-gateway",
),
(
"overlong last segment keeps end",
"~/work/company/very-long-service-name",
14,
"…-service-name",
),
] {
assert_eq!(shorten_path_display(path, width), expected, "{case}");
}
}
#[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}");
}