Skip to main content

retroglyph_ui/
testing.rs

1//! Headless widget-level test harness: [`WidgetHarness`] drives a bare [`Interaction<Id>`]/[`Ui`]
2//! against [`Headless`], without an
3//! [`App`](retroglyph_core::app::App).
4//!
5//! `retroglyph_core::testing::TestHarness` is the right tool once a whole app exists to drive,
6//! but it's generic over `A: App<Headless>`, so a widget or a bare `Interaction<Id>` needs a
7//! throwaway `App` impl just to reach it. A widget test that only needs a few multi-frame
8//! sequences gets away with `Interaction::new()`/`begin_frame()` inline instead (see
9//! `crates/ui/src/widget/button.rs`'s own tests), but that stops scaling once a widget's suite is
10//! built from repeated click/assert sequences: `Menu`'s tests (`crates/ui/src/widget/menu.rs`)
11//! are exactly that shape, each hand-rolling the same register-hits/feed-events/resolve frame
12//! pair `WidgetHarness` now does once. Feature-gated (`testing`), no effect on release builds.
13
14use alloc::collections::VecDeque;
15use alloc::string::String;
16
17use retroglyph_core::backend::Headless;
18use retroglyph_core::event::{
19    Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
20};
21use retroglyph_core::grid::Pos;
22use retroglyph_core::terminal::Terminal;
23
24use crate::interact::Interaction;
25use crate::ui::Ui;
26
27/// Drives a bare [`Interaction<Id>`] against a [`Headless`] backend: queues synthetic input,
28/// steps a frame through a caller-supplied draw closure, and reads back the rendered view.
29///
30/// The sibling of `retroglyph_core::testing::TestHarness` for testing a widget (or a bare
31/// [`Interaction<Id>`]) directly, without wrapping it in an [`App`](retroglyph_core::app::App)
32/// first. Where `TestHarness` hands each frame to `App::update`, `WidgetHarness` hands each frame
33/// a [`Ui<'_, '_, Id>`](Ui) built from its own [`Interaction<Id>`] and [`Headless`] surface, and
34/// leaves whatever state a widget needs (a `MenuState`, a selection index, ...) owned by the
35/// caller's closure instead of an `App` impl.
36///
37/// # Frame timing differs from `TestHarness`
38///
39/// `TestHarness::step` drains at most one queued [`Event`] per call, because it's reproducing how
40/// a real `App` sees input trickle in one `Backend` push at a time. `WidgetHarness::step` feeds
41/// [`Interaction::handle_event`] directly, with no `Backend` queue in between, so nothing forces
42/// that one-event-per-frame pacing: [`step`](Self::step) drains its *entire* queue in one call,
43/// between [`begin_frame`](Interaction::begin_frame) and the draw closure -- [`Interaction`]'s
44/// own docs call this "the realistic call pattern" (see `Interaction`'s
45/// `press_and_release_in_separate_frames_still_resolves_a_click` test), and it's load-bearing for
46/// Tab: `FocusRing`'s registration order only finalizes inside `begin_frame`, so a Tab fed before
47/// it would still cycle through the previous frame's stale order. One consequence: a pointer
48/// gesture (click, hover) fed to `step` resolves on the *following* `step` call, not the one that
49/// fed it, since the flags `handle_event` sets aren't read until the next `begin_frame`; a key
50/// press that moves focus (Tab) or a live-state field like
51/// [`wants_pointer`](Interaction::wants_pointer) update immediately, within the same `step`. A
52/// gesture that needs to be observed mid-flight (a drag, a hover that switches mid-scroll) still
53/// needs its own [`mouse_move`](Self::mouse_move)/[`step`](Self::step) pair per intermediate
54/// position, exactly like driving [`Interaction`] by hand.
55///
56/// # Examples
57///
58/// ```
59/// use retroglyph_core::grid::Rect;
60/// use retroglyph_ui::testing::WidgetHarness;
61/// use retroglyph_ui::widget::{Button, InteractiveWidget};
62///
63/// #[derive(Clone, Copy, PartialEq, Eq)]
64/// enum Id {
65///     Save,
66/// }
67///
68/// let mut harness = WidgetHarness::<Id>::new(10, 3);
69/// let area = Rect::new(0, 0, 7, 1);
70/// let button = Button::new("Save");
71///
72/// // Frame 1: register the button's hit rect (nothing queued yet).
73/// harness.step(|ui| ui.show(area, Id::Save, &button));
74///
75/// harness.click(2, 0);
76/// // Frame 2: feeds the click; the flags it sets aren't read until the next `begin_frame`.
77/// harness.step(|ui| ui.show(area, Id::Save, &button));
78/// // Frame 3: resolves it.
79/// let response = harness.step(|ui| ui.show(area, Id::Save, &button));
80/// assert!(response.clicked());
81/// ```
82pub struct WidgetHarness<Id> {
83    term: Terminal<Headless>,
84    interaction: Interaction<Id>,
85    queued: VecDeque<Event>,
86}
87
88impl<Id> WidgetHarness<Id> {
89    /// Creates a harness with a `width` x `height` [`Headless`] backend and a fresh
90    /// [`Interaction<Id>`].
91    #[must_use]
92    pub fn new(width: u16, height: u16) -> Self {
93        Self {
94            term: Terminal::new(Headless::new(width, height)),
95            interaction: Interaction::new(),
96            queued: VecDeque::new(),
97        }
98    }
99
100    /// Queues a synthetic event for the next [`step`](Self::step) call.
101    ///
102    /// Only queues: nothing sees it until a frame runs. Prefer the typed helpers
103    /// ([`click`](Self::click), [`key`](Self::key), [`mouse_move`](Self::mouse_move)) unless the
104    /// [`Event`] variant needed isn't one of them.
105    pub fn push_event(&mut self, event: Event) {
106        self.queued.push_back(event);
107    }
108
109    /// Queues a left-button click (press then release) at `(x, y)`, with no modifiers.
110    ///
111    /// Both events are drained by the same [`step`](Self::step) call, but (see "Frame timing" on
112    /// [`WidgetHarness`]) that only sets the underlying flags: the click itself resolves on the
113    /// `step` *after* that one, once its `begin_frame` reads them.
114    pub fn click(&mut self, x: u16, y: u16) {
115        self.click_button(x, y, MouseButton::Left);
116    }
117
118    /// Queues a click (press then release) with `button` at `(x, y)`, with no modifiers.
119    pub fn click_button(&mut self, x: u16, y: u16, button: MouseButton) {
120        let position = Pos::new(x, y);
121        for kind in [MouseEventKind::Down(button), MouseEventKind::Up(button)] {
122            self.push_event(Event::Mouse(MouseEvent::new(
123                kind,
124                position,
125                KeyModifiers::NONE,
126            )));
127        }
128    }
129
130    /// Queues a pointer move to `(x, y)`, with no buttons held.
131    pub fn mouse_move(&mut self, x: u16, y: u16) {
132        self.push_event(Event::Mouse(MouseEvent::new(
133            MouseEventKind::Moved,
134            Pos::new(x, y),
135            KeyModifiers::NONE,
136        )));
137    }
138
139    /// Queues a key press of `code`, with no modifiers.
140    pub fn key(&mut self, code: KeyCode) {
141        self.key_with(code, KeyModifiers::NONE);
142    }
143
144    /// Queues a key press of `code` with `modifiers`.
145    pub fn key_with(&mut self, code: KeyCode, modifiers: KeyModifiers) {
146        self.push_event(Event::Key(KeyEvent::new(code, modifiers)));
147    }
148
149    /// The underlying [`Interaction<Id>`], for anything not wrapped directly (focus queries,
150    /// [`Interaction::hovered`](crate::interact::Interaction::hovered)).
151    #[must_use]
152    pub const fn interaction(&self) -> &Interaction<Id> {
153        &self.interaction
154    }
155
156    /// The rendered view as of the last [`step`](Self::step) call (see
157    /// [`Headless::format_view`]).
158    #[must_use]
159    pub fn view(&self) -> String {
160        self.term.backend().format_view()
161    }
162
163    /// [`view`](Self::view), with [`Headless::SPACE_GLYPH`] converted back to a literal space.
164    ///
165    /// See `retroglyph_core::testing::TestHarness::readable_view` for why this exists: `view`
166    /// renders a blank cell as `·` so layout is visible in text diffs, which defeats an ordinary
167    /// plain-text assertion like `view.contains("Save")`.
168    #[must_use]
169    pub fn readable_view(&self) -> String {
170        self.view().replace(Headless::SPACE_GLYPH, " ")
171    }
172}
173
174impl<Id: Copy + PartialEq> WidgetHarness<Id> {
175    /// Runs exactly one frame: drains every currently queued event into
176    /// [`Interaction::handle_event`], then runs `f` inside
177    /// [`Interaction::begin_frame`]/[`end_frame`](Interaction::end_frame) and presents.
178    ///
179    /// See "Frame timing" on [`WidgetHarness`] for why the whole queue drains at once rather than
180    /// one event per call.
181    pub fn step<R>(&mut self, f: impl FnOnce(&mut Ui<'_, '_, Id>) -> R) -> R {
182        self.interaction.begin_frame();
183        // Fed between `begin_frame` and `f`, not before `begin_frame`: `Interaction`'s own docs
184        // call this "the realistic call pattern" (see `press_and_release_in_separate_frames...`
185        // on `Interaction`), and it's load-bearing for Tab -- `FocusRing`'s registration order
186        // only finalizes *inside* `begin_frame`, so a Tab fed before it would still cycle through
187        // the previous frame's stale order.
188        for event in self.queued.drain(..) {
189            let _ = self.interaction.handle_event(&event);
190        }
191        let result = f(&mut Ui::new(
192            &mut self.term.surface(),
193            &mut self.interaction,
194        ));
195        self.interaction.end_frame();
196        // `Headless::Error` is `Infallible`: absorbed here so callers never see a `Result` for a
197        // `present` call that cannot fail, mirroring `TestHarness::step` (retroglyph#612).
198        let Ok(()) = self.term.present();
199        result
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use retroglyph_core::color::Style;
207    use retroglyph_core::grid::Rect;
208
209    use crate::interact::{Response, Sense};
210
211    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
212    enum Id {
213        Save,
214        Cancel,
215    }
216
217    fn register(harness: &mut WidgetHarness<Id>, area: Rect) {
218        let _ = harness.step(|ui| ui.interaction().interact(area, Id::Save, Sense::click()));
219    }
220
221    /// A widget's own interact call, factored out so a test can call it once to feed a queued
222    /// gesture and once more to observe the frame after: pointer flags fed via `handle_event`
223    /// resolve on the *next* `step`, not the one that fed them (see [`step`](WidgetHarness::step)'s
224    /// doc comment).
225    fn interact(harness: &mut WidgetHarness<Id>, area: Rect) -> Response<Id> {
226        harness.step(|ui| ui.interaction().interact(area, Id::Save, Sense::click()))
227    }
228
229    #[test]
230    fn click_resolves_one_step_after_it_was_queued() {
231        let mut harness = WidgetHarness::<Id>::new(10, 3);
232        let area = Rect::new(0, 0, 7, 1);
233
234        register(&mut harness, area); // frame 1: nothing queued yet, nothing resolves
235
236        harness.click(2, 0);
237        let _ = interact(&mut harness, area); // frame 2: feeds the click, doesn't resolve it yet
238        let response = interact(&mut harness, area); // frame 3: resolves it
239
240        assert!(response.clicked());
241    }
242
243    #[test]
244    fn click_button_queues_a_non_left_button() {
245        let mut harness = WidgetHarness::<Id>::new(10, 3);
246        let area = Rect::new(0, 0, 7, 1);
247        register(&mut harness, area);
248
249        harness.click_button(2, 0, MouseButton::Right);
250        let _ = interact(&mut harness, area);
251        let response = interact(&mut harness, area);
252
253        // `Sense::click()` only resolves the left button: this proves `click_button` actually
254        // queued a right-button gesture rather than silently falling back to `click`'s left one.
255        assert!(!response.clicked());
256    }
257
258    #[test]
259    fn mouse_move_updates_hover_without_a_click() {
260        let mut harness = WidgetHarness::<Id>::new(10, 3);
261        let area = Rect::new(0, 0, 7, 1);
262        register(&mut harness, area);
263
264        harness.mouse_move(2, 0);
265        let _ = interact(&mut harness, area);
266        let response = interact(&mut harness, area);
267
268        assert!(response.hovered());
269        assert!(!response.clicked());
270    }
271
272    #[test]
273    fn key_feeds_a_press_with_no_modifiers() {
274        let mut harness = WidgetHarness::<Id>::new(10, 3);
275        let area = Rect::new(0, 0, 7, 1);
276        register(&mut harness, area); // registers Save as focusable for the *next* frame
277
278        harness.key(KeyCode::Tab);
279        let response = harness.step(|ui| ui.interaction().interact(area, Id::Save, Sense::click()));
280
281        assert!(response.focused());
282    }
283
284    #[test]
285    fn step_presents_before_view_reflects_it() {
286        let mut harness = WidgetHarness::<Id>::new(3, 1);
287        assert!(harness.view().starts_with('·'));
288
289        harness.step(|ui| {
290            ui.surface().put((0, 0), '@', Style::default());
291        });
292
293        assert!(harness.view().starts_with('@'));
294    }
295
296    #[test]
297    fn readable_view_converts_the_middle_dot_back_to_a_literal_space() {
298        let mut harness = WidgetHarness::<Id>::new(12, 1);
299        harness.step(|ui| {
300            ui.surface().print((0, 0), "Save Game", Style::default());
301        });
302
303        assert!(!harness.view().contains("Save Game"));
304        assert!(harness.readable_view().contains("Save Game"));
305    }
306
307    #[test]
308    fn push_event_queues_an_arbitrary_event() {
309        let mut harness = WidgetHarness::<Id>::new(10, 3);
310        let area = Rect::new(0, 0, 7, 1);
311        register(&mut harness, area);
312
313        harness.push_event(Event::Mouse(MouseEvent::new(
314            MouseEventKind::Down(MouseButton::Left),
315            Pos::new(2, 0),
316            KeyModifiers::NONE,
317        )));
318        harness.push_event(Event::Mouse(MouseEvent::new(
319            MouseEventKind::Up(MouseButton::Left),
320            Pos::new(2, 0),
321            KeyModifiers::NONE,
322        )));
323        let _ = interact(&mut harness, area);
324        let response = interact(&mut harness, area);
325
326        assert!(response.clicked());
327    }
328
329    #[test]
330    fn interaction_exposes_the_underlying_context() {
331        let mut harness = WidgetHarness::<Id>::new(10, 3);
332        let area = Rect::new(0, 0, 7, 1);
333        register(&mut harness, area);
334
335        harness.mouse_move(2, 0);
336        let _ = interact(&mut harness, area);
337        let _ = interact(&mut harness, area);
338
339        assert_eq!(harness.interaction().hovered(), Some(Id::Save));
340    }
341
342    #[test]
343    fn distinct_ids_track_independently() {
344        let mut harness = WidgetHarness::<Id>::new(10, 3);
345        let save_area = Rect::new(0, 0, 4, 1);
346        let cancel_area = Rect::new(5, 0, 4, 1);
347        let frame = |harness: &mut WidgetHarness<Id>| {
348            harness.step(|ui| {
349                let save = ui
350                    .interaction()
351                    .interact(save_area, Id::Save, Sense::click());
352                let cancel = ui
353                    .interaction()
354                    .interact(cancel_area, Id::Cancel, Sense::click());
355                (save, cancel)
356            })
357        };
358        let _ = frame(&mut harness);
359
360        harness.click(6, 0);
361        let _ = frame(&mut harness);
362        let (save, cancel) = frame(&mut harness);
363
364        assert!(!save.clicked());
365        assert!(cancel.clicked());
366    }
367}