#![allow(dead_code)]
use std::ops::Range;
use std::time::{Duration, Instant};
use crate::action::Action;
use crate::config::Config;
use crate::eggpool::{EggpoolFetchOutcome, EggpoolPeriod, EggpoolResult, EggpoolSummary};
use crate::endpoint::Endpoint;
use crate::normalized::NormalizedSnapshot;
use crate::poller::{PollBatch, PollOutcome};
pub type SystemId = String;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reachability {
Pending,
Online,
Offline,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefreshStatus {
Idle,
Polling {
generation: u64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemViewMode {
Normal,
Condensed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Systems,
Eggpool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EggpoolStatus {
Idle,
Refreshing,
WorkerUnavailable,
}
#[derive(Debug, Clone)]
pub struct EggpoolState {
pub endpoint: crate::config::EggpoolEntry,
pub period: EggpoolPeriod,
pub request_generation: u64,
pub status: EggpoolStatus,
pub summary: Option<EggpoolSummary>,
pub last_success_at: Option<Instant>,
pub last_attempt_at: Option<Instant>,
pub last_error: Option<EggpoolFetchOutcome>,
}
#[derive(Debug, Clone)]
pub struct SystemState {
pub id: SystemId,
pub endpoint: Endpoint,
pub configured_name: Option<String>,
pub reachability: Reachability,
pub latest: Option<NormalizedSnapshot>,
pub last_success_at: Option<Instant>,
pub last_attempt_at: Option<Instant>,
pub latency: Option<Duration>,
pub last_error: Option<PollOutcome>,
}
#[derive(Debug)]
pub struct AppState {
pub systems: Vec<SystemState>,
pub selected_id: Option<SystemId>,
pub viewport_top_id: Option<SystemId>,
pub last_applied_generation: u64,
pub refresh_status: RefreshStatus,
pub terminal_size: Option<(u16, u16)>,
pub active_pane: Pane,
pub system_view_mode: SystemViewMode,
pub drives_expanded: bool,
pub eggpool: Option<EggpoolState>,
}
impl AppState {
#[must_use]
pub fn from_config(config: &Config) -> Self {
let systems: Vec<SystemState> = config
.systems
.iter()
.map(|entry| SystemState {
id: entry.id.clone(),
endpoint: entry.to_endpoint(),
configured_name: entry.name.clone(),
reachability: Reachability::Pending,
latest: None,
last_success_at: None,
last_attempt_at: None,
latency: None,
last_error: None,
})
.collect();
let selected_id = systems.first().map(|s| s.id.clone());
let viewport_top_id = selected_id.clone();
let eggpool = config.eggpool.clone().map(|endpoint| EggpoolState {
endpoint,
period: EggpoolPeriod::Hour,
request_generation: 0,
status: EggpoolStatus::Idle,
summary: None,
last_success_at: None,
last_attempt_at: None,
last_error: None,
});
Self {
systems,
selected_id,
viewport_top_id,
last_applied_generation: 0,
refresh_status: RefreshStatus::Idle,
terminal_size: None,
active_pane: if config.systems.is_empty() && eggpool.is_some() {
Pane::Eggpool
} else {
Pane::Systems
},
system_view_mode: SystemViewMode::Normal,
drives_expanded: false,
eggpool,
}
}
pub fn apply_batch(&mut self, batch: &PollBatch) {
if batch.generation <= self.last_applied_generation {
return;
}
for result in &batch.results {
if let Some(system) = self.systems.iter_mut().find(|s| s.id == result.system_id) {
match &result.outcome {
PollOutcome::Cancelled => {}
PollOutcome::Online(snapshot) => {
system.reachability = Reachability::Online;
system.latest = Some(NormalizedSnapshot::from_v1(snapshot));
system.last_success_at = Some(batch.completed_at);
system.last_attempt_at = Some(batch.completed_at);
system.latency = Some(result.latency);
system.last_error = None;
}
PollOutcome::OnlineV2(snapshot) => {
system.reachability = Reachability::Online;
system.latest = Some(NormalizedSnapshot::from_v2_payload(snapshot));
system.last_success_at = Some(batch.completed_at);
system.last_attempt_at = Some(batch.completed_at);
system.latency = Some(result.latency);
system.last_error = None;
}
_ => {
system.reachability = Reachability::Offline;
system.last_attempt_at = Some(batch.completed_at);
system.last_error = Some(result.outcome.clone());
}
}
}
}
self.last_applied_generation = batch.generation;
ensure_selected_visible(self);
}
#[allow(clippy::match_same_arms)]
pub fn apply_action(&mut self, action: Action) {
match action {
Action::MoveDown => {
if self.active_pane == Pane::Eggpool {
self.move_eggpool_period(true);
return;
}
let order = self.display_order();
self.move_selection(&order, 1);
}
Action::MoveUp => {
if self.active_pane == Pane::Eggpool {
self.move_eggpool_period(false);
return;
}
let order = self.display_order();
self.move_selection(&order, -1_isize);
}
Action::PageDown if self.active_pane == Pane::Systems => {
let order = self.display_order();
let page = self.page_size();
self.move_selection(&order, page);
}
Action::PageUp if self.active_pane == Pane::Systems => {
let order = self.display_order();
let page = self.page_size();
self.move_selection(&order, -page);
}
Action::SelectFirst if self.active_pane == Pane::Systems => {
let order = self.display_order();
self.selected_id = order
.first()
.and_then(|&i| self.systems.get(i).map(|s| &s.id))
.cloned();
}
Action::SelectLast if self.active_pane == Pane::Systems => {
let order = self.display_order();
self.selected_id = order
.last()
.and_then(|&i| self.systems.get(i).map(|s| &s.id))
.cloned();
}
Action::PreviousPane => self.cycle_pane(false),
Action::NextPane => self.cycle_pane(true),
Action::ToggleSystemView if self.active_pane == Pane::Systems => {
self.system_view_mode = match self.system_view_mode {
SystemViewMode::Normal => SystemViewMode::Condensed,
SystemViewMode::Condensed => SystemViewMode::Normal,
};
}
Action::PageDown
| Action::PageUp
| Action::SelectFirst
| Action::SelectLast
| Action::RefreshNow
| Action::Quit => {}
Action::ToggleSystemView | Action::ToggleDrives
if self.active_pane == Pane::Eggpool => {}
Action::ToggleSystemView => {}
Action::ToggleDrives => {
self.drives_expanded = !self.drives_expanded;
}
Action::Resize { width, height } => {
self.terminal_size = Some((width, height));
ensure_selected_visible(self);
}
}
ensure_selected_visible(self);
}
#[must_use]
pub fn display_order(&self) -> Vec<usize> {
let mut online = Vec::new();
let mut offline = Vec::new();
for (i, system) in self.systems.iter().enumerate() {
match system.reachability {
Reachability::Online => online.push(i),
Reachability::Offline | Reachability::Pending => offline.push(i),
}
}
online.extend(offline);
online
}
pub fn apply_eggpool_result(&mut self, result: &EggpoolResult) {
let Some(eggpool) = self.eggpool.as_mut() else {
return;
};
if result.generation != eggpool.request_generation || result.period != eggpool.period {
return;
}
if !matches!(result.outcome, EggpoolFetchOutcome::Cancelled) {
eggpool.request_generation = result.generation;
eggpool.status = EggpoolStatus::Idle;
eggpool.last_attempt_at = Some(result.completed_at);
match &result.outcome {
EggpoolFetchOutcome::Online(summary) => {
eggpool.summary = Some(summary.clone());
eggpool.last_success_at = Some(result.completed_at);
eggpool.last_error = None;
}
error => eggpool.last_error = Some(error.clone()),
}
}
}
pub fn begin_eggpool_request(&mut self) -> Option<(EggpoolPeriod, u64)> {
let eggpool = self.eggpool.as_mut()?;
eggpool.request_generation = eggpool.request_generation.wrapping_add(1);
eggpool.status = EggpoolStatus::Refreshing;
Some((eggpool.period, eggpool.request_generation))
}
pub fn mark_eggpool_worker_unavailable(&mut self) {
if let Some(eggpool) = self.eggpool.as_mut() {
eggpool.status = EggpoolStatus::WorkerUnavailable;
}
}
#[must_use]
pub fn eggpool_request(&self) -> Option<(EggpoolPeriod, u64)> {
self.eggpool
.as_ref()
.map(|eggpool| (eggpool.period, eggpool.request_generation))
}
fn move_eggpool_period(&mut self, longer: bool) {
let Some(eggpool) = self.eggpool.as_mut() else {
return;
};
let next = if longer {
eggpool.period.longer()
} else {
eggpool.period.shorter()
};
if next == eggpool.period {
return;
}
eggpool.period = next;
eggpool.request_generation = eggpool.request_generation.wrapping_add(1);
eggpool.status = EggpoolStatus::Refreshing;
eggpool.summary = None;
eggpool.last_error = None;
}
fn cycle_pane(&mut self, next: bool) {
match (
self.active_pane,
self.systems.is_empty(),
self.eggpool.is_some(),
next,
) {
(Pane::Systems, false, true, _) => self.active_pane = Pane::Eggpool,
(Pane::Eggpool, _, true, _) if !self.systems.is_empty() => {
self.active_pane = Pane::Systems;
}
_ => {}
}
}
fn move_selection(&mut self, order: &[usize], offset: isize) {
if order.is_empty() {
self.selected_id = None;
return;
}
let current_pos = self
.selected_id
.as_ref()
.and_then(|sel| order.iter().position(|&i| &self.systems[i].id == sel))
.unwrap_or(0);
let len = order.len();
let new_pos = if offset >= 0 {
current_pos.saturating_add(usize::try_from(offset).unwrap_or(len))
} else {
current_pos.saturating_sub(usize::try_from(-offset).unwrap_or(current_pos))
}
.min(len - 1);
self.selected_id = order
.get(new_pos)
.and_then(|&i| self.systems.get(i))
.map(|s| s.id.clone());
}
fn page_size(&self) -> isize {
let height = self
.terminal_size
.map_or(24, |(_, h)| h)
.saturating_sub(view_header_height(self.system_view_mode));
let order = self.display_order();
let top_pos = self
.viewport_top_id
.as_ref()
.and_then(|top| order.iter().position(|&i| &self.systems[i].id == top))
.unwrap_or(0);
let mut rows = 0_u16;
let mut count = 0_isize;
for &idx in order.iter().skip(top_pos) {
let h = entry_height(self, idx);
if rows + h > height && count > 0 {
break;
}
rows += h;
count += 1;
}
count.max(1)
}
}
#[must_use]
pub fn entry_height(state: &AppState, system_index: usize) -> u16 {
let Some(system) = state.systems.get(system_index) else {
return 0;
};
match (state.system_view_mode, system.reachability) {
(SystemViewMode::Condensed, _) => {
if state.drives_expanded
&& state.selected_id.as_deref() == Some(system.id.as_str())
&& system.reachability == Reachability::Online
{
1_u16.saturating_add(valid_drive_count(system))
} else {
1
}
}
(SystemViewMode::Normal, Reachability::Pending | Reachability::Offline) => 1,
(SystemViewMode::Normal, Reachability::Online) => {
let details = if state.drives_expanded
&& state.selected_id.as_deref() == Some(system.id.as_str())
{
system
.latest
.as_ref()
.and_then(|snapshot| snapshot.drives.as_ref())
.map_or(0, |drives| valid_drive_count_from_slice(drives))
} else {
0
};
5_u16.saturating_add(details)
}
}
}
#[must_use]
pub fn visible_range(
display_order: &[usize],
state: &AppState,
top_index: usize,
height: u16,
) -> Range<usize> {
if height == 0 {
return 0..0;
}
let mut rows_used = 0_u16;
let mut count = 0_usize;
for &idx in display_order.iter().skip(top_index) {
if idx >= state.systems.len() {
break;
}
let h = entry_height(state, idx);
if count == 0 && height < minimum_render_height(state, idx) {
return top_index..top_index;
}
if rows_used + h > height && count > 0 {
break;
}
rows_used += h;
count += 1;
}
top_index..(top_index + count)
}
fn minimum_render_height(state: &AppState, system_index: usize) -> u16 {
match state
.systems
.get(system_index)
.map(|system| (state.system_view_mode, system.reachability))
{
Some((SystemViewMode::Normal, Reachability::Online)) => 5,
Some(_) => 1,
None => 0,
}
}
pub fn ensure_selected_visible(state: &mut AppState) {
let order = state.display_order();
if order.is_empty() {
return;
}
let (_, height) = state.terminal_size.unwrap_or((80, 24));
let selected_pos = state
.selected_id
.as_ref()
.and_then(|sel| order.iter().position(|&i| &state.systems[i].id == sel));
let top_pos = state
.viewport_top_id
.as_ref()
.and_then(|top| order.iter().position(|&i| &state.systems[i].id == top))
.unwrap_or(0);
let Some(selected_pos) = selected_pos else {
return;
};
let usable_height = height.saturating_sub(view_header_height(state.system_view_mode));
let visible = visible_range(&order, state, top_pos, usable_height);
if visible.contains(&selected_pos) {
return;
}
if selected_pos < top_pos {
state.viewport_top_id = Some(state.systems[order[selected_pos]].id.clone());
return;
}
if selected_pos >= top_pos {
let mut candidate = selected_pos;
while candidate > top_pos {
let previous = candidate - 1;
let range = visible_range(&order, state, previous, usable_height);
if range.contains(&selected_pos) {
candidate = previous;
} else {
break;
}
}
state.viewport_top_id = Some(state.systems[order[candidate]].id.clone());
}
}
#[must_use]
pub const fn view_header_height(system_view_mode: SystemViewMode) -> u16 {
match system_view_mode {
SystemViewMode::Normal => 0,
SystemViewMode::Condensed => 2,
}
}
fn valid_drive_count(system: &SystemState) -> u16 {
system
.latest
.as_ref()
.and_then(|snapshot| snapshot.drives.as_deref())
.map_or(0, valid_drive_count_from_slice)
}
fn valid_drive_count_from_slice(drives: &[crate::normalized::NormalizedDrive]) -> u16 {
drives
.iter()
.filter(|drive| drive.total_bytes > 0 && drive.used_bytes <= drive.total_bytes)
.count()
.try_into()
.unwrap_or(u16::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{EggpoolEntry, EggpoolScheme, SystemEntry};
use gregg_protocol::test_support::LinuxSnapshotBuilder;
use gregg_protocol::StatusSnapshot;
fn test_config_with_ids(ids: &[&str]) -> Config {
let mut config = Config::default();
for (i, id) in ids.iter().enumerate() {
config.systems.push(SystemEntry {
id: (*id).to_string(),
host: format!("host{i}.local"),
port: 11310 + u16::try_from(i).unwrap(),
name: Some(format!("System {i}")),
});
}
config
}
fn eggpool_config(with_system: bool) -> Config {
let mut config = if with_system {
test_config_with_ids(&["system"])
} else {
Config::default()
};
config.eggpool = Some(EggpoolEntry {
id: "eggpool-id".into(),
host: "pool.local".into(),
port: 11300,
scheme: EggpoolScheme::Http,
name: Some("Main EggPool".into()),
api_key_env: None,
});
config
}
fn make_snapshot() -> StatusSnapshot {
LinuxSnapshotBuilder::default().build()
}
#[test]
fn from_config_creates_correct_initial_state() {
let config = test_config_with_ids(&["a", "b", "c"]);
let state = AppState::from_config(&config);
assert_eq!(state.systems.len(), 3);
assert_eq!(state.selected_id.as_deref(), Some("a"));
assert_eq!(state.viewport_top_id.as_deref(), Some("a"));
assert_eq!(state.last_applied_generation, 0);
assert_eq!(state.refresh_status, RefreshStatus::Idle);
assert!(state.terminal_size.is_none());
for system in &state.systems {
assert_eq!(system.reachability, Reachability::Pending);
assert!(system.latest.is_none());
}
}
#[test]
fn from_config_empty_systems() {
let config = Config::default();
let state = AppState::from_config(&config);
assert!(state.systems.is_empty());
assert!(state.selected_id.is_none());
assert!(state.viewport_top_id.is_none());
}
#[test]
fn pane_initialization_and_cycling_follow_configured_sources() {
let systems = AppState::from_config(&test_config_with_ids(&["a"]));
assert_eq!(systems.active_pane, Pane::Systems);
let eggpool = AppState::from_config(&eggpool_config(false));
assert_eq!(eggpool.active_pane, Pane::Eggpool);
assert!(eggpool.eggpool.is_some());
let mut both = AppState::from_config(&eggpool_config(true));
both.apply_action(Action::NextPane);
assert_eq!(both.active_pane, Pane::Eggpool);
both.apply_action(Action::PreviousPane);
assert_eq!(both.active_pane, Pane::Systems);
}
#[test]
fn eggpool_period_movement_is_bounded_and_invalidates_old_summary() {
let mut state = AppState::from_config(&eggpool_config(false));
assert_eq!(state.eggpool.as_ref().unwrap().period, EggpoolPeriod::Hour);
state.apply_action(Action::MoveUp);
assert_eq!(state.eggpool.as_ref().unwrap().period, EggpoolPeriod::Hour);
state.apply_action(Action::MoveDown);
state.apply_action(Action::MoveDown);
state.apply_action(Action::MoveDown);
state.apply_action(Action::MoveDown);
let eggpool = state.eggpool.as_ref().unwrap();
assert_eq!(eggpool.period, EggpoolPeriod::Month);
assert_eq!(eggpool.request_generation, 3);
assert!(eggpool.summary.is_none());
}
#[test]
fn eggpool_results_reject_stale_or_mismatched_requests_and_retain_same_period_failures() {
let mut state = AppState::from_config(&eggpool_config(false));
state.apply_action(Action::MoveDown);
let now = Instant::now();
let summary = EggpoolSummary {
accounted_tokens: 42,
cache_read_ratio: Some(0.5),
output_tokens_per_second: 2.0,
avg_ttft_ms: Some(12.0),
period: EggpoolPeriod::Day,
};
let result = |generation, period, outcome| EggpoolResult {
generation,
period,
started_at: now,
completed_at: now,
outcome,
};
state.apply_eggpool_result(&result(
0,
EggpoolPeriod::Day,
EggpoolFetchOutcome::Online(summary.clone()),
));
assert!(state.eggpool.as_ref().unwrap().summary.is_none());
state.apply_eggpool_result(&result(
1,
EggpoolPeriod::Hour,
EggpoolFetchOutcome::Online(summary.clone()),
));
assert!(state.eggpool.as_ref().unwrap().summary.is_none());
state.apply_eggpool_result(&result(
1,
EggpoolPeriod::Day,
EggpoolFetchOutcome::Online(summary),
));
assert!(state.eggpool.as_ref().unwrap().summary.is_some());
state.apply_eggpool_result(&result(1, EggpoolPeriod::Day, EggpoolFetchOutcome::Timeout));
assert!(state.eggpool.as_ref().unwrap().summary.is_some());
assert!(matches!(
state.eggpool.as_ref().unwrap().last_error,
Some(EggpoolFetchOutcome::Timeout)
));
state.apply_eggpool_result(&result(
1,
EggpoolPeriod::Day,
EggpoolFetchOutcome::Online(EggpoolSummary {
accounted_tokens: 43,
cache_read_ratio: None,
output_tokens_per_second: 3.0,
avg_ttft_ms: None,
period: EggpoolPeriod::Day,
}),
));
assert_eq!(
state
.eggpool
.as_ref()
.unwrap()
.summary
.as_ref()
.unwrap()
.accounted_tokens,
43
);
}
#[test]
fn apply_batch_online_result() {
let config = test_config_with_ids(&["a", "b"]);
let mut state = AppState::from_config(&config);
let snap = make_snapshot();
let batch = PollBatch {
generation: 1,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: "a".into(),
endpoint: state.systems[0].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(snap.clone())),
latency: Duration::from_millis(50),
}],
};
state.apply_batch(&batch);
assert_eq!(state.systems[0].reachability, Reachability::Online);
assert!(state.systems[0].latest.is_some());
assert!(state.systems[0].last_success_at.is_some());
assert!(state.systems[0].latency.is_some());
assert!(state.systems[0].last_error.is_none());
assert_eq!(state.last_applied_generation, 1);
assert_eq!(state.systems[1].reachability, Reachability::Pending);
}
#[test]
fn apply_batch_offline_result() {
let config = test_config_with_ids(&["a"]);
let mut state = AppState::from_config(&config);
let batch = PollBatch {
generation: 1,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: "a".into(),
endpoint: state.systems[0].endpoint.clone(),
outcome: PollOutcome::ConnectionRefused,
latency: Duration::from_millis(10),
}],
};
state.apply_batch(&batch);
assert_eq!(state.systems[0].reachability, Reachability::Offline);
assert!(state.systems[0].latest.is_none());
assert!(state.systems[0].last_attempt_at.is_some());
assert!(state.systems[0].last_error.is_some());
}
#[test]
fn apply_batch_rejects_old_generation() {
let config = test_config_with_ids(&["a"]);
let mut state = AppState::from_config(&config);
let batch = PollBatch {
generation: 2,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: "a".into(),
endpoint: state.systems[0].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(make_snapshot())),
latency: Duration::from_millis(50),
}],
};
state.apply_batch(&batch);
assert_eq!(state.last_applied_generation, 2);
let old_batch = PollBatch {
generation: 1,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: "a".into(),
endpoint: state.systems[0].endpoint.clone(),
outcome: PollOutcome::ConnectionRefused,
latency: Duration::from_millis(10),
}],
};
state.apply_batch(&old_batch);
assert_eq!(state.last_applied_generation, 2);
assert_eq!(state.systems[0].reachability, Reachability::Online);
}
#[test]
fn apply_batch_cancelled_no_state_change() {
let config = test_config_with_ids(&["a"]);
let mut state = AppState::from_config(&config);
let batch = PollBatch {
generation: 1,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: "a".into(),
endpoint: state.systems[0].endpoint.clone(),
outcome: PollOutcome::Cancelled,
latency: Duration::from_millis(50),
}],
};
state.apply_batch(&batch);
assert_eq!(state.systems[0].reachability, Reachability::Pending);
}
#[test]
fn display_order_online_first() {
let config = test_config_with_ids(&["a", "b", "c"]);
let mut state = AppState::from_config(&config);
let batch = PollBatch {
generation: 1,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: "b".into(),
endpoint: state.systems[1].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(make_snapshot())),
latency: Duration::from_millis(50),
}],
};
state.apply_batch(&batch);
let order = state.display_order();
assert_eq!(order.len(), 3);
assert_eq!(state.systems[order[0]].id, "b");
let remaining: Vec<&str> = order[1..]
.iter()
.map(|&i| state.systems[i].id.as_str())
.collect();
assert_eq!(remaining, vec!["a", "c"]);
}
#[test]
fn display_order_preserves_configured_order() {
let config = test_config_with_ids(&["a", "b", "c", "d"]);
let mut state = AppState::from_config(&config);
let batch = PollBatch {
generation: 1,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![
crate::poller::PollResult {
system_id: "c".into(),
endpoint: state.systems[2].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(make_snapshot())),
latency: Duration::from_millis(50),
},
crate::poller::PollResult {
system_id: "a".into(),
endpoint: state.systems[0].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(make_snapshot())),
latency: Duration::from_millis(50),
},
],
};
state.apply_batch(&batch);
let order = state.display_order();
assert_eq!(state.systems[order[0]].id, "a");
assert_eq!(state.systems[order[1]].id, "c");
assert_eq!(state.systems[order[2]].id, "b");
assert_eq!(state.systems[order[3]].id, "d");
}
#[test]
fn select_next_moves_forward() {
let config = test_config_with_ids(&["a", "b", "c"]);
let mut state = AppState::from_config(&config);
assert_eq!(state.selected_id.as_deref(), Some("a"));
state.apply_action(Action::MoveDown);
assert_eq!(state.selected_id.as_deref(), Some("b"));
state.apply_action(Action::MoveDown);
assert_eq!(state.selected_id.as_deref(), Some("c"));
state.apply_action(Action::MoveDown);
assert_eq!(state.selected_id.as_deref(), Some("c"));
}
#[test]
fn select_previous_moves_backward() {
let config = test_config_with_ids(&["a", "b", "c"]);
let mut state = AppState::from_config(&config);
state.apply_action(Action::MoveDown);
state.apply_action(Action::MoveDown);
assert_eq!(state.selected_id.as_deref(), Some("c"));
state.apply_action(Action::MoveUp);
assert_eq!(state.selected_id.as_deref(), Some("b"));
state.apply_action(Action::MoveUp);
assert_eq!(state.selected_id.as_deref(), Some("a"));
state.apply_action(Action::MoveUp);
assert_eq!(state.selected_id.as_deref(), Some("a"));
}
#[test]
fn select_first_and_last() {
let config = test_config_with_ids(&["a", "b", "c"]);
let mut state = AppState::from_config(&config);
state.apply_action(Action::SelectLast);
assert_eq!(state.selected_id.as_deref(), Some("c"));
state.apply_action(Action::SelectFirst);
assert_eq!(state.selected_id.as_deref(), Some("a"));
}
#[test]
fn page_down_and_up() {
let config = test_config_with_ids(&["a", "b", "c", "d", "e", "f", "g", "h"]);
let mut state = AppState::from_config(&config);
state.terminal_size = Some((80, 20));
state.apply_action(Action::PageDown);
let after_page_down = state.selected_id.clone();
assert_ne!(after_page_down.as_deref(), Some("a"));
state.apply_action(Action::PageUp);
let after_page_up = state.selected_id.clone();
assert_eq!(after_page_up.as_deref(), Some("a"));
}
#[test]
fn selection_preserved_across_reorder() {
let config = test_config_with_ids(&["a", "b", "c"]);
let mut state = AppState::from_config(&config);
state.apply_action(Action::MoveDown);
assert_eq!(state.selected_id.as_deref(), Some("b"));
let batch = PollBatch {
generation: 1,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![crate::poller::PollResult {
system_id: "a".into(),
endpoint: state.systems[0].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(make_snapshot())),
latency: Duration::from_millis(50),
}],
};
state.apply_batch(&batch);
assert_eq!(state.selected_id.as_deref(), Some("b"));
}
#[test]
fn entry_height_online_is_five() {
let mut state = AppState {
systems: vec![SystemState {
id: "test".into(),
endpoint: Endpoint::new("host".into(), 11310, None),
configured_name: None,
reachability: Reachability::Online,
latest: None,
last_success_at: None,
last_attempt_at: None,
latency: None,
last_error: None,
}],
selected_id: Some("test".into()),
viewport_top_id: Some("test".into()),
last_applied_generation: 0,
refresh_status: RefreshStatus::Idle,
terminal_size: None,
active_pane: Pane::Systems,
system_view_mode: SystemViewMode::Normal,
drives_expanded: false,
eggpool: None,
};
assert_eq!(entry_height(&state, 0), 5);
state.systems[0].reachability = Reachability::Pending;
assert_eq!(entry_height(&state, 0), 1);
state.systems[0].reachability = Reachability::Offline;
assert_eq!(entry_height(&state, 0), 1);
}
#[test]
fn visible_range_handles_mixed_heights() {
let config = test_config_with_ids(&["a", "b", "c", "d", "e"]);
let state = AppState::from_config(&config);
let order = state.display_order();
let range = visible_range(&order, &state, 0, 20);
assert!(!range.is_empty());
}
#[test]
fn visible_range_small_terminal() {
let config = test_config_with_ids(&["a", "b", "c"]);
let mut state = AppState::from_config(&config);
state.systems[0].reachability = Reachability::Online;
let order = state.display_order();
let range = visible_range(&order, &state, 0, 3);
assert!(range.is_empty());
}
#[test]
fn visible_range_online_boundary_is_five_rows() {
let config = test_config_with_ids(&["a"]);
let mut state = AppState::from_config(&config);
state.systems[0].reachability = Reachability::Online;
let order = state.display_order();
assert!(visible_range(&order, &state, 0, 4).is_empty());
assert_eq!(visible_range(&order, &state, 0, 5), 0..1);
}
#[test]
fn visible_range_first_offline_entry_does_not_reserve_online_height() {
let config = test_config_with_ids(&["offline", "online"]);
let mut state = AppState::from_config(&config);
state.systems[1].reachability = Reachability::Online;
let order = vec![0, 1];
assert_eq!(visible_range(&order, &state, 0, 1), 0..1);
}
#[test]
fn visible_range_expanded_online_entry_clips_only_drive_rows() {
let config = test_config_with_ids(&["a"]);
let mut state = AppState::from_config(&config);
state.systems[0].reachability = Reachability::Online;
state.systems[0].latest = Some(NormalizedSnapshot::from_v1(&make_snapshot()));
state.systems[0].latest.as_mut().unwrap().drives = Some(
(0..3)
.map(|index| crate::normalized::NormalizedDrive {
name: format!("drive{index}"),
used_bytes: 1,
total_bytes: 2,
available_bytes: None,
})
.collect(),
);
state.selected_id = Some("a".into());
state.drives_expanded = true;
let order = state.display_order();
assert_eq!(visible_range(&order, &state, 0, 5), 0..1);
assert_eq!(visible_range(&order, &state, 0, 6), 0..1);
assert_eq!(entry_height(&state, 0), 8);
let viewport =
crate::ui::layout::compute_viewport(&state, ratatui::layout::Rect::new(0, 0, 80, 5));
assert_eq!(viewport[0].drive_rows_visible, 0);
let viewport =
crate::ui::layout::compute_viewport(&state, ratatui::layout::Rect::new(0, 0, 80, 6));
assert_eq!(viewport[0].drive_rows_visible, 1);
}
#[test]
fn ensure_selected_visible_adjusts_viewport() {
let config = test_config_with_ids(&["a", "b", "c", "d", "e"]);
let mut state = AppState::from_config(&config);
state.terminal_size = Some((80, 6));
state.apply_action(Action::SelectLast);
assert_eq!(state.selected_id.as_deref(), Some("e"));
ensure_selected_visible(&mut state);
let order = state.display_order();
let top_pos = state
.viewport_top_id
.as_ref()
.and_then(|top| order.iter().position(|&i| &state.systems[i].id == top));
let selected_pos = order
.iter()
.position(|&i| &state.systems[i].id == state.selected_id.as_ref().unwrap());
assert!(top_pos.is_some());
assert!(selected_pos.is_some());
assert!(selected_pos.unwrap() >= top_pos.unwrap());
}
#[test]
fn selection_stays_visible_across_dynamic_online_entries() {
let config = test_config_with_ids(&["a", "b", "c", "d"]);
let mut state = AppState::from_config(&config);
state.terminal_size = Some((80, 10));
for system in &mut state.systems {
system.reachability = Reachability::Online;
system.latest = Some(NormalizedSnapshot::from_v1(&make_snapshot()));
}
state.apply_action(Action::SelectLast);
let order = state.display_order();
let top = order
.iter()
.position(|&index| state.systems[index].id == state.viewport_top_id.clone().unwrap())
.unwrap();
let selected = order
.iter()
.position(|&index| state.systems[index].id == state.selected_id.clone().unwrap())
.unwrap();
assert_eq!(top, 2);
assert!(visible_range(&order, &state, top, 10).contains(&selected));
state.apply_action(Action::MoveUp);
assert_eq!(state.viewport_top_id.as_deref(), Some("c"));
}
#[test]
fn expansion_changes_only_selected_entry_height() {
let config = test_config_with_ids(&["a", "b"]);
let mut state = AppState::from_config(&config);
for system in &mut state.systems {
system.reachability = Reachability::Online;
system.latest = Some(NormalizedSnapshot::from_v1(&make_snapshot()));
}
state.systems[0].latest.as_mut().unwrap().drives =
Some(vec![crate::normalized::NormalizedDrive {
name: "/".into(),
used_bytes: 1,
total_bytes: 2,
available_bytes: None,
}]);
assert_eq!(entry_height(&state, 0), 5);
assert_eq!(entry_height(&state, 1), 5);
state.apply_action(Action::ToggleDrives);
assert_eq!(entry_height(&state, 0), 6);
assert_eq!(entry_height(&state, 1), 5);
}
#[test]
fn resize_updates_terminal_size() {
let config = test_config_with_ids(&["a"]);
let mut state = AppState::from_config(&config);
state.apply_action(Action::Resize {
width: 120,
height: 40,
});
assert_eq!(state.terminal_size, Some((120, 40)));
}
#[test]
fn empty_config_no_selection() {
let config = Config::default();
let mut state = AppState::from_config(&config);
state.apply_action(Action::MoveDown);
assert!(state.selected_id.is_none());
state.apply_action(Action::MoveUp);
assert!(state.selected_id.is_none());
state.apply_action(Action::SelectFirst);
assert!(state.selected_id.is_none());
state.apply_action(Action::SelectLast);
assert!(state.selected_id.is_none());
}
#[test]
fn multiple_systems_online_offline_mixed_display_order() {
let config = test_config_with_ids(&["a", "b", "c", "d", "e"]);
let mut state = AppState::from_config(&config);
let batch = PollBatch {
generation: 1,
started_at: Instant::now(),
completed_at: Instant::now(),
results: vec![
crate::poller::PollResult {
system_id: "a".into(),
endpoint: state.systems[0].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(make_snapshot())),
latency: Duration::from_millis(50),
},
crate::poller::PollResult {
system_id: "c".into(),
endpoint: state.systems[2].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(make_snapshot())),
latency: Duration::from_millis(50),
},
crate::poller::PollResult {
system_id: "e".into(),
endpoint: state.systems[4].endpoint.clone(),
outcome: PollOutcome::Online(Box::new(make_snapshot())),
latency: Duration::from_millis(50),
},
],
};
state.apply_batch(&batch);
let order = state.display_order();
assert_eq!(order.len(), 5);
assert_eq!(state.systems[order[0]].id, "a");
assert_eq!(state.systems[order[1]].id, "c");
assert_eq!(state.systems[order[2]].id, "e");
assert_eq!(state.systems[order[3]].id, "b");
assert_eq!(state.systems[order[4]].id, "d");
}
#[test]
fn view_controls_wrap_and_preserve_selection_and_expansion() {
let config = test_config_with_ids(&["a", "b"]);
let mut state = AppState::from_config(&config);
state.terminal_size = Some((80, 8));
state.systems[0].reachability = Reachability::Online;
state.systems[0].latest = Some(NormalizedSnapshot::from_v1(&make_snapshot()));
state.selected_id = Some("a".into());
state.apply_action(Action::ToggleDrives);
state.apply_action(Action::ToggleSystemView);
assert_eq!(state.system_view_mode, SystemViewMode::Condensed);
assert!(state.drives_expanded);
assert_eq!(state.selected_id.as_deref(), Some("a"));
state.apply_action(Action::ToggleSystemView);
assert_eq!(state.system_view_mode, SystemViewMode::Normal);
assert!(state.drives_expanded);
}
#[test]
fn condensed_expansion_counts_only_valid_drive_rows() {
let config = test_config_with_ids(&["a"]);
let mut state = AppState::from_config(&config);
state.system_view_mode = SystemViewMode::Condensed;
state.drives_expanded = true;
state.systems[0].reachability = Reachability::Online;
let mut snapshot = NormalizedSnapshot::from_v1(&make_snapshot());
snapshot.drives = Some(vec![
crate::normalized::NormalizedDrive {
name: "/".into(),
used_bytes: 1,
total_bytes: 2,
available_bytes: None,
},
crate::normalized::NormalizedDrive {
name: "/bad".into(),
used_bytes: 3,
total_bytes: 2,
available_bytes: None,
},
]);
state.systems[0].latest = Some(snapshot);
assert_eq!(entry_height(&state, 0), 2);
}
}