use super::super::*;
#[allow(unused_imports)]
use super::support::*;
#[test]
fn action_ssm_run_label_and_glyph() {
use crate::theme::IconStyle;
assert_eq!(Action::SsmRun.label(), "Run SSM shell command");
assert!(!Action::SsmRun.glyph(IconStyle::Powerline).is_empty());
assert!(!Action::SsmRun.glyph(IconStyle::Unicode).is_empty());
assert!(!Action::SsmRun.glyph(IconStyle::Ascii).is_empty());
}
#[test]
fn view_mode_labels() {
assert_eq!(ViewMode::Default.label(), "default");
assert_eq!(ViewMode::Compact.label(), "compact");
assert_eq!(ViewMode::Spacious.label(), "spacious");
}
#[test]
fn render_promotions_orders_newest_first_and_includes_version() {
let now = chrono::Utc::now();
let records = vec![
crate::app::PromotionRecord {
source: "staging".into(),
target: "uat".into(),
version_label: "v1.4.2".into(),
at: now - chrono::Duration::hours(2),
},
crate::app::PromotionRecord {
source: "uat".into(),
target: "prod".into(),
version_label: "v1.4.2".into(),
at: now - chrono::Duration::minutes(5),
},
];
let body = crate::app::render_promotions(&records, now);
let prod_pos = body.find("uat → prod").expect("prod row");
let staging_pos = body.find("staging → uat").expect("staging row");
assert!(
prod_pos < staging_pos,
"newest (uat → prod, 5m ago) should sort above older (staging → uat, 2h ago)"
);
assert!(body.contains("version=v1.4.2"), "version label: {body}");
}
#[test]
fn render_env_resources_tree_shows_asg_with_nested_instances() {
let mut res = empty_resources();
res.asgs = vec!["awseb-AWSEBAutoScalingGroup-XYZ".into()];
res.instances = vec!["i-0abc".into(), "i-0def".into(), "i-0ghi".into()];
let body = crate::app::render_env_resources_tree(&res, "prod-api", "Web");
assert!(body.contains("Auto-scaling groups (1)"));
assert!(body.contains("└─ awseb-AWSEBAutoScalingGroup-XYZ"));
assert!(body.contains("├─ i-0abc"));
assert!(body.contains("├─ i-0def"));
assert!(body.contains("└─ i-0ghi"));
}
#[test]
fn render_env_resources_tree_skips_empty_sections() {
let mut res = empty_resources();
res.asgs = vec!["asg-1".into()];
let body = crate::app::render_env_resources_tree(&res, "small-env", "Web");
assert!(body.contains("Auto-scaling groups (1)"));
assert!(!body.contains("Load balancers"));
assert!(!body.contains("Launch configurations"));
assert!(!body.contains("Queues"));
}
#[test]
fn render_env_resources_tree_handles_zero_resources() {
let res = empty_resources();
let body = crate::app::render_env_resources_tree(&res, "fresh-env", "Web");
assert!(body.contains("(no resources reported"));
}
#[test]
fn short_fingerprint_is_stable_and_diffs() {
let a = crate::app::short_fingerprint("hunter2");
let b = crate::app::short_fingerprint("hunter2");
let c = crate::app::short_fingerprint("hunter3");
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(a.len(), 8);
}
#[test]
fn assign_app_colors_stable_first_appearance() {
use ratatui::style::Color;
let palette = vec![Color::Red, Color::Green, Color::Blue];
let names = ["app-a", "app-b", "app-a", "app-c", "app-b"];
let m = assign_app_colors(names.iter().copied(), &palette);
assert_eq!(m.get("app-a").copied(), Some(Color::Red));
assert_eq!(m.get("app-b").copied(), Some(Color::Green));
assert_eq!(m.get("app-c").copied(), Some(Color::Blue));
assert_eq!(m.len(), 3);
}
#[test]
fn bucket_delta_only_envs_in_both() {
let mut prev = HashMap::new();
prev.insert("a".into(), "Green".into());
prev.insert("b".into(), "Red".into());
prev.insert("c".into(), "Green".into()); let next = vec![
fake_env("a", "Ready", "Yellow", "v1"), fake_env("b", "Ready", "Red", "v1"), fake_env("d", "Ready", "Green", "v1"), ];
let delta = bucket_delta(&prev, &next, |e| e.health.clone());
let map: BTreeMap<String, i32> = delta.into_iter().collect();
assert_eq!(map.get("Green").copied(), Some(-1));
assert_eq!(map.get("Yellow").copied(), Some(1));
assert_eq!(map.get("Red").copied(), None);
}
#[test]
fn bucket_delta_empty_prev_yields_no_deltas() {
let prev = HashMap::new();
let next = vec![
fake_env("a", "Ready", "Green", "v1"),
fake_env("b", "Ready", "Red", "v1"),
];
let delta = bucket_delta(&prev, &next, |e| e.health.clone());
assert!(
delta.is_empty(),
"expected no deltas with empty prev, got {delta:?}"
);
}
#[test]
fn diff_envs_marks_differing_fields() {
let a = fake_env("prod", "Ready", "Green", "v1");
let b = fake_env("staging", "Updating", "Yellow", "v2");
let out = diff_envs(&a, &b, false, &[]);
assert!(out.contains("≠ Status"));
assert!(out.contains("≠ Health"));
assert!(out.contains("≠ Version"));
assert!(out.contains("≠ Name"));
assert!(out.contains("≠ CNAME"));
assert!(out.contains(" Application"));
assert!(out.contains(" Tier"));
assert!(out.contains(" Platform"));
}
#[test]
fn diff_envs_redacts_cname() {
let a = fake_env("prod", "Ready", "Green", "v1");
let b = fake_env("staging", "Updating", "Yellow", "v2");
let out = diff_envs(&a, &b, true, &[]);
assert!(!out.contains("prod.elb.amazonaws.com"));
assert!(out.contains("▓"));
}
#[test]
fn diff_envs_drops_ignored_rows() {
let a = fake_env("prod", "Ready", "Green", "v1");
let b = fake_env("staging", "Updating", "Yellow", "v2");
let keys = parse_ignore_keys(Some("version,cname,updated"));
let out = diff_envs(&a, &b, false, &keys);
assert!(!out.contains("Version"), "Version row should be ignored");
assert!(!out.contains("CNAME"), "CNAME row should be ignored");
assert!(!out.contains("Updated"), "Updated row should be ignored");
assert!(out.contains("≠ Status"));
assert!(out.contains(" Application"));
}
#[tokio::test]
async fn cycle_saved_view_wraps_forward_through_saved_views() {
let mut app = test_app();
app.saved_views.insert(
"dev".into(),
crate::app::encode_filter_only_view("tag:env=dev"),
);
app.saved_views.insert(
"prod".into(),
crate::app::encode_filter_only_view("tag:env=prod"),
);
app.saved_views.insert(
"staging".into(),
crate::app::encode_filter_only_view("tag:env=staging"),
);
app.view.set_filter("tag:env=dev");
app.cycle_saved_view(1);
assert_eq!(app.view.filter().text(), "tag:env=prod");
app.cycle_saved_view(1);
assert_eq!(app.view.filter().text(), "tag:env=staging");
app.cycle_saved_view(1);
assert_eq!(app.view.filter().text(), "tag:env=dev");
}
#[tokio::test]
async fn cycle_saved_view_wraps_backward_and_handles_no_active() {
let mut app = test_app();
app.saved_views.insert(
"dev".into(),
crate::app::encode_filter_only_view("tag:env=dev"),
);
app.saved_views.insert(
"staging".into(),
crate::app::encode_filter_only_view("tag:env=staging"),
);
app.view.set_filter("tag:env=dev");
app.cycle_saved_view(-1);
assert_eq!(app.view.filter().text(), "tag:env=staging");
app.view.set_filter("some-random-text");
app.cycle_saved_view(1);
assert_eq!(
app.view.filter().text(),
"tag:env=dev",
"forward-with-no-active → first"
);
app.view.set_filter("some-random-text");
app.cycle_saved_view(-1);
assert_eq!(
app.view.filter().text(),
"tag:env=staging",
"backward-with-no-active → last"
);
}
#[tokio::test]
async fn cycle_saved_view_noop_with_empty_views() {
let mut app = test_app();
app.view.set_filter("keep-me");
app.cycle_saved_view(1);
assert_eq!(app.view.filter().text(), "keep-me");
}
#[tokio::test]
async fn cycle_saved_view_with_full_view_applies_sort_and_group_too() {
let mut app = test_app();
app.saved_views.insert(
"dev".into(),
crate::app::encode_filter_only_view("tag:env=dev"),
);
app.saved_views.insert(
"by-app".into(),
"filter=tag:env=prod;sort=app:asc;grouped=true;scope=envs".into(),
);
app.view.set_filter("tag:env=dev");
app.view.set_grouped(false);
app.cycle_saved_view(1); assert_eq!(app.view.filter().text(), "tag:env=prod");
assert!(
app.view.grouped(),
"full view must apply its grouped=true alongside the filter"
);
}
#[test]
fn view_mode_cycle_includes_spacious() {
assert_eq!(ViewMode::Default.next(), ViewMode::Compact);
assert_eq!(ViewMode::Compact.next(), ViewMode::Spacious);
assert_eq!(ViewMode::Spacious.next(), ViewMode::Default);
assert_eq!(ViewMode::Spacious.label(), "spacious");
}
#[tokio::test]
async fn render_main_table_includes_seeded_env_name() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod-canary", "uflexi", "Web", "Green")];
app.rebuild_view();
let frame = render(&mut app, 160, 24);
assert!(
frame.contains("api-prod-canary"),
"rendered frame should show seeded env name; got:\n{frame}"
);
}
#[tokio::test]
async fn render_main_table_includes_inst_column_header_and_data() {
let mut app = test_app();
app.environments = vec![
mk_env("api-prod", "uflexi", "Web", "Green"),
mk_env("api-staging", "uflexi", "Web", "Green"),
];
app.env_instance_counts.insert(
"api-prod".into(),
crate::aws::EnvInstanceCounts {
healthy: 3,
total: 3,
},
);
app.rebuild_view();
let frame = render(&mut app, 160, 24);
assert!(
frame.contains("INST"),
"expected INST column header in rendered frame; got:\n{frame}"
);
assert!(
frame.contains("3/3"),
"expected '3/3' for env with seeded counts; got:\n{frame}"
);
assert!(
frame.contains("—"),
"expected em-dash placeholder for env with no counts; got:\n{frame}"
);
}
#[tokio::test]
async fn render_colours_health_dots_by_tier() {
let mut app = test_app();
app.environments = vec![
mk_env("svc-red", "uflexi", "Web", "Red"),
mk_env("svc-green", "uflexi", "Web", "Green"),
];
app.rebuild_view();
app.table_state.select(None);
let buf = render_buf(&mut app, 120, 30);
let theme = app.theme.clone();
let red_row = find_row(&buf, "svc-red").expect("red env row rendered");
let green_row = find_row(&buf, "svc-green").expect("green env row rendered");
assert!(
row_has_fg(&buf, red_row, theme.health_red),
"Red env row should paint a health_red cell"
);
assert!(
row_has_fg(&buf, green_row, theme.health_green),
"Green env row should paint a health_green cell"
);
assert!(
!row_has_fg(&buf, green_row, theme.health_red),
"Green env row must not paint any health_red cell"
);
}
#[tokio::test]
async fn render_demo_ironwood_row_shows_muted_dashes() {
let mut app = test_app();
crate::demo_fixture::install(&mut app);
app.table_state.select(None); let theme = app.theme.clone();
let buf = render_buf(&mut app, 160, 30);
let y = find_row(&buf, "ironwood").expect("ironwood row rendered");
assert!(
count_symbol_fg(&buf, y, "—", theme.muted) >= 2,
"ironwood row should show muted — for INST and COST"
);
}
#[tokio::test]
async fn render_redact_masks_the_cname() {
let mut app = test_app();
app.environments = vec![mk_env("svc", "uflexi", "Web", "Green")];
app.rebuild_view();
app.table_state.select(None);
app.view.redact = false;
let shown = render(&mut app, 160, 30);
assert!(shown.contains("svc.example.com"), "cname visible when off");
app.view.redact = true;
let hidden = render(&mut app, 160, 30);
assert!(
!hidden.contains("svc.example.com"),
"cname must be masked when redact is on"
);
assert!(hidden.contains('▓'), "redacted cells render as ▓ blocks");
}
#[tokio::test]
async fn ascii_icon_mode_renders_no_unicode_arrows() {
let cfg = crate::config::Config {
icons: "ascii".into(),
..crate::config::Config::default()
};
let mut app = App::for_tests(crate::aws::AwsClient::stub(), cfg);
app.environments = vec![
mk_env("api-prod", "uflexi", "Web", "Red"),
mk_env("api-staging", "uflexi", "Web", "Green"),
];
app.rebuild_view();
app.table_state.select(Some(0));
app.health_delta = vec![("Red".to_string(), 1)];
app.status_delta = vec![("Ready".to_string(), -1)];
let out = render(&mut app, 160, 44);
for g in ['▲', '▼'] {
assert!(!out.contains(g), "ascii mode rendered {g}:\n{out}");
}
assert!(
out.contains('^') || out.contains('v'),
"the ascii forms replaced them:\n{out}"
);
}
#[tokio::test]
async fn the_anomaly_badge_is_ascii_at_its_call_site_too() {
let cfg = crate::config::Config {
icons: "ascii".into(),
..crate::config::Config::default()
};
let mut app = App::for_tests(crate::aws::AwsClient::stub(), cfg);
app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Red")];
app.rebuild_view();
app.table_state.select(Some(0));
app.open_detail();
let detail = app.detail.as_mut().expect("detail opened");
detail.tab_idx = detail
.tabs
.iter()
.position(|t| *t == DetailTab::Metrics)
.expect("Metrics tab present");
detail.loading_metrics = false;
let now = chrono::Utc::now();
detail.metrics = vec![crate::aws::MetricSeries {
id: "req5xx".into(),
label: "5xx".into(),
points: (0..6)
.map(|i| {
let v = if i == 5 { 99.0 } else { 1.0 };
(now - chrono::Duration::minutes(6 - i as i64), v)
})
.collect(),
}];
let out = render(&mut app, 160, 44);
assert!(
out.contains("anomaly"),
"the badge has to be on screen for this test to mean anything:\n{out}"
);
assert!(
!out.contains('▲'),
"ascii mode rendered ▲ in the anomaly badge:\n{out}"
);
}
#[tokio::test]
async fn a_red_env_gets_a_red_status_pill_in_the_table() {
let mut app = test_app();
app.view.hidden_cols.insert("HEALTH".into());
app.view.hidden_cols.insert("TREND".into());
let mut calm = mk_env("api-calm", "uflexi", "Web", "Green");
calm.status = "Ready".into();
let mut red = mk_env("api-red", "uflexi", "Web", "Red");
red.status = "Ready".into();
app.environments = vec![calm, red];
app.rebuild_view();
let buf = render_buf(&mut app, 170, 20);
let calm_row = find_row(&buf, "api-calm").expect("calm row rendered");
let red_row = find_row(&buf, "api-red").expect("red row rendered");
assert!(
row_has_fg(&buf, red_row, app.theme.health_red),
"a Red env's STATUS pill must carry the alert colour"
);
assert!(
!row_has_fg(&buf, calm_row, app.theme.health_red),
"and a healthy env's must not — otherwise the assertion above \
proves nothing about the alert"
);
}
#[tokio::test]
async fn the_group_separator_row_renders_its_app_and_summary() {
let mut app = test_app();
app.environments = vec![
mk_env("alpha-prod", "ALPHAAPP", "Web", "Green"),
mk_env("beta-prod", "BETAAPP", "Web", "Red"),
mk_env("beta-staging", "BETAAPP", "Web", "Green"),
];
app.view.invalidate();
app.rebuild_view();
app.execute_command("group on");
assert!(app.view.grouped(), "grouping is on");
let out = render(&mut app, 190, 30);
assert!(
out.contains("BETAAPP"),
"the separator names the group it introduces:\n{out}"
);
assert!(
out.contains("2 envs") && out.contains("1 red"),
"and summarises it — this is the per-app health an operator \
reads without expanding anything:\n{out}"
);
}
#[tokio::test]
async fn a_stale_view_cache_drops_rows_instead_of_panicking() {
let mut app = test_app();
app.environments = vec![
mk_env("alpha", "uflexi", "Web", "Green"),
mk_env("beta", "uflexi", "Web", "Green"),
mk_env("gamma", "uflexi", "Web", "Green"),
];
app.view.invalidate();
app.rebuild_view();
assert_eq!(app.view.display().len(), 3, "cache holds three rows");
app.environments.truncate(1);
let out = render(&mut app, 150, 20);
assert!(
out.contains("alpha"),
"the surviving row still renders:\n{out}"
);
assert!(
!out.contains("gamma"),
"and the dropped one is simply absent:\n{out}"
);
app.export_json();
app.export_markdown();
}
#[tokio::test]
async fn export_json_stays_valid_when_the_view_cache_is_stale() {
let mut app = test_app();
app.environments = vec![
mk_env("alpha", "uflexi", "Web", "Green"),
mk_env("beta", "uflexi", "Web", "Green"),
mk_env("gamma", "uflexi", "Web", "Green"),
];
app.view.invalidate();
app.rebuild_view();
assert_eq!(app.view.filtered().len(), 3);
app.environments.truncate(1);
app.export_json();
let json = app
.status_message
.as_deref()
.expect("export reports its outcome");
assert!(
json.contains("exported 1 rows"),
"counts what it emitted, not what it planned: {json}"
);
}
#[test]
fn export_json_body_has_no_trailing_comma_when_rows_are_skipped() {
let rows = [" {\"name\":\"alpha\"}".to_string()];
let body = format!("[\n{}\n]", rows.join(",\n"));
assert!(
serde_json::from_str::<serde_json::Value>(&body).is_ok(),
"a single row must not carry a separator: {body}"
);
let two = [
" {\"name\":\"alpha\"}".to_string(),
" {\"name\":\"beta\"}".to_string(),
];
let body = format!("[\n{}\n]", two.join(",\n"));
assert!(
serde_json::from_str::<serde_json::Value>(&body).is_ok(),
"{body}"
);
}
mod mode_state_invariant {
use super::*;
async fn app_in(mode: Mode) -> App {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "uflexi", "WebServer", "Green")];
app.rebuild_view();
app.mode = mode;
app
}
#[tokio::test]
#[should_panic(expected = "mode is Detail but its state is None")]
async fn detail_mode_without_its_state_is_loud() {
let mut app = app_in(Mode::Detail).await;
app.detail = None;
let _ = render(&mut app, 120, 40);
}
#[tokio::test]
#[should_panic(expected = "mode is Action but its state is None")]
async fn action_mode_without_its_state_is_loud() {
let mut app = app_in(Mode::Action).await;
app.action_flow = None;
let _ = render(&mut app, 120, 40);
}
#[tokio::test]
#[should_panic(expected = "mode is Form but its state is None")]
async fn form_mode_without_its_state_is_loud() {
let mut app = app_in(Mode::Form).await;
app.form = None;
let _ = render(&mut app, 120, 40);
}
#[tokio::test]
#[should_panic(expected = "mode is Picker but its state is None")]
async fn picker_mode_without_its_state_is_loud() {
let mut app = app_in(Mode::Picker).await;
app.picker = None;
let _ = render(&mut app, 120, 40);
}
#[tokio::test]
#[should_panic(expected = "mode is Dlq but its state is None")]
async fn dlq_mode_without_its_state_is_loud() {
let mut app = app_in(Mode::Dlq).await;
app.dlq = None;
let _ = render(&mut app, 120, 40);
}
#[tokio::test]
#[should_panic(expected = "mode is Shell but its state is None")]
async fn shell_mode_without_its_state_is_loud() {
let mut app = app_in(Mode::Shell).await;
app.current_shell = None;
let _ = render(&mut app, 120, 40);
}
#[tokio::test]
async fn holding_state_without_the_mode_is_fine() {
let mut app = app_in(Mode::Normal).await;
app.table_state.select(Some(0));
app.open_detail();
app.mode = Mode::Help; let _ = render(&mut app, 120, 40);
}
}