use super::*;
use crate::test_support::FakeRepository;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::sync::mpsc;
fn test_app() -> App {
test_app_with_repository(FakeRepository::default()).0
}
fn test_app_with_repository(repository: FakeRepository) -> (App, FakeRepository) {
let (event_tx, event_rx) = mpsc::channel();
let observer = repository.clone();
let manager = Rc::new(RefCell::new(ServicesManager::new(Box::new(repository))));
let table = TableServices::new(event_tx.clone(), manager.clone());
let filter = Filter::new(event_tx.clone(), String::new());
let log = ServiceLog::new(event_tx.clone(), manager.clone());
let details = ServiceDetails::new(event_tx.clone(), manager.clone());
(
App::new(event_tx, event_rx, table, filter, log, details, manager),
observer,
)
}
#[test]
fn translates_known_dbus_errors() {
let message = get_user_friendly_error("org.freedesktop.DBus.Error.AccessDenied: rejected");
assert!(message.contains("Access denied"));
}
#[test]
fn preserves_unknown_errors() {
assert_eq!(get_user_friendly_error("custom failure"), "custom failure");
}
#[test]
fn connection_change_commits_active_context_only_after_success() {
let (mut app, observer) = test_app_with_repository(FakeRepository::default());
app.update_connection_and_reset(1);
assert_eq!(app.active_connection, ConnectionType::Session);
assert_eq!(app.selected_tab_index, 1);
assert_eq!(observer.calls(), ["connection:session"]);
}
#[test]
fn failed_connection_change_preserves_previous_active_context() {
let repository = FakeRepository::default();
repository.fail("connection");
let (mut app, observer) = test_app_with_repository(repository);
app.update_connection_and_reset(1);
assert_eq!(app.active_connection, ConnectionType::System);
assert_eq!(app.selected_tab_index, 0);
assert_eq!(observer.calls(), ["connection:session"]);
assert!(matches!(app.event_rx.recv().unwrap(), AppEvent::Error(_)));
}
#[test]
fn translates_each_supported_dbus_error_family() {
for (error, expected) in [
(
"org.freedesktop.DBus.Error.InteractiveAuthorizationRequired",
"permission",
),
("org.freedesktop.DBus.Error.ServiceUnknown", "not available"),
("org.freedesktop.DBus.Error.NoReply", "did not respond"),
("org.freedesktop.systemd1.NoSuchUnit", "doesn't exist"),
] {
assert!(get_user_friendly_error(error).contains(expected));
}
}
#[test]
fn help_popup_is_rendered_with_sections_and_highlighted_title() {
let app = test_app();
let backend = TestBackend::new(100, 45);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| app.draw_help_popup(frame, frame.area()))
.unwrap();
let buffer = terminal.backend().buffer();
let rendered = buffer
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(rendered.contains("SYSTEMD MANAGER TUI - HELP"));
assert!(rendered.contains("Navigation:"));
assert!(rendered.contains("Service Control:"));
assert!(rendered.contains("Ctrl+c - Quit"));
assert!(
buffer
.content()
.iter()
.any(|cell| cell.symbol() == "S" && cell.fg == Color::Cyan)
);
}
#[test]
fn shortcuts_panel_renders_context_actions_and_global_exit() {
let app = test_app();
let backend = TestBackend::new(70, 7);
let mut terminal = Terminal::new(backend).unwrap();
let shortcuts = vec![Line::from("Custom action: x")];
terminal
.draw(|frame| app.draw_shortcuts(frame, frame.area(), &shortcuts))
.unwrap();
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(rendered.contains("Shortcuts"));
assert!(rendered.contains("Custom action: x"));
assert!(rendered.contains("Exit: Ctrl + c"));
}
#[test]
fn ctrl_c_hides_the_cursor_and_stops_the_app() {
let mut app = test_app();
let backend = TestBackend::new(20, 5);
let expected_hidden_backend = backend.clone();
let mut terminal = Terminal::new(backend).unwrap();
terminal.show_cursor().unwrap();
let handled = app
.handle_quit_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut terminal,
)
.unwrap();
assert!(handled);
assert!(!app.running);
assert_eq!(terminal.backend(), &expected_hidden_backend);
}
#[test]
fn ctrl_shift_c_also_uses_the_controlled_shutdown_path() {
let mut app = test_app();
let backend = TestBackend::new(20, 5);
let expected_hidden_backend = backend.clone();
let mut terminal = Terminal::new(backend).unwrap();
terminal.show_cursor().unwrap();
let handled = app
.handle_quit_key(
KeyEvent::new(
KeyCode::Char('C'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
),
&mut terminal,
)
.unwrap();
assert!(handled);
assert!(!app.running);
assert_eq!(terminal.backend(), &expected_hidden_backend);
}
#[test]
fn plain_c_does_not_stop_the_app_or_change_cursor_visibility() {
let mut app = test_app();
let backend = TestBackend::new(20, 5);
let mut visible_backend = backend.clone();
visible_backend.show_cursor().unwrap();
let mut terminal = Terminal::new(backend).unwrap();
terminal.show_cursor().unwrap();
let handled = app
.handle_quit_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
&mut terminal,
)
.unwrap();
assert!(!handled);
assert!(app.running);
assert_eq!(terminal.backend(), &visible_backend);
}
#[test]
fn instance_dialog_keeps_shortcut_letters_and_navigation_out_of_main_ui() {
let (mut app, observer) = test_app_with_repository(FakeRepository::default());
app.table_service.services = vec![crate::test_support::service(
"worker@.service",
"inactive",
"disabled",
)];
app.table_service.refresh("");
app.table_service
.act_on_selected_service(&ServiceAction::Start);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
for c in "hlfs".chars() {
app.handle_instance_key(
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE),
&mut terminal,
)
.unwrap();
}
app.handle_instance_key(
KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
&mut terminal,
)
.unwrap();
app.handle_instance_key(
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
&mut terminal,
)
.unwrap();
assert_eq!(app.selected_tab_index, 0);
assert!(app.filter.input.is_empty());
assert_eq!(observer.calls(), ["start:worker@hlfs.service"]);
}
#[test]
fn ctrl_c_in_instance_dialog_quits_without_executing_the_action() {
let (mut app, observer) = test_app_with_repository(FakeRepository::default());
app.table_service.services = vec![crate::test_support::service(
"worker@.service",
"inactive",
"disabled",
)];
app.table_service.refresh("");
app.table_service
.act_on_selected_service(&ServiceAction::Start);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.handle_instance_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut terminal,
)
.unwrap();
assert!(!app.running);
assert!(observer.calls().is_empty());
}
#[test]
fn error_dismissal_consumes_shortcut_keys_and_preserves_pending_errors() {
let (mut app, observer) = test_app_with_repository(FakeRepository::default());
app.errors.push_back("first failure".into());
app.errors.push_back("second failure".into());
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.handle_modal_key(
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
&mut terminal,
)
.unwrap();
assert_eq!(
app.errors.front().map(String::as_str),
Some("second failure")
);
assert!(observer.calls().is_empty());
assert!(app.event_rx.try_recv().is_err());
assert!(app.filter.input.is_empty());
app.handle_modal_key(
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
&mut terminal,
)
.unwrap();
assert!(app.errors.is_empty());
assert!(app.running);
}
#[test]
fn enter_dismisses_error_without_submitting_underlying_instance_prompt() {
let (mut app, observer) = test_app_with_repository(FakeRepository::default());
app.table_service.services = vec![crate::test_support::service(
"worker@.service",
"inactive",
"disabled",
)];
app.table_service.refresh("");
app.table_service
.act_on_selected_service(&ServiceAction::Start);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.handle_modal_key(
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
&mut terminal,
)
.unwrap();
app.errors.push_back("background lookup failed".into());
app.handle_modal_key(
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
&mut terminal,
)
.unwrap();
assert!(app.errors.is_empty());
assert!(app.table_service.has_instance_prompt());
assert!(observer.calls().is_empty());
app.handle_modal_key(
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
&mut terminal,
)
.unwrap();
assert_eq!(observer.calls(), ["start:worker@a.service"]);
}
#[test]
fn error_modal_ignores_key_release_and_allows_ctrl_c() {
let mut app = test_app();
app.errors.push_back("failure".into());
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
let mut key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
key.kind = KeyEventKind::Release;
app.handle_modal_key(key, &mut terminal).unwrap();
assert_eq!(app.errors.len(), 1);
app.handle_modal_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut terminal,
)
.unwrap();
assert!(!app.running);
}
#[test]
fn editor_errors_use_event_queue_and_error_popup_renders_without_reading_input() {
let app = test_app();
app.report_error("editor unavailable").unwrap();
let AppEvent::Error(message) = app.event_rx.recv_timeout(Duration::from_secs(1)).unwrap()
else {
panic!("expected error event");
};
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| app.draw_error_popup(frame, frame.area(), &message))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("editor unavailable"));
assert!(text.contains("Press any key to dismiss"));
}