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: 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}