systemd-manager-tui 1.3.0

TUI for managing systemd services
use super::*;
use crate::test_support::{FakeRepository, service};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::sync::mpsc;

fn details_with(repository: FakeRepository) -> (ServiceDetails, mpsc::Receiver<AppEvent>) {
    let (sender, receiver) = mpsc::channel();
    let manager = ServicesManager::new(Box::new(repository));
    (
        ServiceDetails::new(sender, Rc::new(RefCell::new(manager))),
        receiver,
    )
}

fn rendered_text(details: &mut ServiceDetails, width: u16, height: u16) -> String {
    let backend = TestBackend::new(width, height);
    let mut terminal = Terminal::new(backend).unwrap();
    terminal
        .draw(|frame| details.render(frame, frame.area()))
        .unwrap();
    terminal
        .backend()
        .buffer()
        .content()
        .iter()
        .map(|cell| cell.symbol())
        .collect()
}

#[test]
fn fetches_and_renders_a_unit_file() {
    let fake = FakeRepository::with_content("", "[Unit]\nDescription=Demo\n# comment", 0);
    let observer = fake.clone();
    let (mut details, _receiver) = details_with(fake);
    details.update(service("demo.service", "active", "enabled"));

    details.fetch_unit_file();
    let screen = rendered_text(&mut details, 60, 8);

    assert!(screen.contains("demo.service file"));
    assert!(screen.contains("[Unit]"));
    assert!(screen.contains("Description=Demo"));
    assert!(screen.contains("# comment"));
    assert_eq!(observer.calls(), ["cat:demo.service"]);
}

#[test]
fn reports_repository_error_as_app_event() {
    let fake = FakeRepository::default();
    fake.fail("cat");
    let (mut details, receiver) = details_with(fake);
    details.update(service("broken.service", "active", "enabled"));

    details.fetch_unit_file();

    assert!(
        matches!(receiver.recv().unwrap(), AppEvent::Error(message) if message == "cat failed")
    );
}

#[test]
fn navigation_updates_scroll_and_emits_actions() {
    let (mut details, receiver) = details_with(FakeRepository::default());
    details.on_key_event(KeyEvent::new(
        KeyCode::PageDown,
        crossterm::event::KeyModifiers::NONE,
    ));
    assert_eq!(details.scroll, 10);

    details.on_key_event(KeyEvent::new(
        KeyCode::Char('e'),
        crossterm::event::KeyModifiers::NONE,
    ));
    assert!(matches!(
        receiver.recv().unwrap(),
        AppEvent::Action(Actions::EditCurrentService)
    ));
}

#[test]
fn unit_file_rendering_applies_systemd_syntax_colors() {
    let fake = FakeRepository::with_content(
        "",
        "[Service]\nDescription=Demo\n# comment\n; another comment",
        0,
    );
    let (mut details, _receiver) = details_with(fake);
    details.update(service("demo.service", "active", "enabled"));
    details.fetch_unit_file();
    let backend = TestBackend::new(60, 8);
    let mut terminal = Terminal::new(backend).unwrap();

    terminal
        .draw(|frame| details.render(frame, frame.area()))
        .unwrap();

    let cells = terminal.backend().buffer().content();
    assert!(
        cells
            .iter()
            .any(|cell| cell.symbol() == "[" && cell.fg == Color::LightBlue)
    );
    assert!(
        cells
            .iter()
            .any(|cell| cell.symbol() == "D" && cell.fg == Color::Yellow)
    );
    assert!(
        cells
            .iter()
            .any(|cell| cell.symbol() == "#" && cell.fg == Color::Green)
    );
    assert!(
        cells
            .iter()
            .any(|cell| cell.symbol() == ";" && cell.fg == Color::Green)
    );
}

#[test]
fn leaving_details_clears_content_and_returns_to_list() {
    let fake = FakeRepository::with_content("", "[Service]\nExecStart=/bin/true", 0);
    let (mut details, receiver) = details_with(fake);
    details.update(service("demo.service", "active", "enabled"));
    details.fetch_unit_file();
    details.scroll = 12;

    details.on_key_event(KeyEvent::new(
        KeyCode::Esc,
        crossterm::event::KeyModifiers::NONE,
    ));

    assert!(details.service.is_none());
    assert!(details.unit_file.is_empty());
    assert_eq!(details.scroll, 0);
    assert!(matches!(
        receiver.recv().unwrap(),
        AppEvent::Action(Actions::GoList)
    ));
}

#[test]
fn switching_from_details_clears_content_and_opens_log() {
    let fake = FakeRepository::with_content("", "old content", 0);
    let (mut details, receiver) = details_with(fake);
    details.update(service("demo.service", "active", "enabled"));
    details.fetch_unit_file();

    details.on_key_event(KeyEvent::new(
        KeyCode::Right,
        crossterm::event::KeyModifiers::NONE,
    ));

    assert!(details.service.is_none());
    assert!(details.unit_file.is_empty());
    assert!(matches!(
        receiver.recv().unwrap(),
        AppEvent::Action(Actions::GoLog)
    ));
}

#[test]
fn fetching_without_selected_service_is_a_noop() {
    let fake = FakeRepository::default();
    let observer = fake.clone();
    let (mut details, receiver) = details_with(fake);

    details.fetch_unit_file();

    assert!(details.unit_file.is_empty());
    assert!(observer.calls().is_empty());
    assert!(receiver.try_recv().is_err());
}

#[test]
fn upward_scrolling_saturates_at_zero() {
    let (mut details, _receiver) = details_with(FakeRepository::default());

    details.on_key_event(KeyEvent::new(
        KeyCode::Up,
        crossterm::event::KeyModifiers::NONE,
    ));
    details.on_key_event(KeyEvent::new(
        KeyCode::PageUp,
        crossterm::event::KeyModifiers::NONE,
    ));

    assert_eq!(details.scroll, 0);
}