use crate::buffer::Buffer;
use crate::context::Context;
use crate::event::{
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind,
};
use crate::rect::Rect;
use crate::style::Style;
use crate::{run_frame_kernel, FrameState, RunConfig};
pub struct EventBuilder {
events: Vec<Event>,
}
impl EventBuilder {
pub fn new() -> Self {
Self { events: Vec::new() }
}
pub fn key(mut self, c: char) -> Self {
self.events.push(Event::Key(KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
}));
self
}
pub fn key_code(mut self, code: KeyCode) -> Self {
self.events.push(Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
}));
self
}
pub fn key_with(mut self, code: KeyCode, modifiers: KeyModifiers) -> Self {
self.events.push(Event::Key(KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
}));
self
}
pub fn click(mut self, x: u32, y: u32) -> Self {
self.events.push(Event::Mouse(MouseEvent {
kind: MouseKind::Down(MouseButton::Left),
x,
y,
modifiers: KeyModifiers::NONE,
pixel_x: None,
pixel_y: None,
}));
self
}
pub fn mouse_up(mut self, x: u32, y: u32) -> Self {
self.events.push(Event::mouse_up(x, y));
self
}
pub fn drag(mut self, x: u32, y: u32) -> Self {
self.events.push(Event::mouse_drag(x, y));
self
}
pub fn key_release(mut self, c: char) -> Self {
self.events.push(Event::key_release(c));
self
}
pub fn focus_gained(mut self) -> Self {
self.events.push(Event::FocusGained);
self
}
pub fn focus_lost(mut self) -> Self {
self.events.push(Event::FocusLost);
self
}
pub fn scroll_up(mut self, x: u32, y: u32) -> Self {
self.events.push(Event::Mouse(MouseEvent {
kind: MouseKind::ScrollUp,
x,
y,
modifiers: KeyModifiers::NONE,
pixel_x: None,
pixel_y: None,
}));
self
}
pub fn scroll_down(mut self, x: u32, y: u32) -> Self {
self.events.push(Event::Mouse(MouseEvent {
kind: MouseKind::ScrollDown,
x,
y,
modifiers: KeyModifiers::NONE,
pixel_x: None,
pixel_y: None,
}));
self
}
pub fn paste(mut self, text: impl Into<String>) -> Self {
self.events.push(Event::Paste(text.into()));
self
}
pub fn resize(mut self, width: u32, height: u32) -> Self {
self.events.push(Event::Resize(width, height));
self
}
pub fn build(self) -> Vec<Event> {
self.events
}
}
impl Default for EventBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct TestBackend {
buffer: Buffer,
width: u32,
height: u32,
frame_state: FrameState,
frames: Option<Vec<FrameRecord>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FrameRecord {
pub snapshot: String,
pub lines: Vec<String>,
}
impl FrameRecord {
pub fn to_string_trimmed(&self) -> String {
let mut lines = self.lines.clone();
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
lines.join("\n")
}
pub fn line(&self, y: u32) -> &str {
self.lines
.get(y as usize)
.map(|s| s.as_str())
.unwrap_or_default()
}
pub fn assert_contains(&self, expected: &str) {
for line in &self.lines {
if line.contains(expected) {
return;
}
}
let mut detail = String::new();
for (y, line) in self.lines.iter().enumerate() {
detail.push_str(&format!(" {y}: {line}\n"));
}
panic!("FrameRecord does not contain {expected:?}.\nFrame:\n{detail}");
}
}
impl TestBackend {
pub fn new(width: u32, height: u32) -> Self {
let area = Rect::new(0, 0, width, height);
Self {
buffer: Buffer::empty(area),
width,
height,
frame_state: FrameState::default(),
frames: None,
}
}
pub fn record_frames(mut self) -> Self {
if self.frames.is_none() {
self.frames = Some(Vec::new());
}
self
}
pub fn frames(&self) -> &[FrameRecord] {
self.frames.as_deref().unwrap_or(&[])
}
fn capture_frame(&mut self) {
if let Some(frames) = self.frames.as_mut() {
let snapshot = self.buffer.snapshot_format();
let mut lines = Vec::with_capacity(self.height as usize);
for y in 0..self.height {
let mut s = String::new();
for x in 0..self.width {
s.push_str(&self.buffer.get(x, y).symbol);
}
lines.push(s.trim_end().to_string());
}
frames.push(FrameRecord { snapshot, lines });
}
}
fn render_frame(
&mut self,
events: Vec<Event>,
setup_state: impl FnOnce(&mut FrameState),
f: impl FnOnce(&mut Context),
) {
setup_state(&mut self.frame_state);
self.buffer.reset();
let mut once = Some(f);
let mut render = |ui: &mut Context| {
if let Some(f) = once.take() {
f(ui);
} else {
panic!("render closure called twice");
}
};
let _ = run_frame_kernel(
&mut self.buffer,
&mut self.frame_state,
&RunConfig::default(),
(self.width, self.height),
events,
false,
&mut render,
);
self.capture_frame();
}
pub fn render(&mut self, f: impl FnOnce(&mut Context)) {
self.render_frame(Vec::new(), |_| {}, f);
}
pub fn render_with_events(
&mut self,
events: Vec<Event>,
focus_index: usize,
prev_focus_count: usize,
f: impl FnOnce(&mut Context),
) {
self.render_frame(
events,
|state| {
state.focus.focus_index = focus_index;
state.focus.prev_focus_count = prev_focus_count;
},
f,
);
}
pub fn run_with_events(&mut self, events: Vec<Event>, f: impl FnOnce(&mut crate::Context)) {
self.render_with_events(events, 0, 0, f);
}
pub fn line(&self, y: u32) -> String {
let mut s = String::new();
for x in 0..self.width {
s.push_str(&self.buffer.get(x, y).symbol);
}
s.trim_end().to_string()
}
pub fn assert_line(&self, y: u32, expected: &str) {
let line = self.line(y);
assert_eq!(
line, expected,
"Line {y}: expected {expected:?}, got {line:?}"
);
}
pub fn assert_line_contains(&self, y: u32, expected: &str) {
let line = self.line(y);
assert!(
line.contains(expected),
"Line {y}: expected to contain {expected:?}, got {line:?}"
);
}
pub fn assert_contains(&self, expected: &str) {
for y in 0..self.height {
if self.line(y).contains(expected) {
return;
}
}
let mut all_lines = String::new();
for y in 0..self.height {
all_lines.push_str(&format!("{}: {}\n", y, self.line(y)));
}
panic!("Buffer does not contain {expected:?}.\nBuffer:\n{all_lines}");
}
pub fn buffer(&self) -> &Buffer {
&self.buffer
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn to_string_trimmed(&self) -> String {
let mut lines = Vec::with_capacity(self.height as usize);
for y in 0..self.height {
lines.push(self.line(y));
}
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
lines.join("\n")
}
pub fn assert_not_contains(&self, expected: &str) {
let mut offending: Vec<(u32, String)> = Vec::new();
for y in 0..self.height {
let line = self.line(y);
if line.contains(expected) {
offending.push((y, line));
}
}
if !offending.is_empty() {
let detail = offending
.iter()
.map(|(y, l)| format!(" row {y}: {l:?}"))
.collect::<Vec<_>>()
.join("\n");
panic!("Buffer unexpectedly contains {expected:?}:\n{detail}");
}
}
pub fn assert_line_not_contains(&self, y: u32, expected: &str) {
let line = self.line(y);
assert!(
!line.contains(expected),
"Line {y}: expected NOT to contain {expected:?}, but got {line:?}"
);
}
pub fn assert_empty_line(&self, y: u32) {
let line = self.line(y);
assert!(line.is_empty(), "Line {y}: expected empty, got {line:?}");
}
pub fn assert_style_at(&self, x: u32, y: u32, expected: Style) {
let actual = self.buffer.get(x, y).style;
assert_eq!(
actual, expected,
"Style mismatch at ({x}, {y}): expected {expected:?}, got {actual:?}"
);
}
pub fn sequence(&mut self) -> TestSequence<'_> {
TestSequence {
backend: self,
steps: Vec::new(),
}
}
pub fn type_string(&mut self, s: &str, mut render: impl FnMut(&mut Context)) {
for ch in s.chars() {
let events = vec![Event::Key(KeyEvent {
code: KeyCode::Char(ch),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
})];
self.render_frame(events, |_| {}, &mut render);
}
}
}
struct TestStep<'a> {
events: Vec<Event>,
render: Box<dyn FnOnce(&mut Context) + 'a>,
}
pub struct TestSequence<'a> {
backend: &'a mut TestBackend,
steps: Vec<TestStep<'a>>,
}
impl<'a> TestSequence<'a> {
pub fn tick(mut self, f: impl FnOnce(&mut Context) + 'a) -> Self {
self.steps.push(TestStep {
events: Vec::new(),
render: Box::new(f),
});
self
}
pub fn key(mut self, code: KeyCode, f: impl FnOnce(&mut Context) + 'a) -> Self {
let events = vec![Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
})];
self.steps.push(TestStep {
events,
render: Box::new(f),
});
self
}
pub fn type_string(mut self, s: &str, f: impl FnOnce(&mut Context) + 'a) -> Self {
let events = s
.chars()
.map(|c| {
Event::Key(KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
})
})
.collect();
self.steps.push(TestStep {
events,
render: Box::new(f),
});
self
}
pub fn events(mut self, events: Vec<Event>, f: impl FnOnce(&mut Context) + 'a) -> Self {
self.steps.push(TestStep {
events,
render: Box::new(f),
});
self
}
pub fn run(self) {
let backend = self.backend;
for step in self.steps {
let TestStep { events, render } = step;
let mut once = Some(render);
let f = move |ui: &mut Context| {
if let Some(f) = once.take() {
f(ui);
}
};
backend.render_frame(events, |_| {}, f);
}
}
}
impl std::fmt::Display for TestBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_string_trimmed())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{KeyEventKind, MouseKind};
#[test]
fn event_builder_mouse_up_produces_up_event() {
let events = EventBuilder::new().mouse_up(5, 3).build();
assert_eq!(events.len(), 1);
match &events[0] {
Event::Mouse(m) => {
assert!(matches!(m.kind, MouseKind::Up(MouseButton::Left)));
assert_eq!(m.x, 5);
assert_eq!(m.y, 3);
}
_ => panic!("expected mouse event"),
}
}
#[test]
fn event_builder_drag_produces_drag_event() {
let events = EventBuilder::new().drag(10, 5).build();
assert_eq!(events.len(), 1);
match &events[0] {
Event::Mouse(m) => {
assert!(matches!(m.kind, MouseKind::Drag(MouseButton::Left)));
assert_eq!(m.x, 10);
assert_eq!(m.y, 5);
}
_ => panic!("expected mouse event"),
}
}
#[test]
fn event_builder_key_release_produces_release_event() {
let events = EventBuilder::new().key_release('a').build();
assert_eq!(events.len(), 1);
match &events[0] {
Event::Key(k) => {
assert_eq!(k.code, KeyCode::Char('a'));
assert!(matches!(k.kind, KeyEventKind::Release));
}
_ => panic!("expected key event"),
}
}
#[test]
fn event_builder_focus_events_chaining() {
let events = EventBuilder::new().focus_lost().focus_gained().build();
assert_eq!(events, vec![Event::FocusLost, Event::FocusGained]);
}
#[test]
fn record_frames_disabled_returns_empty_slice() {
let mut tb = TestBackend::new(10, 2);
tb.render(|ui| {
ui.text("hi");
});
assert!(tb.frames().is_empty());
}
#[test]
fn record_frames_captures_each_render() {
let mut tb = TestBackend::new(20, 2).record_frames();
for n in 0..3 {
tb.render(|ui| {
ui.text(format!("frame {n}"));
});
}
assert_eq!(tb.frames().len(), 3);
tb.frames()[0].assert_contains("frame 0");
tb.frames()[1].assert_contains("frame 1");
tb.frames()[2].assert_contains("frame 2");
}
#[test]
fn record_frames_stores_styled_snapshot() {
let mut tb = TestBackend::new(10, 1).record_frames();
tb.render(|ui| {
ui.text("hi").bold();
});
let frame = &tb.frames()[0];
assert!(
frame.snapshot.contains("bold"),
"snapshot missing bold marker: {:?}",
frame.snapshot
);
}
#[test]
fn record_frames_idempotent_when_called_twice() {
let tb = TestBackend::new(10, 1).record_frames();
let mut tb = tb.record_frames();
tb.render(|ui| {
ui.text("a");
});
assert_eq!(tb.frames().len(), 1);
}
#[test]
fn frame_record_to_string_trimmed_drops_trailing_blank_rows() {
let mut tb = TestBackend::new(10, 4).record_frames();
tb.render(|ui| {
ui.text("hello");
});
let frame = &tb.frames()[0];
assert_eq!(frame.lines.len(), 4);
let s = frame.to_string_trimmed();
assert!(!s.ends_with('\n'));
assert!(s.starts_with("hello"));
}
#[test]
fn sequence_runs_multiple_steps_in_order() {
let mut tb = TestBackend::new(20, 2).record_frames();
tb.sequence()
.tick(|ui| {
ui.text("step-1");
})
.tick(|ui| {
ui.text("step-2");
})
.tick(|ui| {
ui.text("step-3");
})
.run();
assert_eq!(tb.frames().len(), 3);
tb.frames()[0].assert_contains("step-1");
tb.frames()[1].assert_contains("step-2");
tb.frames()[2].assert_contains("step-3");
}
#[test]
fn sequence_key_step_injects_event() {
let mut tb = TestBackend::new(20, 2);
tb.sequence()
.key(KeyCode::Esc, |ui| {
ui.text("after-esc");
})
.run();
tb.assert_contains("after-esc");
}
#[test]
fn sequence_type_string_collapses_into_single_step() {
let mut tb = TestBackend::new(20, 2).record_frames();
tb.sequence()
.type_string("abc", |ui| {
ui.text("done");
})
.run();
assert_eq!(tb.frames().len(), 1);
tb.frames()[0].assert_contains("done");
}
#[test]
fn sequence_events_step_takes_arbitrary_batch() {
let mut tb = TestBackend::new(20, 2);
let events = EventBuilder::new()
.key('a')
.key_code(KeyCode::Enter)
.build();
tb.sequence()
.events(events, |ui| {
ui.text("ran");
})
.run();
tb.assert_contains("ran");
}
#[test]
fn type_string_renders_one_frame_per_char() {
let mut tb = TestBackend::new(20, 2).record_frames();
tb.type_string("abc", |ui| {
ui.text("char");
});
assert_eq!(tb.frames().len(), 3);
}
#[test]
fn type_string_handles_empty_input() {
let mut tb = TestBackend::new(20, 2).record_frames();
tb.type_string("", |ui| {
ui.text("never-called");
});
assert_eq!(tb.frames().len(), 0);
}
#[test]
fn assert_not_contains_passes_when_absent() {
let mut tb = TestBackend::new(20, 2);
tb.render(|ui| {
ui.text("hello world");
});
tb.assert_not_contains("error");
}
#[test]
#[should_panic(expected = "Buffer unexpectedly contains")]
fn assert_not_contains_panics_when_present() {
let mut tb = TestBackend::new(20, 2);
tb.render(|ui| {
ui.text("error: fail");
});
tb.assert_not_contains("error");
}
#[test]
fn assert_line_not_contains_passes_when_other_row_has_substring() {
let mut tb = TestBackend::new(20, 3);
tb.render(|ui| {
let _ = ui.col(|ui| {
ui.text("first");
ui.text("second");
});
});
tb.assert_line_not_contains(0, "second");
}
#[test]
#[should_panic(expected = "Line 0: expected NOT to contain")]
fn assert_line_not_contains_panics_when_present() {
let mut tb = TestBackend::new(20, 1);
tb.render(|ui| {
ui.text("hello");
});
tb.assert_line_not_contains(0, "ello");
}
#[test]
fn assert_empty_line_passes_for_blank_row() {
let mut tb = TestBackend::new(20, 2);
tb.render(|ui| {
ui.text("only-row-0");
});
tb.assert_empty_line(1);
}
#[test]
#[should_panic(expected = "Line 0: expected empty")]
fn assert_empty_line_panics_when_non_blank() {
let mut tb = TestBackend::new(20, 2);
tb.render(|ui| {
ui.text("not-empty");
});
tb.assert_empty_line(0);
}
#[test]
fn assert_style_at_passes_for_matching_style() {
use crate::style::{Color, Modifiers};
let mut tb = TestBackend::new(10, 1);
tb.render(|ui| {
ui.text("x").fg(Color::Red);
});
let expected = Style {
fg: Some(Color::Red),
bg: None,
modifiers: Modifiers::NONE,
};
tb.assert_style_at(0, 0, expected);
}
#[test]
#[should_panic(expected = "Style mismatch")]
fn assert_style_at_panics_on_mismatch() {
use crate::style::Color;
let mut tb = TestBackend::new(10, 1);
tb.render(|ui| {
ui.text("x").fg(Color::Red);
});
let expected = Style::new().fg(Color::Blue);
tb.assert_style_at(0, 0, expected);
}
}