use super::*;
use crate::containers::{ContainerCacheEntry, ContainerInfo, ContainerRuntime};
use std::collections::HashMap;
type RawContainer<'a> = (&'a str, &'a str, &'a str, &'a str);
type RawCacheEntry<'a> = (&'a str, &'a [RawContainer<'a>]);
fn cache_with(entries: &[RawCacheEntry<'_>]) -> HashMap<String, ContainerCacheEntry> {
let mut map = HashMap::new();
for (alias, items) in entries {
let containers = items
.iter()
.map(|(id, name, image, state)| ContainerInfo {
id: id.to_string(),
names: name.to_string(),
image: image.to_string(),
state: state.to_string(),
status: "Up 5 minutes".to_string(),
ports: String::new(),
})
.collect();
map.insert(
alias.to_string(),
ContainerCacheEntry {
timestamp: 0,
runtime: ContainerRuntime::Docker,
engine_version: None,
containers,
},
);
}
map
}
fn app_with_cache(cache: HashMap<String, ContainerCacheEntry>) -> App {
let mut app = crate::demo::build_demo_app();
app.container_state.set_cache(cache);
app
}
#[test]
fn alpha_host_sort_orders_by_host_then_name() {
let cache = cache_with(&[
("zeus", &[("1", "alpha", "img", "running")]),
(
"apollo",
&[
("2", "zebra", "img", "running"),
("3", "ant", "img", "exited"),
],
),
]);
let app = app_with_cache(cache);
let rows = visible_rows(&app);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].alias, "apollo");
assert_eq!(rows[0].name, "ant");
assert_eq!(rows[1].alias, "apollo");
assert_eq!(rows[1].name, "zebra");
assert_eq!(rows[2].alias, "zeus");
}
#[test]
fn alpha_container_sort_orders_by_name_then_host() {
let cache = cache_with(&[
("zeus", &[("1", "alpha", "img", "running")]),
("apollo", &[("2", "zebra", "img", "running")]),
]);
let mut app = app_with_cache(cache);
app.containers_overview.sort_mode = ContainersSortMode::AlphaContainer;
let rows = visible_rows(&app);
assert_eq!(rows[0].name, "alpha");
assert_eq!(rows[1].name, "zebra");
}
#[test]
fn search_filters_on_alias_name_or_image() {
let cache = cache_with(&[
("zeus", &[("1", "alpha", "redis:7", "running")]),
("apollo", &[("2", "zebra", "postgres:16", "exited")]),
]);
let mut app = app_with_cache(cache);
app.search.set_query(Some("postgres".to_string()));
let rows = visible_rows(&app);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].name, "zebra");
app.search.set_query(Some("ZEUS".to_string()));
let rows = visible_rows(&app);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].alias, "zeus");
}
#[test]
fn empty_search_query_returns_everything() {
let cache = cache_with(&[("zeus", &[("1", "alpha", "img", "running")])]);
let mut app = app_with_cache(cache);
app.search.set_query(Some(String::new()));
let rows = visible_rows(&app);
assert_eq!(rows.len(), 1);
}
#[test]
fn clean_name_strips_docker_leading_slash() {
assert_eq!(clean_name("/web"), "web");
assert_eq!(clean_name("web"), "web");
}
#[test]
fn is_running_is_case_insensitive() {
assert!(is_running("running"));
assert!(is_running("Running"));
assert!(!is_running("exited"));
assert!(!is_running(""));
}
#[test]
fn format_iso_timestamp_strips_t_and_fraction() {
assert_eq!(
format_iso_timestamp("2026-05-09T08:00:00Z"),
Some("2026-05-09 08:00:00".to_string())
);
assert_eq!(
format_iso_timestamp("2026-05-09T08:00:00.123456789Z"),
Some("2026-05-09 08:00:00".to_string())
);
}
#[test]
fn format_iso_timestamp_rejects_empty_and_zero_time() {
assert_eq!(format_iso_timestamp(""), None);
assert_eq!(format_iso_timestamp("0001-01-01T00:00:00Z"), None);
}
#[test]
fn pad_or_truncate_pads_short_strings() {
assert_eq!(pad_or_truncate("hi", 5), "hi ");
}
#[test]
fn pad_or_truncate_truncates_long_strings() {
let out = pad_or_truncate("abcdefghij", 5);
assert_eq!(out.chars().count(), 5);
assert!(out.ends_with('…'));
}
fn col_row(name: &str, image: &str) -> ContainerRow {
ContainerRow {
id: format!("id-{}", name),
alias: "h".to_string(),
name: name.to_string(),
image: image.to_string(),
state: "running".to_string(),
status: "Up 1m".to_string(),
ports: String::new(),
uptime: Some("1m".to_string()),
cache_timestamp: 0,
}
}
#[test]
fn compute_columns_enables_uptime_when_wide_enough() {
let rows = [col_row("svc", "img:1")];
let cols = compute_columns(rows.iter(), 200, false);
assert!(cols.show_uptime);
}
#[test]
fn compute_columns_keeps_uptime_at_modest_width() {
let rows = [col_row(
"very-long-container-name-here",
"registry.example.com/long/image:v1",
)];
let cols = compute_columns(rows.iter(), 75, false);
assert!(cols.show_uptime, "UPTIME survives modest widths");
}
#[test]
fn compute_columns_flexes_image_to_anchor_uptime_right() {
let rows = [col_row("svc", "img:1")];
let cols = compute_columns(rows.iter(), 200, false);
let consumed =
HIGHLIGHT_W + MARKER_W + STATUS_DOT_W + cols.name + GAP_W + cols.image + GAP_W + UPTIME_W;
assert_eq!(consumed, 200, "rendered row spans full content width");
assert!(cols.image > IMAGE_MIN, "image flexed beyond minimum");
}
#[test]
fn compute_columns_flexes_image_with_host_column_visible() {
let rows = [col_row("svc", "img:1")];
let cols = compute_columns(rows.iter(), 200, true);
let consumed = HIGHLIGHT_W
+ MARKER_W
+ STATUS_DOT_W
+ cols.host
+ GAP_W
+ cols.name
+ GAP_W
+ cols.image
+ GAP_W
+ UPTIME_W;
assert_eq!(
consumed, 200,
"rendered row with HOST column spans full content width"
);
}
#[test]
fn compute_columns_drops_uptime_at_extreme_width() {
let rows = [col_row("svc", "img")];
let cols = compute_columns(rows.iter(), 35, false);
assert!(!cols.show_uptime);
}
#[test]
fn state_glyph_running_with_unhealthy_health_uses_error_tier() {
let (glyph, _) = state_glyph("running", Some("unhealthy"), "Up 1m", None, 0);
assert_eq!(glyph, design::ICON_ONLINE);
}
#[test]
fn state_glyph_dead_state_uses_error_glyph() {
let (glyph, _) = state_glyph("dead", None, "Dead", None, 0);
assert_eq!(glyph, design::ICON_ERROR);
}
#[test]
fn state_glyph_exited_with_nonzero_code_uses_error_glyph() {
let (glyph, _) = state_glyph("exited", None, "Exited (137) 2h ago", None, 0);
assert_eq!(glyph, design::ICON_ERROR);
}
#[test]
fn state_glyph_exited_with_zero_code_uses_hollow_circle() {
let (glyph, _) = state_glyph("exited", None, "Exited (0) 1m ago", None, 0);
assert_eq!(glyph, design::ICON_STOPPED);
}
#[test]
fn state_glyph_paused_uses_half_circle() {
let (glyph, _) = state_glyph("paused", None, "Paused", None, 0);
assert_eq!(glyph, design::ICON_PAUSED);
}
#[test]
fn state_glyph_running_no_health_pulses_default_dot() {
let (glyph, _) = state_glyph("running", None, "Up 5d", None, 0);
assert_eq!(glyph, design::ICON_ONLINE);
}
#[test]
fn state_glyph_podman_stopped_treated_as_exited() {
let (glyph, _) = state_glyph("stopped", None, "", Some(137), 0);
assert_eq!(glyph, design::ICON_ERROR);
let (glyph, _) = state_glyph("stopped", None, "", Some(0), 0);
assert_eq!(glyph, design::ICON_STOPPED);
}
#[test]
fn state_glyph_podman_exited_empty_status_uses_inspect_exit_code() {
let (glyph, _) = state_glyph("exited", None, "", Some(137), 0);
assert_eq!(glyph, design::ICON_ERROR);
let (glyph, _) = state_glyph("exited", None, "", Some(0), 0);
assert_eq!(glyph, design::ICON_STOPPED);
let (glyph, _) = state_glyph("exited", None, "", None, 0);
assert_eq!(glyph, design::ICON_STOPPED);
}
fn make_container_info(id: &str, state: &str, status: &str) -> crate::containers::ContainerInfo {
crate::containers::ContainerInfo {
id: id.to_string(),
names: "svc".to_string(),
image: "img".to_string(),
state: state.to_string(),
status: status.to_string(),
ports: String::new(),
}
}
fn seed_inspect_exit_code(app: &mut App, id: &str, exit_code: i32) {
use crate::app::InspectCacheEntry;
app.containers_overview.inspect_cache.entries.insert(
id.to_string(),
InspectCacheEntry {
timestamp: 0,
result: Ok(crate::containers::ContainerInspect {
exit_code,
..Default::default()
}),
},
);
}
#[test]
fn container_has_nonzero_exit_docker_status_nonzero() {
let app = app_with_cache(HashMap::new());
let c = make_container_info("c1", "exited", "Exited (137) 2h ago");
assert!(container_has_nonzero_exit(&app, &c));
}
#[test]
fn container_has_nonzero_exit_docker_status_zero() {
let app = app_with_cache(HashMap::new());
let c = make_container_info("c2", "exited", "Exited (0) 1m ago");
assert!(!container_has_nonzero_exit(&app, &c));
}
#[test]
fn container_has_nonzero_exit_podman_empty_status_with_inspect_nonzero() {
let mut app = app_with_cache(HashMap::new());
app.containers_overview.inspect_cache.entries.clear();
seed_inspect_exit_code(&mut app, "c3", 137);
let c = make_container_info("c3", "exited", "");
assert!(container_has_nonzero_exit(&app, &c));
}
#[test]
fn container_has_nonzero_exit_podman_empty_status_no_inspect_is_false() {
let mut app = app_with_cache(HashMap::new());
app.containers_overview.inspect_cache.entries.clear();
let c = make_container_info("c4", "exited", "");
assert!(!container_has_nonzero_exit(&app, &c));
}
#[test]
fn container_has_nonzero_exit_running_state_blocks_inspect_fallback() {
let mut app = app_with_cache(HashMap::new());
app.containers_overview.inspect_cache.entries.clear();
seed_inspect_exit_code(&mut app, "c5", 137);
let c = make_container_info("c5", "running", "");
assert!(!container_has_nonzero_exit(&app, &c));
}
#[test]
fn container_has_nonzero_exit_podman3_stopped_state_uses_fallback() {
let mut app = app_with_cache(HashMap::new());
app.containers_overview.inspect_cache.entries.clear();
seed_inspect_exit_code(&mut app, "c6", 1);
let c = make_container_info("c6", "stopped", "");
assert!(container_has_nonzero_exit(&app, &c));
}
fn cached_fp(app: &App) -> Option<u64> {
app.containers_overview
.view_cache
.borrow()
.as_ref()
.map(|(fp, _)| *fp)
}
#[test]
fn view_cache_starts_empty_and_populates_on_first_call() {
let cache = cache_with(&[("host1", &[("id1", "web", "nginx", "running")])]);
let app = app_with_cache(cache);
*app.containers_overview.view_cache.borrow_mut() = None;
assert!(cached_fp(&app).is_none());
let _ = visible_rows(&app);
assert!(cached_fp(&app).is_some());
}
#[test]
fn view_cache_hits_on_identical_state() {
let cache = cache_with(&[("h", &[("i", "a", "img", "running")])]);
let app = app_with_cache(cache);
*app.containers_overview.view_cache.borrow_mut() = None;
let rows1 = visible_rows(&app);
let fp1 = cached_fp(&app).unwrap();
let rows2 = visible_rows(&app);
let fp2 = cached_fp(&app).unwrap();
assert_eq!(fp1, fp2);
assert_eq!(rows1, rows2);
}
#[test]
fn view_cache_invalidates_on_sort_mode_change() {
let cache = cache_with(&[("h", &[("i", "a", "img", "running")])]);
let mut app = app_with_cache(cache);
*app.containers_overview.view_cache.borrow_mut() = None;
let _ = visible_rows(&app);
let fp_before = cached_fp(&app).unwrap();
app.containers_overview.sort_mode = ContainersSortMode::AlphaContainer;
let _ = visible_rows(&app);
assert_ne!(fp_before, cached_fp(&app).unwrap());
}
#[test]
fn view_cache_invalidates_on_search_query_change() {
let cache = cache_with(&[("h", &[("i", "web", "img", "running")])]);
let mut app = app_with_cache(cache);
*app.containers_overview.view_cache.borrow_mut() = None;
let _ = visible_rows(&app);
let fp_before = cached_fp(&app).unwrap();
app.search.set_query(Some("web".to_string()));
let _ = visible_rows(&app);
assert_ne!(fp_before, cached_fp(&app).unwrap());
}
#[test]
fn view_cache_invalidates_on_container_cache_timestamp_bump() {
let mut app = app_with_cache(cache_with(&[("h", &[("i", "web", "img", "running")])]));
*app.containers_overview.view_cache.borrow_mut() = None;
let _ = visible_rows(&app);
let fp_before = cached_fp(&app).unwrap();
if let Some(entry) = app.container_state.cache_entry_mut("h") {
entry.timestamp += 1;
}
let _ = visible_rows(&app);
assert_ne!(fp_before, cached_fp(&app).unwrap());
}
#[test]
fn view_cache_invalidates_on_collapsed_hosts_toggle() {
let cache = cache_with(&[("h", &[("i", "web", "img", "running")])]);
let mut app = app_with_cache(cache);
*app.containers_overview.view_cache.borrow_mut() = None;
let _ = visible_rows(&app);
let fp_before = cached_fp(&app).unwrap();
app.containers_overview
.collapsed_hosts
.insert("h".to_string());
let _ = visible_rows(&app);
assert_ne!(fp_before, cached_fp(&app).unwrap());
}
#[test]
fn build_detail_lines_running_container_has_no_exit_row() {
let row = ContainerRow {
id: "c1".to_string(),
alias: "web".to_string(),
name: "nginx".to_string(),
image: "nginx:1.25".to_string(),
state: "running".to_string(),
status: "Up 3 hours".to_string(),
ports: "0.0.0.0:80->80/tcp".to_string(),
uptime: Some("3h".to_string()),
cache_timestamp: 0,
};
let inspect = crate::containers::ContainerInspect {
exit_code: 0,
oom_killed: false,
started_at: "2026-05-09T08:00:00Z".to_string(),
finished_at: String::new(),
health: Some("healthy".to_string()),
restart_count: 0,
command: Some(vec!["nginx".to_string(), "-g".to_string()]),
entrypoint: None,
env_count: 5,
mount_count: 1,
networks: vec![],
image_digest: None,
restart_policy: None,
user: None,
privileged: false,
readonly_rootfs: false,
apparmor_profile: None,
seccomp_profile: None,
cap_add: Vec::new(),
cap_drop: Vec::new(),
mounts: Vec::new(),
compose_project: None,
compose_service: None,
..Default::default()
};
let result = Ok(inspect);
let lines = build_detail_lines(&row, Some(&result), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("nginx"));
assert!(text.contains("on web"));
assert!(text.contains("Up 3 hours"));
assert!(text.contains("HEALTH"));
assert!(text.contains("healthy"));
assert!(text.contains("Started"));
assert!(
!text.contains("ATTENTION"),
"running container must not raise ATTENTION card"
);
assert!(
!text.contains("OOM"),
"running container must not show OOM row"
);
assert!(
!text.contains("Stopped"),
"running container must not show Stopped row"
);
}
#[test]
fn build_detail_lines_oom_killed_shows_exit_and_oom() {
let row = ContainerRow {
id: "c2".to_string(),
alias: "db".to_string(),
name: "postgres".to_string(),
image: "postgres:16".to_string(),
state: "exited".to_string(),
status: "Exited (137) 2 minutes ago".to_string(),
ports: String::new(),
uptime: None,
cache_timestamp: 0,
};
let inspect = crate::containers::ContainerInspect {
exit_code: 137,
oom_killed: true,
started_at: "2026-05-09T07:00:00Z".to_string(),
finished_at: "2026-05-09T08:00:00Z".to_string(),
health: None,
restart_count: 3,
command: None,
entrypoint: Some(vec!["/docker-entrypoint.sh".to_string()]),
env_count: 0,
mount_count: 0,
networks: vec![],
image_digest: None,
restart_policy: None,
user: None,
privileged: false,
readonly_rootfs: false,
apparmor_profile: None,
seccomp_profile: None,
cap_add: Vec::new(),
cap_drop: Vec::new(),
mounts: Vec::new(),
compose_project: None,
compose_service: None,
..Default::default()
};
let result = Ok(inspect);
let lines = build_detail_lines(&row, Some(&result), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("Exit"));
assert!(text.contains("137"));
assert!(text.contains("OOM"));
assert!(text.contains("killed"));
assert!(text.contains("Restarts"));
assert!(text.contains("Stopped"));
assert!(text.contains("CMD"));
assert!(text.contains("/docker-entrypoint.sh"));
assert!(!text.contains("Command"));
}
#[test]
fn build_detail_lines_cmd_card_keeps_breathing_room_against_right_border() {
let row = ContainerRow {
id: "c3".to_string(),
alias: "h".to_string(),
name: "svc".to_string(),
image: "i:1".to_string(),
state: "running".to_string(),
status: "Up 1m".to_string(),
ports: String::new(),
uptime: Some("1m".to_string()),
cache_timestamp: 0,
};
let cmd = "a".repeat(200);
let inspect = crate::containers::ContainerInspect {
command: Some(vec![cmd]),
..Default::default()
};
let result = Ok(inspect);
let lines = build_detail_lines(&row, Some(&result), false, 0, 48);
let mut in_cmd_card = false;
let mut content_lines_checked = 0;
for line in &lines {
let raw = line.to_string();
if raw.contains("CMD") && raw.contains("─") {
in_cmd_card = true;
continue;
}
if in_cmd_card {
if raw.starts_with('╰') {
break;
}
if raw.contains('a') {
let trimmed_end = raw.trim_end();
let last_border = trimmed_end
.rfind('│')
.expect("CMD content line ends with right border");
let before_border = &trimmed_end[..last_border];
assert!(
before_border.ends_with(' '),
"CMD card content must keep at least one space before │, got: {raw:?}"
);
content_lines_checked += 1;
}
}
}
assert!(
content_lines_checked > 0,
"expected at least one CMD content line to verify"
);
}
#[test]
fn build_detail_lines_no_inspect_shows_loading_when_in_flight() {
let row = ContainerRow {
id: "c3".to_string(),
alias: "host".to_string(),
name: "demo".to_string(),
image: "img".to_string(),
state: "running".to_string(),
status: "Up 1m".to_string(),
ports: String::new(),
uptime: Some("1m".to_string()),
cache_timestamp: 0,
};
let lines = build_detail_lines(&row, None, true, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("loading"));
}
#[test]
fn build_detail_lines_renders_audit_fields_when_inspect_present() {
let row = ContainerRow {
id: "abcdef0123456789".to_string(),
alias: "audit-host".to_string(),
name: "auth-svc".to_string(),
image: "auth:1.2.3".to_string(),
state: "running".to_string(),
status: "Up 5 weeks (healthy)".to_string(),
ports: "0.0.0.0:443->443/tcp,127.0.0.1:9000->9000/tcp".to_string(),
uptime: Some("5w".to_string()),
cache_timestamp: 0,
};
let inspect = crate::containers::ContainerInspect {
exit_code: 0,
oom_killed: false,
started_at: "2026-04-02T19:46:58Z".to_string(),
finished_at: String::new(),
health: Some("healthy".to_string()),
restart_count: 0,
command: Some(vec!["/auth".to_string()]),
entrypoint: None,
env_count: 12,
mount_count: 2,
networks: vec![],
image_digest: Some(
"sha256:a4f1e7c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7c91d".to_string(),
),
restart_policy: Some("unless-stopped".to_string()),
user: Some("root".to_string()),
privileged: false,
readonly_rootfs: false,
apparmor_profile: Some("docker-default".to_string()),
seccomp_profile: Some("default".to_string()),
cap_add: Vec::new(),
cap_drop: vec!["NET_RAW".to_string()],
mounts: vec![
crate::containers::MountInfo {
source: "/etc/letsencrypt".to_string(),
destination: "/etc/letsencrypt".to_string(),
read_only: false,
},
crate::containers::MountInfo {
source: "certs".to_string(),
destination: "/etc/nginx/certs".to_string(),
read_only: true,
},
],
compose_project: Some("auth-stack".to_string()),
compose_service: Some("auth".to_string()),
..Default::default()
};
let result = Ok(inspect);
let lines = build_detail_lines(&row, Some(&result), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("LIFECYCLE"));
assert!(text.contains("Restart"), "expected Restart row");
assert!(
text.contains("unless-stopped"),
"expected restart policy to render"
);
assert!(text.contains("Restarts"), "expected Restarts count row");
assert!(text.contains("SECURITY"));
assert!(text.contains("User"));
assert!(text.contains("root"));
assert!(text.contains("Caps -"));
assert!(text.contains("NET_RAW"));
assert!(
!text.contains("AppArmor"),
"docker-default apparmor profile is noise; suppress"
);
assert!(
!text.contains("Seccomp"),
"default seccomp profile is noise; suppress"
);
assert!(text.contains("APP"));
assert!(text.contains("Digest"));
assert!(text.contains("sha256:a4f1e7…c91d"));
assert!(text.contains("NETWORK"));
assert!(
text.contains(":443"),
"expected :443 branch in network ladder"
);
assert!(text.contains("pub"), "public binding must surface");
assert!(
!text.contains(":9000 pub"),
"loopback ports must not be flagged pub"
);
assert!(text.contains("MOUNTS"));
assert!(text.contains("rw"));
assert!(text.contains("ro"));
assert!(text.contains("/etc/nginx/certs"));
let lines_strs: Vec<&str> = text.lines().collect();
let mount_header_idx = lines_strs
.iter()
.position(|l| l.contains("MOUNTS"))
.expect("MOUNTS header must be present");
let mount_rows: Vec<&&str> = lines_strs[mount_header_idx + 1..]
.iter()
.take_while(|l| !l.starts_with("\u{2570}") && !l.contains("COMPOSE"))
.filter(|l| l.contains(" \u{2192} "))
.collect();
assert_eq!(
mount_rows.len(),
2,
"two mount rows must contain the arrow within the MOUNTS card"
);
let arrow_columns: Vec<usize> = mount_rows
.iter()
.map(|line| {
let byte_pos = line.find(" \u{2192} ").unwrap_or(usize::MAX);
line[..byte_pos].chars().count()
})
.collect();
assert_eq!(
arrow_columns[0], arrow_columns[1],
"arrows must align across mount rows"
);
let expected_arrow_col = "│ /etc/letsencrypt".chars().count();
assert_eq!(
arrow_columns[0], expected_arrow_col,
"arrow must hug the longest source, not float on a 50/50 split"
);
assert!(
!text.contains("Env 12"),
"Env count teaser dropped; full list not implemented"
);
for row in &mount_rows {
assert!(
row.ends_with("rw \u{2502}") || row.ends_with("ro \u{2502}"),
"mode tag must be followed by a space before the right border, got {row:?}"
);
}
}
#[test]
fn build_detail_lines_inspect_error_shows_error_message() {
let row = ContainerRow {
id: "c4".to_string(),
alias: "host".to_string(),
name: "demo".to_string(),
image: "img".to_string(),
state: "running".to_string(),
status: "Up 1m".to_string(),
ports: String::new(),
uptime: Some("1m".to_string()),
cache_timestamp: 0,
};
let err: Result<crate::containers::ContainerInspect, String> =
Err("permission denied".to_string());
let lines = build_detail_lines(&row, Some(&err), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("error"));
assert!(text.contains("permission denied"));
}
fn make_row(name: &str, alias: &str, state: &str, status: &str) -> ContainerRow {
ContainerRow {
id: "abc123def456".to_string(),
alias: alias.to_string(),
name: name.to_string(),
image: "img:latest".to_string(),
state: state.to_string(),
status: status.to_string(),
ports: String::new(),
uptime: None,
cache_timestamp: 0,
}
}
#[test]
fn health_card_omitted_when_no_healthcheck() {
let row = make_row("svc", "host", "running", "Up 1m");
let inspect = crate::containers::ContainerInspect {
health: None,
health_test: None,
health_failing_streak: None,
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(inspect)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(
!text.contains("HEALTH"),
"HEALTH card must stay hidden when image has no healthcheck"
);
}
#[test]
fn health_card_renders_unhealthy_with_streak() {
let row = make_row("svc", "host", "running", "Up 1m");
let inspect = crate::containers::ContainerInspect {
health: Some("unhealthy".to_string()),
health_test: Some(vec![
"CMD".to_string(),
"curl".to_string(),
"-fs".to_string(),
]),
health_interval_ns: Some(30_000_000_000),
health_failing_streak: Some(4),
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(inspect)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("HEALTH"));
assert!(text.contains("unhealthy"));
assert!(text.contains("curl -fs"));
assert!(text.contains("4 failing"));
assert!(text.contains("30s interval"));
}
#[test]
fn resources_card_omitted_when_no_limits() {
let row = make_row("svc", "host", "running", "Up 1m");
let inspect = crate::containers::ContainerInspect {
memory_limit: None,
cpu_limit_nanos: None,
pids_limit: None,
log_driver: Some("json-file".to_string()),
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(inspect)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(
!text.contains("RESOURCES"),
"RESOURCES card must stay hidden when no limits and json-file logs"
);
}
#[test]
fn resources_card_renders_when_memory_set() {
let row = make_row("svc", "host", "running", "Up 1m");
let inspect = crate::containers::ContainerInspect {
memory_limit: Some(536870912),
cpu_limit_nanos: Some(1_500_000_000),
pids_limit: Some(200),
log_driver: Some("json-file".to_string()),
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(inspect)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("RESOURCES"));
assert!(text.contains("512 MB"));
assert!(text.contains("1.5 cores"));
assert!(text.contains("200"));
assert!(
!text.contains("Logs"),
"default json-file log driver stays silent"
);
}
#[test]
fn resources_card_surfaces_non_standard_log_driver() {
let row = make_row("svc", "host", "running", "Up 1m");
let inspect = crate::containers::ContainerInspect {
log_driver: Some("syslog".to_string()),
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(inspect)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("RESOURCES"));
assert!(text.contains("Logs"));
assert!(text.contains("syslog"));
}
#[test]
fn security_card_omitted_for_default_profile() {
let row = make_row("svc", "host", "running", "Up 1m");
let inspect = crate::containers::ContainerInspect {
user: Some("app".to_string()),
privileged: false,
readonly_rootfs: false,
apparmor_profile: Some("docker-default".to_string()),
seccomp_profile: Some("default".to_string()),
cap_add: vec![],
cap_drop: vec![],
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(inspect)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(
!text.contains("SECURITY"),
"SECURITY stays hidden for non-root + default profiles + no caps"
);
}
#[test]
fn security_card_renders_when_privileged() {
let row = make_row("svc", "host", "running", "Up 1m");
let inspect = crate::containers::ContainerInspect {
privileged: true,
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(inspect)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("SECURITY"));
assert!(text.contains("Privileged"));
}
#[test]
fn compose_card_only_when_compose_managed() {
let row = make_row("svc", "host", "running", "Up 1m");
let bare = crate::containers::ContainerInspect::default();
let lines = build_detail_lines(&row, Some(&Ok(bare)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(!text.contains("COMPOSE"));
let managed = crate::containers::ContainerInspect {
compose_project: Some("edge".to_string()),
compose_service: Some("nginx".to_string()),
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(managed)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("COMPOSE"));
assert!(text.contains("Project"));
assert!(text.contains("edge"));
}
#[test]
fn attention_card_only_for_failed_or_oom_containers() {
let row = make_row("svc", "host", "exited", "Exited (137)");
let oom = crate::containers::ContainerInspect {
exit_code: 137,
oom_killed: true,
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(oom)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("ATTENTION"));
assert!(text.contains("OOM"));
assert!(text.contains("137"));
let healthy_row = make_row("svc", "host", "running", "Up 1m");
let healthy = crate::containers::ContainerInspect::default();
let lines = build_detail_lines(&healthy_row, Some(&Ok(healthy)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(!text.contains("ATTENTION"));
}
#[test]
fn stop_signal_only_when_overrides_default() {
let row = make_row("svc", "host", "running", "Up 1m");
let default_sig = crate::containers::ContainerInspect {
restart_policy: Some("no".to_string()),
stop_signal: Some("SIGTERM".to_string()),
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(default_sig)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(
!text.contains("Stop sig"),
"default SIGTERM stays silent in LIFECYCLE card"
);
let custom_sig = crate::containers::ContainerInspect {
restart_policy: Some("no".to_string()),
stop_signal: Some("SIGQUIT".to_string()),
stop_timeout: Some(30),
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(custom_sig)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("Stop sig"));
assert!(text.contains("SIGQUIT"));
assert!(text.contains("30s timeout"));
}
#[test]
fn format_memory_bytes_units() {
assert_eq!(format_memory_bytes(512 * 1024 * 1024), "512 MB");
assert_eq!(format_memory_bytes(1024 * 1024 * 1024), "1 GB");
assert_eq!(format_memory_bytes(1536 * 1024 * 1024), "1.5 GB");
}
#[test]
fn format_cpu_nanos_whole_and_fractional() {
assert_eq!(format_cpu_nanos(1_000_000_000), "1 cores");
assert_eq!(format_cpu_nanos(2_000_000_000), "2 cores");
assert_eq!(format_cpu_nanos(1_500_000_000), "1.5 cores");
}
#[test]
fn format_duration_ns_picks_natural_unit() {
assert_eq!(format_duration_ns(30_000_000_000), "30s");
assert_eq!(format_duration_ns(120_000_000_000), "2m");
assert_eq!(format_duration_ns(7_200_000_000_000), "2h");
}
#[test]
fn network_ladder_renders_mode_and_hostname() {
let row = make_row("svc", "host", "running", "Up 1m");
let inspect = crate::containers::ContainerInspect {
network_mode: Some("bridge".to_string()),
hostname: Some("c1abc123".to_string()),
networks: vec![crate::containers::NetworkInfo {
name: "edge_default".to_string(),
ip_address: "172.18.0.5".to_string(),
}],
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(inspect)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("NETWORK"));
assert!(text.contains('\u{25CB}'), "○ host node missing");
assert!(text.contains('\u{25CF}'), "● network node missing");
assert!(text.contains("bridge"));
assert!(text.contains("edge_default"));
assert!(text.contains("172.18.0.5"));
assert!(text.contains('\u{25C9}'), "◉ container node missing");
assert!(text.contains("svc"));
assert!(text.contains("c1abc123"));
}
#[test]
fn workdir_root_is_suppressed_app_keeps_other_paths() {
let row = make_row("svc", "host", "running", "Up 1m");
let root = crate::containers::ContainerInspect {
working_dir: Some("/".to_string()),
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(root)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(
!text.contains("WorkDir"),
"implicit / WorkDir stays silent in APP card"
);
let custom = crate::containers::ContainerInspect {
working_dir: Some("/var/lib/postgres".to_string()),
..Default::default()
};
let lines = build_detail_lines(&row, Some(&Ok(custom)), false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("WorkDir"));
assert!(text.contains("/var/lib/postgres"));
}
#[test]
fn cache_only_render_omits_inspect_cards() {
let row = ContainerRow {
id: "deadbeef0000".to_string(),
alias: "host".to_string(),
name: "svc".to_string(),
image: "img:1".to_string(),
state: "running".to_string(),
status: "Up 1m".to_string(),
ports: "0.0.0.0:80->80/tcp".to_string(),
uptime: Some("1m".to_string()),
cache_timestamp: 0,
};
let lines = build_detail_lines(&row, None, false, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("svc"));
assert!(text.contains("on host"));
assert!(text.contains("Up 1m"));
assert!(text.contains("APP"));
assert!(text.contains("img:1"));
assert!(text.contains("deadbeef0000"));
assert!(text.contains("NETWORK"));
assert!(text.contains(":80"));
assert!(text.contains("pub"));
assert!(!text.contains("LIFECYCLE"));
assert!(!text.contains("HEALTH"));
assert!(!text.contains("RESOURCES"));
assert!(!text.contains("MOUNTS"));
assert!(!text.contains("SECURITY"));
assert!(!text.contains("COMPOSE"));
assert!(!text.contains("ATTENTION"));
assert!(!text.contains("DETAILS"));
}
#[test]
fn details_card_shows_loading_when_inspect_in_flight() {
let row = make_row("svc", "host", "running", "Up 1m");
let lines = build_detail_lines(&row, None, true, 0, 48);
let text: String = lines.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("DETAILS"));
assert!(text.contains("loading"));
assert!(text.contains("fetching inspect"));
}
#[test]
fn logs_card_omitted_when_height_below_three() {
let logs: Vec<String> = vec!["a".to_string()];
let lines = build_logs_card(Some(&Ok(logs)), false, 96, 2);
assert!(lines.is_empty());
}
#[test]
fn logs_card_renders_open_close_borders_when_height_three() {
let lines = build_logs_card(None, false, 96, 3);
assert_eq!(lines.len(), 3, "open + one inner + close");
let first = lines[0].to_string();
let last = lines[2].to_string();
assert!(first.contains("LOGS"));
assert!(first.starts_with('\u{256D}'));
assert!(last.starts_with('\u{2570}'));
}
#[test]
fn logs_card_fills_when_more_lines_than_capacity() {
let logs: Vec<String> = (0..30).map(|i| format!("line{}", i)).collect();
let lines = build_logs_card(Some(&Ok(logs)), false, 96, 14);
assert_eq!(lines.len(), 14);
let body: Vec<String> = lines[1..13].iter().map(|l| l.to_string()).collect();
for (i, expected) in (18..30).enumerate() {
assert!(
body[i].contains(&format!("line{}", expected)),
"row {} expected line{} got {}",
i,
expected,
body[i]
);
}
assert!(!lines.iter().any(|l| l.to_string().contains("line17")));
assert!(!lines.iter().any(|l| l.to_string().contains("line0 ")));
}
#[test]
fn logs_card_pads_when_fewer_lines_than_capacity() {
let logs: Vec<String> = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let lines = build_logs_card(Some(&Ok(logs)), false, 96, 14);
assert_eq!(lines.len(), 14);
let body_text = lines[1..13]
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("|");
assert!(body_text.contains("a"));
assert!(body_text.contains("b"));
assert!(body_text.contains("c"));
for line in &lines[4..13] {
let s = line.to_string();
assert!(s.starts_with('\u{2502}'));
assert!(s.ends_with('\u{2502}'));
}
}
#[test]
fn logs_card_loading_state_renders_status() {
let lines = build_logs_card(None, true, 96, 8);
let text = lines
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("LOGS"));
assert!(text.contains("loading"));
}
#[test]
fn logs_card_error_state_renders_message() {
let err: Result<Vec<String>, String> = Err("permission denied".to_string());
let lines = build_logs_card(Some(&err), false, 96, 8);
let text = lines
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("error"));
assert!(text.contains("permission denied"));
}
#[test]
fn logs_card_empty_log_set_says_no_output() {
let lines = build_logs_card(Some(&Ok(vec![])), false, 96, 8);
let text = lines
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("(no output)"));
}
#[test]
fn logs_card_truncates_overlong_lines() {
let long_line = "x".repeat(300);
let lines = build_logs_card(Some(&Ok(vec![long_line])), false, 48, 5);
let body = lines[1].to_string();
assert!(
body.contains('…'),
"expected ellipsis on truncated line, got: {}",
body
);
}
#[test]
fn logs_card_height_exactly_matches_card_height() {
for h in [3usize, 5, 8, 14, 30] {
let logs: Vec<String> = vec!["one".to_string(), "two".to_string()];
let lines = build_logs_card(Some(&Ok(logs)), false, 96, h);
assert_eq!(
lines.len(),
h,
"card_height={} must produce exactly {} lines",
h,
h
);
}
}
#[test]
fn wrap_to_lines_returns_short_input_unchanged() {
let out = wrap_to_lines("hello world", 30, 3);
assert_eq!(out, vec!["hello world".to_string()]);
}
#[test]
fn wrap_to_lines_splits_on_width() {
let out = wrap_to_lines("abcdefghijkl", 5, 4);
assert_eq!(out, vec!["abcde", "fghij", "kl"]);
}
#[test]
fn wrap_to_lines_truncates_with_ellipsis_when_overflow() {
let out = wrap_to_lines("abcdefghijkl", 4, 2);
assert_eq!(out.len(), 2);
assert_eq!(out[0], "abcd");
assert!(out[1].ends_with('\u{2026}'));
}
#[test]
fn wrap_to_lines_zero_args_return_empty() {
assert!(wrap_to_lines("anything", 0, 5).is_empty());
assert!(wrap_to_lines("anything", 10, 0).is_empty());
}
#[test]
fn pad_or_truncate_path_pads_short() {
let out = pad_or_truncate_path("/etc", 10);
assert_eq!(out, "/etc ");
}
#[test]
fn pad_or_truncate_path_truncates_left_to_preserve_leaf() {
let out = pad_or_truncate_path("/very/long/prefix/foo/bar", 12);
assert_eq!(out.chars().count(), 12);
assert!(out.starts_with('\u{2026}'));
assert!(out.contains("foo/bar"));
}
#[test]
fn pad_or_truncate_path_exact_width_returns_self() {
let out = pad_or_truncate_path("abcdef", 6);
assert_eq!(out, "abcdef");
}
#[test]
fn snap_top_to_card_boundary_keeps_complete_cards() {
let line = |c: char| Line::from(Span::raw(c.to_string()));
let lines = vec![
line('\u{256D}'), line(' '),
line('\u{2570}'), line('\u{256D}'),
line(' '),
line('\u{2570}'),
];
assert_eq!(snap_top_to_card_boundary(&lines, 6), 6);
assert_eq!(snap_top_to_card_boundary(&lines, 5), 3);
assert_eq!(snap_top_to_card_boundary(&lines, 3), 3);
assert_eq!(snap_top_to_card_boundary(&lines, 2), 2);
}
#[test]
fn snap_top_to_card_boundary_no_close_lines_returns_cap() {
let line = |c: char| Line::from(Span::raw(c.to_string()));
let lines = vec![line('a'), line('b'), line('c')];
assert_eq!(snap_top_to_card_boundary(&lines, 2), 2);
}
#[test]
fn format_health_test_strips_cmd_prefix() {
let test = vec![
"CMD".to_string(),
"curl".to_string(),
"-fs".to_string(),
"http://localhost".to_string(),
];
assert_eq!(format_health_test(&test), "curl -fs http://localhost");
let shell = vec!["CMD-SHELL".to_string(), "ps -ef | grep nginx".to_string()];
assert_eq!(format_health_test(&shell), "ps -ef | grep nginx");
let none = vec!["NONE".to_string()];
assert_eq!(format_health_test(&none), "disabled");
}
#[test]
fn count_states_buckets_each_kind() {
let containers = vec![
ContainerInfo {
id: "1".into(),
names: "a".into(),
image: "img".into(),
state: "running".into(),
status: "Up".into(),
ports: String::new(),
},
ContainerInfo {
id: "2".into(),
names: "b".into(),
image: "img".into(),
state: "running".into(),
status: "Up".into(),
ports: String::new(),
},
ContainerInfo {
id: "3".into(),
names: "c".into(),
image: "img".into(),
state: "exited".into(),
status: "Exited (0) 1h ago".into(),
ports: String::new(),
},
ContainerInfo {
id: "4".into(),
names: "d".into(),
image: "img".into(),
state: "dead".into(),
status: "Dead".into(),
ports: String::new(),
},
ContainerInfo {
id: "5".into(),
names: "e".into(),
image: "img".into(),
state: "paused".into(),
status: "Paused".into(),
ports: String::new(),
},
ContainerInfo {
id: "6".into(),
names: "f".into(),
image: "img".into(),
state: "restarting".into(),
status: "Restarting".into(),
ports: String::new(),
},
];
let c = count_states(&containers);
assert_eq!(c.running, 2);
assert_eq!(c.exited, 1);
assert_eq!(c.dead, 1);
assert_eq!(c.paused, 1);
assert_eq!(c.restarting, 1);
assert_eq!(c.created, 0);
}
#[test]
fn exit_code_extracted_when_present() {
assert_eq!(
parse_exit_code_from_status("Exited (137) 2h ago"),
Some(137)
);
assert_eq!(parse_exit_code_from_status("Exited (0) 1m ago"), Some(0));
}
#[test]
fn exit_code_absent_when_status_does_not_match() {
assert_eq!(parse_exit_code_from_status("Up 3 days"), None);
assert_eq!(parse_exit_code_from_status("Exited"), None);
assert_eq!(parse_exit_code_from_status("Exited (abc)"), None);
assert_eq!(parse_exit_code_from_status(""), None);
}
fn host_detail_text(app: &App, alias: &str, total: usize, running: usize) -> String {
let lines = build_host_detail_lines(app, alias, total, running, 80, 30);
lines.iter().map(|l| l.to_string() + "\n").collect()
}
#[test]
fn host_detail_renders_status_and_fleet_cards_for_healthy_host() {
let app = crate::demo::build_demo_app();
let alias = "aws-api-staging";
let entry = app.container_state.cache_entry(alias).expect("demo seeded");
let total = entry.containers.len();
let running = entry
.containers
.iter()
.filter(|c| c.state == "running")
.count();
let text = host_detail_text(&app, alias, total, running);
assert!(text.contains("STATUS"));
assert!(text.contains("FLEET"));
assert!(text.contains("ACTIONS"));
assert!(text.contains("HOST"));
assert!(text.contains("Docker 25.0.3"));
}
#[test]
fn host_detail_attention_card_appears_for_dead_or_oom_or_restart_loop() {
let app = crate::demo::build_demo_app();
let entry = app
.container_state
.cache_entry("bastion-ams")
.expect("seeded");
let total = entry.containers.len();
let running = entry
.containers
.iter()
.filter(|c| c.state == "running")
.count();
let text = host_detail_text(&app, "bastion-ams", total, running);
assert!(text.contains("ATTENTION"));
assert!(text.contains("Restart loop"));
}
#[test]
fn host_detail_runtime_falls_back_to_label_only_without_engine_version() {
let app = crate::demo::build_demo_app();
let entry = app
.container_state
.cache_entry("gateway-vpn")
.expect("seeded");
let total = entry.containers.len();
let running = entry
.containers
.iter()
.filter(|c| c.state == "running")
.count();
let text = host_detail_text(&app, "gateway-vpn", total, running);
assert!(text.contains("Runtime"));
assert!(text.contains("Docker"));
assert!(!text.contains("Docker 25.0"));
assert!(!text.contains("Docker 24.0"));
}
#[test]
fn host_detail_actions_disable_when_nothing_running() {
let cache = cache_with(&[(
"host-x",
&[("1", "a", "img", "exited"), ("2", "b", "img", "exited")],
)]);
let app = app_with_cache(cache);
let text = host_detail_text(&app, "host-x", 2, 0);
assert!(text.contains("ACTIONS"));
assert!(text.contains("nothing running"));
}
#[test]
fn host_detail_actions_verbs_align_with_card_value_column() {
let cache = cache_with(&[("host-z", &[("1", "n", "img", "running")])]);
let app = app_with_cache(cache);
let lines = build_host_detail_lines(&app, "host-z", 1, 1, 60, 40);
let k_row = lines
.iter()
.find(|line| {
line.spans
.iter()
.any(|s| s.content == "Restart running on host")
})
.expect("ACTIONS card must include the K row");
let key_span = &k_row.spans[1];
assert_eq!(
key_span.content.len(),
design::SECTION_LABEL_W as usize,
"ACTIONS key column must be SECTION_LABEL_W wide so verbs align with sibling-card values"
);
assert!(
key_span.content.starts_with("K"),
"first ACTIONS row is the Restart binding"
);
}
#[test]
fn host_detail_last_card_stretches_to_panel_bottom() {
let cache = cache_with(&[("host-y", &[("1", "n", "img", "running")])]);
let app = app_with_cache(cache);
let lines = build_host_detail_lines(&app, "host-y", 1, 1, 60, 40);
assert_eq!(lines.len(), 40);
let last = lines.last().expect("at least one line");
let first_span = last.spans.first().expect("border line carries spans");
assert!(first_span.content.starts_with(design::BOX_BL));
}
fn cache_with_age(alias: &str, age_secs: u64) -> HashMap<String, ContainerCacheEntry> {
let mut map = HashMap::new();
let now = current_unix_secs();
map.insert(
alias.to_string(),
ContainerCacheEntry {
timestamp: now.saturating_sub(age_secs),
runtime: ContainerRuntime::Docker,
engine_version: Some("25.0.3".to_string()),
containers: vec![ContainerInfo {
id: "1".into(),
names: "n".into(),
image: "img".into(),
state: "running".into(),
status: "Up 1 hour".into(),
ports: String::new(),
}],
},
);
map
}
#[test]
fn host_detail_attention_card_fires_for_stale_listing() {
let cache = cache_with_age("host-stale", 700);
let app = app_with_cache(cache);
let text = host_detail_text(&app, "host-stale", 1, 1);
assert!(text.contains("ATTENTION"));
assert!(text.contains("Stale"));
assert!(text.contains("r to refresh"));
}
#[test]
fn host_detail_no_attention_card_when_listing_is_fresh_and_nothing_wrong() {
let cache = cache_with_age("host-fresh", 30);
let app = app_with_cache(cache);
let text = host_detail_text(&app, "host-fresh", 1, 1);
assert!(!text.contains("ATTENTION"));
}
#[test]
fn host_detail_fleet_shows_count_when_tunnel_count_is_set_but_inactive() {
let cache = cache_with(&[("host-tc", &[("1", "n", "img", "running")])]);
let mut app = app_with_cache(cache);
let host = crate::ssh_config::model::HostEntry {
alias: "host-tc".to_string(),
hostname: "10.0.0.1".to_string(),
user: "deploy".to_string(),
port: 22,
tunnel_count: 3,
..Default::default()
};
app.hosts_state.list_mut().push(host);
let text = host_detail_text(&app, "host-tc", 1, 1);
assert!(text.contains("Tunnels"));
assert!(text.contains("3"));
}
#[test]
fn host_detail_fleet_marks_group_folded_when_collapsed() {
let cache = cache_with(&[("host-fold", &[("1", "n", "img", "running")])]);
let mut app = app_with_cache(cache);
app.containers_overview
.collapsed_hosts
.insert("host-fold".to_string());
let text = host_detail_text(&app, "host-fold", 1, 1);
assert!(text.contains("Group"));
assert!(text.contains("folded"));
}
#[test]
fn host_detail_actions_label_changes_when_group_collapsed() {
let cache = cache_with(&[("host-ex", &[("1", "n", "img", "running")])]);
let mut app = app_with_cache(cache);
app.containers_overview
.collapsed_hosts
.insert("host-ex".to_string());
let text = host_detail_text(&app, "host-ex", 1, 1);
assert!(text.contains("Expand group"));
assert!(!text.contains("Collapse group"));
}
#[test]
fn host_detail_ping_renders_each_status_variant() {
let cache = cache_with(&[("host-p", &[("1", "n", "img", "running")])]);
let mut app = app_with_cache(cache);
app.ping.insert_status(
"host-p".into(),
crate::app::PingStatus::Reachable { rtt_ms: 38 },
);
assert!(host_detail_text(&app, "host-p", 1, 1).contains("38ms"));
app.ping.insert_status(
"host-p".into(),
crate::app::PingStatus::Slow { rtt_ms: 812 },
);
let slow = host_detail_text(&app, "host-p", 1, 1);
assert!(slow.contains("slow"));
assert!(slow.contains("812ms"));
app.ping
.insert_status("host-p".into(), crate::app::PingStatus::Unreachable);
assert!(host_detail_text(&app, "host-p", 1, 1).contains("unreachable"));
app.ping
.insert_status("host-p".into(), crate::app::PingStatus::Checking);
assert!(host_detail_text(&app, "host-p", 1, 1).contains("checking"));
app.ping
.insert_status("host-p".into(), crate::app::PingStatus::Skipped);
assert!(host_detail_text(&app, "host-p", 1, 1).contains("--"));
app.ping.remove_status("host-p");
assert!(host_detail_text(&app, "host-p", 1, 1).contains("--"));
}
#[test]
fn restart_loop_threshold_boundary_at_five_excludes_six_includes() {
let app = crate::demo::build_demo_app();
let make = |restart_count: u32| {
let info = ContainerInfo {
id: "boundary-id".into(),
names: "svc".into(),
image: "img".into(),
state: "running".into(),
status: "Up 1m".into(),
ports: String::new(),
};
(info, restart_count)
};
let mut probe = app;
probe.containers_overview.inspect_cache.entries.clear();
let (info_at_5, _) = make(5);
let (info_at_6, _) = make(6);
for rc in [5u32, 6u32] {
let inspect = crate::containers::ContainerInspect {
restart_count: rc,
..Default::default()
};
probe.containers_overview.inspect_cache.entries.insert(
"boundary-id".into(),
crate::app::InspectCacheEntry {
timestamp: 0,
result: Ok(inspect),
},
);
let containers = if rc == 5 {
vec![info_at_5.clone()]
} else {
vec![info_at_6.clone()]
};
let signals = collect_inspect_signals(&probe, &containers);
if rc == 5 {
assert!(
signals.restart_loops.is_empty(),
"restart_count == 5 must NOT trigger restart loop"
);
} else {
assert_eq!(
signals.restart_loops.len(),
1,
"restart_count == 6 must trigger one restart loop"
);
assert_eq!(signals.restart_loops[0].1, 6);
}
}
}
#[test]
fn host_detail_truncates_restart_loop_rows_at_attention_cap() {
let mut app = crate::demo::build_demo_app();
app.containers_overview.inspect_cache.entries.clear();
let mut containers: Vec<ContainerInfo> = Vec::new();
for i in 0..5 {
let id = format!("loopy-{}", i);
let info = ContainerInfo {
id: id.clone(),
names: format!("svc-{}", i),
image: "img".into(),
state: "running".into(),
status: "Up 1m".into(),
ports: String::new(),
};
containers.push(info);
let inspect = crate::containers::ContainerInspect {
restart_count: 20,
..Default::default()
};
app.containers_overview.inspect_cache.entries.insert(
id,
crate::app::InspectCacheEntry {
timestamp: 0,
result: Ok(inspect),
},
);
}
app.container_state.insert_cache_entry(
"loopy-host".into(),
ContainerCacheEntry {
timestamp: current_unix_secs(),
runtime: ContainerRuntime::Docker,
engine_version: None,
containers,
},
);
let text = host_detail_text(&app, "loopy-host", 5, 5);
let count = text.matches("Restart loop").count();
assert_eq!(
count, ATTENTION_RESTART_LOOP_CAP,
"ATTENTION must cap restart-loop rows at the documented limit"
);
}
#[test]
fn host_detail_action_qualifier_uses_count_with_correct_pluralisation() {
let cache_one = cache_with(&[("host-1", &[("1", "n", "img", "running")])]);
let app_one = app_with_cache(cache_one);
let text_one = host_detail_text(&app_one, "host-1", 1, 1);
assert!(text_one.contains("1 container"));
assert!(!text_one.contains("1 containers"));
let cache_many = cache_with(&[(
"host-2",
&[("1", "a", "img", "running"), ("2", "b", "img", "running")],
)]);
let app_many = app_with_cache(cache_many);
let text_many = host_detail_text(&app_many, "host-2", 2, 2);
assert!(text_many.contains("2 containers"));
}