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: push synthetic events onto `Headless` with `push_event`, drive them through an `App`
13//! with `TestHarness` until the loop settles, then assert on the resulting `Headless` frame
14//! directly.
15//!
16//! [`conformance`](crate::testing::conformance) is a different tool for a different job: it drives a backend directly (no
17//! `App`, no `Terminal`) through the obligations [`Output`](crate::backend::Output),
18//! [`Cursor`](crate::backend::Cursor), and [`Input`](crate::backend::Input) each promise but
19//! that a lone `impl` block never states, catching the five backends in this workspace (and any
20//! future one) disagreeing on them (retroglyph#763).
21
22pub mod conformance;
23mod recording;
24
25pub use recording::InputRecording;
26
27use crate::app::{App, Flow, Frame};
28use crate::backend::Headless;
29use crate::event::{
30 Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
31};
32use crate::grid::Pos;
33use crate::terminal::Terminal;
34use alloc::collections::VecDeque;
35use alloc::string::String;
36use alloc::vec;
37use alloc::vec::Vec;
38use core::fmt;
39use core::time::Duration;
40
41/// Fixed per-frame delta [`TestHarness::step`](crate::testing::TestHarness::step) hands to [`App::update`](crate::app::App::update).
42///
43/// Headless tests have no wall clock; this exists only so [`Frame::delta`](crate::app::Frame::delta)-driven code (tweens,
44/// [`FrameClock`](crate::frames::FrameClock)) advances instead of stalling. 16ms is one frame at
45/// ~60fps; the value is otherwise arbitrary, but it is load-bearing for any test that counts steps
46/// to reach an animation state: a duration-D animation finishes in `ceil(D / 16ms)` steps, so
47/// changing this shifts those step counts.
48pub const STEP_DELTA: Duration = Duration::from_millis(16);
49
50/// Default step budget for [`TestHarness::run`](crate::testing::TestHarness::run) before it treats a
51/// non-draining event queue as a stuck app and panics.
52///
53/// Sized to comfortably clear any single queued gesture (a click is two events plus the trailing
54/// event-free frame the two-frame rule costs), with headroom, while still failing fast on an app
55/// that never drains its input. The exact value is picked by feel, not measured; a test with a
56/// legitimately long settle should call [`settle`](crate::testing::TestHarness::settle) with a
57/// larger budget rather than raise this shared default.
58pub const DEFAULT_MAX_STEPS: u32 = 64;
59
60/// Drives an [`App`](crate::app::App) against a [`Headless`](crate::backend::Headless) backend: queues synthetic input, steps frames, and
61/// reads back the rendered view.
62///
63/// # The two-frame rule
64///
65/// A press and a release queued together resolve a frame later than the same gesture arriving
66/// from real input, because hit-testing (e.g. `retroglyph-ui`' `Interaction`) snapshots the
67/// *previous* frame's pointer state before this frame's queued events are applied, and because
68/// [`step`](Self::step) drains at most one queued event per call, `click`'s Down and Up land in
69/// two separate frames rather than one, costing a third, event-free frame before that snapshot
70/// catches up: `resolved_press` from Down's frame latches `active` on Up's frame, and only the
71/// frame after *that* sees `resolved_release`. [`click`](Self::click) queues both events for you;
72/// [`run`](Self::run) and [`settle`](Self::settle) already run that trailing event-free frame, so
73/// call one of those after queuing input rather than a fixed number of manual
74/// [`step`](Self::step) calls.
75///
76/// # Presenting
77///
78/// [`step`](Self::step) presents automatically: skipped on [`Flow::Idle`](crate::app::Flow::Idle), skipped as a no-op if
79/// `update` already presented (mirroring [`run_on`](crate::app::run_on)'s own
80/// behavior). Nothing queued is visible in [`view`](Self::view) until a `step` call has run.
81///
82/// # Examples
83///
84/// ```
85/// use retroglyph_core::testing::TestHarness;
86/// use retroglyph_core::app::{App, Flow, Frame};
87/// use retroglyph_core::backend::Backend;
88/// use retroglyph_core::color::Style;
89/// use retroglyph_core::terminal::Terminal;
90///
91/// struct Counter(u32);
92///
93/// impl<B: Backend> App<B> for Counter {
94/// fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow {
95/// // Draining (not just `has_input`, which leaves the event buffered) matters here:
96/// // `run` follows the queue's last event with a trailing event-free frame (see "the
97/// // two-frame rule" above), and an undrained event would still be visible to
98/// // `has_input` on that next frame too, double-counting it.
99/// if term.drain_events().count() > 0 {
100/// self.0 += 1;
101/// }
102/// term.surface()
103/// .put((0, 0), char::from_digit(self.0, 10).unwrap_or('?'), Style::default());
104/// if frame.frame > 10 {
105/// Flow::Exit
106/// } else {
107/// Flow::Continue
108/// }
109/// }
110/// }
111///
112/// let mut harness = TestHarness::new(10, 1);
113/// let mut app = Counter(0);
114///
115/// harness.key(retroglyph_core::event::KeyCode::Char(' '));
116/// harness.run(&mut app);
117///
118/// assert_eq!(app.0, 1);
119/// assert!(harness.view().starts_with('1'));
120/// ```
121pub struct TestHarness {
122 term: Terminal<Headless>,
123 frame: u64,
124 queued: VecDeque<Vec<Event>>,
125 step_delta: Duration,
126}
127
128impl TestHarness {
129 /// Creates a harness with a `width` x `height` [`Headless`](crate::backend::Headless) backend.
130 #[must_use]
131 pub fn new(width: u16, height: u16) -> Self {
132 Self {
133 term: Terminal::new(Headless::new(width, height)),
134 frame: 0,
135 queued: VecDeque::new(),
136 step_delta: STEP_DELTA,
137 }
138 }
139
140 /// Overrides the [`Frame::delta`](crate::app::Frame::delta) [`step`](Self::step) hands to
141 /// [`App::update`](crate::app::App::update), replacing [`STEP_DELTA`] (builder style).
142 ///
143 /// For an app under test whose animation timing is calibrated against a different fixed
144 /// delta than [`STEP_DELTA`]'s 16ms (a `retroglyph_ui::animate::Tween`/[`FrameClock`](crate::frames::FrameClock)
145 /// tuned in real seconds, for example): matching that calibration here keeps however many
146 /// `step` calls an animation takes to settle the same as what tuned it, rather than retuning
147 /// the animation (or the test's assertions) around the harness's own default instead.
148 #[must_use]
149 pub const fn with_step_delta(mut self, delta: Duration) -> Self {
150 self.step_delta = delta;
151 self
152 }
153
154 /// Queues a synthetic event for the next [`step`](Self::step) call.
155 ///
156 /// Only queues: the app does not see it until a frame runs. Prefer the typed helpers
157 /// ([`click`](Self::click), [`key`](Self::key), [`mouse_move`](Self::mouse_move)) unless the
158 /// [`Event`](crate::event::Event) variant needed isn't one of them.
159 pub fn push_event(&mut self, event: Event) {
160 self.queued.push_back(vec![event]);
161 }
162
163 /// Queues every event in `events` to land together in a single [`step`](Self::step) call,
164 /// rather than one per frame.
165 ///
166 /// For a gesture whose events a real backend delivers within one poll (a drag's coalesced
167 /// moves, a paste, a multi-key chord) and that the app under test expects to see in the same
168 /// [`App::update`](crate::app::App::update) call: [`push_event`](Self::push_event) queuing
169 /// each one separately would spread them across frames instead, which is what a real backend
170 /// never does and what [`Terminal::drain_events`](crate::terminal::Terminal::drain_events)
171 /// callers written against real input don't expect.
172 ///
173 /// [`Headless`](crate::backend::Headless) coalesces consecutive
174 /// [`Event::Mouse`](crate::event::Event::Mouse) [`Moved`](crate::event::MouseEventKind::Moved)
175 /// events into just the last one, matching a real backend's own polling behavior: a
176 /// `push_frame` of several successive moves is seen by the app as only the final position.
177 pub fn push_frame(&mut self, events: impl IntoIterator<Item = Event>) {
178 self.queued.push_back(events.into_iter().collect());
179 }
180
181 /// Queues a left-button click (press then release) at `(x, y)`, with no modifiers.
182 ///
183 /// See "the two-frame rule" on [`TestHarness`](crate::testing::TestHarness) before asserting after a single
184 /// [`step`](Self::step): use [`run`](Self::run)/[`settle`](Self::settle) instead.
185 pub fn click(&mut self, x: u16, y: u16) {
186 self.click_button(x, y, MouseButton::Left);
187 }
188
189 /// Queues a click (press then release) with `button` at `(x, y)`, with no modifiers.
190 pub fn click_button(&mut self, x: u16, y: u16, button: MouseButton) {
191 let position = Pos::new(x, y);
192 for kind in [MouseEventKind::Down(button), MouseEventKind::Up(button)] {
193 self.push_event(Event::Mouse(MouseEvent {
194 kind,
195 position,
196 // `Headless` is a character-mode backend: it has no sub-cell pixel position to
197 // report, matching the crossterm backend's own convention (see `MouseEvent`'s
198 // docs) rather than guessing one.
199 pixel_position: None,
200 modifiers: KeyModifiers::NONE,
201 }));
202 }
203 }
204
205 /// Queues a pointer move to `(x, y)`, with no buttons held.
206 pub fn mouse_move(&mut self, x: u16, y: u16) {
207 self.push_event(Event::Mouse(MouseEvent {
208 kind: MouseEventKind::Moved,
209 position: Pos::new(x, y),
210 pixel_position: None,
211 modifiers: KeyModifiers::NONE,
212 }));
213 }
214
215 /// Queues a key press of `code`, with no modifiers.
216 pub fn key(&mut self, code: KeyCode) {
217 self.key_with(code, KeyModifiers::NONE);
218 }
219
220 /// Queues a key press of `code` with `modifiers`.
221 pub fn key_with(&mut self, code: KeyCode, modifiers: KeyModifiers) {
222 self.push_event(Event::Key(KeyEvent::new(code, modifiers)));
223 }
224
225 /// Resizes the backend and queues the matching [`Event::Resize`](crate::event::Event::Resize) a real terminal would also
226 /// deliver.
227 ///
228 /// Unlike calling <code>[term_mut](Self::term_mut)().[resize](crate::terminal::Terminal::resize)</code>
229 /// directly, this also queues the event, matching what a real backend delivers alongside its
230 /// own resize.
231 pub fn resize(&mut self, width: u16, height: u16) {
232 self.term.resize(width, height);
233 self.push_event(Event::Resize(width, height));
234 }
235
236 /// Runs exactly one frame: pops at most one queued event into the backend, calls
237 /// [`App::update`](crate::app::App::update), and presents unless `update` returned [`Flow::Idle`](crate::app::Flow::Idle) or already presented.
238 ///
239 /// Draining only one queued event per call, rather than the whole queue at once, is what
240 /// reproduces the two-frame rule described on [`TestHarness`](crate::testing::TestHarness) instead of masking it.
241 ///
242 /// Calls [`App::init`](crate::app::App::init) first, on frame 0 only, mirroring what every
243 /// other driver in this crate does before its own first `update`.
244 pub fn step<A: App<Headless>>(&mut self, app: &mut A) -> Flow {
245 if self.frame == 0 {
246 app.init(&mut self.term);
247 }
248 if let Some(events) = self.queued.pop_front() {
249 for event in events {
250 self.term.backend_mut().push_event(event);
251 }
252 }
253 let frame = Frame::new(self.step_delta, self.frame);
254 self.frame = self.frame.wrapping_add(1);
255 let present_count_before = self.term.present_count();
256 let flow = app.update(&mut self.term, &frame);
257 if flow != Flow::Idle && self.term.present_count() == present_count_before {
258 // `Headless::Error` is `Infallible`: absorbed here so callers never see a `Result`
259 // for a `present` call that cannot fail, rather than each one writing its own
260 // "never panics in practice" doc (retroglyph#612).
261 let Ok(()) = self.term.present();
262 }
263 flow
264 }
265
266 /// Runs [`step`](Self::step) once per currently-queued frame, plus one further event-free
267 /// frame past that, stopping early on [`Flow::Exit`](crate::app::Flow::Exit), bounded by
268 /// `max_steps`.
269 ///
270 /// This is the "run until settled" primitive: queuing input only stages it, `settle` resolves
271 /// the two-frame rule (see [`TestHarness`](crate::testing::TestHarness)) instead of requiring
272 /// manual `step` calls per gesture. The trailing event-free frame matters because hit-testing
273 /// (e.g. `retroglyph-ui`'s `Interaction`) reads a one-shot flag latched by the *previous*
274 /// frame's input: stopping right after the last queued frame's own `step` would return before
275 /// that flag is ever observed, leaving a queued click's `clicked()` structurally unreachable
276 /// through this API. The budget `settle` needs is therefore fixed up front, as the queue's
277 /// length (at least one, for the empty-queue case) plus that trailing frame, rather than
278 /// discovered by looping on the queue's state as it drains.
279 ///
280 /// # Errors
281 ///
282 /// Returns [`RunError::ExceededMaxSteps`](crate::testing::RunError::ExceededMaxSteps) if that
283 /// budget exceeds `max_steps`: a queue with more staged frames than `max_steps` allows is a
284 /// bug in the test, not a case to loop on forever.
285 pub fn settle<A: App<Headless>>(
286 &mut self,
287 app: &mut A,
288 max_steps: u32,
289 ) -> Result<u32, RunError> {
290 let needed = u32::try_from(self.queued.len().max(1))
291 .unwrap_or(u32::MAX)
292 .saturating_add(1);
293 for steps in 1..=needed {
294 if self.step(app) == Flow::Exit {
295 return Ok(steps);
296 }
297 if steps >= max_steps && steps < needed {
298 return Err(RunError::ExceededMaxSteps { max_steps });
299 }
300 }
301 Ok(needed)
302 }
303
304 /// [`settle`](Self::settle) with [`DEFAULT_MAX_STEPS`], panicking instead of returning an
305 /// error.
306 ///
307 /// # Panics
308 ///
309 /// Panics if the queue is still non-empty after [`DEFAULT_MAX_STEPS`] steps.
310 pub fn run<A: App<Headless>>(&mut self, app: &mut A) -> u32 {
311 match self.settle(app, DEFAULT_MAX_STEPS) {
312 Ok(steps) => steps,
313 Err(err) => panic!("{err}"),
314 }
315 }
316
317 /// Runs a fixed number of frames, regardless of queue state or the [`Flow`](crate::app::Flow) each one returns.
318 ///
319 /// For tests asserting on the app still running after N frames (e.g. an idle animation)
320 /// rather than on input settling; [`run`](Self::run)/[`settle`](Self::settle) cover the
321 /// input-resolution case.
322 pub fn run_steps<A: App<Headless>>(&mut self, app: &mut A, steps: u32) {
323 for _ in 0..steps {
324 self.step(app);
325 }
326 }
327
328 /// The rendered view as of the last [`step`](Self::step) call (see
329 /// [`Headless::format_view`](crate::backend::Headless::format_view)).
330 #[must_use]
331 pub fn view(&self) -> String {
332 self.term.backend().format_view()
333 }
334
335 /// [`view`](Self::view), with [`Headless::SPACE_GLYPH`] converted back to a literal space.
336 ///
337 /// `view` renders a blank cell as `·` rather than a space so layout is visible in text
338 /// diffs (see [`Headless::format_view`](crate::backend::Headless::format_view)); that's the
339 /// right default for snapshot tests, but it defeats an ordinary plain-text assertion like
340 /// `view.contains("New Game")`, since the label's actual space never appears literally. Use
341 /// this instead of `view` for that case.
342 #[must_use]
343 pub fn readable_view(&self) -> String {
344 self.view().replace(Headless::SPACE_GLYPH, " ")
345 }
346
347 /// The [`Pos`] of `needle`'s first occurrence in the current [`view`](Self::view), or
348 /// `None` if it doesn't appear.
349 ///
350 /// "First" is row-major: top row before bottom, left before right within a row. `needle` is
351 /// matched against a single row at a time (a label wrapped across rows by [`Headless::draw_text`](crate::backend::Headless)
352 /// won't match) and must consist of single-cell glyphs (a wide glyph's spacer cell, per
353 /// [`Headless::format_view`](crate::backend::Headless::format_view), renders blank, not as a second copy of the glyph, so a
354 /// `needle` containing one can never match).
355 ///
356 /// A space in `needle` is matched against [`format_view`](crate::backend::Headless::format_view)'s own `·`
357 /// stand-in for a blank cell, so a multi-word `needle` (`"Save Game"`) matches the view's
358 /// rendered `"Save·Game"` without the caller having to know about that substitution.
359 #[must_use]
360 pub fn find_text(&self, needle: &str) -> Option<Pos> {
361 if needle.is_empty() {
362 return None;
363 }
364 // `format_view` stands `·` in for a plain space so layout is visible in text diffs
365 // (`Headless::display_glyph`); matching against that convention here is what lets a
366 // caller pass a natural, space-separated label instead of pre-encoding it themselves.
367 let needle: String = needle
368 .chars()
369 .map(|c| if c == ' ' { Headless::SPACE_GLYPH } else { c })
370 .collect();
371 for (y, row) in self.view().lines().enumerate() {
372 if let Some(byte_index) = row.find(needle.as_str()) {
373 // `str::find` returns a byte offset; the harness's coordinates are cell (char)
374 // indices, and every glyph `format_view` emits is one `char`, so counting chars
375 // up to that byte offset converts one to the other.
376 let x = row[..byte_index].chars().count();
377 // Both indices are bounded by the backend's own grid size, which is a `u16` x
378 // `u16` extent (`Headless::new`'s own parameters): `y` never exceeds the number
379 // of rows `format_view` emits, and `x` never exceeds one row's cell count.
380 #[allow(clippy::cast_possible_truncation)]
381 return Some(Pos::new(x as u16, y as u16));
382 }
383 }
384 None
385 }
386
387 /// [`click`](Self::click)s the first occurrence of `needle` in the current [`view`](Self::view).
388 ///
389 /// See [`find_text`](Self::find_text) for what "first occurrence" means and its matching
390 /// rules (row-major, single-row, `·`-for-space).
391 ///
392 /// # Errors
393 ///
394 /// Returns [`ClickTextError::NotFound`] if `needle` doesn't appear in the current view.
395 pub fn click_text(&mut self, needle: &str) -> Result<(), ClickTextError> {
396 let pos = self
397 .find_text(needle)
398 .ok_or_else(|| ClickTextError::NotFound {
399 needle: needle.into(),
400 })?;
401 self.click(pos.x, pos.y);
402 Ok(())
403 }
404
405 /// Creates a harness sized to `recording`'s recorded backend dimensions, ready for
406 /// [`replay`](Self::replay).
407 #[must_use]
408 pub fn from_recording(recording: &InputRecording) -> Self {
409 Self::new(recording.width(), recording.height())
410 }
411
412 /// Drives every event in `recording` into `app`, reproducing each recorded gap as whole
413 /// [`STEP_DELTA`] steps rather than collapsing it to zero.
414 ///
415 /// Each recorded `(delay, event)` pair becomes `round(delay / STEP_DELTA)` event-free
416 /// [`run_steps`](Self::run_steps) calls, then `event` is queued and settled with
417 /// [`run`](Self::run). This is what lets a replay reproduce a bug that depends on elapsed
418 /// time between inputs (a [`FrameClock`](crate::frames::FrameClock)/tween-driven cooldown, a
419 /// hold-to-charge mechanic): a naive replay that ran one `run()` per event with no delay in
420 /// between would silently fail to reproduce any of those. Sub-`STEP_DELTA` jitter in the
421 /// original recording is rounded away, which is expected and harmless -- nothing in this
422 /// harness has finer time resolution than one step to begin with.
423 ///
424 /// # Panics
425 ///
426 /// Panics if [`run`](Self::run) does (an event fails to drain within
427 /// [`DEFAULT_MAX_STEPS`]); see that method.
428 pub fn replay<A: App<Headless>>(&mut self, recording: &InputRecording, app: &mut A) {
429 for (delay, event) in recording.events() {
430 self.run_steps(app, Self::steps_for_delay(*delay));
431 self.push_event(event.clone());
432 self.run(app);
433 }
434 }
435
436 /// Rounds `delay` to the nearest whole number of [`STEP_DELTA`] steps.
437 ///
438 /// No zero-`step_nanos` guard: [`STEP_DELTA`] is a fixed, non-zero constant (16ms), not a
439 /// runtime value, so that branch would be dead code no test could ever exercise.
440 fn steps_for_delay(delay: Duration) -> u32 {
441 let step_nanos = STEP_DELTA.as_nanos();
442 let steps = (delay.as_nanos() + step_nanos / 2) / step_nanos;
443 u32::try_from(steps).unwrap_or(u32::MAX)
444 }
445
446 /// The underlying [`Terminal`](crate::terminal::Terminal), for anything not wrapped directly (cursor position,
447 /// [`Terminal::grid`](crate::terminal::Terminal::grid), a manual [`Terminal::draw`](crate::terminal::Terminal::draw) outside the `App` loop).
448 #[must_use]
449 pub const fn term(&self) -> &Terminal<Headless> {
450 &self.term
451 }
452
453 /// The underlying [`Terminal`](crate::terminal::Terminal), mutably.
454 #[must_use]
455 pub const fn term_mut(&mut self) -> &mut Terminal<Headless> {
456 &mut self.term
457 }
458}
459
460/// Error returned by [`TestHarness::settle`](crate::testing::TestHarness::settle) when the queue held more staged frames than the step budget.
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462#[non_exhaustive]
463pub enum RunError {
464 /// The queue needed more than `max_steps` [`TestHarness::step`](crate::testing::TestHarness::step) calls to drain, plus the trailing event-free frame.
465 ExceededMaxSteps {
466 /// The budget that was exceeded.
467 max_steps: u32,
468 },
469}
470
471impl fmt::Display for RunError {
472 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473 match self {
474 Self::ExceededMaxSteps { max_steps } => write!(
475 f,
476 "TestHarness::settle did not drain its event queue within {max_steps} steps"
477 ),
478 }
479 }
480}
481
482impl core::error::Error for RunError {}
483
484/// Error returned by [`TestHarness::click_text`](crate::testing::TestHarness::click_text) when `needle` doesn't appear in the
485/// current [`view`](crate::testing::TestHarness::view).
486#[derive(Debug, Clone, PartialEq, Eq)]
487#[non_exhaustive]
488pub enum ClickTextError {
489 /// `needle` did not appear anywhere in [`TestHarness::view`](crate::testing::TestHarness::view) at the time of the call.
490 NotFound {
491 /// The text that was searched for.
492 needle: String,
493 },
494}
495
496impl fmt::Display for ClickTextError {
497 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
498 match self {
499 Self::NotFound { needle } => write!(f, "{needle:?} not found in view"),
500 }
501 }
502}
503
504impl core::error::Error for ClickTextError {}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::app::Flow;
510 use crate::backend::Backend;
511 use crate::color::Style;
512 use crate::grid::HasSize;
513
514 struct Clicker {
515 clicks: u32,
516 }
517
518 impl<B: Backend> App<B> for Clicker {
519 fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
520 for event in term.drain_events() {
521 if matches!(
522 event,
523 Event::Mouse(MouseEvent {
524 kind: MouseEventKind::Down(MouseButton::Left),
525 ..
526 })
527 ) {
528 self.clicks += 1;
529 }
530 }
531 term.surface().put((0, 0), 'x', Style::default());
532 Flow::Continue
533 }
534 }
535
536 #[test]
537 fn step_calls_init_once_before_the_first_update() {
538 struct RecordsInit {
539 init_calls: u32,
540 updates: u32,
541 }
542 impl<B: Backend> App<B> for RecordsInit {
543 fn init(&mut self, _term: &mut Terminal<B>) {
544 self.init_calls += 1;
545 }
546 fn update(&mut self, _term: &mut Terminal<B>, _frame: &Frame) -> Flow {
547 self.updates += 1;
548 Flow::Continue
549 }
550 }
551
552 let mut harness = TestHarness::new(3, 1);
553 let mut app = RecordsInit {
554 init_calls: 0,
555 updates: 0,
556 };
557 harness.step(&mut app);
558 harness.step(&mut app);
559 harness.step(&mut app);
560 assert_eq!(app.init_calls, 1, "init must run exactly once");
561 assert_eq!(app.updates, 3);
562 }
563
564 #[test]
565 fn step_presents_before_view_reflects_it() {
566 struct Drawer;
567 impl<B: Backend> App<B> for Drawer {
568 fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
569 term.surface().put((0, 0), '@', Style::default());
570 Flow::Continue
571 }
572 }
573
574 let mut harness = TestHarness::new(3, 1);
575 let mut app = Drawer;
576 assert!(harness.view().starts_with('·'));
577 harness.step(&mut app);
578 assert!(harness.view().starts_with('@'));
579 }
580
581 #[test]
582 fn click_resolves_after_settle_not_after_one_step() {
583 let mut harness = TestHarness::new(5, 1);
584 let mut app = Clicker { clicks: 0 };
585 harness.click(0, 0);
586
587 // A single step only delivers one of the two queued events (see the two-frame rule).
588 harness.step(&mut app);
589 assert_eq!(
590 app.clicks, 1,
591 "the queued Down event resolves on the first step"
592 );
593
594 harness.run(&mut app);
595 assert!(harness.view().starts_with('x'));
596 }
597
598 #[test]
599 fn settle_reports_exceeded_max_steps() {
600 struct NeverDrains;
601 impl<B: Backend> App<B> for NeverDrains {
602 fn update(&mut self, _term: &mut Terminal<B>, _frame: &Frame) -> Flow {
603 Flow::Continue
604 }
605 }
606
607 let mut harness = TestHarness::new(2, 1);
608 let mut app = NeverDrains;
609 harness.push_event(Event::Key(KeyEvent::new(
610 KeyCode::Char('q'),
611 KeyModifiers::NONE,
612 )));
613 harness.push_event(Event::Key(KeyEvent::new(
614 KeyCode::Char('w'),
615 KeyModifiers::NONE,
616 )));
617
618 // `settle`'s loop bound is the harness's own queue length, independent of whether `app`
619 // drains anything from `term`; a 0-step budget with a non-empty queue always exceeds it
620 // after the first (mandatory) step, so this is deterministic regardless of app behavior.
621 let err = harness.settle(&mut app, 0).unwrap_err();
622 assert_eq!(err, RunError::ExceededMaxSteps { max_steps: 0 });
623 }
624
625 #[test]
626 fn run_steps_ignores_flow_exit() {
627 struct AlwaysExits {
628 calls: u32,
629 }
630 impl<B: Backend> App<B> for AlwaysExits {
631 fn update(&mut self, _term: &mut Terminal<B>, _frame: &Frame) -> Flow {
632 self.calls += 1;
633 Flow::Exit
634 }
635 }
636
637 let mut harness = TestHarness::new(2, 1);
638 let mut app = AlwaysExits { calls: 0 };
639 harness.run_steps(&mut app, 3);
640 assert_eq!(app.calls, 3);
641 }
642
643 #[test]
644 fn with_step_delta_overrides_the_delta_step_hands_to_update() {
645 struct RecordsDelta {
646 seen: Option<Duration>,
647 }
648 impl<B: Backend> App<B> for RecordsDelta {
649 fn update(&mut self, _term: &mut Terminal<B>, frame: &Frame) -> Flow {
650 self.seen = Some(frame.delta);
651 Flow::Continue
652 }
653 }
654
655 let mut harness = TestHarness::new(2, 1).with_step_delta(Duration::from_millis(100));
656 let mut app = RecordsDelta { seen: None };
657 harness.step(&mut app);
658 assert_eq!(app.seen, Some(Duration::from_millis(100)));
659 }
660
661 #[test]
662 fn resize_updates_backend_and_queues_event() {
663 struct Resized {
664 seen: Option<(u16, u16)>,
665 }
666 impl<B: Backend> App<B> for Resized {
667 fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
668 for event in term.drain_events() {
669 if let Event::Resize(w, h) = event {
670 self.seen = Some((w, h));
671 }
672 }
673 Flow::Continue
674 }
675 }
676
677 let mut harness = TestHarness::new(4, 4);
678 let mut app = Resized { seen: None };
679 harness.resize(8, 2);
680 harness.run(&mut app);
681 assert_eq!(harness.term().size().width(), 8);
682 assert_eq!(app.seen, Some((8, 2)));
683 }
684
685 #[test]
686 fn run_error_display_message() {
687 let err = RunError::ExceededMaxSteps { max_steps: 5 };
688 assert_eq!(
689 err.to_string(),
690 "TestHarness::settle did not drain its event queue within 5 steps"
691 );
692 }
693
694 /// Draws two fixed labels, and (like [`Clicker`]) counts left-button-down events, so
695 /// [`click_text`](TestHarness::click_text) tests can assert it actually clicked, not merely
696 /// that it located the right cell.
697 struct Labels {
698 clicks: u32,
699 }
700
701 impl<B: Backend> App<B> for Labels {
702 fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
703 for event in term.drain_events() {
704 if matches!(
705 event,
706 Event::Mouse(MouseEvent {
707 kind: MouseEventKind::Down(MouseButton::Left),
708 ..
709 })
710 ) {
711 self.clicks += 1;
712 }
713 }
714 term.surface().print((2, 1), "Quit", Style::default());
715 term.surface().print((2, 2), "Save Game", Style::default());
716 Flow::Continue
717 }
718 }
719
720 #[test]
721 fn find_text_locates_first_occurrence_row_major() {
722 let mut harness = TestHarness::new(12, 4);
723 let mut app = Labels { clicks: 0 };
724 harness.step(&mut app);
725
726 assert_eq!(harness.find_text("Quit"), Some(Pos::new(2, 1)));
727 }
728
729 #[test]
730 fn find_text_matches_a_space_against_the_view_middle_dot() {
731 let mut harness = TestHarness::new(12, 4);
732 let mut app = Labels { clicks: 0 };
733 harness.step(&mut app);
734
735 // `"Save Game"` never appears literally: `format_view` renders the space between the
736 // words as `·`. This only passes if `find_text` accounts for that substitution itself.
737 assert_eq!(harness.find_text("Save Game"), Some(Pos::new(2, 2)));
738 }
739
740 #[test]
741 fn readable_view_converts_the_middle_dot_back_to_a_literal_space() {
742 let mut harness = TestHarness::new(12, 4);
743 let mut app = Labels { clicks: 0 };
744 harness.step(&mut app);
745
746 // `view()` renders the space in "Save Game" as `·`, so a naive `.contains("Save Game")`
747 // against it would fail even though the label is on screen; `readable_view` is what lets
748 // that assertion pass without the caller hand-rolling the substitution themselves.
749 assert!(!harness.view().contains("Save Game"));
750 assert!(harness.readable_view().contains("Save Game"));
751 }
752
753 #[test]
754 fn find_text_returns_none_when_absent() {
755 let mut harness = TestHarness::new(12, 4);
756 let mut app = Labels { clicks: 0 };
757 harness.step(&mut app);
758
759 assert_eq!(harness.find_text("Cancel"), None);
760 }
761
762 #[test]
763 fn find_text_returns_none_for_an_empty_needle() {
764 let mut harness = TestHarness::new(12, 4);
765 let mut app = Labels { clicks: 0 };
766 harness.step(&mut app);
767
768 // An empty needle matches every row trivially (`str::find("")` returns `Some(0)`), which
769 // would make `find_text("")` report a match at `(0, 0)` regardless of the view's actual
770 // content; treating it as absent instead avoids that meaningless result.
771 assert_eq!(harness.find_text(""), None);
772 }
773
774 #[test]
775 fn click_text_clicks_the_located_cell() {
776 let mut harness = TestHarness::new(12, 4);
777 let mut app = Labels { clicks: 0 };
778 harness.step(&mut app);
779
780 harness.click_text("Quit").unwrap();
781 harness.run(&mut app);
782
783 assert_eq!(app.clicks, 1);
784 }
785
786 #[test]
787 fn click_text_errs_when_absent() {
788 let mut harness = TestHarness::new(12, 4);
789 let mut app = Labels { clicks: 0 };
790 harness.step(&mut app);
791
792 let err = harness.click_text("Cancel").unwrap_err();
793 assert_eq!(err.to_string(), "\"Cancel\" not found in view");
794 }
795
796 /// Registers a key press only if a cooldown started by the previous accepted press has fully
797 /// elapsed (100ms), tracked via [`Frame::delta`] rather than a step count -- exactly the
798 /// class of timer-driven state [`TestHarness::replay`]'s faithful-timing design exists to
799 /// reproduce correctly.
800 struct CooldownGate {
801 cooldown_remaining: Duration,
802 hits: u32,
803 }
804
805 impl CooldownGate {
806 const COOLDOWN: Duration = Duration::from_millis(100);
807
808 const fn new() -> Self {
809 Self {
810 cooldown_remaining: Duration::ZERO,
811 hits: 0,
812 }
813 }
814 }
815
816 impl App<Headless> for CooldownGate {
817 fn update(&mut self, term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
818 self.cooldown_remaining = self.cooldown_remaining.saturating_sub(frame.delta);
819 for event in term.drain_events() {
820 if matches!(event, Event::Key(_)) && self.cooldown_remaining.is_zero() {
821 self.hits += 1;
822 self.cooldown_remaining = Self::COOLDOWN;
823 }
824 }
825 Flow::Continue
826 }
827 }
828
829 fn key_press(c: char) -> Event {
830 Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
831 }
832
833 /// Proves `replay`'s timing is faithful, not coarse: the same two key presses, 200ms apart
834 /// in the recording, register as two hits when the recorded gap is honored (the cooldown
835 /// from the first has expired by the time the second arrives) but collapse to one hit when
836 /// replayed back-to-back with the delay discarded (a naive "one `run()` per event" replay
837 /// would get this wrong).
838 #[test]
839 fn replay_reproduces_recorded_timing_not_a_coarse_back_to_back_replay() {
840 let mut recording = InputRecording::new(4, 1);
841 recording.push(Duration::ZERO, key_press('a'));
842 recording.push(Duration::from_millis(200), key_press('b'));
843
844 let mut faithful = TestHarness::from_recording(&recording);
845 let mut faithful_app = CooldownGate::new();
846 faithful.replay(&recording, &mut faithful_app);
847 assert_eq!(
848 faithful_app.hits, 2,
849 "the recorded 200ms gap should let the cooldown expire before the second press"
850 );
851
852 let mut coarse = TestHarness::new(recording.width(), recording.height());
853 let mut coarse_app = CooldownGate::new();
854 for (_, event) in recording.events() {
855 coarse.push_event(event.clone());
856 coarse.run(&mut coarse_app);
857 }
858 assert_eq!(
859 coarse_app.hits, 1,
860 "discarding the recorded delay should leave the cooldown still active for the second press"
861 );
862 }
863
864 #[test]
865 fn from_recording_sizes_the_harness_to_the_recording() {
866 let recording = InputRecording::new(7, 3);
867 let harness = TestHarness::from_recording(&recording);
868 assert_eq!(harness.term().size().width(), 7);
869 assert_eq!(harness.term().size().height(), 3);
870 }
871}