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}