systemd-manager-tui 1.3.0

TUI for managing systemd services
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"));
}