Skip to main content

retroglyph_core/testing/
mod.rs

1//! Headless test harness driving an [`App`](crate::app::App) with synthetic input.
2//!
3//! Also home to [`conformance`](crate::testing::conformance), the cross-backend harness that
4//! tests a raw [`Backend`](crate::backend::Backend) facet against its own trait contract.
5//!
6//! [`TestHarness`](crate::testing::TestHarness) owns the drive-until-settled loop that a test
7//! would otherwise hand-roll around `Headless` (retroglyph#612): `Headless` supplies the backend
8//! and [`Headless::push_event`](crate::backend::Headless::push_event), and the harness supplies
9//! everything between that and the assertion. Feature-gated
10//! (`testing`), no effect on release builds. Not a UI-testing framework: no assertions, no
11//! matchers, no fixtures, just the loop and the input synthesis that otherwise gets rewritten per
12//! consumer. See ["Driving an `App` with `TestHarness`"](https://github.com/crates-lurey-io/retroglyph/blob/main/docs/testing.md#driving-an-app-with-testharness)
13//! for the full workflow.
14//!
15//! [`conformance`](crate::testing::conformance) is a different tool for a different job: it drives a backend directly (no
16//! `App`, no `Terminal`) through the obligations [`Output`](crate::backend::Output),
17//! [`Cursor`](crate::backend::Cursor), and [`Input`](crate::backend::Input) each promise but
18//! that a lone `impl` block never states, catching the five backends in this workspace (and any
19//! future one) disagreeing on them (retroglyph#763).
20
21pub mod conformance;
22
23use crate::app::{App, Flow, Frame};
24use crate::backend::Headless;
25use crate::event::{
26    Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
27};
28use crate::grid::Pos;
29use crate::terminal::Terminal;
30use alloc::collections::VecDeque;
31use alloc::string::String;
32use core::fmt;
33use core::time::Duration;
34
35/// Fixed per-frame delta [`TestHarness::step`](crate::testing::TestHarness::step) hands to [`App::update`](crate::app::App::update).
36///
37/// Headless tests have no wall clock; this exists only so [`Frame::delta`](crate::app::Frame::delta)-driven code (tweens,
38/// [`FrameClock`](crate::frames::FrameClock)) advances instead of stalling. 16ms is one frame at
39/// ~60fps; the value is otherwise arbitrary, but it is load-bearing for any test that counts steps
40/// to reach an animation state: a duration-D animation finishes in `ceil(D / 16ms)` steps, so
41/// changing this shifts those step counts.
42pub const STEP_DELTA: Duration = Duration::from_millis(16);
43
44/// Default step budget for [`TestHarness::run`](crate::testing::TestHarness::run) before it treats a
45/// non-draining event queue as a stuck app and panics.
46///
47/// Sized to comfortably clear any single queued gesture (a click is two events, the two-frame rule
48/// costs a frame each), with headroom, while still failing fast on an app that never drains its
49/// input. The exact value is picked by feel, not measured; a test with a legitimately long settle
50/// should call [`settle`](crate::testing::TestHarness::settle) with a larger budget rather than
51/// raise this shared default.
52pub const DEFAULT_MAX_STEPS: u32 = 64;
53
54/// Drives an [`App`](crate::app::App) against a [`Headless`](crate::backend::Headless) backend: queues synthetic input, steps frames, and
55/// reads back the rendered view.
56///
57/// # The two-frame rule
58///
59/// A press and a release queued together resolve a frame later than the same gesture arriving
60/// from real input, because hit-testing (e.g. `retroglyph-ui`' `Interaction`) snapshots the
61/// *previous* frame's pointer state before this frame's queued events are applied. [`click`](
62/// Self::click) queues both events for you, but resolving them still costs two frames: call
63/// [`run`](Self::run) or [`settle`](Self::settle) after queuing input, not a single
64/// [`step`](Self::step).
65///
66/// # Presenting
67///
68/// [`step`](Self::step) presents automatically: skipped on [`Flow::Idle`](crate::app::Flow::Idle), skipped as a no-op if
69/// `update` already presented (mirroring [`run_blocking`](crate::app::run_blocking)'s own
70/// behavior). Nothing queued is visible in [`view`](Self::view) until a `step` call has run.
71///
72/// # Examples
73///
74/// ```
75/// use retroglyph_core::testing::TestHarness;
76/// use retroglyph_core::app::{App, Flow, Frame};
77/// use retroglyph_core::backend::Backend;
78/// use retroglyph_core::color::Style;
79/// use retroglyph_core::terminal::Terminal;
80///
81/// struct Counter(u32);
82///
83/// impl<B: Backend> App<B> for Counter {
84///     fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow {
85///         if term.has_input() {
86///             self.0 += 1;
87///         }
88///         term.surface()
89///             .put((0, 0), char::from_digit(self.0, 10).unwrap_or('?'), Style::default());
90///         if frame.frame > 10 {
91///             Flow::Exit
92///         } else {
93///             Flow::Continue
94///         }
95///     }
96/// }
97///
98/// let mut harness = TestHarness::new(10, 1);
99/// let mut app = Counter(0);
100///
101/// harness.key(retroglyph_core::event::KeyCode::Char(' '));
102/// harness.run(&mut app);
103///
104/// assert_eq!(app.0, 1);
105/// assert!(harness.view().starts_with('1'));
106/// ```
107pub struct TestHarness {
108    term: Terminal<Headless>,
109    frame: u64,
110    queued: VecDeque<Event>,
111}
112
113impl TestHarness {
114    /// Creates a harness with a `width` x `height` [`Headless`](crate::backend::Headless) backend.
115    #[must_use]
116    pub fn new(width: u16, height: u16) -> Self {
117        Self {
118            term: Terminal::new(Headless::new(width, height)),
119            frame: 0,
120            queued: VecDeque::new(),
121        }
122    }
123
124    /// Queues a synthetic event for the next [`step`](Self::step) call.
125    ///
126    /// Only queues: the app does not see it until a frame runs. Prefer the typed helpers
127    /// ([`click`](Self::click), [`key`](Self::key), [`mouse_move`](Self::mouse_move)) unless the
128    /// [`Event`](crate::event::Event) variant needed isn't one of them.
129    pub fn push_event(&mut self, event: Event) {
130        self.queued.push_back(event);
131    }
132
133    /// Queues a left-button click (press then release) at `(x, y)`, with no modifiers.
134    ///
135    /// See "the two-frame rule" on [`TestHarness`](crate::testing::TestHarness) before asserting after a single
136    /// [`step`](Self::step): use [`run`](Self::run)/[`settle`](Self::settle) instead.
137    pub fn click(&mut self, x: u16, y: u16) {
138        self.click_button(x, y, MouseButton::Left);
139    }
140
141    /// Queues a click (press then release) with `button` at `(x, y)`, with no modifiers.
142    pub fn click_button(&mut self, x: u16, y: u16, button: MouseButton) {
143        let position = Pos::new(x, y);
144        for kind in [MouseEventKind::Down(button), MouseEventKind::Up(button)] {
145            self.push_event(Event::Mouse(MouseEvent {
146                kind,
147                position,
148                // `Headless` is a character-mode backend: it has no sub-cell pixel position to
149                // report, matching the crossterm backend's own convention (see `MouseEvent`'s
150                // docs) rather than guessing one.
151                pixel_position: None,
152                modifiers: KeyModifiers::NONE,
153            }));
154        }
155    }
156
157    /// Queues a pointer move to `(x, y)`, with no buttons held.
158    pub fn mouse_move(&mut self, x: u16, y: u16) {
159        self.push_event(Event::Mouse(MouseEvent {
160            kind: MouseEventKind::Moved,
161            position: Pos::new(x, y),
162            pixel_position: None,
163            modifiers: KeyModifiers::NONE,
164        }));
165    }
166
167    /// Queues a key press of `code`, with no modifiers.
168    pub fn key(&mut self, code: KeyCode) {
169        self.key_with(code, KeyModifiers::NONE);
170    }
171
172    /// Queues a key press of `code` with `modifiers`.
173    pub fn key_with(&mut self, code: KeyCode, modifiers: KeyModifiers) {
174        self.push_event(Event::Key(KeyEvent::new(code, modifiers)));
175    }
176
177    /// Resizes the backend and queues the matching [`Event::Resize`](crate::event::Event::Resize) a real terminal would also
178    /// deliver.
179    ///
180    /// Unlike calling <code>[term_mut](Self::term_mut)().[resize](crate::terminal::Terminal::resize)</code>
181    /// directly, this also queues the event, matching what a real backend delivers alongside its
182    /// own resize.
183    pub fn resize(&mut self, width: u16, height: u16) {
184        self.term.resize(width, height);
185        self.push_event(Event::Resize(width, height));
186    }
187
188    /// Runs exactly one frame: pops at most one queued event into the backend, calls
189    /// [`App::update`](crate::app::App::update), and presents unless `update` returned [`Flow::Idle`](crate::app::Flow::Idle) or already presented.
190    ///
191    /// Draining only one queued event per call, rather than the whole queue at once, is what
192    /// reproduces the two-frame rule described on [`TestHarness`](crate::testing::TestHarness) instead of masking it.
193    pub fn step<A: App<Headless>>(&mut self, app: &mut A) -> Flow {
194        if let Some(event) = self.queued.pop_front() {
195            self.term.backend_mut().push_event(event);
196        }
197        let frame = Frame {
198            delta: STEP_DELTA,
199            frame: self.frame,
200        };
201        self.frame = self.frame.wrapping_add(1);
202        let present_count_before = self.term.present_count();
203        let flow = app.update(&mut self.term, &frame);
204        if flow != Flow::Idle && self.term.present_count() == present_count_before {
205            // `Headless::Error` is `Infallible`: absorbed here so callers never see a `Result`
206            // for a `present` call that cannot fail, rather than each one writing its own
207            // "never panics in practice" doc (retroglyph#612).
208            let Ok(()) = self.term.present();
209        }
210        flow
211    }
212
213    /// Runs [`step`](Self::step) until the event queue is empty (with at least one frame run),
214    /// stopping early on [`Flow::Exit`](crate::app::Flow::Exit), bounded by `max_steps`.
215    ///
216    /// This is the "run until settled" primitive: queuing input only stages it, `settle` resolves
217    /// the two-frame rule (see [`TestHarness`](crate::testing::TestHarness)) instead of requiring two manual `step` calls per
218    /// gesture.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`RunError::ExceededMaxSteps`](crate::testing::RunError::ExceededMaxSteps) if the queue is still non-empty after `max_steps`
223    /// steps: an app that never drains its input is a bug in the test or the app, not a case to
224    /// loop on forever.
225    pub fn settle<A: App<Headless>>(
226        &mut self,
227        app: &mut A,
228        max_steps: u32,
229    ) -> Result<u32, RunError> {
230        let mut steps = 0;
231        loop {
232            let flow = self.step(app);
233            steps += 1;
234            if flow == Flow::Exit || self.queued.is_empty() {
235                return Ok(steps);
236            }
237            if steps >= max_steps {
238                return Err(RunError::ExceededMaxSteps { max_steps });
239            }
240        }
241    }
242
243    /// [`settle`](Self::settle) with [`DEFAULT_MAX_STEPS`], panicking instead of returning an
244    /// error.
245    ///
246    /// # Panics
247    ///
248    /// Panics if the queue is still non-empty after [`DEFAULT_MAX_STEPS`] steps.
249    pub fn run<A: App<Headless>>(&mut self, app: &mut A) -> u32 {
250        match self.settle(app, DEFAULT_MAX_STEPS) {
251            Ok(steps) => steps,
252            Err(err) => panic!("{err}"),
253        }
254    }
255
256    /// Runs a fixed number of frames, regardless of queue state or the [`Flow`](crate::app::Flow) each one returns.
257    ///
258    /// For tests asserting on the app still running after N frames (e.g. an idle animation)
259    /// rather than on input settling; [`run`](Self::run)/[`settle`](Self::settle) cover the
260    /// input-resolution case.
261    pub fn run_steps<A: App<Headless>>(&mut self, app: &mut A, steps: u32) {
262        for _ in 0..steps {
263            self.step(app);
264        }
265    }
266
267    /// The rendered view as of the last [`step`](Self::step) call (see
268    /// [`Headless::format_view`](crate::backend::Headless::format_view)).
269    #[must_use]
270    pub fn view(&self) -> String {
271        self.term.backend().format_view()
272    }
273
274    /// The underlying [`Terminal`](crate::terminal::Terminal), for anything not wrapped directly (cursor position,
275    /// [`Terminal::grid`](crate::terminal::Terminal::grid), a manual [`Terminal::draw`](crate::terminal::Terminal::draw) outside the `App` loop).
276    #[must_use]
277    pub const fn term(&self) -> &Terminal<Headless> {
278        &self.term
279    }
280
281    /// The underlying [`Terminal`](crate::terminal::Terminal), mutably.
282    #[must_use]
283    pub const fn term_mut(&mut self) -> &mut Terminal<Headless> {
284        &mut self.term
285    }
286}
287
288/// Error returned by [`TestHarness::settle`](crate::testing::TestHarness::settle) when the queue never drained within the step budget.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290#[non_exhaustive]
291pub enum RunError {
292    /// The queue still had pending events after `max_steps` [`TestHarness::step`](crate::testing::TestHarness::step) calls.
293    ExceededMaxSteps {
294        /// The budget that was exceeded.
295        max_steps: u32,
296    },
297}
298
299impl fmt::Display for RunError {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        match self {
302            Self::ExceededMaxSteps { max_steps } => write!(
303                f,
304                "TestHarness::settle did not drain its event queue within {max_steps} steps"
305            ),
306        }
307    }
308}
309
310impl core::error::Error for RunError {}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::app::Flow;
316    use crate::backend::Backend;
317    use crate::color::Style;
318    use ixy::HasSize;
319
320    struct Clicker {
321        clicks: u32,
322    }
323
324    impl<B: Backend> App<B> for Clicker {
325        fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
326            for event in term.drain_events() {
327                if matches!(
328                    event,
329                    Event::Mouse(MouseEvent {
330                        kind: MouseEventKind::Down(MouseButton::Left),
331                        ..
332                    })
333                ) {
334                    self.clicks += 1;
335                }
336            }
337            term.surface().put((0, 0), 'x', Style::default());
338            Flow::Continue
339        }
340    }
341
342    #[test]
343    fn step_presents_before_view_reflects_it() {
344        struct Drawer;
345        impl<B: Backend> App<B> for Drawer {
346            fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
347                term.surface().put((0, 0), '@', Style::default());
348                Flow::Continue
349            }
350        }
351
352        let mut harness = TestHarness::new(3, 1);
353        let mut app = Drawer;
354        assert!(harness.view().starts_with('ยท'));
355        harness.step(&mut app);
356        assert!(harness.view().starts_with('@'));
357    }
358
359    #[test]
360    fn click_resolves_after_settle_not_after_one_step() {
361        let mut harness = TestHarness::new(5, 1);
362        let mut app = Clicker { clicks: 0 };
363        harness.click(0, 0);
364
365        // A single step only delivers one of the two queued events (see the two-frame rule).
366        harness.step(&mut app);
367        assert_eq!(
368            app.clicks, 1,
369            "the queued Down event resolves on the first step"
370        );
371
372        harness.run(&mut app);
373        assert!(harness.view().starts_with('x'));
374    }
375
376    #[test]
377    fn settle_reports_exceeded_max_steps() {
378        struct NeverDrains;
379        impl<B: Backend> App<B> for NeverDrains {
380            fn update(&mut self, _term: &mut Terminal<B>, _frame: &Frame) -> Flow {
381                Flow::Continue
382            }
383        }
384
385        let mut harness = TestHarness::new(2, 1);
386        let mut app = NeverDrains;
387        harness.push_event(Event::Key(KeyEvent::new(
388            KeyCode::Char('q'),
389            KeyModifiers::NONE,
390        )));
391        harness.push_event(Event::Key(KeyEvent::new(
392            KeyCode::Char('w'),
393            KeyModifiers::NONE,
394        )));
395
396        // `settle`'s loop bound is the harness's own queue length, independent of whether `app`
397        // drains anything from `term`; a 0-step budget with a non-empty queue always exceeds it
398        // after the first (mandatory) step, so this is deterministic regardless of app behavior.
399        let err = harness.settle(&mut app, 0).unwrap_err();
400        assert_eq!(err, RunError::ExceededMaxSteps { max_steps: 0 });
401    }
402
403    #[test]
404    fn run_steps_ignores_flow_exit() {
405        struct AlwaysExits {
406            calls: u32,
407        }
408        impl<B: Backend> App<B> for AlwaysExits {
409            fn update(&mut self, _term: &mut Terminal<B>, _frame: &Frame) -> Flow {
410                self.calls += 1;
411                Flow::Exit
412            }
413        }
414
415        let mut harness = TestHarness::new(2, 1);
416        let mut app = AlwaysExits { calls: 0 };
417        harness.run_steps(&mut app, 3);
418        assert_eq!(app.calls, 3);
419    }
420
421    #[test]
422    fn resize_updates_backend_and_queues_event() {
423        struct Resized {
424            seen: Option<(u16, u16)>,
425        }
426        impl<B: Backend> App<B> for Resized {
427            fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
428                for event in term.drain_events() {
429                    if let Event::Resize(w, h) = event {
430                        self.seen = Some((w, h));
431                    }
432                }
433                Flow::Continue
434            }
435        }
436
437        let mut harness = TestHarness::new(4, 4);
438        let mut app = Resized { seen: None };
439        harness.resize(8, 2);
440        harness.run(&mut app);
441        assert_eq!(harness.term().size().width(), 8);
442        assert_eq!(app.seen, Some((8, 2)));
443    }
444
445    #[test]
446    fn run_error_display_message() {
447        let err = RunError::ExceededMaxSteps { max_steps: 5 };
448        assert_eq!(
449            err.to_string(),
450            "TestHarness::settle did not drain its event queue within 5 steps"
451        );
452    }
453}