Skip to main content

leviath_cli/tui/
mod.rs

1//! Seams shared by every Leviath terminal UI.
2//!
3//! A ratatui app has exactly two pieces that cannot run under `cargo test`:
4//! taking over the real terminal (raw mode + alternate screen + a
5//! `CrosstermBackend` on real stdout) and blocking on real keyboard input.
6//! [`TerminalSetup`] and [`EventSource`] abstract those two, so a UI's whole
7//! loop is unit-testable against a [`ratatui::backend::TestBackend`] and a
8//! canned event list while the real crossterm bindings live in the
9//! coverage-excluded `lev` binary.
10//!
11//! This started as `commands/dashboard`-private code. It moved here when the
12//! `lev setup` wizard became a second ratatui surface: both drive the same
13//! `CrosstermSetup` from `main.rs`, share [`theme`], and share the test doubles
14//! below.
15//!
16//! ## Why the test doubles live here, not in each UI's test module
17//!
18//! `cargo-llvm-cov` reports generic functions per *instantiation*. A UI loop
19//! generic over `B: Backend` that monomorphizes over two backend types gets two
20//! region reports, and any arm exercised in only one of them shows as partially
21//! covered. Keeping exactly one `TestEventSource` and one
22//! `TestBackendHarness` for the whole crate means each loop monomorphizes
23//! once, and both the success and the error arms of its `?`s land inside that
24//! single instantiation. Both doubles therefore carry an injectable-failure
25//! switch rather than having an always-failing sibling type.
26
27pub mod theme;
28
29use crossterm::event::Event;
30use ratatui::Terminal;
31use std::time::Duration;
32
33/// Abstracts "give me the next input event, or `None` if the poll timeout
34/// elapses" (i.e. `crossterm::event::poll` + `event::read`), so a UI's main
35/// loop can be driven by canned events in tests instead of blocking on a real
36/// terminal.
37pub trait EventSource {
38    fn poll_event(&mut self, timeout: Duration) -> std::io::Result<Option<Event>>;
39}
40
41/// Production [`EventSource`]: reads real terminal input via crossterm.
42/// Uses injectable function pointers for `poll` and `read` so the two
43/// branches of `poll_event` can be exercised in unit tests without a real
44/// TTY.  In production, construct via [`CrosstermEventSource::new`]. Wired
45/// into the real UIs only by the binary.
46pub struct CrosstermEventSource {
47    poll_fn: fn(Duration) -> std::io::Result<bool>,
48    read_fn: fn() -> std::io::Result<Event>,
49}
50
51#[allow(clippy::new_without_default)] // constructed only by the binary's real UI entrypoints
52impl CrosstermEventSource {
53    pub fn new() -> Self {
54        Self {
55            poll_fn: crossterm::event::poll,
56            read_fn: crossterm::event::read,
57        }
58    }
59}
60
61impl EventSource for CrosstermEventSource {
62    fn poll_event(&mut self, timeout: Duration) -> std::io::Result<Option<Event>> {
63        if (self.poll_fn)(timeout)? {
64            Ok(Some((self.read_fn)()?))
65        } else {
66            Ok(None)
67        }
68    }
69}
70
71/// Abstracts terminal setup/teardown so a UI's generic core can be tested with
72/// a [`ratatui::backend::TestBackend`] and no-op TTY operations. The real
73/// crossterm implementation (`CrosstermSetup`) lives in the binary, since it
74/// can only be exercised against a real terminal.
75pub trait TerminalSetup {
76    type B: ratatui::backend::Backend;
77    fn enable(&mut self) -> anyhow::Result<()>;
78    fn create_terminal(&mut self) -> anyhow::Result<Terminal<Self::B>>;
79    fn disable(&mut self);
80    fn print_done(&self);
81}
82
83// ─── Test doubles (shared crate-wide; see the module docs for why) ───────────
84
85#[cfg(test)]
86pub(crate) use test_doubles::*;
87
88#[cfg(test)]
89mod test_doubles {
90    use super::*;
91    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
92
93    /// Build a plain unmodified key-press event, the overwhelmingly common
94    /// shape in UI tests.
95    pub(crate) fn key(code: KeyCode) -> Event {
96        Event::Key(KeyEvent::new(code, KeyModifiers::empty()))
97    }
98
99    /// Build a key-press event carrying modifiers (`Ctrl-S`, `Shift-Tab`, …).
100    pub(crate) fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
101        Event::Key(KeyEvent::new(code, modifiers))
102    }
103
104    /// The crate's single test [`EventSource`]. Two modes, both reachable from
105    /// one type:
106    /// - scripted: yields a fixed sequence (one `Option<Event>` per
107    ///   `poll_event` call - `Some(e)` -> `Ok(Some(e))`, `None` -> `Ok(None)`,
108    ///   i.e. a simulated poll-timeout tick), then `None` forever once
109    ///   exhausted.
110    /// - failing (`fail = true`): every `poll_event` returns `Err`, to drive a
111    ///   loop's `?`-propagation path.
112    pub(crate) struct TestEventSource {
113        events: std::collections::VecDeque<Option<Event>>,
114        fail: bool,
115    }
116
117    impl TestEventSource {
118        /// Construct from a list of concrete events (all wrapped in `Some`).
119        pub(crate) fn new(events: Vec<Event>) -> Self {
120            Self {
121                events: events.into_iter().map(Some).collect(),
122                fail: false,
123            }
124        }
125
126        /// Construct from a list of `Option<Event>`, allowing explicit `None`
127        /// ticks (simulated poll timeouts with no input) to be interleaved.
128        pub(crate) fn new_with_nones(events: Vec<Option<Event>>) -> Self {
129            Self {
130                events: events.into(),
131                fail: false,
132            }
133        }
134
135        /// Construct a source whose `poll_event` always errors.
136        pub(crate) fn failing() -> Self {
137            Self {
138                events: std::collections::VecDeque::new(),
139                fail: true,
140            }
141        }
142    }
143
144    impl EventSource for TestEventSource {
145        fn poll_event(&mut self, _timeout: Duration) -> std::io::Result<Option<Event>> {
146            if self.fail {
147                return Err(std::io::Error::other("simulated event source failure"));
148            }
149            Ok(self.events.pop_front().flatten())
150        }
151    }
152
153    /// The crate's single test [`ratatui::backend::Backend`]: a thin wrapper
154    /// around a real [`ratatui::backend::TestBackend`] that adds a `fail_draw`
155    /// switch, so both the success and the `?`-error arms of a loop's
156    /// `terminal.draw(...)?` are exercised within the *same* instantiation.
157    pub(crate) struct TestBackendHarness {
158        inner: ratatui::backend::TestBackend,
159        fail_draw: bool,
160    }
161
162    impl TestBackendHarness {
163        pub(crate) fn new(width: u16, height: u16) -> Self {
164            Self {
165                inner: ratatui::backend::TestBackend::new(width, height),
166                fail_draw: false,
167            }
168        }
169
170        pub(crate) fn failing(width: u16, height: u16) -> Self {
171            Self {
172                inner: ratatui::backend::TestBackend::new(width, height),
173                fail_draw: true,
174            }
175        }
176
177        /// The cells last drawn, so a test can assert on what a user would
178        /// actually read rather than only that drawing did not panic.
179        pub(crate) fn buffer(&self) -> &ratatui::buffer::Buffer {
180            self.inner.buffer()
181        }
182
183        /// The drawn frame as newline-separated rows of text.
184        pub(crate) fn text(&self) -> String {
185            let buffer = self.buffer();
186            let width = buffer.area.width as usize;
187            buffer
188                .content
189                .chunks(width)
190                .map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
191                .collect::<Vec<_>>()
192                .join("\n")
193        }
194    }
195
196    /// ratatui 0.30's `TestBackend` is infallible (`Error = Infallible`);
197    /// the harness keeps `io::Error` so the fail-draw switch still exercises
198    /// the loops' error arms. `into_ok` converts the inner results: an
199    /// `Infallible` error is a proof no error exists, so the conversion has
200    /// no failure branch.
201    fn into_ok<T>(result: Result<T, std::convert::Infallible>) -> std::io::Result<T> {
202        match result {
203            Ok(value) => Ok(value),
204        }
205    }
206
207    impl ratatui::backend::Backend for TestBackendHarness {
208        type Error = std::io::Error;
209
210        fn draw<'a, I>(&mut self, content: I) -> std::io::Result<()>
211        where
212            I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>,
213        {
214            if self.fail_draw {
215                return Err(std::io::Error::other("simulated draw failure"));
216            }
217            into_ok(self.inner.draw(content))
218        }
219
220        fn hide_cursor(&mut self) -> std::io::Result<()> {
221            into_ok(self.inner.hide_cursor())
222        }
223        fn show_cursor(&mut self) -> std::io::Result<()> {
224            into_ok(self.inner.show_cursor())
225        }
226        fn get_cursor_position(&mut self) -> std::io::Result<ratatui::layout::Position> {
227            into_ok(self.inner.get_cursor_position())
228        }
229        fn set_cursor_position<P: Into<ratatui::layout::Position>>(
230            &mut self,
231            position: P,
232        ) -> std::io::Result<()> {
233            into_ok(self.inner.set_cursor_position(position))
234        }
235        fn clear(&mut self) -> std::io::Result<()> {
236            into_ok(self.inner.clear())
237        }
238        fn clear_region(&mut self, region: ratatui::backend::ClearType) -> std::io::Result<()> {
239            into_ok(self.inner.clear_region(region))
240        }
241        fn size(&self) -> std::io::Result<ratatui::layout::Size> {
242            into_ok(self.inner.size())
243        }
244        fn window_size(&mut self) -> std::io::Result<ratatui::backend::WindowSize> {
245            into_ok(self.inner.window_size())
246        }
247        fn flush(&mut self) -> std::io::Result<()> {
248            into_ok(self.inner.flush())
249        }
250    }
251
252    /// A ready-to-draw terminal over the shared test backend.
253    pub(crate) fn test_terminal() -> Terminal<TestBackendHarness> {
254        Terminal::new(TestBackendHarness::new(120, 40)).unwrap()
255    }
256
257    /// Test [`TerminalSetup`]: a [`TestBackendHarness`] terminal and no-op TTY
258    /// operations, so a UI's generic core monomorphizes only over test doubles
259    /// in the measured test build - never over the real `CrosstermBackend`,
260    /// which can't be driven under `cargo test`. The two `_should_fail` flags
261    /// drive the `setup.enable()?` and `setup.create_terminal()?` failure arms
262    /// deterministically.
263    pub(crate) struct TestSetup {
264        pub(crate) enable_should_fail: bool,
265        pub(crate) create_should_fail: bool,
266    }
267
268    impl TestSetup {
269        pub(crate) fn new() -> Self {
270            Self {
271                enable_should_fail: false,
272                create_should_fail: false,
273            }
274        }
275    }
276
277    impl TerminalSetup for TestSetup {
278        type B = TestBackendHarness;
279
280        fn enable(&mut self) -> anyhow::Result<()> {
281            if self.enable_should_fail {
282                anyhow::bail!("simulated enable failure");
283            }
284            Ok(())
285        }
286
287        fn create_terminal(&mut self) -> anyhow::Result<Terminal<Self::B>> {
288            if self.create_should_fail {
289                anyhow::bail!("simulated create_terminal failure");
290            }
291            Terminal::new(TestBackendHarness::new(80, 24)).map_err(anyhow::Error::from)
292        }
293
294        fn disable(&mut self) {}
295
296        fn print_done(&self) {}
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crossterm::event::KeyCode;
304
305    // ─── CrosstermEventSource ───────────────────────────────────────────────
306    //
307    // `poll_event` has four paths: poll-ready-then-read, poll-timeout, and the
308    // `?` error arm of each call. Injecting fn pointers exercises all four with
309    // no real TTY. The doubles are named fns reused across the tests rather
310    // than per-test closures, because a closure passed only to a test that
311    // never invokes it is itself an uncovered function.
312
313    fn poll_ready(_: Duration) -> std::io::Result<bool> {
314        Ok(true)
315    }
316    fn poll_timeout(_: Duration) -> std::io::Result<bool> {
317        Ok(false)
318    }
319    fn poll_fails(_: Duration) -> std::io::Result<bool> {
320        Err(std::io::Error::other("poll exploded"))
321    }
322    fn read_resize() -> std::io::Result<Event> {
323        Ok(Event::Resize(80, 24))
324    }
325    fn read_fails() -> std::io::Result<Event> {
326        Err(std::io::Error::other("read exploded"))
327    }
328
329    #[test]
330    fn crossterm_event_source_returns_the_read_event_when_poll_reports_ready() {
331        let mut source = CrosstermEventSource {
332            poll_fn: poll_ready,
333            read_fn: read_resize,
334        };
335
336        let event = source.poll_event(Duration::from_millis(1)).unwrap();
337
338        assert_eq!(event, Some(Event::Resize(80, 24)));
339    }
340
341    #[test]
342    fn crossterm_event_source_returns_none_when_poll_times_out() {
343        // `read_fn` is supplied but must never run: a timeout tick reports no
344        // event rather than reading one.
345        let mut source = CrosstermEventSource {
346            poll_fn: poll_timeout,
347            read_fn: read_resize,
348        };
349
350        let event = source.poll_event(Duration::from_millis(1)).unwrap();
351
352        assert!(event.is_none());
353    }
354
355    #[test]
356    fn crossterm_event_source_propagates_a_poll_error() {
357        let mut source = CrosstermEventSource {
358            poll_fn: poll_fails,
359            read_fn: read_resize,
360        };
361
362        let err = source.poll_event(Duration::from_millis(1)).unwrap_err();
363
364        assert!(err.to_string().contains("poll exploded"));
365    }
366
367    #[test]
368    fn crossterm_event_source_propagates_a_read_error() {
369        let mut source = CrosstermEventSource {
370            poll_fn: poll_ready,
371            read_fn: read_fails,
372        };
373
374        let err = source.poll_event(Duration::from_millis(1)).unwrap_err();
375
376        assert!(err.to_string().contains("read exploded"));
377    }
378
379    #[test]
380    fn crossterm_event_source_new_stores_the_real_crossterm_functions() {
381        // Taking a function's address never invokes it, so constructing the
382        // production source touches no real terminal state.
383        let _source = CrosstermEventSource::new();
384    }
385
386    #[test]
387    fn test_event_source_yields_scripted_events_then_none_forever() {
388        let mut source = TestEventSource::new(vec![key(KeyCode::Esc)]);
389
390        assert_eq!(
391            source.poll_event(Duration::from_millis(1)).unwrap(),
392            Some(key(KeyCode::Esc))
393        );
394        // Exhausted: every later poll is a timeout tick, not an error.
395        assert!(
396            source
397                .poll_event(Duration::from_millis(1))
398                .unwrap()
399                .is_none()
400        );
401        assert!(
402            source
403                .poll_event(Duration::from_millis(1))
404                .unwrap()
405                .is_none()
406        );
407    }
408
409    #[test]
410    fn test_event_source_interleaves_explicit_timeout_ticks() {
411        let mut source = TestEventSource::new_with_nones(vec![None, Some(key(KeyCode::Enter))]);
412
413        assert!(
414            source
415                .poll_event(Duration::from_millis(1))
416                .unwrap()
417                .is_none()
418        );
419        assert_eq!(
420            source.poll_event(Duration::from_millis(1)).unwrap(),
421            Some(key(KeyCode::Enter))
422        );
423    }
424
425    #[test]
426    fn test_event_source_failing_mode_errors_on_every_poll() {
427        let mut source = TestEventSource::failing();
428
429        assert!(source.poll_event(Duration::from_millis(1)).is_err());
430        assert!(source.poll_event(Duration::from_millis(1)).is_err());
431    }
432
433    #[test]
434    fn key_with_carries_its_modifiers() {
435        let event = key_with(KeyCode::Char('s'), crossterm::event::KeyModifiers::CONTROL);
436
437        assert_eq!(
438            event,
439            Event::Key(crossterm::event::KeyEvent::new(
440                KeyCode::Char('s'),
441                crossterm::event::KeyModifiers::CONTROL
442            ))
443        );
444        // …and the plain helper does not.
445        assert_ne!(event, key(KeyCode::Char('s')));
446    }
447
448    #[test]
449    fn test_backend_harness_draws_or_fails_on_demand() {
450        use ratatui::backend::Backend;
451
452        let mut ok = TestBackendHarness::new(10, 3);
453        assert!(ok.draw(std::iter::empty()).is_ok());
454        // Every non-draw method delegates to the inner TestBackend.
455        assert!(ok.hide_cursor().is_ok());
456        assert!(ok.show_cursor().is_ok());
457        assert!(ok.get_cursor_position().is_ok());
458        assert!(
459            ok.set_cursor_position(ratatui::layout::Position::new(0, 0))
460                .is_ok()
461        );
462        assert!(ok.clear().is_ok());
463        assert!(ok.clear_region(ratatui::backend::ClearType::All).is_ok());
464        assert!(ok.size().is_ok());
465        assert!(ok.window_size().is_ok());
466        assert!(ok.flush().is_ok());
467
468        let mut bad = TestBackendHarness::failing(10, 3);
469        assert!(bad.draw(std::iter::empty()).is_err());
470    }
471
472    #[test]
473    fn test_terminal_is_ready_to_draw() {
474        let mut terminal = test_terminal();
475        assert!(terminal.draw(|_| {}).is_ok());
476    }
477
478    #[test]
479    fn test_setup_succeeds_by_default_and_fails_when_switched() {
480        let mut setup = TestSetup::new();
481        assert!(setup.enable().is_ok());
482        assert!(setup.create_terminal().is_ok());
483        setup.disable();
484        setup.print_done();
485
486        let mut enable_fails = TestSetup {
487            enable_should_fail: true,
488            create_should_fail: false,
489        };
490        assert!(enable_fails.enable().is_err());
491
492        let mut create_fails = TestSetup {
493            enable_should_fail: false,
494            create_should_fail: true,
495        };
496        assert!(create_fails.create_terminal().is_err());
497    }
498}