use super::build_update_label;
use crate::app::GroupBy;
#[test]
fn label_fits_fully() {
let label = build_update_label("2.7.0", Some("New feature"), "purple update", 80);
assert_eq!(label, " v2.7.0: New feature (run purple update) ");
}
#[test]
fn label_no_headline() {
let label = build_update_label("2.7.0", None, "purple update", 80);
assert_eq!(label, " v2.7.0 available, run purple update ");
}
#[test]
fn label_truncates_at_various_widths() {
use unicode_width::UnicodeWidthStr;
let hl = "Provider metadata uses provider-specific terminology (instance, vm_size, zone, location, image, specs)";
let hint = "purple update";
let full = " v2.7.0: Provider metadata uses provider-specific terminology (instance, vm_size, zone, location, image, specs) (run purple update) ";
assert_eq!(full.width(), 132);
assert_eq!(build_update_label("2.7.0", Some(hl), hint, 136), full);
let label_80 = build_update_label("2.7.0", Some(hl), hint, 80);
assert!(
label_80.contains('\u{2026}'),
"Should contain ellipsis: {}",
label_80
);
assert!(label_80.contains("(run purple update)"));
assert!(
label_80.width() <= 76,
"Should fit in budget: width={}",
label_80.width()
);
let label_60 = build_update_label("2.7.0", Some(hl), hint, 60);
assert!(label_60.contains('\u{2026}'));
assert!(label_60.contains("(run purple update)"));
assert!(
label_60.width() <= 56,
"Should fit in budget: width={}",
label_60.width()
);
assert!(label_60.width() < label_80.width());
assert_eq!(
build_update_label("2.7.0", Some(hl), hint, 30),
" v2.7.0 available, run purple update "
);
}
#[test]
fn label_falls_back_when_very_narrow() {
let label = build_update_label("2.7.0", Some("Headline"), "purple update", 30);
assert_eq!(label, " v2.7.0 available, run purple update ");
}
#[test]
fn label_brew_hint() {
let label = build_update_label(
"2.7.0",
Some("Fix"),
"brew upgrade erickochen/purple/purple",
80,
);
assert_eq!(
label,
" v2.7.0: Fix (run brew upgrade erickochen/purple/purple) "
);
}
#[test]
fn label_zero_width() {
let label = build_update_label("2.7.0", Some("Headline"), "purple update", 0);
assert_eq!(label, " v2.7.0 available, run purple update ");
}
use super::{Columns, HOST_MIN, MARKER_WIDTH, footer_spans, pattern_footer_spans};
#[test]
fn test_padded_zero() {
assert_eq!(Columns::padded(0), 0);
}
#[test]
fn test_padded_nonzero() {
assert_eq!(Columns::padded(10), 12);
}
#[test]
fn test_columns_collapse_priority_last_then_tags_then_address() {
let cols = Columns::compute(
10, 20, 10, 6, 60, false,
);
assert_eq!(
cols.history, 0,
"LAST should be hidden first when too narrow"
);
assert!(
cols.tags > 0,
"Tags should still be present after LAST is hidden"
);
}
#[test]
fn test_columns_compute_flex_gap() {
let cols = Columns::compute(
10, 15, 8, 5, 200, false,
);
assert!(
cols.flex_gap > 0,
"flex_gap should be positive with wide content"
);
let gap = if 200 >= 120 { 3 } else { 2 };
let left = MARKER_WIDTH + 1 + 2 + cols.alias + gap + cols.host; let mut right = 0;
if cols.tags > 0 {
right += cols.tags;
}
if cols.history > 0 {
right += cols.history;
}
let right_cols = [cols.tags > 0, cols.history > 0]
.iter()
.filter(|&&b| b)
.count();
let right_gaps = if right_cols > 1 {
(right_cols - 1) * gap
} else {
0
};
assert_eq!(
cols.flex_gap,
200usize.saturating_sub(left + right + right_gaps)
);
}
#[test]
fn test_columns_compute_host_shrinks() {
let cols = Columns::compute(
8, 30, 0, 0, 40, false,
);
assert!(
cols.host >= HOST_MIN,
"Host should stay >= HOST_MIN ({}), got {}",
HOST_MIN,
cols.host
);
assert!(
cols.host < 34,
"Host should have shrunk from padded value (34), got {}",
cols.host
);
}
#[test]
fn test_footer_no_grouped_indicator() {
let spans = footer_spans(false, false, false);
let text: String = spans.iter().map(|s| s.content.to_string()).collect();
assert!(
!text.contains("grouped"),
"Footer should NOT contain 'grouped' indicator, got: {}",
text
);
}
#[test]
fn footer_shows_core_actions() {
let spans = footer_spans(false, false, false);
let text: String = spans.iter().map(|s| s.content.to_string()).collect();
assert!(text.contains("Enter"));
assert!(text.contains("connect"));
assert!(text.contains("/"));
assert!(text.contains("search"));
assert!(text.contains("#"));
assert!(text.contains("tag"));
assert!(text.contains("v"));
}
#[test]
fn footer_view_label_detail_when_compact() {
let spans = footer_spans(false, false, false);
let text: String = spans.iter().map(|s| s.content.to_string()).collect();
assert!(text.contains("detail"));
}
#[test]
fn footer_view_label_compact_when_detail() {
let spans = footer_spans(true, false, false);
let text: String = spans.iter().map(|s| s.content.to_string()).collect();
assert!(text.contains("compact"));
}
#[test]
fn footer_down_only_indicator() {
let spans = footer_spans(false, true, false);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("DOWN ONLY"));
}
#[test]
fn footer_selection_active_replaces_view_hints_with_bulk_actions() {
let spans = footer_spans(false, false, true);
let text: String = spans.iter().map(|s| s.content.to_string()).collect();
assert!(text.contains("bulk tag"), "missing bulk tag hint: {text}");
assert!(text.contains(" run "), "missing run hint: {text}");
assert!(text.contains("clear"), "missing clear hint: {text}");
assert!(text.contains("help"), "missing help hint: {text}");
assert!(
!text.contains(" cmds "),
"selection footer should drop cmds"
);
assert!(
!text.contains(" detail "),
"selection footer should drop view label"
);
}
#[test]
fn brand_label_purple_when_ungrouped_hosts_when_grouped() {
use super::brand_label_for_group;
assert_eq!(brand_label_for_group(&GroupBy::None), " purple ");
assert_eq!(brand_label_for_group(&GroupBy::Provider), " HOSTS ");
assert_eq!(
brand_label_for_group(&GroupBy::Tag("env".to_string())),
" HOSTS "
);
}
#[test]
fn pattern_footer_shows_core_actions() {
let spans = pattern_footer_spans(false);
let text: String = spans.iter().map(|s| s.content.to_string()).collect();
assert!(text.contains("/"));
assert!(text.contains("search"));
assert!(text.contains("#"));
assert!(text.contains("v"));
}
#[test]
fn pattern_footer_detail_label_when_compact() {
let spans = pattern_footer_spans(false);
let text: String = spans.iter().map(|s| s.content.to_string()).collect();
assert!(text.contains("detail"));
}
#[test]
fn layout_has_group_bar_and_footer() {
use ratatui::layout::{Constraint, Layout, Rect};
let area = Rect::new(0, 0, 120, 40);
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), ])
.split(area);
assert_eq!(chunks[0].height, 3, "group bar should be 3 rows");
assert_eq!(chunks[2].height, 1, "footer should be 1 row");
assert!(chunks[2].y > chunks[1].y + chunks[1].height - 1);
}
#[test]
fn layout_no_group_bar_when_ungrouped() {
use ratatui::layout::{Constraint, Layout, Rect};
let area = Rect::new(0, 0, 120, 40);
let chunks = Layout::vertical([
Constraint::Length(0), Constraint::Min(5), Constraint::Length(1), ])
.split(area);
assert_eq!(chunks[0].height, 0, "group bar should be hidden");
assert_eq!(
chunks[1].height, 39,
"host list should get all remaining rows"
);
}
#[test]
fn layout_with_search_has_group_bar() {
use ratatui::layout::{Constraint, Layout, Rect};
let area = Rect::new(0, 0, 120, 40);
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
assert_eq!(chunks[0].height, 3, "group bar should be 3 rows");
assert_eq!(chunks[2].height, 1, "search bar should be 1 row");
assert_eq!(chunks[3].height, 1, "footer should be 1 row");
}
#[test]
fn columns_hide_full_priority_chain() {
let cols_wide = Columns::compute(10, 15, 8, 5, 200, false);
assert!(cols_wide.history > 0, "history visible at 200");
assert!(cols_wide.tags > 0, "tags visible at 200");
assert!(cols_wide.host > 0, "host visible at 200");
let cols_no_history = Columns::compute(10, 15, 8, 5, 50, false);
assert_eq!(cols_no_history.history, 0, "history should hide first");
let cols_no_tags = Columns::compute(10, 15, 8, 5, 40, false);
assert_eq!(cols_no_tags.history, 0, "history still hidden");
assert_eq!(cols_no_tags.tags, 0, "tags should hide second");
let cols_no_host = Columns::compute(10, 15, 8, 5, 20, false);
assert_eq!(cols_no_host.history, 0);
assert_eq!(cols_no_host.tags, 0);
assert_eq!(cols_no_host.host, 0, "host should hide last");
}
#[test]
fn columns_detail_mode_no_host() {
let cols = Columns::compute(10, 15, 8, 5, 200, true);
assert_eq!(cols.host, 0, "host should be 0 in detail_mode");
assert!(cols.detail_mode, "detail_mode flag should be set");
assert!(cols.tags > 0, "tags visible in detail_mode");
assert!(cols.history > 0, "history visible in detail_mode");
}
#[test]
fn format_rtt_millis() {
assert_eq!(super::format_rtt(42), "42ms");
}
#[test]
fn format_rtt_zero() {
assert_eq!(super::format_rtt(0), "0ms");
}
#[test]
fn format_rtt_boundary_999() {
assert_eq!(super::format_rtt(999), "999ms");
}
#[test]
fn format_rtt_boundary_1000() {
assert_eq!(super::format_rtt(1000), "1.0s");
}
#[test]
fn format_rtt_seconds() {
assert_eq!(super::format_rtt(1500), "1.5s");
}
#[test]
fn format_rtt_capped() {
assert_eq!(super::format_rtt(12000), "10s+");
}
#[test]
fn format_rtt_boundary_9949() {
assert_eq!(super::format_rtt(9949), "9.9s");
}
#[test]
fn format_rtt_boundary_9950() {
assert_eq!(super::format_rtt(9950), "10s+");
}
#[test]
fn format_rtt_boundary_10000() {
assert_eq!(super::format_rtt(10000), "10s+");
}
#[test]
fn format_rtt_u32_max() {
assert_eq!(super::format_rtt(u32::MAX), "10s+");
}
#[test]
fn composite_host_label_hostname_only() {
let host = crate::ssh_config::model::HostEntry {
hostname: "example.com".to_string(),
port: 22,
..Default::default()
};
assert_eq!(super::composite_host_label(&host), "example.com");
}
#[test]
fn composite_host_label_non_default_port() {
let host = crate::ssh_config::model::HostEntry {
hostname: "example.com".to_string(),
port: 2222,
..Default::default()
};
assert_eq!(super::composite_host_label(&host), "example.com:2222");
}
#[test]
fn composite_host_label_no_user_prefix() {
let host = crate::ssh_config::model::HostEntry {
hostname: "example.com".to_string(),
user: "admin".to_string(),
port: 22,
..Default::default()
};
let label = super::composite_host_label(&host);
assert!(
!label.contains('@'),
"composite label should not include user@"
);
assert_eq!(label, "example.com");
}
#[test]
fn composite_host_width_default_port() {
let host = crate::ssh_config::model::HostEntry {
hostname: "example.com".to_string(),
port: 22,
..Default::default()
};
assert_eq!(super::composite_host_width(&host), "example.com".len());
}
#[test]
fn composite_host_width_non_default_port() {
let host = crate::ssh_config::model::HostEntry {
hostname: "example.com".to_string(),
port: 2222,
..Default::default()
};
assert_eq!(super::composite_host_width(&host), 16);
}
#[test]
fn composite_host_width_port_zero() {
let host = crate::ssh_config::model::HostEntry {
hostname: "host".to_string(),
port: 0,
..Default::default()
};
assert_eq!(super::composite_host_width(&host), 6);
}
#[test]
fn composite_host_width_port_max() {
let host = crate::ssh_config::model::HostEntry {
hostname: "h".to_string(),
port: 65535,
..Default::default()
};
assert_eq!(super::composite_host_width(&host), 7);
}
#[test]
fn columns_detail_mode_collapse_priority() {
let cols_wide = Columns::compute(10, 15, 8, 5, 100, true);
assert_eq!(cols_wide.host, 0, "detail_mode: no host");
assert!(cols_wide.tags > 0, "tags visible at 100");
assert!(cols_wide.history > 0, "history visible at 100");
let cols_narrow = Columns::compute(10, 15, 8, 5, 25, true);
assert_eq!(cols_narrow.host, 0);
assert_eq!(
cols_narrow.history, 0,
"history should hide first in detail_mode"
);
let cols_very_narrow = Columns::compute(10, 15, 8, 5, 18, true);
assert_eq!(cols_very_narrow.host, 0);
assert_eq!(cols_very_narrow.history, 0);
assert_eq!(cols_very_narrow.tags, 0, "tags should hide after history");
}
#[test]
fn tag_bar_empty_input_shows_cursor_then_placeholder() {
let spans = super::tag_bar_spans("", &[]);
let texts: Vec<&str> = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(texts[0], " tags: ");
assert_eq!(texts[1], "_");
assert!(texts[2].contains("e.g."));
}
#[test]
fn tag_bar_with_input_shows_input_then_cursor() {
let spans = super::tag_bar_spans("prod, staging", &[]);
let texts: Vec<&str> = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(texts[0], " tags: ");
assert_eq!(texts[1], "prod, staging");
assert_eq!(texts[2], "_");
assert_eq!(texts.len(), 3);
}
#[test]
fn tag_bar_with_provider_tags_shows_prefix() {
let ptags = vec!["cloud".to_string(), "eu".to_string()];
let spans = super::tag_bar_spans("web", &ptags);
let texts: Vec<&str> = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(texts[0], " tags: ");
assert_eq!(texts[1], "[cloud, eu] ");
assert_eq!(texts[2], "web");
assert_eq!(texts[3], "_");
}
use super::{HostItemContext, build_host_item};
use crate::ssh_config::model::HostEntry;
fn detail_columns(alias: usize) -> Columns {
Columns {
alias,
host: 0,
tags: 0,
history: 0,
gap: 2,
flex_gap: 0,
detail_mode: true,
}
}
fn full_columns(alias: usize, host: usize) -> Columns {
Columns {
alias,
host,
tags: 0,
history: 0,
gap: 2,
flex_gap: 0,
detail_mode: false,
}
}
fn render_item_to_string(item: ratatui::widgets::ListItem<'_>, width: u16) -> String {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::{List, Widget};
let area = Rect::new(0, 0, width, 1);
let mut buf = Buffer::empty(area);
let list = List::new(vec![item]);
list.render(area, &mut buf);
let mut s = String::new();
for x in 0..width {
let cell = &buf[(x, 0)];
s.push_str(cell.symbol());
}
s
}
fn make_ctx<'a>(cols: &'a Columns, tunnel_active: bool, detail_mode: bool) -> HostItemContext<'a> {
let ping = Box::leak(Box::new(std::collections::HashMap::new()));
let history = Box::leak(Box::new(crate::history::ConnectionHistory::default()));
HostItemContext {
ping_status: ping,
history,
tunnel_active,
query: None,
cols,
multi_selected: false,
group_by: &GroupBy::None,
detail_mode,
spinner_tick: 0,
}
}
#[test]
fn detail_mode_no_indicators() {
let cols = detail_columns(20);
let host = HostEntry {
alias: "web-server".into(),
..Default::default()
};
let ctx = make_ctx(&cols, false, true);
let item = build_host_item(&host, &ctx);
let rendered = render_item_to_string(item, 30);
assert!(rendered.contains("web-server"));
assert!(!rendered.contains('\u{2197}'), "no jump indicator expected");
assert!(
!rendered.contains('\u{21C4}'),
"no tunnel indicator expected"
);
}
#[test]
fn detail_mode_jump_indicator_visible() {
let cols = detail_columns(20);
let host = HostEntry {
alias: "bastion".into(),
proxy_jump: "gateway".into(),
..Default::default()
};
let ctx = make_ctx(&cols, false, true);
let item = build_host_item(&host, &ctx);
let rendered = render_item_to_string(item, 30);
assert!(rendered.contains("bastion"));
assert!(rendered.contains('\u{2197}'), "jump indicator missing");
assert!(
!rendered.contains('\u{21C4}'),
"tunnel indicator should not appear"
);
}
#[test]
fn detail_mode_tunnel_indicator_visible() {
let cols = detail_columns(20);
let host = HostEntry {
alias: "db-primary".into(),
tunnel_count: 1,
..Default::default()
};
let ctx = make_ctx(&cols, false, true);
let item = build_host_item(&host, &ctx);
let rendered = render_item_to_string(item, 30);
assert!(rendered.contains("db-primary"));
assert!(rendered.contains('\u{21C4}'), "tunnel indicator missing");
assert!(
!rendered.contains('\u{2197}'),
"jump indicator should not appear"
);
}
#[test]
fn detail_mode_both_indicators_truncate_alias() {
let cols = detail_columns(20);
let host = HostEntry {
alias: "very-long-hostname-here".into(),
proxy_jump: "gateway".into(),
tunnel_count: 2,
..Default::default()
};
let ctx = make_ctx(&cols, false, true);
let item = build_host_item(&host, &ctx);
let rendered = render_item_to_string(item, 30);
assert!(rendered.contains('\u{2197}'), "jump indicator missing");
assert!(rendered.contains('\u{21C4}'), "tunnel indicator missing");
assert!(
!rendered.contains("very-long-hostname-here"),
"alias should be truncated"
);
}
#[test]
fn non_detail_mode_indicators_in_address_column() {
let cols = full_columns(20, 30);
let host = HostEntry {
alias: "bastion".into(),
hostname: "10.0.0.1".into(),
proxy_jump: "gateway".into(),
tunnel_count: 1,
..Default::default()
};
let ctx = make_ctx(&cols, true, false);
let item = build_host_item(&host, &ctx);
let rendered = render_item_to_string(item, 60);
assert!(rendered.contains('\u{2197}'), "jump indicator missing");
assert!(rendered.contains('\u{21C4}'), "tunnel indicator missing");
assert!(
rendered.contains("10.0.0.1"),
"hostname should appear in address column"
);
}