use super::*;
use crate::domain::connection_type::ConnectionType;
use crate::domain::service_repository::ServiceRepository;
use crate::domain::service_state::ServiceState;
use crate::test_support::FakeRepository;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::collections::HashMap;
use std::sync::mpsc;
fn request_context(name: &str) -> ServiceRequestContext {
ServiceRequestContext {
connection: ConnectionType::System,
service_name: name.to_string(),
}
}
struct EmptyRepository;
impl ServiceRepository for EmptyRepository {
fn list_services(&self, _: bool) -> Result<Vec<Service>, Box<dyn Error>> {
Ok(vec![])
}
fn unit_files_state(&self, _: Vec<Service>) -> Result<HashMap<String, String>, Box<dyn Error>> {
Ok(HashMap::new())
}
fn list_service_files(&self) -> Result<Vec<Service>, Box<dyn Error>> {
Ok(vec![])
}
fn get_unit(&self, _: &str) -> Result<Service, Box<dyn Error>> {
Err("not found".into())
}
fn get_service_log(&self, _: &str) -> Result<String, Box<dyn Error>> {
Ok(String::new())
}
fn start_service(&self, _: &str) -> Result<Service, Box<dyn Error>> {
Err("unsupported".into())
}
fn stop_service(&self, _: &str) -> Result<Service, Box<dyn Error>> {
Err("unsupported".into())
}
fn restart_service(&self, _: &str) -> Result<Service, Box<dyn Error>> {
Err("unsupported".into())
}
fn enable_service(&self, _: &str) -> Result<Service, Box<dyn Error>> {
Err("unsupported".into())
}
fn disable_service(&self, _: &str) -> Result<Service, Box<dyn Error>> {
Err("unsupported".into())
}
fn mask_service(&self, _: &str) -> Result<Service, Box<dyn Error>> {
Err("unsupported".into())
}
fn unmask_service(&self, _: &str) -> Result<Service, Box<dyn Error>> {
Err("unsupported".into())
}
fn reload_daemon(&self) -> Result<(), Box<dyn Error>> {
Ok(())
}
fn change_connection(&mut self, _: ConnectionType) -> Result<(), Box<dyn Error>> {
Ok(())
}
fn systemctl_cat(&self, _: &str) -> Result<String, Box<dyn Error>> {
Ok(String::new())
}
fn get_active_enter_timestamp(&self, _: &str) -> Result<u64, Box<dyn Error>> {
Ok(0)
}
}
fn table_with_repository(
repository: impl ServiceRepository + 'static,
) -> (TableServices, mpsc::Receiver<AppEvent>) {
let (sender, receiver) = mpsc::channel();
let manager = ServicesManager::new(Box::new(repository));
(
TableServices::new(sender, Rc::new(RefCell::new(manager))),
receiver,
)
}
fn table_with_receiver() -> (TableServices, mpsc::Receiver<AppEvent>) {
table_with_repository(EmptyRepository)
}
fn table() -> TableServices {
table_with_receiver().0
}
fn service(name: &str, active: &str) -> Service {
Service::new(
name.to_string(),
String::new(),
ServiceState::new(
"loaded".into(),
active.into(),
"running".into(),
"enabled".into(),
),
)
}
#[test]
fn navigation_on_empty_list_clears_selection() {
let mut table = table();
table.select_next();
table.select_previous();
table.select_page_down();
table.select_page_up();
assert_eq!(table.table_state.selected(), None);
}
#[test]
fn navigation_wraps_in_both_directions() {
let mut table = table();
table.filtered_services = vec![
service("a.service", "active"),
service("b.service", "inactive"),
];
table.table_state.select(Some(1));
table.select_next();
assert_eq!(table.table_state.selected(), Some(0));
table.select_previous();
assert_eq!(table.table_state.selected(), Some(1));
}
#[test]
fn filter_combines_name_and_active_state() {
let mut table = table();
table.active_filter_state = ActiveFilterState::Active;
let services = vec![
service("alpha.service", "active"),
service("beta.service", "active"),
service("alpha.timer", "inactive"),
];
let filtered = table.filter("alpha", &services);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name(), "alpha.service");
}
#[test]
fn file_state_resolution_prefers_loaded_state_map() {
let loading = Service::new(
"demo.service".into(),
String::new(),
ServiceState::new(
"loaded".into(),
"active".into(),
"running".into(),
LOADING_PLACEHOLDER.into(),
),
);
let states = HashMap::from([("demo.service".to_string(), "enabled".to_string())]);
assert_eq!(resolve_file(&loading, Some(&states)), "enabled");
}
#[test]
fn unresolved_file_state_uses_loading_placeholder_before_background_loading_finishes() {
let unit = Service::new(
"transient.service".into(),
String::new(),
ServiceState::new(
"loaded".into(),
"active".into(),
"running".into(),
LOADING_PLACEHOLDER.into(),
),
);
assert_eq!(
resolve_file(&unit, Some(&HashMap::new())),
LOADING_PLACEHOLDER
);
assert_eq!(resolve_file(&unit, None), LOADING_PLACEHOLDER);
}
#[test]
fn explicit_service_file_state_wins_over_loaded_state_map() {
let unit = service("demo.service", "active");
let states = HashMap::from([("demo.service".to_string(), "masked".to_string())]);
assert_eq!(resolve_file(&unit, Some(&states)), "enabled");
}
#[test]
fn renders_service_table_to_test_backend() {
let mut table = table();
table.filtered_services = vec![service("demo.service", "active")];
table.table_state.select(Some(0));
let backend = TestBackend::new(90, 6);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| table.render(frame, frame.area()))
.unwrap();
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(rendered.contains("Name"));
assert!(rendered.contains("demo.service"));
assert!(rendered.contains("active (running)"));
assert!(rendered.contains("enabled"));
}
#[test]
fn active_filter_state_cycles_through_every_option() {
let state = ActiveFilterState::All;
let state = state.next();
assert!(state == ActiveFilterState::Active);
assert_eq!(state.as_str(), "active");
let state = state.next();
assert!(state == ActiveFilterState::Inactive);
assert_eq!(state.as_str(), "inactive");
let state = state.next();
assert!(state == ActiveFilterState::Failed);
assert_eq!(state.as_str(), "failed");
assert!(state.next() == ActiveFilterState::All);
}
#[test]
fn each_active_state_filter_selects_only_matching_services() {
let mut table = table();
let services = vec![
service("active.service", "active"),
service("inactive.service", "inactive"),
service("failed.service", "failed"),
];
for (filter_state, expected_name) in [
(ActiveFilterState::Active, "active.service"),
(ActiveFilterState::Inactive, "inactive.service"),
(ActiveFilterState::Failed, "failed.service"),
] {
table.active_filter_state = filter_state;
let filtered = table.filter("", &services);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name(), expected_name);
}
}
#[test]
fn refresh_repairs_an_out_of_bounds_selection() {
let mut table = table();
table.services = vec![service("demo.service", "active")];
table.table_state.select(Some(99));
table.refresh("");
assert_eq!(table.table_state.selected(), Some(0));
assert_eq!(table.get_selected_service().unwrap().name(), "demo.service");
}
#[test]
fn service_shortcut_dispatches_action_and_locks_input() {
let (mut table, receiver) = table_with_receiver();
table.on_key_event(KeyEvent::new(
KeyCode::Char('s'),
crossterm::event::KeyModifiers::NONE,
));
assert!(table.ignore_key_events);
assert!(matches!(
receiver.recv().unwrap(),
AppEvent::Action(Actions::ServiceAction(ServiceAction::Start))
));
assert!(table.shortcuts().is_empty());
}
#[test]
fn selected_row_uses_distinct_enabled_and_disabled_colors() {
let mut table = table();
table.filtered_services = vec![service("demo.service", "active")];
table.table_state.select(Some(0));
let backend = TestBackend::new(90, 6);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| table.render(frame, frame.area()))
.unwrap();
assert!(
terminal
.backend()
.buffer()
.content()
.iter()
.any(|cell| cell.bg == Color::Blue)
);
table.set_ignore_key_events(true);
let backend = TestBackend::new(90, 6);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| table.render(frame, frame.area()))
.unwrap();
assert!(
terminal
.backend()
.buffer()
.content()
.iter()
.any(|cell| cell.bg == Color::DarkGray)
);
}
#[test]
fn timestamp_update_is_applied_only_to_current_selection() {
let mut table = table();
table.selected_service_name = Some("current.service".into());
table.update_timestamp(request_context("old.service"), Some(10));
assert!(table.active_enter_timestamp.is_none());
table.update_timestamp(request_context("current.service"), Some(20));
assert_eq!(table.active_enter_timestamp, Some(20));
assert!(table.has_active_runtime());
}
#[test]
fn timestamp_update_is_ignored_after_connection_changes() {
let mut table = table();
table.selected_service_name = Some("same-name.service".into());
table.set_active_connection(ConnectionType::Session);
table.selected_service_name = Some("same-name.service".into());
table.update_timestamp(request_context("same-name.service"), Some(20));
assert_eq!(table.active_enter_timestamp, None);
}
#[test]
fn unit_file_states_are_applied_only_for_the_current_list_request() {
let (mut table, receiver) = table_with_receiver();
table.spawn_query_listener();
let stale_context = *table.current_list_context.lock().unwrap();
table.set_active_connection(ConnectionType::Session);
let current_context = *table.current_list_context.lock().unwrap();
table
.event_tx
.send(QueryUnitFile::Finished(
stale_context,
HashMap::from([("stale.service".into(), "enabled".into())]),
))
.unwrap();
assert!(receiver.recv_timeout(Duration::from_millis(50)).is_err());
assert!(table.states.lock().unwrap().is_empty());
table
.event_tx
.send(QueryUnitFile::Finished(
current_context,
HashMap::from([("current.service".into(), "disabled".into())]),
))
.unwrap();
assert!(matches!(
receiver.recv_timeout(Duration::from_secs(1)),
Ok(AppEvent::Action(Actions::Redraw))
));
assert_eq!(
table
.states
.lock()
.unwrap()
.get("current.service")
.map(String::as_str),
Some("disabled")
);
}
#[test]
fn older_refresh_cannot_overwrite_current_states_on_the_same_connection() {
let (mut table, receiver) = table_with_receiver();
let stale_context = *table.current_list_context.lock().unwrap();
let current_context = ListRequestContext {
generation: stale_context.generation + 1,
..stale_context
};
*table.current_list_context.lock().unwrap() = current_context;
table.spawn_query_listener();
for (context, state) in [(current_context, "disabled"), (stale_context, "enabled")] {
table
.event_tx
.send(QueryUnitFile::Finished(
context,
HashMap::from([("demo.service".into(), state.into())]),
))
.unwrap();
}
table
.event_tx
.send(QueryUnitFile::Error(current_context, "barrier".into()))
.unwrap();
assert!(matches!(
receiver.recv_timeout(Duration::from_secs(1)),
Ok(AppEvent::Action(Actions::Redraw))
));
assert!(matches!(
receiver.recv_timeout(Duration::from_secs(1)),
Ok(AppEvent::Error(message)) if message == "barrier"
));
assert_eq!(
table
.states
.lock()
.unwrap()
.get("demo.service")
.map(String::as_str),
Some("disabled")
);
assert!(receiver.try_recv().is_err());
}
#[test]
fn stale_lookup_errors_are_ignored_but_current_errors_are_reported() {
let (mut table, receiver) = table_with_receiver();
let current_context = *table.current_list_context.lock().unwrap();
table.spawn_query_listener();
for context in [
ListRequestContext {
generation: current_context.generation.wrapping_sub(1),
..current_context
},
ListRequestContext {
connection: ConnectionType::Session,
..current_context
},
] {
table
.event_tx
.send(QueryUnitFile::Error(context, "stale failure".into()))
.unwrap();
}
table
.event_tx
.send(QueryUnitFile::Error(
current_context,
"current failure".into(),
))
.unwrap();
assert!(matches!(
receiver.recv_timeout(Duration::from_secs(1)),
Ok(AppEvent::Error(message)) if message == "current failure"
));
assert!(receiver.try_recv().is_err());
}
#[test]
fn background_workers_start_once_and_are_signalled_on_drop() {
let (mut table, receiver) = table_with_receiver();
let shutdown = table.workers_shutdown.clone();
table.spawn_query_listener();
table.spawn_timestamp_worker();
let query_thread = table.query_listener_handle.as_ref().unwrap().thread().id();
let timestamp_thread = table
.timestamp_worker_handle
.as_ref()
.unwrap()
.thread()
.id();
table.spawn_query_listener();
table.spawn_timestamp_worker();
assert_eq!(
table.query_listener_handle.as_ref().unwrap().thread().id(),
query_thread
);
assert_eq!(
table
.timestamp_worker_handle
.as_ref()
.unwrap()
.thread()
.id(),
timestamp_thread
);
assert!(receiver.try_recv().is_err());
drop(table);
assert!(shutdown.load(Ordering::Acquire));
}
#[test]
fn successful_service_action_updates_row_and_unlocks_input() {
let fake = FakeRepository::default();
let observer = fake.clone();
let (mut table, _receiver) = table_with_repository(fake);
let unit = service("demo.service", "inactive");
table.services = vec![unit.clone()];
table.filtered_services = vec![unit];
table.table_state.select(Some(0));
table.set_ignore_key_events(true);
table.act_on_selected_service(&ServiceAction::Start);
assert_eq!(observer.calls(), ["start:demo.service"]);
assert_eq!(table.services[0].state().active(), "active");
assert!(!table.ignore_key_events);
}
#[test]
fn failed_service_action_reports_error_and_unlocks_input() {
let fake = FakeRepository::default();
fake.fail("start");
let observer = fake.clone();
let (mut table, receiver) = table_with_repository(fake);
let unit = service("broken.service", "inactive");
table.services = vec![unit.clone()];
table.filtered_services = vec![unit];
table.table_state.select(Some(0));
table.set_ignore_key_events(true);
table.act_on_selected_service(&ServiceAction::Start);
assert!(matches!(
receiver.recv().unwrap(),
AppEvent::Error(message) if message == "start failed"
));
assert!(observer.calls().contains(&"list:false".to_string()));
assert!(!table.ignore_key_events);
}
#[test]
fn failed_refresh_keeps_the_current_list_and_selection() {
let fake = FakeRepository::default();
fake.fail("list");
let (mut table, receiver) = table_with_repository(fake);
let existing = service("existing.service", "active");
table.services = vec![existing.clone()];
table.filtered_services = vec![existing];
table.table_state.select(Some(0));
table.fetch_and_refresh("existing");
assert_eq!(table.services.len(), 1);
assert_eq!(table.services[0].name(), "existing.service");
assert_eq!(
table.get_selected_service().unwrap().name(),
"existing.service"
);
assert!(matches!(
receiver.recv().unwrap(),
AppEvent::Error(message)
if message.contains("system connection")
&& message.contains("list failed")
&& message.contains("current list was kept")
));
}
#[test]
fn toggle_mask_chooses_operation_from_loaded_file_state() {
let fake = FakeRepository::default();
let observer = fake.clone();
let (mut table, _receiver) = table_with_repository(fake);
let unit = crate::test_support::service("demo.service", "inactive", LOADING_PLACEHOLDER);
table.services = vec![unit.clone()];
table.filtered_services = vec![unit];
table.table_state.select(Some(0));
table
.states
.lock()
.unwrap()
.insert("demo.service".into(), "masked".into());
table.act_on_selected_service(&ServiceAction::ToggleMask);
let calls = observer.calls();
assert!(calls.contains(&"unmask:demo.service".to_string()));
assert!(!calls.contains(&"mask:demo.service".to_string()));
}
#[test]
fn rendered_rows_expose_active_state_and_loading_styles() {
let mut table = table();
table.filtered_services = vec![
service("active.service", "active"),
service("starting.service", "activating"),
service("inactive.service", "inactive"),
service("failed.service", "failed"),
Service::new(
"loading.service".into(),
String::new(),
ServiceState::new(
"loaded".into(),
"active".into(),
String::new(),
LOADING_PLACEHOLDER.into(),
),
),
];
table.table_state.select(None);
let backend = TestBackend::new(100, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| table.render(frame, frame.area()))
.unwrap();
let cells = terminal.backend().buffer().content();
for color in [Color::Green, Color::Yellow, Color::DarkGray, Color::Red] {
assert!(cells.iter().any(|cell| cell.fg == color));
}
assert!(cells.iter().any(|cell| {
cell.symbol() == "L"
&& cell.modifier.contains(Modifier::ITALIC)
&& cell.modifier.contains(Modifier::DIM)
}));
}
#[test]
fn every_list_shortcut_dispatches_its_expected_action() {
fn assert_action(key: char, expected: fn(Actions) -> bool) {
let (mut table, receiver) = table_with_receiver();
table.on_key_event(KeyEvent::new(
KeyCode::Char(key),
crossterm::event::KeyModifiers::NONE,
));
match receiver.recv().unwrap() {
AppEvent::Action(action) => assert!(expected(action), "wrong action for key {key}"),
AppEvent::Key(_) | AppEvent::Error(_) => panic!("wrong event for key {key}"),
}
}
assert_action('r', |action| {
matches!(action, Actions::ServiceAction(ServiceAction::Restart))
});
assert_action('x', |action| {
matches!(action, Actions::ServiceAction(ServiceAction::Stop))
});
assert_action('e', |action| {
matches!(action, Actions::ServiceAction(ServiceAction::Enable))
});
assert_action('d', |action| {
matches!(action, Actions::ServiceAction(ServiceAction::Disable))
});
assert_action('u', |action| {
matches!(action, Actions::ServiceAction(ServiceAction::RefreshAll))
});
assert_action('f', |action| {
matches!(action, Actions::ServiceAction(ServiceAction::ToggleFilter))
});
assert_action('m', |action| {
matches!(action, Actions::ServiceAction(ServiceAction::ToggleMask))
});
assert_action('?', |action| matches!(action, Actions::ShowHelp));
assert_action('c', |action| matches!(action, Actions::GoDetails));
assert_action('v', |action| matches!(action, Actions::GoLog));
}
#[test]
fn page_navigation_wraps_for_lists_larger_than_jump() {
let mut table = table();
table.filtered_services = (0..11)
.map(|index| service(&format!("{index}.service"), "active"))
.collect();
table.table_state.select(Some(0));
table.select_page_up();
assert_eq!(table.table_state.selected(), Some(1));
table.select_page_down();
assert_eq!(table.table_state.selected(), Some(0));
}
#[test]
fn active_service_runtime_is_rendered_instead_of_raw_state() {
let mut table = table();
table.filtered_services = vec![service("demo.service", "active")];
table.table_state.select(Some(0));
table.selected_service_name = Some("demo.service".into());
table.last_timestamp_fetch = Some(Instant::now());
table.active_enter_timestamp = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_micros() as u64
- 65_000_000,
);
let backend = TestBackend::new(90, 6);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| table.render(frame, frame.area()))
.unwrap();
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(rendered.contains("Uptime: 1m"));
assert!(!rendered.contains("active (running)"));
}
#[test]
fn toggle_mask_masks_an_unmasked_service() {
let fake = FakeRepository::default();
let observer = fake.clone();
let (mut table, _receiver) = table_with_repository(fake);
let unit = service("demo.service", "inactive");
table.services = vec![unit.clone()];
table.filtered_services = vec![unit];
table.table_state.select(Some(0));
table
.states
.lock()
.unwrap()
.insert("demo.service".into(), "enabled".into());
table.act_on_selected_service(&ServiceAction::ToggleMask);
let calls = observer.calls();
assert!(calls.contains(&"mask:demo.service".to_string()));
assert!(!calls.contains(&"unmask:demo.service".to_string()));
}
fn run_list_shortcut(table: &mut TableServices, receiver: &mpsc::Receiver<AppEvent>, key: char) {
table.on_key_event(KeyEvent::new(
KeyCode::Char(key),
crossterm::event::KeyModifiers::NONE,
));
let AppEvent::Action(Actions::ServiceAction(action)) =
receiver.recv_timeout(Duration::from_secs(1)).unwrap()
else {
panic!("expected a list action");
};
table.act_on_selected_service(&action);
assert!(!table.ignore_key_events);
}
#[test]
fn list_all_shortcut_reveals_inactive_units_after_empty_search() {
let fake = FakeRepository::with_services(
vec![service("running.service", "active")],
vec![
service("hidden.service", "inactive"),
service("unrelated.service", "inactive"),
],
);
let (mut table, receiver) = table_with_repository(fake);
table.fetch_and_refresh("hidden");
assert!(table.get_selected_service().is_none());
assert!(table.filtered_services.is_empty());
run_list_shortcut(&mut table, &receiver, 'f');
assert!(table.filter_all);
assert_eq!(table.old_filter_text, "hidden");
assert_eq!(table.filtered_services.len(), 1);
assert_eq!(
table.get_selected_service().unwrap().name(),
"hidden.service"
);
assert_eq!(
table.get_selected_service().unwrap().state().active(),
"inactive"
);
run_list_shortcut(&mut table, &receiver, 'f');
assert!(!table.filter_all);
assert!(table.filtered_services.is_empty());
assert!(table.table_state.selected().is_none());
}
#[test]
fn global_shortcuts_work_when_no_units_match() {
let fake = FakeRepository::default();
let observer = fake.clone();
let (mut table, receiver) = table_with_repository(fake);
table.refresh("missing");
run_list_shortcut(&mut table, &receiver, 'u');
assert!(observer.calls().contains(&"list:false".to_string()));
run_list_shortcut(&mut table, &receiver, 'f');
assert!(table.filter_all);
run_list_shortcut(&mut table, &receiver, 'f');
assert!(!table.filter_all);
assert_eq!(table.old_filter_text, "missing");
assert!(table.filtered_services.is_empty());
assert!(table.table_state.selected().is_none());
}
fn instance_key(table: &mut TableServices, code: KeyCode) {
table.on_instance_key(KeyEvent::new(code, crossterm::event::KeyModifiers::NONE));
}
#[test]
fn template_operations_wait_for_instance_and_preserve_template_row() {
for (action, operation) in [
(ServiceAction::Start, "start"),
(ServiceAction::Stop, "stop"),
(ServiceAction::Restart, "restart"),
(ServiceAction::Enable, "enable"),
(ServiceAction::Disable, "disable"),
] {
let fake = FakeRepository::default();
let observer = fake.clone();
let (mut table, _receiver) = table_with_repository(fake);
table.services = vec![service("worker@.service", "inactive")];
table.refresh("worker");
table.act_on_selected_service(&action);
assert!(table.has_instance_prompt());
assert!(observer.calls().is_empty());
instance_key(&mut table, KeyCode::Enter);
assert!(table.has_instance_prompt());
assert!(observer.calls().is_empty());
for c in "tenant-1".chars() {
instance_key(&mut table, KeyCode::Char(c));
}
instance_key(&mut table, KeyCode::Enter);
assert!(!table.has_instance_prompt());
assert!(!table.ignore_key_events);
assert!(
observer
.calls()
.contains(&format!("{operation}:worker@tenant-1.service"))
);
assert!(
!observer
.calls()
.iter()
.any(|call| call.ends_with(":worker@.service"))
);
assert_eq!(
table.get_selected_service().unwrap().name(),
"worker@tenant-1.service"
);
assert!(
table
.services
.iter()
.any(|unit| unit.name() == "worker@.service")
);
assert_eq!(table.old_filter_text, "worker");
}
}
#[test]
fn cancel_and_connection_change_discard_pending_instance_actions() {
let fake = FakeRepository::default();
let observer = fake.clone();
let (mut table, _receiver) = table_with_repository(fake);
table.services = vec![service("worker@.service", "inactive")];
table.refresh("");
table.act_on_selected_service(&ServiceAction::Start);
instance_key(&mut table, KeyCode::Char('a'));
instance_key(&mut table, KeyCode::Esc);
assert!(!table.has_instance_prompt());
assert!(!table.ignore_key_events);
assert!(observer.calls().is_empty());
table.act_on_selected_service(&ServiceAction::Enable);
instance_key(&mut table, KeyCode::Char('b'));
table.set_active_connection(ConnectionType::Session);
instance_key(&mut table, KeyCode::Enter);
assert!(!table.has_instance_prompt());
assert!(!table.ignore_key_events);
assert!(observer.calls().is_empty());
}
#[test]
fn existing_instances_execute_directly_and_instance_failures_unlock_input() {
let fake = FakeRepository::default();
let observer = fake.clone();
let (mut table, receiver) = table_with_repository(fake);
table.services = vec![service("worker@existing.service", "inactive")];
table.refresh("");
table.act_on_selected_service(&ServiceAction::Start);
assert!(!table.has_instance_prompt());
assert_eq!(observer.calls(), ["start:worker@existing.service"]);
observer.fail("start");
table.services = vec![service("worker@.service", "inactive")];
table.refresh("");
table.act_on_selected_service(&ServiceAction::Start);
instance_key(&mut table, KeyCode::Char('a'));
instance_key(&mut table, KeyCode::Enter);
assert!(!table.has_instance_prompt());
assert!(!table.ignore_key_events);
assert!(
matches!(receiver.recv_timeout(Duration::from_secs(1)), Ok(AppEvent::Error(error)) if error == "start failed")
);
}
#[test]
fn pending_instance_keeps_original_template_when_selection_changes() {
let fake = FakeRepository::default();
let observer = fake.clone();
let (mut table, _receiver) = table_with_repository(fake);
table.services = vec![
service("worker@.service", "inactive"),
service("other@.service", "inactive"),
];
table.refresh("");
table.act_on_selected_service(&ServiceAction::Start);
table.set_selected_index(1);
instance_key(&mut table, KeyCode::Char('a'));
instance_key(&mut table, KeyCode::Enter);
assert_eq!(observer.calls(), ["start:worker@a.service"]);
}
#[test]
fn masking_a_template_keeps_template_scope_without_prompting() {
let fake = FakeRepository::default();
let observer = fake.clone();
let (mut table, _receiver) = table_with_repository(fake);
table.services = vec![service("worker@.service", "inactive")];
table.refresh("");
table
.states
.lock()
.unwrap()
.insert("worker@.service".into(), "disabled".into());
table.act_on_selected_service(&ServiceAction::ToggleMask);
assert!(!table.has_instance_prompt());
assert!(observer.calls().contains(&"mask:worker@.service".into()));
}
#[test]
fn new_instance_can_be_masked_without_refreshing_the_state_cache() {
let fake = FakeRepository::default();
let observer = fake.clone();
let (mut table, _) = table_with_repository(fake);
table.services = vec![service("worker@.service", "inactive")];
table.refresh("");
table.act_on_selected_service(&ServiceAction::Start);
instance_key(&mut table, KeyCode::Char('a'));
instance_key(&mut table, KeyCode::Enter);
assert_eq!(
table.get_selected_service().unwrap().name(),
"worker@a.service"
);
assert!(
!table
.states
.lock()
.unwrap()
.contains_key("worker@a.service")
);
table.act_on_selected_service(&ServiceAction::ToggleMask);
assert!(
observer
.calls()
.contains(&"mask:worker@a.service".to_string())
);
assert!(!table.ignore_key_events);
}
#[test]
fn mask_action_prefers_fresh_unit_state_to_stale_cache() {
let fake = FakeRepository::default();
let observer = fake.clone();
let (mut table, _) = table_with_repository(fake);
table.services = vec![crate::test_support::service(
"worker@a.service",
"inactive",
"masked",
)];
table.refresh("");
table
.states
.lock()
.unwrap()
.insert("worker@a.service".into(), "disabled".into());
table.act_on_selected_service(&ServiceAction::ToggleMask);
assert!(
observer
.calls()
.contains(&"unmask:worker@a.service".to_string())
);
assert!(
!observer
.calls()
.contains(&"mask:worker@a.service".to_string())
);
}