envision 0.16.0

A ratatui framework for collaborative TUI development with headless testing support
Documentation
use super::*;

use ratatui::widgets::Paragraph;

use crate::backend::CaptureBackend;
use crate::input::{Event, Key};
use crate::overlay::{Overlay, OverlayAction};
use crate::theme::Theme;

// ========== Test App ==========

struct TestApp;

#[derive(Clone, Default)]
struct TestState {
    value: String,
}

#[derive(Clone, Debug, PartialEq)]
enum TestMsg {
    Set(String),
    FromOverlay(String),
}

impl App for TestApp {
    type State = TestState;
    type Message = TestMsg;

    fn init() -> (Self::State, super::super::command::Command<Self::Message>) {
        (TestState::default(), super::super::command::Command::none())
    }

    fn update(
        state: &mut Self::State,
        msg: Self::Message,
    ) -> super::super::command::Command<Self::Message> {
        match msg {
            TestMsg::Set(v) => state.value = v,
            TestMsg::FromOverlay(v) => state.value = format!("overlay:{v}"),
        }
        super::super::command::Command::none()
    }

    fn view(state: &Self::State, frame: &mut ratatui::Frame) {
        let text = if state.value.is_empty() {
            "Hello".to_string()
        } else {
            state.value.clone()
        };
        frame.render_widget(Paragraph::new(text), frame.area());
    }

    fn handle_event(event: &Event) -> Option<Self::Message> {
        if let Some(key) = event.as_key() {
            if let Key::Char(c) = key.code {
                return Some(TestMsg::Set(c.to_string()));
            }
        }
        None
    }
}

// ========== Test Overlays ==========

struct ConsumingOverlay;

impl Overlay<TestMsg> for ConsumingOverlay {
    fn handle_event(&mut self, _event: &Event) -> OverlayAction<TestMsg> {
        OverlayAction::Consumed
    }

    fn view(&self, _ctx: &mut crate::component::RenderContext<'_, '_>) {}
}

struct MessageOverlay {
    msg: String,
}

impl Overlay<TestMsg> for MessageOverlay {
    fn handle_event(&mut self, _event: &Event) -> OverlayAction<TestMsg> {
        OverlayAction::KeepAndMessage(TestMsg::FromOverlay(self.msg.clone()))
    }

    fn view(&self, _ctx: &mut crate::component::RenderContext<'_, '_>) {}
}

struct DismissOverlay;

impl Overlay<TestMsg> for DismissOverlay {
    fn handle_event(&mut self, _event: &Event) -> OverlayAction<TestMsg> {
        OverlayAction::Dismiss
    }

    fn view(&self, _ctx: &mut crate::component::RenderContext<'_, '_>) {}
}

struct DismissWithMsgOverlay {
    msg: String,
}

impl Overlay<TestMsg> for DismissWithMsgOverlay {
    fn handle_event(&mut self, _event: &Event) -> OverlayAction<TestMsg> {
        OverlayAction::DismissWithMessage(TestMsg::FromOverlay(self.msg.clone()))
    }

    fn view(&self, _ctx: &mut crate::component::RenderContext<'_, '_>) {}
}

struct PropagateOverlay;

impl Overlay<TestMsg> for PropagateOverlay {
    fn handle_event(&mut self, _event: &Event) -> OverlayAction<TestMsg> {
        OverlayAction::Propagate
    }

    fn view(&self, _ctx: &mut crate::component::RenderContext<'_, '_>) {}
}

// ========== Helper ==========

fn new_core() -> RuntimeCore<TestApp, CaptureBackend> {
    let (state, _cmd) = TestApp::init();
    let backend = CaptureBackend::new(40, 10);
    let terminal = ratatui::Terminal::new(backend).unwrap();

    RuntimeCore {
        state,
        terminal,
        events: EventQueue::new(),
        overlay_stack: OverlayStack::new(),
        theme: Theme::default(),
        should_quit: false,
        max_messages_per_tick: 100,
    }
}

// ========== Tests ==========

