use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::backend::{Backend, TestBackend};
use crate::buffer::{Buffer, Cell};
use crate::element::Element;
use crate::error::Error;
use crate::runtime::AppCore;
const TASK_YIELD_BUDGET: usize = 8;
pub struct TestTerminal {
core: AppCore,
backend: TestBackend,
}
impl TestTerminal {
pub fn new(width: u16, height: u16, el: Element) -> Result<Self, Error> {
let mut t = TestTerminal {
core: AppCore::new(el, (width, height)),
backend: TestBackend::new(width, height),
};
t.core.process_wakes();
t.flush_clipboard();
t.core.draw(&mut t.backend)?;
Ok(t)
}
pub async fn tick(&mut self) -> Result<(), Error> {
for _ in 0..TASK_YIELD_BUDGET {
tokio::task::yield_now().await;
}
self.core.process_wakes();
self.flush_clipboard();
self.core.draw(&mut self.backend)
}
pub fn resize(&mut self, width: u16, height: u16) -> Result<(), Error> {
let clipboard = std::mem::take(&mut self.backend.clipboard);
self.backend = TestBackend::new(width, height);
self.backend.clipboard = clipboard;
self.core.resize(width, height);
self.core.process_wakes();
self.flush_clipboard();
self.core.draw(&mut self.backend)
}
pub fn frame_text(&self) -> String {
self.backend.to_text()
}
pub fn cell(&self, x: u16, y: u16) -> Option<Cell> {
let buffer = self.buffer();
(x < buffer.width() && y < buffer.height()).then(|| *buffer.get(x, y))
}
pub fn buffer(&self) -> &Buffer {
&self.backend.buffer
}
pub fn row(&self, y: u16) -> Vec<Cell> {
let buffer = self.buffer();
if y >= buffer.height() {
return Vec::new();
}
(0..buffer.width()).map(|x| *buffer.get(x, y)).collect()
}
pub fn exited(&self) -> bool {
self.core.exited
}
pub fn send_key(&mut self, code: KeyCode) -> Result<(), Error> {
self.send_key_event(KeyEvent::new(code, KeyModifiers::NONE))
}
pub fn send_key_event(&mut self, ev: KeyEvent) -> Result<(), Error> {
self.core.dispatch_key(ev);
self.core.process_wakes();
self.flush_clipboard();
self.core.draw(&mut self.backend)
}
pub fn send_paste(&mut self, text: &str) -> Result<(), Error> {
self.core.dispatch_paste(text);
self.core.process_wakes();
self.flush_clipboard();
self.core.draw(&mut self.backend)
}
pub fn clipboard(&self) -> &[String] {
&self.backend.clipboard
}
fn flush_clipboard(&mut self) {
for text in self.core.take_pending_clipboard() {
self.backend.copy_to_clipboard(&text).unwrap();
}
}
}
pub fn render_once<C: crate::component::Component>(props: &C::Props) -> Element {
const DETACHED_FIBER: crate::fiber::FiberId = crate::fiber::FiberId::MAX;
let mut slots = Vec::new();
let (wake, _) = tokio::sync::mpsc::unbounded_channel();
let runtime = crate::hooks::RuntimeHandle {
wake,
size: std::sync::Arc::new(std::sync::Mutex::new((80, 24))),
scrollback: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
};
let element = {
let mut hooks = crate::hooks::Hooks::new(
&mut slots,
std::any::type_name::<C>(),
DETACHED_FIBER,
runtime,
true,
std::rc::Rc::new(crate::fiber::ContextMap::default()),
);
C::render(props, &mut hooks)
};
for slot in slots {
slot.unmount();
}
element
}
#[cfg(test)]
mod tests {
use super::*;
use crate::component::Component;
use crate::element::Element;
use crate::hooks::Hooks;
use crate::props::TextProps;
struct Boot;
#[derive(Clone, PartialEq, Default)]
struct BootProps;
impl Component for Boot {
type Props = BootProps;
fn render(_: &BootProps, hooks: &mut Hooks) -> Element {
let n = hooks.use_state(|| 0);
let n2 = n.clone();
hooks.use_effect((), move || n2.set(1)); Element::text(TextProps {
content: format!("n={}", n.get()),
..Default::default()
})
}
}
#[tokio::test]
async fn mount_effects_and_wakes_are_processed_before_first_frame() {
let t = TestTerminal::new(10, 1, Element::component::<Boot>(BootProps)).unwrap();
assert!(t.frame_text().contains("n=1"));
}
struct NeverConverges;
#[derive(Clone, PartialEq, Default)]
struct NeverConvergesProps;
impl Component for NeverConverges {
type Props = NeverConvergesProps;
fn render(_: &NeverConvergesProps, hooks: &mut Hooks) -> Element {
let n = hooks.use_state(|| 0);
let n2 = n.clone();
hooks.use_effect(n.get(), move || n2.update(|v| *v += 1));
Element::text(TextProps {
content: format!("n={}", n.get()),
..Default::default()
})
}
}
#[tokio::test]
#[should_panic(expected = "maximum update depth")]
async fn process_wakes_panics_on_non_converging_fixpoint() {
let _ = TestTerminal::new(
10,
1,
Element::component::<NeverConverges>(NeverConvergesProps),
);
}
use crate::element::Node;
use crate::props::ViewProps;
use crate::style::{Color, Weight};
#[tokio::test]
async fn cell_reports_colors_that_frame_text_cannot() {
let el = Element::view(
ViewProps {
background: Color::Blue,
..Default::default()
},
vec![Element::text(TextProps {
content: "hi".into(),
color: Color::Red,
weight: Weight::Bold,
..Default::default()
})],
);
let t = TestTerminal::new(4, 1, el).unwrap();
let cell = t.cell(0, 0).expect("in bounds");
assert_eq!(cell.ch, 'h');
assert_eq!(cell.fg, Color::Red);
assert_eq!(cell.bg, Color::Blue);
assert!(cell.attrs.bold);
}
#[tokio::test]
async fn cell_is_none_outside_the_terminal() {
let t = TestTerminal::new(2, 1, Element::fragment(vec![])).unwrap();
assert!(t.cell(2, 0).is_none());
assert!(t.cell(0, 1).is_none());
}
#[tokio::test]
async fn row_returns_the_whole_line_and_nothing_off_screen() {
let el = Element::view(
ViewProps {
background: Color::Green,
..Default::default()
},
vec![Element::text(TextProps {
content: "ab".into(),
..Default::default()
})],
);
let t = TestTerminal::new(3, 1, el).unwrap();
let row = t.row(0);
assert_eq!(row.len(), 3, "one cell per column, even past the content");
assert_eq!(row[0].bg, Color::Green);
assert_eq!(row[1].bg, Color::Green);
assert_eq!(row[2].bg, Color::Reset, "beyond the box, nothing painted");
assert!(t.row(9).is_empty(), "off-screen rows are empty");
}
struct Counted;
#[derive(Clone, PartialEq, Default)]
struct CountedProps {
total: usize,
visible: usize,
}
impl Component for Counted {
type Props = CountedProps;
fn render(props: &CountedProps, _: &mut Hooks) -> Element {
Element::view(
ViewProps::default(),
(0..props.visible.min(props.total))
.map(|i| {
Element::text(TextProps {
content: format!("row {i}"),
..Default::default()
})
})
.collect(),
)
}
}
#[test]
fn render_once_exposes_what_the_component_built() {
let el = render_once::<Counted>(&CountedProps {
total: 500,
visible: 3,
});
let Node::View { children, .. } = el.node else {
panic!("expected a view")
};
assert_eq!(children.len(), 3, "offscreen rows were never built");
}
#[test]
fn render_once_needs_no_runtime_for_a_hook_free_component() {
let el = render_once::<Counted>(&CountedProps {
total: 0,
visible: 0,
});
let Node::View { children, .. } = el.node else {
panic!("expected a view")
};
assert!(children.is_empty());
}
struct Stateful;
#[derive(Clone, PartialEq, Default)]
struct StatefulProps;
impl Component for Stateful {
type Props = StatefulProps;
fn render(_: &StatefulProps, hooks: &mut Hooks) -> Element {
let n = hooks.use_state(|| 7i32);
n.set(n.get() + 1);
Element::text(TextProps {
content: format!("n={}", n.get()),
..Default::default()
})
}
}
struct WithEffect;
#[derive(Clone, PartialEq, Default)]
struct WithEffectProps {
ran: crate::test_util::Shared<bool>,
}
impl Component for WithEffect {
type Props = WithEffectProps;
fn render(props: &WithEffectProps, hooks: &mut Hooks) -> Element {
let ran = props.ran.clone();
hooks.use_effect((), move || *ran.lock() = true);
Element::text(TextProps::default())
}
}
#[test]
fn render_once_does_not_run_effects() {
let ran = crate::test_util::Shared::default();
render_once::<WithEffect>(&WithEffectProps { ran: ran.clone() });
assert!(!*ran.lock(), "effect bodies must not fire");
}
#[test]
fn render_once_tolerates_state_hooks_with_no_runtime_behind_them() {
let el = render_once::<Stateful>(&StatefulProps);
let Node::Text { props } = el.node else {
panic!("expected text")
};
assert_eq!(props.content, "n=8");
}
}