use crossterm::event::KeyCode;
use super::*;
use crate::tui::event::AppEvent;
fn make_server(id: i32, name: &str, status: &str) -> ServerSummary {
ServerSummary {
id,
name: name.to_string(),
status: status.to_string(),
cpu: 2,
ram_mb: 4096,
disk_gb: 50,
ip: "192.168.1.1".to_string(),
location: "msk".to_string()
}
}
fn make_database(id: i32, name: &str, engine: &str) -> DatabaseSummary {
DatabaseSummary {
id,
name: name.to_string(),
status: "active".to_string(),
engine: engine.to_string(),
size_mb: 512,
..Default::default()
}
}
fn make_s3(id: i32, name: &str) -> S3Summary {
S3Summary {
id,
name: name.to_string(),
region: "ru-1".to_string(),
size_kb: 1_000_000,
object_count: 3
}
}
fn make_k8s(id: i32, name: &str) -> K8sSummary {
K8sSummary {
id,
name: name.to_string(),
status: "running".to_string(),
version: "1.28".to_string(),
cpu: 4,
ram_mb: 8192,
disk_gb: 100
}
}
fn make_project(id: i32, name: &str) -> ProjectSummary {
ProjectSummary {
id,
name: name.to_string(),
server_count: 2,
..Default::default()
}
}
#[test]
fn new_app_defaults() {
let app = App::new(5);
assert!(app.servers.is_empty());
assert!(app.databases.is_empty());
assert!(app.s3_storages.is_empty());
assert!(app.k8s_clusters.is_empty());
assert!(app.projects.is_empty());
assert_eq!(app.selected, 0);
assert_eq!(app.active_tab, ResourceTab::Servers);
assert!(app.running);
assert!(!app.show_help);
assert_eq!(app.refresh_interval, Duration::from_secs(5));
}
#[test]
fn account_default() {
let app = App::new(5);
assert_eq!(app.account.account_id, 0);
assert!(app.account.balance.is_empty());
assert!(app.account.status.is_empty());
}
#[test]
fn select_previous_clamps_at_zero() {
let mut app = App::new(5);
app.servers = vec![make_server(1, "s1", "running")];
app.select_previous();
assert_eq!(app.selected, 0);
}
#[test]
fn select_next_clamps_at_end() {
let mut app = App::new(5);
app.servers = vec![make_server(1, "s1", "running")];
app.select_next();
assert_eq!(app.selected, 0);
}
#[test]
fn select_next_moves_down() {
let mut app = App::new(5);
app.servers = vec![
make_server(1, "s1", "running"),
make_server(2, "s2", "running"),
];
app.select_next();
assert_eq!(app.selected, 1);
}
#[test]
fn select_previous_moves_up() {
let mut app = App::new(5);
app.servers = vec![
make_server(1, "s1", "running"),
make_server(2, "s2", "running"),
];
app.selected = 1;
app.select_previous();
assert_eq!(app.selected, 0);
}
#[test]
fn nav_walks_service_tabs_in_order() {
let mut app = App::new(5);
nav_select_service(&mut app, ResourceTab::Servers);
assert_eq!(app.active_tab, ResourceTab::Servers);
app.nav_down();
assert_eq!(app.active_tab, ResourceTab::Databases);
app.nav_down();
assert_eq!(app.active_tab, ResourceTab::S3);
app.nav_down();
assert_eq!(app.active_tab, ResourceTab::Kubernetes);
app.nav_down();
assert_eq!(app.active_tab, ResourceTab::Balancers);
let last = app.nav_items().len() - 1;
for _ in 0..last {
app.nav_down();
}
assert_eq!(app.nav_selected, last);
assert_eq!(app.active_tab, ResourceTab::Finances);
}
#[test]
fn toggle_help() {
let mut app = App::new(5);
assert!(!app.show_help);
app.toggle_help();
assert!(app.show_help);
app.toggle_help();
assert!(!app.show_help);
}
#[test]
fn quit_sets_running_false() {
let mut app = App::new(5);
app.quit();
assert!(!app.running);
}
#[test]
fn push_cpu_rolling_window() {
let mut app = App::new(5);
for i in 0..65 {
app.push_cpu(f64::from(i));
}
assert_eq!(app.cpu_history.len(), 60);
assert!((*app.cpu_history.front().unwrap() - 5.0).abs() < f64::EPSILON);
assert!((*app.cpu_history.back().unwrap() - 64.0).abs() < f64::EPSILON);
}
#[test]
fn push_ram_rolling_window() {
let mut app = App::new(5);
for i in 0..65 {
app.push_ram(f64::from(i));
}
assert_eq!(app.ram_history.len(), 60);
}
#[test]
fn push_net_in_rolling_window() {
let mut app = App::new(5);
for i in 0..65i32 {
app.push_net_in(f64::from(i));
}
assert_eq!(app.net_in_history.len(), 60);
}
#[test]
fn push_net_out_rolling_window() {
let mut app = App::new(5);
for i in 0..65i32 {
app.push_net_out(f64::from(i));
}
assert_eq!(app.net_out_history.len(), 60);
}
#[test]
fn update_servers_clamps_selection() {
let mut app = App::new(5);
app.servers = vec![
make_server(1, "s1", "running"),
make_server(2, "s2", "running"),
];
app.selected = 5;
app.apply_slice(DataSlice::Servers(vec![make_server(3, "s3", "running")]));
assert_eq!(app.selected, 0);
assert_eq!(app.servers.len(), 1);
}
#[test]
fn update_databases_clamps_selection() {
let mut app = App::new(5);
app.databases = vec![
make_database(1, "db1", "postgres"),
make_database(2, "db2", "mysql"),
];
app.selected = 5;
app.apply_slice(DataSlice::Databases(vec![make_database(3, "db3", "redis")]));
assert_eq!(app.selected, 0);
assert_eq!(app.databases.len(), 1);
}
#[test]
fn update_s3_clamps_selection() {
let mut app = App::new(5);
app.s3_storages = vec![make_s3(1, "bucket1")];
app.selected = 5;
app.apply_slice(DataSlice::S3(vec![make_s3(2, "bucket2")]));
assert_eq!(app.selected, 0);
assert_eq!(app.s3_storages.len(), 1);
}
#[test]
fn update_k8s_clamps_selection() {
let mut app = App::new(5);
app.k8s_clusters = vec![make_k8s(1, "cluster1")];
app.selected = 5;
app.apply_slice(DataSlice::K8s(vec![make_k8s(2, "cluster2")]));
assert_eq!(app.selected, 0);
assert_eq!(app.k8s_clusters.len(), 1);
}
#[test]
fn update_projects_clamps_selection() {
let mut app = App::new(5);
app.projects = vec![make_project(1, "proj1")];
app.selected = 5;
app.apply_slice(DataSlice::Projects(vec![make_project(2, "proj2")]));
assert_eq!(app.selected, 0);
assert_eq!(app.projects.len(), 1);
}
#[test]
fn needs_refresh_after_interval() {
let app = App::new(0);
assert!(app.needs_refresh());
}
#[test]
fn tab_names_and_indices() {
assert_eq!(ResourceTab::Servers.index(), 0);
assert_eq!(ResourceTab::Databases.index(), 1);
assert_eq!(ResourceTab::S3.index(), 2);
assert_eq!(ResourceTab::Kubernetes.index(), 3);
assert_eq!(ResourceTab::Projects.index(), 4);
assert_eq!(ResourceTab::Finances.index(), 19);
assert_eq!(ResourceTab::names().len(), 20);
}
#[test]
fn current_list_len_servers() {
let mut app = App::new(5);
app.servers = vec![make_server(1, "s1", "running")];
assert_eq!(app.current_list_len(), 1);
}
#[test]
fn current_list_len_databases() {
let mut app = App::new(5);
app.active_tab = ResourceTab::Databases;
app.databases = vec![make_database(1, "db1", "pg")];
assert_eq!(app.current_list_len(), 1);
}
#[test]
fn handle_key_quit() {
let mut app = App::new(5);
let event = AppEvent::Key(crossterm::event::KeyEvent {
code: KeyCode::Char('Q'),
modifiers: crossterm::event::KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE
});
let keep_going = crate::tui::event::handle_event(&mut app, event);
assert!(!keep_going);
assert!(!app.running);
}
#[test]
fn handle_key_tab() {
let mut app = App::new(5);
app.pane = Pane::Content;
nav_select_service(&mut app, ResourceTab::Servers);
let event = AppEvent::Key(crossterm::event::KeyEvent {
code: KeyCode::Tab,
modifiers: crossterm::event::KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE
});
let keep_going = crate::tui::event::handle_event(&mut app, event);
assert!(keep_going);
assert_eq!(app.active_tab, ResourceTab::Databases);
}
#[test]
fn handle_key_vim_k() {
let mut app = App::new(5);
app.pane = Pane::Content;
nav_select_service(&mut app, ResourceTab::Servers);
app.servers = vec![
make_server(1, "s1", "running"),
make_server(2, "s2", "running"),
];
app.selected = 1;
let event = AppEvent::Key(crossterm::event::KeyEvent {
code: KeyCode::Char('k'),
modifiers: crossterm::event::KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE
});
let keep_going = crate::tui::event::handle_event(&mut app, event);
assert!(keep_going);
assert_eq!(app.selected, 0);
}
#[test]
fn handle_key_vim_j() {
let mut app = App::new(5);
app.pane = Pane::Content;
nav_select_service(&mut app, ResourceTab::Servers);
app.servers = vec![
make_server(1, "s1", "running"),
make_server(2, "s2", "running"),
];
let event = AppEvent::Key(crossterm::event::KeyEvent {
code: KeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE
});
let keep_going = crate::tui::event::handle_event(&mut app, event);
assert!(keep_going);
assert_eq!(app.selected, 1);
}
fn nav_select_where(app: &mut App, pred: impl Fn(&crate::tui::app::NavKind) -> bool) {
let index = app
.nav_items()
.iter()
.position(|i| pred(&i.kind))
.expect("nav entry");
app.nav_selected = index;
}
fn nav_select_service(app: &mut App, tab: ResourceTab) {
nav_select_where(app, |k| *k == crate::tui::app::NavKind::Service(tab));
}
fn nav_select_project(app: &mut App, index: usize) {
nav_select_where(app, |k| *k == crate::tui::app::NavKind::Project(index));
}
fn key_event(code: KeyCode) -> AppEvent {
AppEvent::Key(crossterm::event::KeyEvent {
code,
modifiers: crossterm::event::KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE
})
}
#[test]
fn handle_key_tab_moves_nav_down() {
let mut app = App::new(5);
nav_select_service(&mut app, ResourceTab::Servers);
let start = app.nav_selected;
assert!(crate::tui::event::handle_event(
&mut app,
key_event(KeyCode::Tab)
));
assert_eq!(app.nav_selected, start + 1);
assert_eq!(app.active_tab, ResourceTab::Databases);
}
#[test]
fn handle_key_backtab_moves_nav_up() {
let mut app = App::new(5);
nav_select_service(&mut app, ResourceTab::Databases);
app.active_tab = ResourceTab::Databases;
assert!(crate::tui::event::handle_event(
&mut app,
key_event(KeyCode::BackTab)
));
assert_eq!(app.active_tab, ResourceTab::Servers);
}
#[test]
fn sidebar_left_right_are_noop() {
let mut app = App::new(5);
app.servers = vec![make_server(1, "s1", "On")];
for code in [
KeyCode::Right,
KeyCode::Left,
KeyCode::Char('l'),
KeyCode::Char('h')
] {
assert!(crate::tui::event::handle_event(&mut app, key_event(code)));
assert_eq!(app.pane, Pane::Sidebar);
assert_eq!(app.nav_selected, 0);
}
}
#[test]
fn enter_opens_content_esc_returns_left_stays() {
let mut app = App::new(5);
app.servers = vec![make_server(1, "s1", "On")];
assert!(crate::tui::event::handle_event(
&mut app,
key_event(KeyCode::Enter)
));
assert_eq!(app.pane, Pane::Content);
for code in [KeyCode::Left, KeyCode::Char('h')] {
assert!(crate::tui::event::handle_event(&mut app, key_event(code)));
assert_eq!(app.pane, Pane::Content);
}
assert!(crate::tui::event::handle_event(
&mut app,
key_event(KeyCode::Esc)
));
assert_eq!(app.pane, Pane::Sidebar);
}
#[test]
fn projects_list_slice_keeps_cached_counts() {
let bare = |id: i32, name: &str| ProjectSummary {
id,
name: name.to_string(),
..Default::default()
};
let mut app = App::new(5);
let mut with_counts = bare(1, "p1");
with_counts.server_count = 4;
app.apply_slice(DataSlice::Projects(vec![with_counts]));
assert_eq!(app.projects[0].server_count, 4);
app.apply_slice(DataSlice::ProjectsList(vec![bare(1, "p1"), bare(2, "p2")]));
assert_eq!(app.projects.len(), 2);
assert_eq!(app.projects[0].server_count, 4);
assert_eq!(app.projects[1].server_count, 0);
}
#[test]
fn shrunken_drill_response_triggers_silent_refetch() {
use crate::tui::app::{DrillItem, DrillView};
let item = |tab: ResourceTab, id: &str, name: &str| DrillItem {
tab,
id: id.to_string(),
kind: String::new(),
name: name.to_string(),
detail: String::new()
};
let full = DrillView {
title: "Caravan".to_string(),
items: vec![
item(ResourceTab::Databases, "42", "db"),
item(ResourceTab::Apps, "7", "api"),
],
selected: 0
};
let flapped = DrillView {
title: "Caravan".to_string(),
items: vec![item(ResourceTab::Apps, "7", "api")],
selected: 0
};
let mut app = App::new(5);
app.projects = vec![make_project(9, "Caravan")];
app.select_project_drill(0);
let _ = app.take_drill_request();
app.apply_drill(9, full.clone());
assert_eq!(app.drill_view().expect("full").items.len(), 2);
app.apply_drill(9, flapped.clone());
assert_eq!(
app.drill_view().expect("kept").items.len(),
2,
"flapped response must not clobber the pane"
);
let retry = app.take_drill_request().expect("silent refetch queued");
assert_eq!(retry.1, 9);
app.apply_drill(9, flapped);
assert_eq!(
app.drill_view().expect("confirmed").items.len(),
1,
"a confirming second response must apply (real deletion)"
);
}
#[test]
fn zeroed_project_counts_do_not_clobber_known_ones() {
let mut app = App::new(5);
app.apply_slice(DataSlice::Projects(vec![make_project(1, "p1")]));
assert_eq!(app.projects[0].server_count, 2);
app.apply_slice(DataSlice::Projects(vec![ProjectSummary {
id: 1,
name: "p1".to_string(),
..Default::default()
}]));
assert_eq!(app.projects[0].server_count, 2);
}
#[test]
fn selecting_project_shows_cache_and_revalidates() {
use crate::tui::app::{DrillItem, DrillView};
let mut app = App::new(5);
app.projects = vec![make_project(7, "Caravan")];
app.select_project_drill(0);
assert!(!app.drill_open());
assert!(app.take_drill_request().is_some());
app.apply_drill(
7,
DrillView {
title: "Caravan".to_string(),
items: vec![DrillItem {
tab: ResourceTab::Servers,
id: "1".to_string(),
kind: "Server".to_string(),
name: "a".to_string(),
detail: String::new()
}],
selected: 0
}
);
assert_eq!(app.drill_view().expect("applied").items.len(), 1);
app.close_drill();
app.select_project_drill(0);
assert_eq!(app.drill_view().expect("cached").items.len(), 1);
}
#[test]
fn stale_drill_result_updates_cache_only() {
use crate::tui::app::{DrillItem, DrillView};
let mut app = App::new(5);
app.projects = vec![make_project(7, "Caravan"), make_project(8, "Other")];
app.select_project_drill(1);
app.apply_drill(
7,
DrillView {
title: "Caravan".to_string(),
items: vec![DrillItem {
tab: ResourceTab::Servers,
id: "1".to_string(),
kind: "Server".to_string(),
name: "a".to_string(),
detail: String::new()
}],
selected: 0
}
);
assert!(app.drill_view().is_none());
assert_eq!(app.drill_cache.get(&7).expect("cached").items.len(), 1);
}
#[test]
fn handle_key_g() {
let mut app = App::new(5);
app.pane = Pane::Content;
app.servers = vec![
make_server(1, "s1", "running"),
make_server(2, "s2", "running"),
];
app.selected = 1;
let event = AppEvent::Key(crossterm::event::KeyEvent {
code: KeyCode::Char('g'),
modifiers: crossterm::event::KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE
});
let keep_going = crate::tui::event::handle_event(&mut app, event);
assert!(keep_going);
assert_eq!(app.selected, 0);
}
#[test]
fn handle_key_dollar() {
let mut app = App::new(5);
app.pane = Pane::Content;
app.servers = vec![
make_server(1, "s1", "running"),
make_server(2, "s2", "running"),
];
app.selected = 0;
let event = AppEvent::Key(crossterm::event::KeyEvent {
code: KeyCode::Char('$'),
modifiers: crossterm::event::KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE
});
let keep_going = crate::tui::event::handle_event(&mut app, event);
assert!(keep_going);
assert_eq!(app.selected, 1);
}
fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(crossterm::event::KeyEvent {
code,
modifiers: crossterm::event::KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE
})
}
#[test]
fn details_open_lists_action_buttons_first() {
let mut app = App::new(5);
app.servers = vec![make_server(7, "web", "On")];
app.selected = 0;
assert!(app.open_selected_detail());
assert!(app.detail_open);
let (copy, action) = crate::tui::widgets::details::interactive_at(&app, 0).expect("first row");
assert!(copy.is_none());
assert_eq!(
action,
Some(crate::tui::widgets::details::DetailAction::Kind(
ActionKind::Start
))
);
let (cursor_copy, cursor_action) =
crate::tui::widgets::details::interactive_at(&app, app.detail_selected)
.expect("cursor row");
assert!(
cursor_copy.is_some(),
"cursor starts on a field, not a button"
);
assert!(cursor_action.is_none());
}
#[test]
fn open_selected_detail_noop_without_selection() {
let mut app = App::new(5);
app.active_tab = ResourceTab::Databases;
app.servers = vec![make_server(7, "web", "On")];
assert!(!app.open_selected_detail());
assert!(!app.detail_open);
}
#[test]
fn details_cursor_clamps_at_edges() {
let mut app = App::new(5);
app.servers = vec![make_server(7, "web", "On")];
app.selected = 0;
assert!(app.open_selected_detail());
app.detail_selected = 0;
crate::tui::event::handle_event(&mut app, key(KeyCode::Up));
assert_eq!(app.detail_selected, 0);
let len = crate::tui::widgets::details::interactive_len(&app);
for _ in 0..len + 3 {
crate::tui::event::handle_event(&mut app, key(KeyCode::Down));
}
assert_eq!(app.detail_selected, len - 1);
}
#[test]
fn details_destructive_action_requires_confirm() {
let mut app = App::new(5);
app.active_tab = ResourceTab::Databases;
app.databases = vec![make_database(42, "pg-prod", "postgres")];
app.selected = 0;
assert!(app.open_selected_detail());
crate::tui::event::handle_event(&mut app, key(KeyCode::Char('k')));
crate::tui::event::handle_event(&mut app, key(KeyCode::Enter));
assert!(app.awaiting_confirm());
assert!(app.take_dispatch().is_none());
app.confirm_action();
let dispatched = app.take_dispatch().expect("confirm dispatches");
assert_eq!(dispatched.tab, ResourceTab::Databases);
assert_eq!(dispatched.kind, ActionKind::Delete);
assert_eq!(dispatched.resource_id, "42");
}
#[test]
fn enter_opens_details_then_runs_action() {
let mut app = App::new(5);
app.pane = Pane::Content;
nav_select_service(&mut app, ResourceTab::Servers);
app.servers = vec![make_server(7, "web", "On")];
crate::tui::event::handle_event(&mut app, key(KeyCode::Enter));
assert!(app.detail_open);
crate::tui::event::handle_event(&mut app, key(KeyCode::Enter));
assert!(
app.take_dispatch().is_none(),
"Enter on the initial cursor row must not fire an action"
);
for _ in 0..4 {
crate::tui::event::handle_event(&mut app, key(KeyCode::Char('k')));
}
crate::tui::event::handle_event(&mut app, key(KeyCode::Enter));
let dispatched = app.take_dispatch().expect("action dispatched");
assert_eq!(dispatched.kind, ActionKind::Shutdown);
assert_eq!(dispatched.resource_id, "7");
}
#[test]
fn details_esc_closes_without_dispatch() {
let mut app = App::new(5);
app.servers = vec![make_server(7, "web", "On")];
app.selected = 0;
assert!(app.open_selected_detail());
crate::tui::event::handle_event(&mut app, key(KeyCode::Esc));
assert!(!app.detail_open);
assert!(app.take_dispatch().is_none());
}
#[test]
fn per_tab_action_sets() {
assert_eq!(ResourceTab::Servers.actions().len(), 5);
assert_eq!(
ResourceTab::Databases.actions(),
&[ActionKind::Backup, ActionKind::Delete]
);
assert_eq!(
ResourceTab::Kubernetes.actions(),
&[
ActionKind::ScaleUp,
ActionKind::ScaleDown,
ActionKind::Delete
]
);
assert_eq!(
ResourceTab::FloatingIps.actions(),
&[ActionKind::Unbind, ActionKind::Delete]
);
assert_eq!(
ResourceTab::NetworkDrives.actions(),
&[ActionKind::Unmount, ActionKind::Delete]
);
assert_eq!(ResourceTab::Projects.actions(), &[ActionKind::Delete]);
assert_eq!(ResourceTab::AiAgents.actions(), &[ActionKind::Delete]);
assert_eq!(ResourceTab::Domains.actions(), &[ActionKind::Delete]);
assert_eq!(ResourceTab::Firewall.actions(), &[ActionKind::Delete]);
assert_eq!(ResourceTab::Vpc.actions(), &[ActionKind::Delete]);
assert!(ResourceTab::Finances.actions().is_empty());
assert!(ResourceTab::Mail.actions().is_empty());
}
#[test]
fn apps_action_set_and_destructive_flags() {
assert_eq!(
ResourceTab::Apps.actions(),
&[ActionKind::Start, ActionKind::Stop, ActionKind::Delete]
);
assert!(ActionKind::ScaleDown.is_destructive());
assert!(ActionKind::Delete.is_destructive());
assert!(!ActionKind::Start.is_destructive());
assert!(!ActionKind::Stop.is_destructive());
assert!(!ActionKind::Unbind.is_destructive());
assert!(!ActionKind::Unmount.is_destructive());
assert!(!ActionKind::ScaleUp.is_destructive());
}
#[test]
fn no_details_on_empty_list() {
let mut app = App::new(5);
app.active_tab = ResourceTab::Domains;
assert!(!app.open_selected_detail());
assert!(!app.detail_open);
}
#[test]
fn toggle_widget_flips_and_marks_dirty() {
let mut app = App::new(5);
assert!(app.is_widget_enabled("stats"));
app.toggle_widget("stats");
assert!(!app.is_widget_enabled("stats"));
assert!(app.prefs_dirty);
assert_eq!(app.hidden_widget_ids(), vec!["stats".to_string()]);
}
#[test]
fn apply_prefs_hides_widgets_and_sets_width() {
let mut app = App::new(5);
app.apply_prefs(
&["account".to_string(), "token_info".to_string()],
55,
false
);
assert!(!app.is_widget_enabled("account"));
assert!(!app.is_widget_enabled("token_info"));
assert!(app.is_widget_enabled("stats"));
assert_eq!(app.list_width_pct, 55);
}
#[test]
fn set_theme_marks_dirty() {
let mut app = App::new(5);
app.set_theme(crate::tui::themes::Theme::CatppuccinMocha);
assert_eq!(app.theme, crate::tui::themes::Theme::CatppuccinMocha);
assert!(app.prefs_dirty);
}
#[test]
fn palette_opens_with_context_commands() {
let mut app = App::new(5);
app.servers = vec![make_server(7, "web", "On")];
app.open_palette();
assert!(app.palette_open());
let cp = app.palette.as_ref().unwrap();
let titles: Vec<&str> = cp
.filtered_commands()
.iter()
.map(|c| c.title.as_str())
.collect();
let reboot_title = format!("{} web", ActionKind::Reboot.display_label());
assert!(titles.iter().any(|t| t.contains(reboot_title.as_str())));
assert!(titles.iter().any(|t| t.starts_with("Theme:")));
let stats_label = rust_i18n::t!("app.widget_stats");
assert!(titles.iter().any(|t| t.contains(stats_label.as_ref())));
}
#[test]
fn palette_run_theme_command() {
let mut app = App::new(5);
app.open_palette();
app.run_command("theme:catppuccin_latte");
assert_eq!(app.theme, crate::tui::themes::Theme::CatppuccinLatte);
}
#[test]
fn palette_run_widget_toggle_command() {
let mut app = App::new(5);
app.run_command("widget:stats");
assert!(!app.is_widget_enabled("stats"));
}
#[test]
fn palette_run_action_command_dispatches() {
let mut app = App::new(5);
app.servers = vec![make_server(7, "web", "On")];
app.run_command("action:reboot");
let dispatched = app.take_dispatch().expect("reboot dispatched via palette");
assert_eq!(dispatched.kind, ActionKind::Reboot);
assert_eq!(dispatched.resource_id, "7");
}
#[test]
fn palette_run_delete_requires_confirm() {
let mut app = App::new(5);
app.servers = vec![make_server(7, "web", "On")];
app.run_command("action:delete");
assert!(app.awaiting_confirm());
assert!(app.take_dispatch().is_none());
}
#[test]
fn filter_narrows_list_and_maps_selection() {
let mut app = App::new(5);
app.servers = vec![
make_server(1, "web-prod", "On"),
make_server(2, "db-prod", "On"),
make_server(3, "web-stage", "On"),
];
assert_eq!(app.current_list_len(), 3);
app.start_filter();
for c in "web".chars() {
app.filter_push(c);
}
assert_eq!(app.filtered_indices(), vec![0, 2]);
assert_eq!(app.current_list_len(), 2);
app.selected = 1;
let (id, name) = app.selected_resource().expect("maps filtered selection");
assert_eq!(id, "3");
assert_eq!(name, "web-stage");
app.filter_clear();
assert_eq!(app.current_list_len(), 3);
assert!(!app.filter_active());
}
#[test]
fn filter_resets_on_nav_change() {
let mut app = App::new(5);
app.servers = vec![make_server(1, "web", "On")];
app.start_filter();
app.filter_push('x');
assert!(app.filter_active());
app.nav_down();
assert!(!app.filter_active());
assert!(app.filter.is_empty());
}
#[test]
fn select_project_drill_queues_fetch() {
let mut app = App::new(5);
app.select_project_drill(0);
assert!(app.take_drill_request().is_none());
app.projects = vec![make_project(3, "ratma")];
app.select_project_drill(0);
let req = app.take_drill_request().expect("drill requested");
assert_eq!(req, (ResourceTab::Projects, 3, "ratma".to_string()));
}
#[test]
fn drill_view_navigation() {
use crate::tui::app::{DrillItem, DrillView};
let mut app = App::new(5);
app.drill = Some(DrillView {
title: "Project 'x'".to_string(),
items: vec![
DrillItem {
tab: ResourceTab::Servers,
id: "1".to_string(),
kind: "Server".to_string(),
name: "a".to_string(),
detail: String::new()
},
DrillItem {
tab: ResourceTab::Databases,
id: "1".to_string(),
kind: "Database".to_string(),
name: "b".to_string(),
detail: String::new()
},
],
selected: 0
});
assert!(app.drill_open());
app.resource_cols = 1;
app.content_move(FocusDir::Down);
assert_eq!(app.drill_view().unwrap().selected, 1);
app.content_move(FocusDir::Down);
assert_eq!(app.drill_view().unwrap().selected, 1);
app.content_move(FocusDir::Up);
assert_eq!(app.drill_view().unwrap().selected, 0);
app.close_drill();
assert!(!app.drill_open());
}
#[test]
fn log_caps_at_200_entries() {
let mut app = App::new(5);
for i in 0..250 {
app.log(crate::tui::app::LogLevel::Info, format!("event {i}"));
}
assert_eq!(app.logs.len(), 200);
assert_eq!(app.logs.back().unwrap().text, "event 249");
}
#[test]
fn force_refresh_sets_request_flag() {
let mut app = App::new(5);
assert!(!app.refresh_requested);
app.force_refresh();
assert!(app.refresh_requested);
}
#[test]
fn slices_stream_in_progressively() {
let mut app = App::new(5);
app.is_loading = true;
app.load_started();
app.apply_slice(DataSlice::Projects(vec![make_project(1, "p1")]));
assert!(!app.is_loading);
assert_eq!(app.projects.len(), 1);
assert!(app.servers.is_empty());
app.apply_slice(DataSlice::Servers(vec![make_server(1, "s1", "On")]));
assert_eq!(app.servers.len(), 1);
app.load_finished();
assert!(app.last_load_errors.is_empty());
assert!(app.status_message.is_some());
}
#[test]
fn group_spinners_clear_as_slices_arrive() {
let mut app = App::new(5);
app.load_started();
assert_eq!(app.projects_pending, 2);
assert_eq!(app.services_pending, 18);
app.apply_slice(DataSlice::Projects(vec![]));
assert_eq!(app.projects_pending, 1);
app.apply_slice(DataSlice::Projects(vec![]));
assert_eq!(app.projects_pending, 0);
app.apply_slice(DataSlice::Servers(vec![]));
assert_eq!(app.services_pending, 17);
app.apply_slice(DataSlice::SshKeys(vec![]));
assert_eq!(app.services_pending, 16);
app.load_finished();
assert_eq!(app.services_pending, 0);
}
#[test]
fn slice_errors_roll_over_and_recover() {
let mut app = App::new(5);
app.load_started();
app.apply_slice(DataSlice::Error("servers: boom".into()));
app.load_finished();
assert_eq!(app.last_load_errors, vec!["servers: boom".to_string()]);
assert!(app.error_message.is_some());
app.load_started();
app.apply_slice(DataSlice::Servers(vec![make_server(1, "s1", "On")]));
app.load_finished();
assert!(app.last_load_errors.is_empty());
assert!(app.error_message.is_none());
assert!(app.status_message.is_some());
}
#[test]
fn apply_data_logs_load_errors_once() {
let mut app = App::new(5);
let data = DashboardData {
account: AccountInfo::default(),
servers: Vec::new(),
databases: Vec::new(),
s3_storages: Vec::new(),
k8s_clusters: Vec::new(),
projects: Vec::new(),
balancers: Vec::new(),
registries: Vec::new(),
domains: Vec::new(),
firewalls: Vec::new(),
floating_ips: Vec::new(),
images: Vec::new(),
network_drives: Vec::new(),
vpcs: Vec::new(),
dedicated_servers: Vec::new(),
mails: Vec::new(),
apps: Vec::new(),
ai_agents: Vec::new(),
knowledge_bases: Vec::new(),
ssh_keys: Vec::new(),
finances: None,
error_message: None,
status_message: None,
load_errors: vec!["databases".to_string()]
};
app.apply_data(data.clone());
let after_first = app.logs.len();
assert!(app.logs.iter().any(|e| e.text.contains("databases")));
app.apply_data(data);
assert_eq!(app.logs.len(), after_first);
}
#[test]
fn create_form_opens_for_projects_and_validates() {
let mut app = App::new(5);
app.active_tab = ResourceTab::Projects;
app.open_create_form();
assert!(app.create_form_open());
assert!(!app.form_submit());
assert!(app.create_form_open());
for c in "web".chars() {
app.form_input(c);
}
assert!(app.form_submit());
let req = app.take_create_request().expect("submitted form queued");
assert_eq!(req.tab, ResourceTab::Projects);
assert_eq!(req.fields[0].value, "web");
}
#[test]
fn create_form_not_for_actionless_tab() {
let mut app = App::new(5);
app.active_tab = ResourceTab::Domains;
app.open_create_form();
assert!(!app.create_form_open());
}
#[test]
fn create_form_opens_for_ssh_keys_with_empty_required_fields() {
let mut app = App::new(5);
app.active_tab = ResourceTab::SshKeys;
app.open_create_form();
let form = app.create_form.as_ref().expect("ssh key form");
assert_eq!(form.tab, ResourceTab::SshKeys);
assert_eq!(form.fields.len(), 2);
assert!(form.fields.iter().all(|f| f.required && f.value.is_empty()));
for c in "laptop".chars() {
app.form_input(c);
}
assert!(!app.form_submit());
app.form_next_field();
for c in "ssh-ed25519 AAAA".chars() {
app.form_input(c);
}
assert!(app.form_submit());
let req = app.take_create_request().expect("submitted form queued");
assert_eq!(req.fields[1].value, "ssh-ed25519 AAAA");
}
#[test]
fn create_form_opens_for_vpc_with_prefills() {
let mut app = App::new(5);
app.active_tab = ResourceTab::Vpc;
app.open_create_form();
let form = app.create_form.as_ref().expect("vpc form");
assert_eq!(form.fields.len(), 3);
assert_eq!(form.fields[1].value, "192.168.0.0/24");
assert_eq!(form.fields[2].value, "ru-1");
assert!(!app.form_submit());
for c in "net".chars() {
app.form_input(c);
}
assert!(app.form_submit());
}
#[test]
fn create_form_opens_for_network_drives_with_prefills() {
let mut app = App::new(5);
app.active_tab = ResourceTab::NetworkDrives;
app.open_create_form();
let form = app.create_form.as_ref().expect("network drive form");
assert_eq!(form.fields.len(), 3);
assert_eq!(form.fields[1].value, "10");
assert_eq!(form.fields[2].value, "nvme");
}
#[test]
fn create_hub_opens_forms_for_simple_resources() {
for tab in [ResourceTab::Vpc, ResourceTab::NetworkDrives] {
let mut app = App::new(5);
app.create_selected = App::create_targets()
.iter()
.position(|t| *t == tab)
.expect("tab offered by the hub");
app.create_activate();
assert!(app.create_form_open(), "{tab:?} must open a form");
assert!(!app.info_popup_open());
assert_eq!(app.create_form.as_ref().expect("form").tab, tab);
}
}
#[test]
fn parse_size_gb_accepts_numbers_and_rejects_garbage() {
assert_eq!(parse_size_gb("10"), Ok(10.0));
assert_eq!(parse_size_gb(" 2.5 "), Ok(2.5));
assert!(parse_size_gb("ten").is_err());
assert!(parse_size_gb("").is_err());
assert!(parse_size_gb("0").is_err());
assert!(parse_size_gb("-5").is_err());
assert!(parse_size_gb("inf").is_err());
}
#[test]
fn select_initial_tab_moves_sidebar_and_tab_together() {
let mut app = App::new(5);
app.databases = vec![make_database(1, "db", "postgres")];
app.select_initial_tab();
assert!(app.initial_tab_set);
assert_eq!(app.active_tab, ResourceTab::Databases);
assert!(matches!(
app.nav_current(),
Some(crate::tui::app::NavKind::Service(ResourceTab::Databases))
));
}
#[test]
fn select_initial_tab_prefers_first_project_with_resources() {
let mut app = App::new(5);
app.projects = vec![make_project(7, "Caravan")];
app.servers = vec![make_server(1, "s", "on")];
app.select_initial_tab();
assert!(matches!(
app.nav_current(),
Some(crate::tui::app::NavKind::Project(0))
));
assert!(app.take_drill_request().is_some());
}
#[test]
fn select_initial_tab_runs_only_once() {
let mut app = App::new(5);
app.s3_storages = vec![make_s3(1, "bucket")];
app.select_initial_tab();
app.databases = vec![make_database(1, "db", "postgres")];
app.select_initial_tab();
assert_eq!(app.active_tab, ResourceTab::S3);
}
#[test]
fn select_initial_tab_waits_for_first_data() {
let mut app = App::new(5);
app.select_initial_tab();
assert!(!app.initial_tab_set);
app.databases = vec![make_database(1, "db", "postgres")];
app.select_initial_tab();
assert!(app.initial_tab_set);
assert_eq!(app.active_tab, ResourceTab::Databases);
}
#[test]
fn arrows_move_card_selection_horizontally() {
let mut app = App::new(5);
app.pane = Pane::Content;
nav_select_service(&mut app, ResourceTab::Servers);
app.servers = vec![
make_server(1, "s1", "on"),
make_server(2, "s2", "on"),
make_server(3, "s3", "on"),
];
app.resource_cols = 3;
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Right));
assert_eq!(app.selected, 1);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Left));
assert_eq!(app.selected, 0);
}
#[test]
fn enter_on_leaf_card_opens_details() {
let mut app = App::new(5);
app.pane = Pane::Content;
nav_select_service(&mut app, ResourceTab::Servers);
app.servers = vec![make_server(7, "web", "On")];
app.selected = 0;
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Enter));
assert!(app.detail_open);
}
#[test]
fn down_moves_card_selection_by_a_row() {
let mut app = App::new(5);
app.pane = Pane::Content;
nav_select_service(&mut app, ResourceTab::Servers);
app.servers = vec![
make_server(1, "s1", "on"),
make_server(2, "s2", "on"),
make_server(3, "s3", "on"),
make_server(4, "s4", "on"),
];
app.resource_cols = 2;
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Down));
assert_eq!(app.selected, 2);
assert_eq!(app.detail_scroll, 0);
}
#[test]
fn down_moves_selection_on_single_column() {
let mut app = App::new(5);
app.pane = Pane::Content;
nav_select_service(&mut app, ResourceTab::Servers);
app.servers = vec![make_server(1, "s1", "on"), make_server(2, "s2", "on")];
app.resource_cols = 1;
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Down));
assert_eq!(app.selected, 1);
assert_eq!(app.pane, Pane::Content);
}
#[test]
fn right_does_not_wrap_to_next_row() {
let mut app = App::new(5);
app.pane = Pane::Content;
app.servers = vec![
make_server(1, "s1", "on"),
make_server(2, "s2", "on"),
make_server(3, "s3", "on"),
make_server(4, "s4", "on"),
];
app.resource_cols = 2;
app.selected = 1;
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Right));
assert_eq!(app.selected, 1);
}
#[test]
fn up_on_top_row_stays_put() {
let mut app = App::new(5);
app.pane = Pane::Content;
app.servers = vec![make_server(1, "s1", "on"), make_server(2, "s2", "on")];
app.resource_cols = 2;
app.selected = 1;
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Up));
assert_eq!(app.selected, 1);
}
#[test]
fn empty_service_focuses_create_button() {
let mut app = App::new(5);
nav_select_service(&mut app, ResourceTab::Servers);
app.nav_open();
assert_eq!(app.pane, Pane::Content);
assert!(app.content_on_create);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Enter));
assert!(app.status_message.is_some());
}
#[test]
fn up_from_first_row_focuses_create_then_down_returns() {
let mut app = App::new(5);
app.servers = vec![make_server(1, "s1", "On"), make_server(2, "s2", "On")];
nav_select_service(&mut app, ResourceTab::Servers);
app.nav_open();
assert!(!app.content_on_create);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Up));
assert!(app.content_on_create);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Up));
assert!(app.content_on_create);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Down));
assert!(!app.content_on_create);
assert_eq!(app.selected, 0);
}
#[test]
fn enter_on_project_item_opens_full_details() {
use crate::tui::app::{DrillItem, DrillView};
let mut app = App::new(5);
app.projects = vec![make_project(7, "Caravan")];
app.databases = vec![make_database(42, "caravan_db", "postgres")];
nav_select_project(&mut app, 0);
app.nav_open();
app.apply_drill(
7,
DrillView {
title: "Caravan".to_string(),
items: vec![DrillItem {
tab: ResourceTab::Databases,
id: "42".to_string(),
kind: "Database".to_string(),
name: "caravan_db".to_string(),
detail: "postgres".to_string()
}],
selected: 0
}
);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Enter));
assert!(app.detail_open);
assert_eq!(app.active_tab, ResourceTab::Databases);
assert_eq!(app.selected, 0);
let start = app.detail_selected;
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Down));
assert_eq!(app.detail_selected, start + 1);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Up));
assert_eq!(app.detail_selected, start);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Esc));
assert!(!app.detail_open);
assert!(app.drill_open());
}
#[test]
fn up_in_project_contents_focuses_create() {
use crate::tui::app::{DrillItem, DrillView};
let mut app = App::new(5);
app.projects = vec![make_project(7, "Caravan")];
nav_select_project(&mut app, 0);
app.nav_open();
app.apply_drill(
7,
DrillView {
title: "Caravan".to_string(),
items: vec![
DrillItem {
tab: ResourceTab::Databases,
id: "1".to_string(),
kind: "Database".into(),
name: "caravan_db".into(),
detail: "postgres18".into()
},
DrillItem {
tab: ResourceTab::Apps,
id: "1".to_string(),
kind: "App".into(),
name: "caravan-api".into(),
detail: "Active".into()
},
],
selected: 0
}
);
app.resource_cols = 6;
assert_eq!(app.pane, Pane::Content);
assert!(!app.content_on_create);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Up));
assert!(
app.content_on_create,
"Up from first row must focus the Create button"
);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Enter));
assert!(
app.create_form_open(),
"Enter on Create must open the project form"
);
}
#[test]
fn sidebar_selection_stays_on_service_when_projects_arrive() {
let bare = |id: i32, name: &str| ProjectSummary {
id,
name: name.to_string(),
..Default::default()
};
let mut app = App::new(5);
nav_select_service(&mut app, ResourceTab::Servers);
let start = app.nav_selected;
app.apply_slice(DataSlice::ProjectsList(vec![bare(1, "p1"), bare(2, "p2")]));
assert_eq!(app.nav_selected, start + 2);
assert!(matches!(
app.nav_current(),
Some(crate::tui::app::NavKind::Service(ResourceTab::Servers))
));
}
#[test]
fn create_hub_is_first_and_opens_project_form() {
let mut app = App::new(5);
assert!(matches!(
app.nav_items()[0].kind,
crate::tui::app::NavKind::Create
));
app.nav_selected = 0;
app.nav_open();
assert_eq!(app.pane, Pane::Content);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Enter));
assert!(app.create_form_open());
}
#[test]
fn create_enter_on_service_opens_guide_popup() {
let mut app = App::new(5);
app.nav_selected = 0;
app.nav_open();
app.resource_cols = 4;
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Right));
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Enter));
assert!(app.info_popup_open());
assert!(!app.create_form_open());
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Esc));
assert!(!app.info_popup_open());
}
#[test]
fn create_hub_grid_walk_is_clamped() {
let mut app = App::new(5);
app.nav_selected = 0;
app.nav_open();
app.resource_cols = 4;
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Left));
assert_eq!(app.create_selected, 0);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Right));
assert_eq!(app.create_selected, 1);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Down));
assert_eq!(app.create_selected, 5);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Esc));
assert_eq!(app.pane, Pane::Sidebar);
}
#[test]
fn settings_entry_is_last_nav_item() {
let app = App::new(5);
let items = app.nav_items();
assert!(matches!(
items.last().expect("items").kind,
crate::tui::app::NavKind::Settings
));
assert!(items.last().expect("items").count.is_none());
}
#[test]
fn theme_picker_applies_selection() {
let mut app = App::new(5);
let start_theme = app.theme;
app.settings_selected = 0;
app.settings_activate();
assert!(app.picker_open());
app.picker_next();
app.picker_apply();
assert!(!app.picker_open());
assert_ne!(app.theme, start_theme);
assert!(app.prefs_dirty);
}
#[test]
fn toggle_setting_flips_in_place_without_picker() {
let mut app = App::new(5);
let before = app.is_widget_enabled("events");
app.settings_selected = 3;
app.settings_activate();
assert!(!app.picker_open());
assert_eq!(app.is_widget_enabled("events"), !before);
assert!(app.prefs_dirty);
}
#[test]
fn picker_esc_closes_without_applying() {
let mut app = App::new(5);
let start_theme = app.theme;
app.settings_selected = 0;
app.settings_activate();
app.picker_next();
app.picker_close();
assert!(!app.picker_open());
assert_eq!(app.theme, start_theme);
}
#[test]
fn toggling_hide_empty_keeps_selection_on_settings() {
let mut app = App::new(5);
app.servers = vec![make_server(1, "s1", "On")];
app.hide_empty_tabs = true;
app.nav_selected = app.nav_items().len() - 1;
app.settings_selected = 4;
app.settings_activate();
assert!(!app.hide_empty_tabs);
assert!(matches!(
app.nav_current(),
Some(crate::tui::app::NavKind::Settings)
));
app.settings_activate();
assert!(app.hide_empty_tabs);
assert!(matches!(
app.nav_current(),
Some(crate::tui::app::NavKind::Settings)
));
}
#[test]
fn hide_empty_sections_filters_sidebar() {
let mut app = App::new(5);
app.servers = vec![make_server(1, "s1", "On")];
let all = app.nav_items().len();
app.settings_selected = 4;
app.settings_activate();
let filtered = app.nav_items().len();
assert!(filtered < all);
assert!(app.nav_items().iter().all(|i| {
!matches!(i.kind, crate::tui::app::NavKind::Service(_))
|| i.count.is_none_or(|count| count > 0)
}));
}
#[test]
fn sidebar_lists_ssh_keys_when_a_key_exists() {
let mut app = App::new(5);
app.hide_empty_tabs = true;
app.ssh_keys = vec![crate::tui::app::SshKeySummary {
id: 1,
name: "laptop".to_string(),
..Default::default()
}];
let items = app.nav_items();
let ssh = items
.iter()
.find(|i| i.kind == crate::tui::app::NavKind::Service(ResourceTab::SshKeys))
.expect("SSH keys entry present");
assert_eq!(ssh.count, Some(1));
app.ssh_keys.clear();
assert!(
!app.nav_items()
.iter()
.any(|i| i.kind == crate::tui::app::NavKind::Service(ResourceTab::SshKeys)),
"empty SSH keys tab must hide when hide_empty_tabs is on"
);
}
#[test]
fn sidebar_always_lists_finances_without_a_count() {
let mut app = App::new(5);
app.hide_empty_tabs = true;
let items = app.nav_items();
let finances = items
.iter()
.find(|i| i.kind == crate::tui::app::NavKind::Service(ResourceTab::Finances))
.expect("Finances entry present");
assert_eq!(finances.count, None);
}
#[test]
fn create_hub_offers_neither_ssh_keys_nor_finances() {
let targets = App::create_targets();
assert!(!targets.contains(&ResourceTab::SshKeys));
assert!(!targets.contains(&ResourceTab::Finances));
}
#[test]
fn settings_keys_walk_cards_and_open_picker() {
let mut app = App::new(5);
let start_theme = app.theme;
let last = app.nav_items().len() - 1;
app.nav_selected = last;
app.nav_open();
assert_eq!(app.pane, Pane::Content);
app.resource_cols = 3;
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Right));
assert_eq!(app.settings_selected, 1);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Down));
assert_eq!(app.settings_selected, 4);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Up));
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Left));
assert_eq!(app.settings_selected, 0);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Enter));
assert!(app.picker_open());
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Down));
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Enter));
assert!(!app.picker_open());
assert_ne!(app.theme, start_theme);
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Esc));
assert_eq!(app.pane, Pane::Sidebar);
}
#[test]
fn sidebar_is_default_pane() {
let app = App::new(5);
assert_eq!(app.pane, Pane::Sidebar);
}
#[test]
fn nav_items_create_then_projects_then_services() {
let mut app = App::new(5);
app.projects = vec![make_project(1, "p1")];
let items = app.nav_items();
assert!(matches!(items[0].kind, crate::tui::app::NavKind::Create));
assert_eq!(items[1].label, "p1");
assert!(items.len() > 2);
}
#[test]
fn nav_open_project_focuses_content_and_fetches_quietly() {
let mut app = App::new(5);
app.projects = vec![make_project(7, "Caravan")];
nav_select_project(&mut app, 0);
app.nav_open();
assert_eq!(app.pane, Pane::Content);
assert!(!app.drill_open());
let req = app.take_drill_request().expect("fetch requested");
assert_eq!(req.1, 7);
}
#[test]
fn nav_open_service_focuses_content() {
let mut app = App::new(5);
app.nav_open();
assert_eq!(app.pane, Pane::Content);
assert_eq!(app.active_tab, ResourceTab::Servers);
}
#[test]
fn esc_from_content_returns_to_sidebar() {
let mut app = App::new(5);
app.pane = Pane::Content;
crate::tui::event::handle_event(&mut app, key_event(KeyCode::Esc));
assert_eq!(app.pane, Pane::Sidebar);
}
#[test]
fn nav_up_down_move_selection_and_tab() {
let mut app = App::new(5);
nav_select_service(&mut app, ResourceTab::Servers);
let start = app.nav_selected;
app.nav_down();
assert_eq!(app.nav_selected, start + 1);
assert_eq!(app.active_tab, ResourceTab::Databases);
app.nav_up();
assert_eq!(app.nav_selected, start);
assert_eq!(app.active_tab, ResourceTab::Servers);
}
#[test]
fn nav_change_closes_open_drill() {
use crate::tui::app::DrillView;
let mut app = App::new(5);
app.projects = vec![make_project(7, "Caravan")];
nav_select_project(&mut app, 0);
app.nav_open();
app.apply_drill(
7,
DrillView {
title: "Caravan".to_string(),
items: Vec::new(),
selected: 0
}
);
assert!(app.drill_open());
app.nav_down();
assert!(!app.drill_open());
}
#[test]
fn language_picker_applies_via_keys() {
let mut app = App::new(5);
let settings_idx = app
.nav_items()
.iter()
.position(|i| matches!(i.kind, crate::tui::app::NavKind::Settings))
.unwrap();
while app.nav_selected < settings_idx {
app.nav_down();
}
crate::tui::event::handle_event(&mut app, key(KeyCode::Enter));
assert_eq!(app.pane, Pane::Content);
app.resource_cols = 3;
crate::tui::event::handle_event(&mut app, key(KeyCode::Right));
assert_eq!(app.settings_selected, 1, "Right must land on Language");
crate::tui::event::handle_event(&mut app, key(KeyCode::Enter));
assert!(app.picker_open(), "Enter must open the language picker");
crate::tui::event::handle_event(&mut app, key(KeyCode::Down));
crate::tui::event::handle_event(&mut app, key(KeyCode::Enter));
assert_eq!(app.language, crate::config::Language::Ru);
app.set_language(crate::config::Language::En);
}