#[test]
fn test_render_succeeds() {
    let mut core = new_core();
    core.render().unwrap();

    let output = core.terminal.backend().to_string();
    assert!(output.contains("Hello"));
}

#[test]
fn test_process_event_no_event() {
    let mut core = new_core();
    let result = core.process_event();
    assert!(matches!(result, ProcessEventResult::NoEvent));
}

#[test]
fn test_process_event_no_overlay_dispatches() {
    let mut core = new_core();
    core.events.push(Event::char('x'));

    let result = core.process_event();
    assert!(matches!(result, ProcessEventResult::Dispatch(TestMsg::Set(ref s)) if s == "x"));
}

#[test]
fn test_process_event_no_overlay_unhandled_event() {
    let mut core = new_core();
    core.events.push(Event::Resize(80, 24));

    let result = core.process_event();
    assert!(matches!(result, ProcessEventResult::Consumed));
}

#[test]
fn test_process_event_consuming_overlay() {
    let mut core = new_core();
    core.push_overlay(Box::new(ConsumingOverlay));
    core.events.push(Event::char('x'));

    let result = core.process_event();
    assert!(matches!(result, ProcessEventResult::Consumed));
}

#[test]
fn test_process_event_message_overlay() {
    let mut core = new_core();
    core.push_overlay(Box::new(MessageOverlay {
        msg: "hello".to_string(),
    }));
    core.events.push(Event::char('x'));

    let result = core.process_event();
    assert!(
        matches!(result, ProcessEventResult::Dispatch(TestMsg::FromOverlay(ref s)) if s == "hello")
    );
    assert_eq!(core.overlay_count(), 1);
}

#[test]
fn test_process_event_dismiss_overlay() {
    let mut core = new_core();
    core.push_overlay(Box::new(DismissOverlay));
    assert_eq!(core.overlay_count(), 1);

    core.events.push(Event::char('x'));
    let result = core.process_event();

    assert!(matches!(result, ProcessEventResult::Consumed));
    assert_eq!(core.overlay_count(), 0);
}

#[test]
fn test_process_event_dismiss_with_message_overlay() {
    let mut core = new_core();
    core.push_overlay(Box::new(DismissWithMsgOverlay {
        msg: "bye".to_string(),
    }));
    assert_eq!(core.overlay_count(), 1);

    core.events.push(Event::char('x'));
    let result = core.process_event();

    assert!(
        matches!(result, ProcessEventResult::Dispatch(TestMsg::FromOverlay(ref s)) if s == "bye")
    );
    assert_eq!(core.overlay_count(), 0);
}

#[test]
fn test_process_event_propagate_overlay() {
    let mut core = new_core();
    core.push_overlay(Box::new(PropagateOverlay));
    core.events.push(Event::char('z'));

    let result = core.process_event();
    assert!(matches!(result, ProcessEventResult::Dispatch(TestMsg::Set(ref s)) if s == "z"));
    assert_eq!(core.overlay_count(), 1);
}

#[test]
fn test_push_and_pop_overlay() {
    let mut core = new_core();
    assert!(!core.has_overlays());
    assert_eq!(core.overlay_count(), 0);

    core.push_overlay(Box::new(ConsumingOverlay));
    assert!(core.has_overlays());
    assert_eq!(core.overlay_count(), 1);

    core.push_overlay(Box::new(PropagateOverlay));
    assert_eq!(core.overlay_count(), 2);

    let popped = core.pop_overlay();
    assert!(popped.is_some());
    assert_eq!(core.overlay_count(), 1);

    let popped = core.pop_overlay();
    assert!(popped.is_some());
    assert_eq!(core.overlay_count(), 0);
    assert!(!core.has_overlays());

    assert!(core.pop_overlay().is_none());
}

#[test]
fn test_clear_overlays() {
    let mut core = new_core();
    core.push_overlay(Box::new(ConsumingOverlay));
    core.push_overlay(Box::new(PropagateOverlay));
    core.push_overlay(Box::new(DismissOverlay));
    assert_eq!(core.overlay_count(), 3);

    core.clear_overlays();
    assert_eq!(core.overlay_count(), 0);
    assert!(!core.has_overlays());
}