#![allow(dead_code)]
pub mod bar;
pub mod condensed;
pub mod diagnostics;
pub mod eggpool;
pub mod layout;
pub mod system_block;
pub mod text;
use ratatui::Frame;
use crate::state::{AppState, Pane, SystemViewMode};
pub fn render(f: &mut Frame, state: &AppState) {
let area = f.area();
if state.systems.is_empty() && state.eggpool.is_none() {
diagnostics::render_empty_config(f, area);
return;
}
if state.active_pane == Pane::Eggpool {
eggpool::render(f, area, state);
return;
}
let minimum_height = match state.system_view_mode {
SystemViewMode::Normal => {
let first_is_online = state
.display_order()
.first()
.and_then(|&index| state.systems.get(index))
.is_some_and(|system| system.reachability == crate::state::Reachability::Online);
if first_is_online {
5
} else {
1
}
}
SystemViewMode::Condensed => 3,
};
if area.width < 24 || area.height < minimum_height || area.height == 0 {
diagnostics::render_too_small(f, area);
return;
}
if state.system_view_mode == SystemViewMode::Condensed {
condensed::render_header(f, area);
}
let entries = layout::compute_viewport(state, area);
let entries_bottom = entries.last().map_or(area.y, |e| e.rect.y + e.rect.height);
let extra_rows = area
.y
.saturating_add(area.height)
.saturating_sub(entries_bottom);
for entry in &entries {
let system = &state.systems[entry.index];
if state.system_view_mode == SystemViewMode::Condensed {
condensed::render_entry(
f,
entry.rect,
system,
entry.is_selected,
entry.drive_rows_visible,
);
continue;
}
match system.reachability {
crate::state::Reachability::Online => {
system_block::render_online(
f,
entry.rect,
system,
entry.is_selected,
entry.drive_rows_visible,
);
}
crate::state::Reachability::Offline | crate::state::Reachability::Pending => {
system_block::render_offline(f, entry.rect, system, entry.is_selected);
}
}
}
if extra_rows >= 1 {
diagnostics::render_key_hint(f, area, state);
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use ratatui::backend::TestBackend;
use ratatui::style::Modifier;
use ratatui::Terminal;
use crate::config::{Config, SystemEntry};
use crate::normalized::NormalizedDrive;
use crate::poller::{PollBatch, PollOutcome};
use crate::state::{AppState, Reachability};
use gregg_protocol::test_support::LinuxSnapshotV2Builder;
use gregg_protocol::test_support::{
LinuxSnapshotBuilder, MacosSnapshotBuilder, WindowsSnapshotV2Builder,
};
use gregg_protocol::v2::DriveMetrics;
use gregg_protocol::StatusSnapshot;
fn render_state(state: &AppState, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| super::render(f, state)).unwrap();
let buf = terminal.backend().buffer().clone();
let mut lines = Vec::new();
for y in 0..buf.area.height {
let mut line = String::new();
for x in 0..buf.area.width {
line.push(
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' ')),
);
}
lines.push(line);
}
lines.join("\n")
}
fn linux_snap() -> StatusSnapshot {
LinuxSnapshotBuilder::default().build()
}
fn linux_snap_custom(usage_pct: f32, iowait_pct: f32, cores: u32) -> StatusSnapshot {
LinuxSnapshotBuilder::default()
.usage_pct(usage_pct)
.iowait_pct(iowait_pct)
.logical_cores(cores)
.build()
}
fn macos_snap() -> StatusSnapshot {
MacosSnapshotBuilder::default().build()
}
fn macos_snap_custom(usage_pct: f32, cores: u32) -> StatusSnapshot {
MacosSnapshotBuilder::default()
.usage_pct(usage_pct)
.logical_cores(cores)
.build()
}
fn test_config(names: &[&str]) -> Config {
let mut config = Config::default();
for (i, name) in names.iter().enumerate() {
config.systems.push(SystemEntry {
id: format!("id-{i}"),
host: format!("host{i}.local"),
port: 11310,
name: Some((*name).to_string()),
});
}
config
}
fn make_online_batch(state: &AppState, system_index: usize, snap: StatusSnapshot) -> PollBatch {
let system = &state.systems[system_index];
PollBatch {
generation: 1,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: system.id.clone(),
endpoint: system.endpoint.clone(),
outcome: PollOutcome::Online(Box::new(snap)),
latency: Duration::from_millis(10),
}],
}
}
fn make_offline_batch(state: &AppState, system_index: usize) -> PollBatch {
let system = &state.systems[system_index];
PollBatch {
generation: 1,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: system.id.clone(),
endpoint: system.endpoint.clone(),
outcome: PollOutcome::ConnectionRefused,
latency: Duration::from_millis(10),
}],
}
}
fn apply_online(state: &mut AppState, index: usize, snap: StatusSnapshot) {
let batch = make_online_batch(state, index, snap);
state.apply_batch(&batch);
}
fn apply_online_v2(
state: &mut AppState,
index: usize,
payload: gregg_protocol::v2::StatusPayloadV2,
generation: u64,
) {
let system_id = state.systems[index].id.clone();
let endpoint = state.systems[index].endpoint.clone();
state.apply_batch(&PollBatch {
generation,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id,
endpoint,
outcome: PollOutcome::OnlineV2(Box::new(payload)),
latency: Duration::from_millis(10),
}],
});
}
fn apply_offline(state: &mut AppState, index: usize) {
let mut batch = make_offline_batch(state, index);
batch.generation = state.last_applied_generation + 1;
state.apply_batch(&batch);
}
fn count_nonblank_lines(output: &str) -> usize {
output.lines().filter(|l| !l.trim().is_empty()).count()
}
fn line_contains(output: &str, line_index: usize, needle: &str) -> bool {
output
.lines()
.nth(line_index)
.is_some_and(|l| l.contains(needle))
}
#[test]
fn render_empty_config() {
let config = Config::default();
let state = AppState::from_config(&config);
let output = render_state(&state, 80, 24);
assert!(
output.contains("No sources configured"),
"expected 'No sources configured' in output:\n{output}"
);
}
#[test]
fn render_too_small_width() {
let config = test_config(&["web1"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 20, 24);
assert!(
output.contains("terminal too"),
"expected 'terminal too' in output:\n{output}"
);
}
#[test]
fn render_too_small_height() {
let config = test_config(&["web1"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 80, 2);
assert!(
output.contains("terminal too"),
"expected 'terminal too' in output:\n{output}"
);
}
#[test]
fn render_online_linux_system() {
let config = test_config(&["web1"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 80, 8);
let lines: Vec<&str> = output.lines().collect();
assert!(
!lines[0].trim().is_empty(),
"header row should not be empty"
);
assert!(
lines[0].contains("web1"),
"header should contain system name 'web1', got: {}",
lines[0]
);
assert!(
lines[1].contains("CPU"),
"row 1 should be CPU bar, got: {}",
lines[1]
);
assert!(
lines[2].contains("MEM"),
"row 2 should be MEM bar, got: {}",
lines[2]
);
assert!(
lines[3].contains("SWP"),
"row 3 should be SWP bar, got: {}",
lines[3]
);
}
#[test]
fn render_online_macos_system() {
let config = test_config(&["mac1"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, macos_snap());
let output = render_state(&state, 80, 8);
let header = output.lines().next().unwrap();
assert!(
header.contains("mac1"),
"header should contain 'mac1', got: {header}"
);
assert!(
header.contains("IO —"),
"macOS header should show 'IO —', got: {header}"
);
assert!(
!header.contains("IO 0.0%"),
"macOS header must not show fabricated 'IO 0.0%', got: {header}"
);
}
#[test]
fn render_offline_system() {
let config = test_config(&["web1"]);
let mut state = AppState::from_config(&config);
apply_offline(&mut state, 0);
let output = render_state(&state, 80, 4);
assert!(
output.contains("offline"),
"expected 'offline' in output:\n{output}"
);
}
#[test]
fn render_offline_system_preserves_configured_ip() {
let mut config = test_config(&["web1"]);
config.systems[0].host = "192.168.183.143".into();
let mut state = AppState::from_config(&config);
apply_offline(&mut state, 0);
let output = render_state(&state, 80, 4);
assert!(output.contains("192.168.183.143:11310"), "{output}");
assert!(!output.contains("192.168.182.143"), "{output}");
}
#[test]
fn render_pending_system() {
let config = test_config(&["web1"]);
let state = AppState::from_config(&config);
let output = render_state(&state, 80, 4);
assert!(
output.contains("pending"),
"expected 'pending' in output:\n{output}"
);
}
#[test]
fn render_mixed_online_offline() {
let config = test_config(&["a", "b", "c", "d"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 1, linux_snap());
state.apply_batch(&PollBatch {
generation: 2,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: state.systems[3].id.clone(),
endpoint: state.systems[3].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(linux_snap())),
latency: Duration::from_millis(10),
}],
});
state.viewport_top_id = None;
let output = render_state(&state, 80, 20);
let lines: Vec<&str> = output.lines().collect();
let b_line = lines
.iter()
.position(|l| l.contains("b "))
.expect("b should be rendered");
let d_line = lines
.iter()
.position(|l| l.contains("d "))
.expect("d should be rendered");
let a_line = lines
.iter()
.position(|l| l.starts_with("a@"))
.expect("a should be rendered");
let c_line = lines
.iter()
.position(|l| l.starts_with("c@"))
.expect("c should be rendered");
assert!(
b_line < a_line,
"online system b (line {b_line}) should appear before offline a (line {a_line})"
);
assert!(
d_line < c_line,
"online system d (line {d_line}) should appear before offline c (line {c_line})"
);
}
#[test]
fn render_selected_online_system() {
let config = test_config(&["a", "b"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
state.apply_batch(&PollBatch {
generation: 2,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: state.systems[1].id.clone(),
endpoint: state.systems[1].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(linux_snap())),
latency: Duration::from_millis(10),
}],
});
assert_eq!(state.selected_id.as_deref(), Some("id-0"));
let backend = TestBackend::new(80, 12);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| super::render(f, &state)).unwrap();
let buf = terminal.backend().buffer().clone();
let cell = buf.cell((0, 0)).unwrap();
let style = cell.style();
assert!(
style.add_modifier.contains(Modifier::REVERSED),
"selected system's header should have REVERSED modifier, got style: {style:?}"
);
}
#[test]
fn render_selected_offline_system() {
let config = test_config(&["a"]);
let mut state = AppState::from_config(&config);
apply_offline(&mut state, 0);
let backend = TestBackend::new(80, 4);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| super::render(f, &state)).unwrap();
let buf = terminal.backend().buffer().clone();
let cell = buf.cell((0, 0)).unwrap();
let style = cell.style();
assert!(
style.add_modifier.contains(Modifier::REVERSED),
"selected offline system should have REVERSED modifier, got style: {style:?}"
);
}
#[test]
fn render_header_wide() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 120, 8);
let header = output.lines().next().unwrap();
assert!(header.contains("srv"), "header: {header}");
assert!(header.contains("IO"), "header: {header}");
assert!(header.contains("x86_64"), "header: {header}");
assert!(header.contains("Linux"), "header: {header}");
}
#[test]
fn render_header_medium() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 50, 8);
let header = output.lines().next().unwrap();
assert!(header.contains("srv"), "header: {header}");
assert!(header.contains("IO"), "header: {header}");
assert!(
!header.contains("x86_64"),
"header should not contain arch at width 50: {header}"
);
}
#[test]
fn render_header_narrow() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 32, 8);
let header = output.lines().next().unwrap();
assert!(header.contains("srv"), "header: {header}");
assert!(header.contains("IO"), "header: {header}");
assert!(
!header.contains("linux"),
"header should not contain os at width 32: {header}"
);
}
#[test]
fn render_bar_zero_percent() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap_custom(0.0, 0.0, 4));
let output = render_state(&state, 80, 8);
let cpu_line = output.lines().nth(1).unwrap();
assert!(
cpu_line.contains("0.0%"),
"CPU bar at 0% should show '0.0%', got: {cpu_line}"
);
}
#[test]
fn render_bar_50_percent() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap_custom(50.0, 0.0, 4));
let output = render_state(&state, 80, 8);
let cpu_line = output.lines().nth(1).unwrap();
assert!(
cpu_line.contains("50.0%"),
"CPU bar at 50% should show '50.0%', got: {cpu_line}"
);
assert!(
cpu_line.contains('|'),
"CPU bar should contain filled '|' chars at 50%, got: {cpu_line}"
);
}
#[test]
fn render_bar_100_percent() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap_custom(100.0, 0.0, 4));
let output = render_state(&state, 80, 8);
let cpu_line = output.lines().nth(1).unwrap();
assert!(
cpu_line.contains("100%"),
"CPU bar at 100% should show '100%', got: {cpu_line}"
);
}
#[test]
fn render_bar_high_percent() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap_custom(99.9, 0.0, 4));
let output = render_state(&state, 80, 8);
let cpu_line = output.lines().nth(1).unwrap();
assert!(
cpu_line.contains("99.9%"),
"CPU bar at 99.9% should show '99.9%', got: {cpu_line}"
);
}
#[test]
fn render_zero_swap() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
let snap = LinuxSnapshotBuilder::default().swap(0, 0).build();
apply_online(&mut state, 0, snap);
let output = render_state(&state, 80, 8);
let swap_line = output.lines().nth(3).unwrap();
assert!(
swap_line.contains("SWP"),
"SWP row should contain label, got: {swap_line}"
);
assert!(
swap_line.contains("0.0%"),
"zero swap should show '0.0%', got: {swap_line}"
);
}
#[test]
fn render_at_width_24() {
let config = test_config(&["x"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 24, 8);
assert!(!output.trim().is_empty());
let header = output.lines().next().unwrap();
assert!(header.contains('x'), "header at width 24: {header}");
}
#[test]
fn render_at_width_32() {
let config = test_config(&["x"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 32, 8);
assert!(!output.trim().is_empty());
let header = output.lines().next().unwrap();
assert!(header.contains('x'), "header at width 32: {header}");
}
#[test]
fn render_at_width_40() {
let config = test_config(&["x"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 40, 8);
assert!(!output.trim().is_empty());
let header = output.lines().next().unwrap();
assert!(header.contains('x'), "header at width 40: {header}");
}
#[test]
fn render_at_width_60() {
let config = test_config(&["x"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 60, 8);
assert!(!output.trim().is_empty());
let header = output.lines().next().unwrap();
assert!(header.contains('x'), "header at width 60: {header}");
}
#[test]
fn render_at_width_120() {
let config = test_config(&["x"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 120, 8);
assert!(!output.trim().is_empty());
let header = output.lines().next().unwrap();
assert!(header.contains('x'), "header at width 120: {header}");
}
#[test]
fn viewport_scrolling() {
let names: Vec<&str> = (0..6).map(|_| "sys").collect();
let config = test_config(&names);
let mut state = AppState::from_config(&config);
for i in 0..6 {
apply_online(&mut state, i, linux_snap());
}
let output = render_state(&state, 80, 12);
let nonblank = count_nonblank_lines(&output);
assert!(
nonblank <= 12,
"should not exceed terminal height, got {nonblank} non-blank lines"
);
assert!(
nonblank >= 8,
"should show at least 2 systems, got {nonblank} non-blank lines"
);
}
#[test]
fn render_unicode_name() {
let mut config = Config::default();
config.systems.push(SystemEntry {
id: "unicode-1".into(),
host: "host1.local".into(),
port: 11310,
name: Some("サーバー①".into()),
});
let mut state = AppState::from_config(&config);
let snap = LinuxSnapshotBuilder::default().build();
apply_online(&mut state, 0, snap);
let output = render_state(&state, 80, 8);
let header = output.lines().next().unwrap();
assert!(
header.starts_with('サ'),
"header should start with unicode name, got: {header}"
);
}
#[test]
fn online_system_uses_five_rows() {
let config = test_config(&["s1"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 80, 5);
let nonblank = count_nonblank_lines(&output);
assert_eq!(nonblank, 5, "one online system should use exactly 5 rows");
}
#[test]
fn render_populated_disk_and_selected_drive_details() {
let config = test_config(&["storage"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
state.systems[0].latest.as_mut().unwrap().drives = Some(vec![
NormalizedDrive {
name: "/".into(),
used_bytes: 238 * 1024 * 1024 * 1024,
total_bytes: 952 * 1024 * 1024 * 1024,
available_bytes: None,
},
NormalizedDrive {
name: "/mnt/archive".into(),
used_bytes: 142 * 1024 * 1024 * 1024,
total_bytes: 477 * 1024 * 1024 * 1024,
available_bytes: None,
},
]);
state.drives_expanded = true;
let output = render_state(&state, 200, 8);
assert!(output.lines().nth(4).unwrap().contains("DISK"));
assert!(
output.contains("380.0 GiB used"),
"expected aggregate detail in output:\n{output}"
);
assert!(output.contains("/mnt/archive"));
assert!(output.contains("142.0 GiB"));
assert!(output.contains("25.0%"));
}
#[test]
fn render_unavailable_disk_does_not_show_zero_percent() {
let config = test_config(&["legacy"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 80, 5);
let disk = output.lines().nth(4).unwrap();
assert!(disk.contains("DISK"));
assert!(disk.contains('—'));
assert!(!disk.contains("0.0%"));
}
#[test]
fn offline_system_uses_one_row() {
let config = test_config(&["s1"]);
let mut state = AppState::from_config(&config);
apply_offline(&mut state, 0);
let output = render_state(&state, 80, 1);
let nonblank = count_nonblank_lines(&output);
assert_eq!(nonblank, 1, "one offline system should use exactly 1 row");
}
#[test]
fn pending_system_uses_one_row() {
let config = test_config(&["s1"]);
let state = AppState::from_config(&config);
let output = render_state(&state, 80, 1);
let nonblank = count_nonblank_lines(&output);
assert_eq!(nonblank, 1, "one pending system should use exactly 1 row");
}
#[test]
fn mixed_online_offline_row_counts() {
let config = test_config(&["a", "b", "c"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
apply_offline(&mut state, 1);
let output = render_state(&state, 80, 7);
let nonblank = count_nonblank_lines(&output);
assert_eq!(
nonblank, 7,
"online(5) + offline(1) + pending(1) = 7, got {nonblank}"
);
}
#[test]
fn io_wait_shown_for_linux() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
let snap = LinuxSnapshotBuilder::default().iowait_pct(3.7).build();
apply_online(&mut state, 0, snap);
let output = render_state(&state, 80, 8);
let header = output.lines().next().unwrap();
assert!(
header.contains("IO 3.7%"),
"Linux header should show IO wait percentage, got: {header}"
);
}
#[test]
fn io_wait_none_for_macos() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, macos_snap());
let output = render_state(&state, 80, 8);
let header = output.lines().next().unwrap();
assert!(
header.contains("IO —"),
"macOS header should show 'IO —' (unsupported), got: {header}"
);
}
#[test]
fn load_averages_rendered_in_header() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
let snap = LinuxSnapshotBuilder::default()
.load(1.50, 2.00, 0.75)
.build();
apply_online(&mut state, 0, snap);
let output = render_state(&state, 80, 8);
let header = output.lines().next().unwrap();
assert!(
header.contains("1.50/2.00/0.75"),
"header should contain load averages, got: {header}"
);
}
#[test]
fn core_count_in_cpu_bar() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
let snap = LinuxSnapshotBuilder::default().logical_cores(16).build();
apply_online(&mut state, 0, snap);
let output = render_state(&state, 80, 8);
let cpu_line = output.lines().nth(1).unwrap();
assert!(
cpu_line.starts_with("CPU"),
"CPU bar should start with label, got: {cpu_line}"
);
assert!(
cpu_line.contains("25.2%"),
"CPU bar should show percentage, got: {cpu_line}"
);
}
#[test]
fn mem_bar_shows_usage_detail() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
let snap = LinuxSnapshotBuilder::default()
.memory(8_000_000_000, 16_000_000_000)
.build();
apply_online(&mut state, 0, snap);
let output = render_state(&state, 80, 8);
let mem_line = output.lines().nth(2).unwrap();
assert!(
mem_line.starts_with("MEM"),
"MEM bar should start with label, got: {mem_line}"
);
assert!(
mem_line.contains("50.0%"),
"MEM bar should show percentage, got: {mem_line}"
);
}
#[test]
fn swap_bar_shows_usage_detail() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
let snap = LinuxSnapshotBuilder::default()
.swap(1_000_000_000, 4_000_000_000)
.build();
apply_online(&mut state, 0, snap);
let output = render_state(&state, 80, 8);
let swap_line = output.lines().nth(3).unwrap();
assert!(
swap_line.starts_with("SWP"),
"SWP bar should start with label, got: {swap_line}"
);
assert!(
swap_line.contains("25.0%"),
"SWP bar should show percentage, got: {swap_line}"
);
}
#[test]
fn multiple_online_systems_render_independently() {
let config = test_config(&["a", "b"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap_custom(10.0, 0.0, 4));
state.apply_batch(&PollBatch {
generation: 2,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: state.systems[1].id.clone(),
endpoint: state.systems[1].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(linux_snap_custom(90.0, 0.0, 8))),
latency: Duration::from_millis(10),
}],
});
let output = render_state(&state, 80, 16);
let lines: Vec<&str> = output.lines().collect();
assert!(lines[0].contains('a'), "first header: {}", lines[0]);
assert!(lines[5].contains('b'), "second header: {}", lines[5]);
assert!(lines[1].contains("10.0%"), "a CPU: {}", lines[1]);
assert!(lines[6].contains("90.0%"), "b CPU: {}", lines[6]);
}
#[test]
fn empty_config_at_various_sizes() {
let config = Config::default();
let state = AppState::from_config(&config);
for &(w, h) in &[(80, 24), (40, 12), (20, 5), (120, 40)] {
let output = render_state(&state, w, h);
assert!(output.contains("No sources"), "at {w}x{h}: {output}");
}
}
#[test]
fn too_small_at_minimum_boundary() {
let config = test_config(&["s"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 23, 24);
assert!(
output.contains("terminal too"),
"width 23 should be too small:\n{output}"
);
}
#[test]
fn too_small_height_at_boundary() {
let config = test_config(&["s"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 80, 3);
assert!(
output.contains("terminal too"),
"height 3 should be too small:\n{output}"
);
}
#[test]
fn width_exactly_24_is_not_too_small() {
let config = test_config(&["s"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 24, 5);
assert!(
!output.contains("terminal too small"),
"width 24 should be valid:\n{output}"
);
assert!(output.contains('s'), "should render system: {output}");
}
#[test]
fn height_exactly_4_is_too_small_for_online_base() {
let config = test_config(&["s"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 80, 4);
assert!(output.contains("terminal too small"));
}
#[test]
fn selection_changes_reversed_style() {
let config = test_config(&["a", "b"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
state.apply_batch(&PollBatch {
generation: 2,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: state.systems[1].id.clone(),
endpoint: state.systems[1].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(linux_snap())),
latency: Duration::from_millis(10),
}],
});
let backend = TestBackend::new(80, 12);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| super::render(f, &state)).unwrap();
let buf = terminal.backend().buffer().clone();
assert!(
buf.cell((0, 0))
.unwrap()
.style()
.add_modifier
.contains(Modifier::REVERSED),
"a should be reversed"
);
assert!(
!buf.cell((0, 4))
.unwrap()
.style()
.add_modifier
.contains(Modifier::REVERSED),
"b should NOT be reversed"
);
state.apply_action(crate::action::Action::MoveDown);
let backend2 = TestBackend::new(80, 12);
let mut terminal2 = Terminal::new(backend2).unwrap();
terminal2.draw(|f| super::render(f, &state)).unwrap();
let buf2 = terminal2.backend().buffer().clone();
assert!(
!buf2
.cell((0, 0))
.unwrap()
.style()
.add_modifier
.contains(Modifier::REVERSED),
"a should NOT be reversed after moving selection"
);
assert!(
buf2.cell((0, 5))
.unwrap()
.style()
.add_modifier
.contains(Modifier::REVERSED),
"b should be reversed after moving selection"
);
}
#[test]
fn cpu_iowait_linux_header_shows_percentage() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
let snap = LinuxSnapshotBuilder::default().iowait_pct(1.2).build();
apply_online(&mut state, 0, snap);
let output = render_state(&state, 80, 8);
let header = output.lines().next().unwrap();
assert!(
header.contains("IO 1.2%"),
"Linux IO should show actual percentage, got: {header}"
);
}
#[test]
fn system_without_configured_name_uses_host() {
let mut config = Config::default();
config.systems.push(SystemEntry {
id: "no-name".into(),
host: "10.0.0.1".into(),
port: 11310,
name: None,
});
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 80, 8);
let header = output.lines().next().unwrap();
assert!(
header.contains("10.0.0.1"),
"should fall back to host when no name configured, got: {header}"
);
}
#[test]
fn offline_system_displays_address() {
let config = test_config(&["web1"]);
let mut state = AppState::from_config(&config);
apply_offline(&mut state, 0);
let output = render_state(&state, 80, 4);
assert!(
output.contains("host0.local:11310"),
"offline line should contain address, got: {output}"
);
assert!(
output.contains("web1"),
"offline line should contain name, got: {output}"
);
}
#[test]
fn very_narrow_width_just_above_minimum() {
let config = test_config(&["x"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 24, 5);
assert!(!output.trim().is_empty());
let header = output.lines().next().unwrap();
assert!(header.contains('x'), "header at 24x4: {header}");
}
#[test]
fn wide_terminal_renders_full_header() {
let config = test_config(&["box"]);
let mut state = AppState::from_config(&config);
let snap = LinuxSnapshotBuilder::default()
.load(1.00, 2.00, 3.00)
.logical_cores(32)
.build();
apply_online(&mut state, 0, snap);
let output = render_state(&state, 200, 40);
let header = output.lines().next().unwrap();
assert!(header.contains("box"), "header: {header}");
assert!(header.contains("IO"), "header: {header}");
assert!(header.contains("1.00/2.00/3.00"), "header: {header}");
assert!(
header.contains("32 cores") || header.contains("32c"),
"header: {header}"
);
assert!(header.contains("Ubuntu"), "header: {header}");
assert!(header.contains("6.8.0"), "header: {header}");
assert!(header.contains("x86_64"), "header: {header}");
}
#[test]
fn no_systems_configured_always_shows_message() {
let config = Config::default();
let state = AppState::from_config(&config);
for &(w, h) in &[(80, 24), (40, 10), (120, 50)] {
let output = render_state(&state, w, h);
assert!(
output.contains("No sources configured"),
"at {w}x{h}: {output}"
);
}
}
#[test]
fn offline_dot_padding() {
let config = test_config(&["short"]);
let mut state = AppState::from_config(&config);
apply_offline(&mut state, 0);
let output = render_state(&state, 80, 4);
let line = output.lines().next().unwrap();
assert!(
line.ends_with('.'),
"offline line should have dot padding, got: {line}"
);
}
#[test]
fn offline_no_padding_when_tight() {
let config = test_config(&["a"]);
let mut state = AppState::from_config(&config);
apply_offline(&mut state, 0);
let output = render_state(&state, 24, 4);
let line = output.lines().next().unwrap();
assert!(
line.contains('a'),
"tight offline line should contain name: {line}"
);
assert!(
line.contains("offl"),
"tight offline line should contain partial status: {line}"
);
}
#[test]
fn display_order_affects_rendering() {
let config = test_config(&["a", "b", "c"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
state.apply_batch(&PollBatch {
generation: 2,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: state.systems[2].id.clone(),
endpoint: state.systems[2].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(linux_snap())),
latency: Duration::from_millis(10),
}],
});
let output = render_state(&state, 80, 20);
let lines: Vec<&str> = output.lines().collect();
let first_header = lines.iter().find(|l| !l.trim().is_empty()).unwrap();
assert!(
first_header.contains('a'),
"first rendered should be online a, got: {first_header}"
);
}
#[test]
fn render_two_offline_systems() {
let config = test_config(&["x", "y"]);
let mut state = AppState::from_config(&config);
apply_offline(&mut state, 0);
state.apply_batch(&PollBatch {
generation: 2,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: state.systems[1].id.clone(),
endpoint: state.systems[1].endpoint.clone(),
outcome: PollOutcome::ConnectionRefused,
latency: Duration::from_millis(10),
}],
});
let output = render_state(&state, 80, 4);
assert!(output.contains('x'), "should contain x: {output}");
assert!(output.contains('y'), "should contain y: {output}");
let nonblank = count_nonblank_lines(&output);
assert_eq!(nonblank, 3, "two offline systems + hint = 3 rows");
}
#[test]
fn resize_round_trip_wide_narrow_wide() {
let config = test_config(&["srv"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let wide = render_state(&state, 120, 24);
let narrow = render_state(&state, 32, 8);
let wide_again = render_state(&state, 120, 24);
assert!(
wide.contains("x86_64"),
"wide: {}",
wide.lines().next().unwrap()
);
assert!(
!narrow.contains("x86_64"),
"narrow should drop arch: {}",
narrow.lines().next().unwrap()
);
assert!(
wide_again.contains("x86_64"),
"wide again: {}",
wide_again.lines().next().unwrap()
);
}
#[test]
fn key_hint_appears_when_extra_space() {
let config = test_config(&["s1"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 80, 12);
assert!(
output.contains("j/k:select"),
"key hint should appear with extra space:\n{output}"
);
}
#[test]
fn key_hint_absent_when_no_extra_space() {
let config = test_config(&["s1"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
let output = render_state(&state, 80, 5);
assert!(
!output.contains("j/k:select"),
"key hint should not appear when no extra space:\n{output}"
);
}
#[test]
fn render_online_system_without_snapshot_does_not_crash() {
let config = test_config(&["s"]);
let mut state = AppState::from_config(&config);
state.systems[0].reachability = Reachability::Online;
let output = render_state(&state, 80, 8);
assert!(
output.contains("waiting for data"),
"should show a pending state: {output}"
);
}
#[test]
fn render_windows_system_shows_commit_row() {
let config = test_config(&["win1"]);
let mut state = AppState::from_config(&config);
let snap = WindowsSnapshotV2Builder::default().build_payload();
let system = &state.systems[0];
let batch = PollBatch {
generation: 1,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: system.id.clone(),
endpoint: system.endpoint.clone(),
outcome: PollOutcome::OnlineV2(Box::new(snap)),
latency: Duration::from_millis(10),
}],
};
state.apply_batch(&batch);
let output = render_state(&state, 80, 8);
let lines: Vec<&str> = output.lines().collect();
assert!(
lines[3].contains("COMMIT"),
"Windows row 3 should contain 'COMMIT', got: {}",
lines[3]
);
assert!(
!lines[3].contains("SWP"),
"Windows row 3 should not contain 'SWP', got: {}",
lines[3]
);
}
#[test]
fn render_condensed_width_tiers_and_header_geometry() {
let config = test_config(&["fleet-host"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
state.system_view_mode = crate::state::SystemViewMode::Condensed;
let wide = render_state(&state, 80, 4);
assert!(wide.lines().next().unwrap().contains("HOST"));
assert!(wide.lines().next().unwrap().contains("IOWAIT"));
assert!(wide.lines().nth(1).unwrap().contains('─'));
assert!(wide.lines().nth(2).unwrap().contains("fleet-host"));
let narrow = render_state(&state, 30, 4);
let header = narrow.lines().next().unwrap();
assert!(header.contains("HOST"));
assert!(!header.contains("LOAD"));
assert!(!header.contains("IOWAIT"));
}
#[test]
fn render_condensed_expansion_keeps_base_row_and_details() {
let config = test_config(&["storage"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
state.system_view_mode = crate::state::SystemViewMode::Condensed;
state.drives_expanded = true;
state.systems[0].latest.as_mut().unwrap().drives = Some(vec![NormalizedDrive {
name: "/archive".into(),
used_bytes: 2 * 1024 * 1024 * 1024,
total_bytes: 4 * 1024 * 1024 * 1024,
available_bytes: None,
}]);
let output = render_state(&state, 80, 5);
assert!(output.lines().nth(2).unwrap().contains("storage"));
assert!(output.contains("/archive"));
assert!(output.contains("50.0%"));
}
#[test]
fn mixed_fleet_renders_protocol_capabilities_and_selected_details_in_both_views() {
let config = test_config(&["legacy", "linux", "mac", "windows", "offline", "pending"]);
let mut state = AppState::from_config(&config);
apply_online(&mut state, 0, linux_snap());
apply_online_v2(
&mut state,
1,
LinuxSnapshotV2Builder::default()
.drives(Some(vec![
DriveMetrics {
name: "/".into(),
used_bytes: 4,
total_bytes: 10,
available_bytes: None,
},
DriveMetrics {
name: "/home".into(),
used_bytes: 6,
total_bytes: 10,
available_bytes: None,
},
]))
.build_payload(),
2,
);
let mut mac = LinuxSnapshotV2Builder::default()
.drives(Some(vec![DriveMetrics {
name: "/Volumes/data".into(),
used_bytes: 1,
total_bytes: 4,
available_bytes: None,
}]))
.build_payload();
mac.snapshot.system.os_name = "macos".into();
mac.snapshot.capabilities.cpu_iowait = false;
mac.snapshot.cpu.iowait_pct = None;
mac.validate().unwrap();
apply_online_v2(&mut state, 2, mac, 3);
apply_online_v2(
&mut state,
3,
WindowsSnapshotV2Builder::default()
.drives(Some(vec![DriveMetrics {
name: "C:\\".into(),
used_bytes: 2,
total_bytes: 8,
available_bytes: None,
}]))
.build_payload(),
4,
);
apply_offline(&mut state, 4);
state.selected_id = Some("id-1".into());
state.drives_expanded = true;
state.viewport_top_id = None;
let normal = render_state(&state, 120, 30);
assert!(normal.contains("DISK"));
assert!(normal.contains("COMMIT"));
assert!(normal.contains("IO —"));
assert!(normal.contains("/home"));
assert!(normal.contains("offline"));
assert!(normal.contains("pending"));
assert!(!normal.contains("/Volumes/data"));
state.apply_action(crate::action::Action::ToggleSystemView);
let condensed = render_state(&state, 120, 12);
assert!(condensed.contains("HOST"));
assert!(condensed.contains("50%"));
assert!(condensed.contains("IOWAIT"));
assert!(condensed.contains("/home"));
assert!(!condensed.contains("/Volumes/data"));
}
}