pub mod conformance;
mod recording;
pub use recording::InputRecording;
use crate::app::{App, Flow, Frame};
use crate::backend::Headless;
use crate::event::{
Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crate::grid::Pos;
use crate::terminal::Terminal;
use alloc::collections::VecDeque;
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
use core::fmt;
use core::time::Duration;
pub const STEP_DELTA: Duration = Duration::from_millis(16);
pub const DEFAULT_MAX_STEPS: u32 = 64;
pub struct TestHarness {
term: Terminal<Headless>,
frame: u64,
queued: VecDeque<Vec<Event>>,
step_delta: Duration,
}
impl TestHarness {
#[must_use]
pub fn new(width: u16, height: u16) -> Self {
Self {
term: Terminal::new(Headless::new(width, height)),
frame: 0,
queued: VecDeque::new(),
step_delta: STEP_DELTA,
}
}
#[must_use]
pub const fn with_step_delta(mut self, delta: Duration) -> Self {
self.step_delta = delta;
self
}
pub fn push_event(&mut self, event: Event) {
self.queued.push_back(vec![event]);
}
pub fn push_frame(&mut self, events: impl IntoIterator<Item = Event>) {
self.queued.push_back(events.into_iter().collect());
}
pub fn click(&mut self, x: u16, y: u16) {
self.click_button(x, y, MouseButton::Left);
}
pub fn click_button(&mut self, x: u16, y: u16, button: MouseButton) {
let position = Pos::new(x, y);
for kind in [MouseEventKind::Down(button), MouseEventKind::Up(button)] {
self.push_event(Event::Mouse(MouseEvent {
kind,
position,
pixel_position: None,
modifiers: KeyModifiers::NONE,
}));
}
}
pub fn mouse_move(&mut self, x: u16, y: u16) {
self.push_event(Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
position: Pos::new(x, y),
pixel_position: None,
modifiers: KeyModifiers::NONE,
}));
}
pub fn key(&mut self, code: KeyCode) {
self.key_with(code, KeyModifiers::NONE);
}
pub fn key_with(&mut self, code: KeyCode, modifiers: KeyModifiers) {
self.push_event(Event::Key(KeyEvent::new(code, modifiers)));
}
pub fn resize(&mut self, width: u16, height: u16) {
self.term.resize(width, height);
self.push_event(Event::Resize(width, height));
}
pub fn step<A: App<Headless>>(&mut self, app: &mut A) -> Flow {
if self.frame == 0 {
app.init(&mut self.term);
}
if let Some(events) = self.queued.pop_front() {
for event in events {
self.term.backend_mut().push_event(event);
}
}
let frame = Frame::new(self.step_delta, self.frame);
self.frame = self.frame.wrapping_add(1);
let present_count_before = self.term.present_count();
let flow = app.update(&mut self.term, &frame);
if flow != Flow::Idle && self.term.present_count() == present_count_before {
let Ok(()) = self.term.present();
}
flow
}
pub fn settle<A: App<Headless>>(
&mut self,
app: &mut A,
max_steps: u32,
) -> Result<u32, RunError> {
let needed = u32::try_from(self.queued.len().max(1))
.unwrap_or(u32::MAX)
.saturating_add(1);
for steps in 1..=needed {
if self.step(app) == Flow::Exit {
return Ok(steps);
}
if steps >= max_steps && steps < needed {
return Err(RunError::ExceededMaxSteps { max_steps });
}
}
Ok(needed)
}
pub fn run<A: App<Headless>>(&mut self, app: &mut A) -> u32 {
match self.settle(app, DEFAULT_MAX_STEPS) {
Ok(steps) => steps,
Err(err) => panic!("{err}"),
}
}
pub fn run_steps<A: App<Headless>>(&mut self, app: &mut A, steps: u32) {
for _ in 0..steps {
self.step(app);
}
}
#[must_use]
pub fn view(&self) -> String {
self.term.backend().format_view()
}
#[must_use]
pub fn readable_view(&self) -> String {
self.view().replace(Headless::SPACE_GLYPH, " ")
}
#[must_use]
pub fn find_text(&self, needle: &str) -> Option<Pos> {
if needle.is_empty() {
return None;
}
let needle: String = needle
.chars()
.map(|c| if c == ' ' { Headless::SPACE_GLYPH } else { c })
.collect();
for (y, row) in self.view().lines().enumerate() {
if let Some(byte_index) = row.find(needle.as_str()) {
let x = row[..byte_index].chars().count();
#[allow(clippy::cast_possible_truncation)]
return Some(Pos::new(x as u16, y as u16));
}
}
None
}
pub fn click_text(&mut self, needle: &str) -> Result<(), ClickTextError> {
let pos = self
.find_text(needle)
.ok_or_else(|| ClickTextError::NotFound {
needle: needle.into(),
})?;
self.click(pos.x, pos.y);
Ok(())
}
#[must_use]
pub fn from_recording(recording: &InputRecording) -> Self {
Self::new(recording.width(), recording.height())
}
pub fn replay<A: App<Headless>>(&mut self, recording: &InputRecording, app: &mut A) {
for (delay, event) in recording.events() {
self.run_steps(app, Self::steps_for_delay(*delay));
self.push_event(event.clone());
self.run(app);
}
}
fn steps_for_delay(delay: Duration) -> u32 {
let step_nanos = STEP_DELTA.as_nanos();
let steps = (delay.as_nanos() + step_nanos / 2) / step_nanos;
u32::try_from(steps).unwrap_or(u32::MAX)
}
#[must_use]
pub const fn term(&self) -> &Terminal<Headless> {
&self.term
}
#[must_use]
pub const fn term_mut(&mut self) -> &mut Terminal<Headless> {
&mut self.term
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RunError {
ExceededMaxSteps {
max_steps: u32,
},
}
impl fmt::Display for RunError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ExceededMaxSteps { max_steps } => write!(
f,
"TestHarness::settle did not drain its event queue within {max_steps} steps"
),
}
}
}
impl core::error::Error for RunError {}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ClickTextError {
NotFound {
needle: String,
},
}
impl fmt::Display for ClickTextError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotFound { needle } => write!(f, "{needle:?} not found in view"),
}
}
}
impl core::error::Error for ClickTextError {}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::Flow;
use crate::backend::Backend;
use crate::color::Style;
use crate::grid::HasSize;
struct Clicker {
clicks: u32,
}
impl<B: Backend> App<B> for Clicker {
fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
for event in term.drain_events() {
if matches!(
event,
Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
..
})
) {
self.clicks += 1;
}
}
term.surface().put((0, 0), 'x', Style::default());
Flow::Continue
}
}
#[test]
fn step_calls_init_once_before_the_first_update() {
struct RecordsInit {
init_calls: u32,
updates: u32,
}
impl<B: Backend> App<B> for RecordsInit {
fn init(&mut self, _term: &mut Terminal<B>) {
self.init_calls += 1;
}
fn update(&mut self, _term: &mut Terminal<B>, _frame: &Frame) -> Flow {
self.updates += 1;
Flow::Continue
}
}
let mut harness = TestHarness::new(3, 1);
let mut app = RecordsInit {
init_calls: 0,
updates: 0,
};
harness.step(&mut app);
harness.step(&mut app);
harness.step(&mut app);
assert_eq!(app.init_calls, 1, "init must run exactly once");
assert_eq!(app.updates, 3);
}
#[test]
fn step_presents_before_view_reflects_it() {
struct Drawer;
impl<B: Backend> App<B> for Drawer {
fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
term.surface().put((0, 0), '@', Style::default());
Flow::Continue
}
}
let mut harness = TestHarness::new(3, 1);
let mut app = Drawer;
assert!(harness.view().starts_with('·'));
harness.step(&mut app);
assert!(harness.view().starts_with('@'));
}
#[test]
fn click_resolves_after_settle_not_after_one_step() {
let mut harness = TestHarness::new(5, 1);
let mut app = Clicker { clicks: 0 };
harness.click(0, 0);
harness.step(&mut app);
assert_eq!(
app.clicks, 1,
"the queued Down event resolves on the first step"
);
harness.run(&mut app);
assert!(harness.view().starts_with('x'));
}
#[test]
fn settle_reports_exceeded_max_steps() {
struct NeverDrains;
impl<B: Backend> App<B> for NeverDrains {
fn update(&mut self, _term: &mut Terminal<B>, _frame: &Frame) -> Flow {
Flow::Continue
}
}
let mut harness = TestHarness::new(2, 1);
let mut app = NeverDrains;
harness.push_event(Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::NONE,
)));
harness.push_event(Event::Key(KeyEvent::new(
KeyCode::Char('w'),
KeyModifiers::NONE,
)));
let err = harness.settle(&mut app, 0).unwrap_err();
assert_eq!(err, RunError::ExceededMaxSteps { max_steps: 0 });
}
#[test]
fn run_steps_ignores_flow_exit() {
struct AlwaysExits {
calls: u32,
}
impl<B: Backend> App<B> for AlwaysExits {
fn update(&mut self, _term: &mut Terminal<B>, _frame: &Frame) -> Flow {
self.calls += 1;
Flow::Exit
}
}
let mut harness = TestHarness::new(2, 1);
let mut app = AlwaysExits { calls: 0 };
harness.run_steps(&mut app, 3);
assert_eq!(app.calls, 3);
}
#[test]
fn with_step_delta_overrides_the_delta_step_hands_to_update() {
struct RecordsDelta {
seen: Option<Duration>,
}
impl<B: Backend> App<B> for RecordsDelta {
fn update(&mut self, _term: &mut Terminal<B>, frame: &Frame) -> Flow {
self.seen = Some(frame.delta);
Flow::Continue
}
}
let mut harness = TestHarness::new(2, 1).with_step_delta(Duration::from_millis(100));
let mut app = RecordsDelta { seen: None };
harness.step(&mut app);
assert_eq!(app.seen, Some(Duration::from_millis(100)));
}
#[test]
fn resize_updates_backend_and_queues_event() {
struct Resized {
seen: Option<(u16, u16)>,
}
impl<B: Backend> App<B> for Resized {
fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
for event in term.drain_events() {
if let Event::Resize(w, h) = event {
self.seen = Some((w, h));
}
}
Flow::Continue
}
}
let mut harness = TestHarness::new(4, 4);
let mut app = Resized { seen: None };
harness.resize(8, 2);
harness.run(&mut app);
assert_eq!(harness.term().size().width(), 8);
assert_eq!(app.seen, Some((8, 2)));
}
#[test]
fn run_error_display_message() {
let err = RunError::ExceededMaxSteps { max_steps: 5 };
assert_eq!(
err.to_string(),
"TestHarness::settle did not drain its event queue within 5 steps"
);
}
struct Labels {
clicks: u32,
}
impl<B: Backend> App<B> for Labels {
fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
for event in term.drain_events() {
if matches!(
event,
Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
..
})
) {
self.clicks += 1;
}
}
term.surface().print((2, 1), "Quit", Style::default());
term.surface().print((2, 2), "Save Game", Style::default());
Flow::Continue
}
}
#[test]
fn find_text_locates_first_occurrence_row_major() {
let mut harness = TestHarness::new(12, 4);
let mut app = Labels { clicks: 0 };
harness.step(&mut app);
assert_eq!(harness.find_text("Quit"), Some(Pos::new(2, 1)));
}
#[test]
fn find_text_matches_a_space_against_the_view_middle_dot() {
let mut harness = TestHarness::new(12, 4);
let mut app = Labels { clicks: 0 };
harness.step(&mut app);
assert_eq!(harness.find_text("Save Game"), Some(Pos::new(2, 2)));
}
#[test]
fn readable_view_converts_the_middle_dot_back_to_a_literal_space() {
let mut harness = TestHarness::new(12, 4);
let mut app = Labels { clicks: 0 };
harness.step(&mut app);
assert!(!harness.view().contains("Save Game"));
assert!(harness.readable_view().contains("Save Game"));
}
#[test]
fn find_text_returns_none_when_absent() {
let mut harness = TestHarness::new(12, 4);
let mut app = Labels { clicks: 0 };
harness.step(&mut app);
assert_eq!(harness.find_text("Cancel"), None);
}
#[test]
fn find_text_returns_none_for_an_empty_needle() {
let mut harness = TestHarness::new(12, 4);
let mut app = Labels { clicks: 0 };
harness.step(&mut app);
assert_eq!(harness.find_text(""), None);
}
#[test]
fn click_text_clicks_the_located_cell() {
let mut harness = TestHarness::new(12, 4);
let mut app = Labels { clicks: 0 };
harness.step(&mut app);
harness.click_text("Quit").unwrap();
harness.run(&mut app);
assert_eq!(app.clicks, 1);
}
#[test]
fn click_text_errs_when_absent() {
let mut harness = TestHarness::new(12, 4);
let mut app = Labels { clicks: 0 };
harness.step(&mut app);
let err = harness.click_text("Cancel").unwrap_err();
assert_eq!(err.to_string(), "\"Cancel\" not found in view");
}
struct CooldownGate {
cooldown_remaining: Duration,
hits: u32,
}
impl CooldownGate {
const COOLDOWN: Duration = Duration::from_millis(100);
const fn new() -> Self {
Self {
cooldown_remaining: Duration::ZERO,
hits: 0,
}
}
}
impl App<Headless> for CooldownGate {
fn update(&mut self, term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
self.cooldown_remaining = self.cooldown_remaining.saturating_sub(frame.delta);
for event in term.drain_events() {
if matches!(event, Event::Key(_)) && self.cooldown_remaining.is_zero() {
self.hits += 1;
self.cooldown_remaining = Self::COOLDOWN;
}
}
Flow::Continue
}
}
fn key_press(c: char) -> Event {
Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
}
#[test]
fn replay_reproduces_recorded_timing_not_a_coarse_back_to_back_replay() {
let mut recording = InputRecording::new(4, 1);
recording.push(Duration::ZERO, key_press('a'));
recording.push(Duration::from_millis(200), key_press('b'));
let mut faithful = TestHarness::from_recording(&recording);
let mut faithful_app = CooldownGate::new();
faithful.replay(&recording, &mut faithful_app);
assert_eq!(
faithful_app.hits, 2,
"the recorded 200ms gap should let the cooldown expire before the second press"
);
let mut coarse = TestHarness::new(recording.width(), recording.height());
let mut coarse_app = CooldownGate::new();
for (_, event) in recording.events() {
coarse.push_event(event.clone());
coarse.run(&mut coarse_app);
}
assert_eq!(
coarse_app.hits, 1,
"discarding the recorded delay should leave the cooldown still active for the second press"
);
}
#[test]
fn from_recording_sizes_the_harness_to_the_recording() {
let recording = InputRecording::new(7, 3);
let harness = TestHarness::from_recording(&recording);
assert_eq!(harness.term().size().width(), 7);
assert_eq!(harness.term().size().height(), 3);
}
}