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}