tui_dispatch_core/testing.rs
1//! Test utilities for tui-dispatch applications
2//!
3//! This module provides helpers for testing TUI applications built with tui-dispatch:
4//!
5//! - [`key`]: Create `KeyEvent` from string (e.g., `key("ctrl+p")`)
6//! - [`key_events`]: Create multiple `Event`s from space-separated key string
7//! - [`TestHarness`]: Generic test harness with action channel and state management
8//! - [`ActionAssertions`]: Fluent assertion trait for action vectors
9//! - Assertion macros for verifying emitted actions
10//!
11//! # Example
12//!
13//! ```ignore
14//! use tui_dispatch::testing::{key, TestHarness, ActionAssertions};
15//!
16//! #[derive(Clone, Debug, PartialEq)]
17//! enum Action {
18//! Increment,
19//! Decrement,
20//! }
21//!
22//! let mut harness = TestHarness::<i32, Action>::new(0);
23//!
24//! // Emit and check actions with fluent API
25//! harness.emit(Action::Decrement);
26//! harness.emit(Action::Increment);
27//! let emitted = harness.drain_emitted();
28//! emitted.assert_first(Action::Decrement);
29//! emitted.assert_contains(Action::Increment);
30//! emitted.assert_count(2);
31//! ```
32
33use std::fmt::Debug;
34
35use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
36use tokio::sync::mpsc;
37
38use crate::event::{ComponentId, Event, EventContext, EventKind};
39use crate::keybindings::parse_key_string;
40use crate::{Action, ActionCategory};
41
42// ============================================================================
43// Fluent Action Assertions
44// ============================================================================
45
46/// Fluent assertion trait for action vectors.
47///
48/// This trait only requires `Debug`, making it usable with any action type.
49/// For equality-based assertions (like `assert_first`), use [`ActionAssertionsEq`].
50///
51/// # Example
52///
53/// ```ignore
54/// use tui_dispatch::testing::ActionAssertions;
55///
56/// let actions = harness.drain_emitted();
57/// actions.assert_not_empty();
58/// actions.assert_count(3);
59/// actions.assert_any_matches(|a| matches!(a, Action::SelectKey(i) if *i > 0));
60/// ```
61pub trait ActionAssertions<A> {
62 /// Assert that the vector is empty.
63 ///
64 /// # Panics
65 /// Panics if the vector is not empty.
66 fn assert_empty(&self);
67
68 /// Assert that the vector is not empty.
69 ///
70 /// # Panics
71 /// Panics if the vector is empty.
72 fn assert_not_empty(&self);
73
74 /// Assert that the vector has exactly `n` elements.
75 ///
76 /// # Panics
77 /// Panics if the count doesn't match.
78 fn assert_count(&self, n: usize);
79
80 /// Assert that the first action matches a predicate.
81 ///
82 /// # Panics
83 /// Panics if the vector is empty or the predicate returns false.
84 fn assert_first_matches<F: Fn(&A) -> bool>(&self, f: F);
85
86 /// Assert that any action matches a predicate.
87 ///
88 /// # Panics
89 /// Panics if no action matches the predicate.
90 fn assert_any_matches<F: Fn(&A) -> bool>(&self, f: F);
91
92 /// Assert that all actions match a predicate.
93 ///
94 /// # Panics
95 /// Panics if any action doesn't match the predicate.
96 fn assert_all_match<F: Fn(&A) -> bool>(&self, f: F);
97
98 /// Assert that no action matches a predicate.
99 ///
100 /// # Panics
101 /// Panics if any action matches the predicate.
102 fn assert_none_match<F: Fn(&A) -> bool>(&self, f: F);
103}
104
105/// Equality-based assertions for action vectors.
106///
107/// This trait requires `PartialEq` for equality comparisons.
108/// For predicate-based assertions that don't need `PartialEq`, use [`ActionAssertions`].
109///
110/// # Example
111///
112/// ```ignore
113/// use tui_dispatch::testing::ActionAssertionsEq;
114///
115/// let actions = harness.drain_emitted();
116/// actions.assert_first(Action::StartSearch);
117/// actions.assert_contains(Action::SelectKey(42));
118/// ```
119pub trait ActionAssertionsEq<A> {
120 /// Assert that the first action equals the expected value.
121 ///
122 /// # Panics
123 /// Panics if the vector is empty or the first action doesn't match.
124 fn assert_first(&self, expected: A);
125
126 /// Assert that the last action equals the expected value.
127 ///
128 /// # Panics
129 /// Panics if the vector is empty or the last action doesn't match.
130 fn assert_last(&self, expected: A);
131
132 /// Assert that the vector contains the expected action.
133 ///
134 /// # Panics
135 /// Panics if no action matches the expected value.
136 fn assert_contains(&self, expected: A);
137
138 /// Assert that the vector does not contain the expected action.
139 ///
140 /// # Panics
141 /// Panics if any action matches the expected value.
142 fn assert_not_contains(&self, expected: A);
143}
144
145// ActionAssertions impl for Vec - only requires Debug
146impl<A: Debug> ActionAssertions<A> for Vec<A> {
147 fn assert_empty(&self) {
148 assert!(
149 self.is_empty(),
150 "Expected no actions to be emitted, but got: {:?}",
151 self
152 );
153 }
154
155 fn assert_not_empty(&self) {
156 assert!(
157 !self.is_empty(),
158 "Expected actions to be emitted, but got none"
159 );
160 }
161
162 fn assert_count(&self, n: usize) {
163 assert_eq!(
164 self.len(),
165 n,
166 "Expected {} action(s), got {}: {:?}",
167 n,
168 self.len(),
169 self
170 );
171 }
172
173 fn assert_first_matches<F: Fn(&A) -> bool>(&self, f: F) {
174 assert!(
175 !self.is_empty(),
176 "Expected first action to match predicate, but no actions were emitted"
177 );
178 assert!(
179 f(&self[0]),
180 "Expected first action to match predicate, got: {:?}",
181 self[0]
182 );
183 }
184
185 fn assert_any_matches<F: Fn(&A) -> bool>(&self, f: F) {
186 assert!(
187 self.iter().any(&f),
188 "Expected any action to match predicate, but none did: {:?}",
189 self
190 );
191 }
192
193 fn assert_all_match<F: Fn(&A) -> bool>(&self, f: F) {
194 for (i, action) in self.iter().enumerate() {
195 assert!(
196 f(action),
197 "Expected all actions to match predicate, but action at index {} didn't: {:?}",
198 i,
199 action
200 );
201 }
202 }
203
204 fn assert_none_match<F: Fn(&A) -> bool>(&self, f: F) {
205 for (i, action) in self.iter().enumerate() {
206 assert!(
207 !f(action),
208 "Expected no action to match predicate, but action at index {} matched: {:?}",
209 i,
210 action
211 );
212 }
213 }
214}
215
216// ActionAssertionsEq impl for Vec - requires PartialEq + Debug
217impl<A: PartialEq + Debug> ActionAssertionsEq<A> for Vec<A> {
218 fn assert_first(&self, expected: A) {
219 assert!(
220 !self.is_empty(),
221 "Expected first action to be {:?}, but no actions were emitted",
222 expected
223 );
224 assert_eq!(
225 &self[0], &expected,
226 "Expected first action to be {:?}, got {:?}",
227 expected, self[0]
228 );
229 }
230
231 fn assert_last(&self, expected: A) {
232 assert!(
233 !self.is_empty(),
234 "Expected last action to be {:?}, but no actions were emitted",
235 expected
236 );
237 let last = self.last().unwrap();
238 assert_eq!(
239 last, &expected,
240 "Expected last action to be {:?}, got {:?}",
241 expected, last
242 );
243 }
244
245 fn assert_contains(&self, expected: A) {
246 assert!(
247 self.iter().any(|a| a == &expected),
248 "Expected actions to contain {:?}, but got: {:?}",
249 expected,
250 self
251 );
252 }
253
254 fn assert_not_contains(&self, expected: A) {
255 assert!(
256 !self.iter().any(|a| a == &expected),
257 "Expected actions NOT to contain {:?}, but it was found in: {:?}",
258 expected,
259 self
260 );
261 }
262}
263
264// ActionAssertions impl for slices - only requires Debug
265impl<A: Debug> ActionAssertions<A> for [A] {
266 fn assert_empty(&self) {
267 assert!(
268 self.is_empty(),
269 "Expected no actions to be emitted, but got: {:?}",
270 self
271 );
272 }
273
274 fn assert_not_empty(&self) {
275 assert!(
276 !self.is_empty(),
277 "Expected actions to be emitted, but got none"
278 );
279 }
280
281 fn assert_count(&self, n: usize) {
282 assert_eq!(
283 self.len(),
284 n,
285 "Expected {} action(s), got {}: {:?}",
286 n,
287 self.len(),
288 self
289 );
290 }
291
292 fn assert_first_matches<F: Fn(&A) -> bool>(&self, f: F) {
293 assert!(
294 !self.is_empty(),
295 "Expected first action to match predicate, but no actions were emitted"
296 );
297 assert!(
298 f(&self[0]),
299 "Expected first action to match predicate, got: {:?}",
300 self[0]
301 );
302 }
303
304 fn assert_any_matches<F: Fn(&A) -> bool>(&self, f: F) {
305 assert!(
306 self.iter().any(&f),
307 "Expected any action to match predicate, but none did: {:?}",
308 self
309 );
310 }
311
312 fn assert_all_match<F: Fn(&A) -> bool>(&self, f: F) {
313 for (i, action) in self.iter().enumerate() {
314 assert!(
315 f(action),
316 "Expected all actions to match predicate, but action at index {} didn't: {:?}",
317 i,
318 action
319 );
320 }
321 }
322
323 fn assert_none_match<F: Fn(&A) -> bool>(&self, f: F) {
324 for (i, action) in self.iter().enumerate() {
325 assert!(
326 !f(action),
327 "Expected no action to match predicate, but action at index {} matched: {:?}",
328 i,
329 action
330 );
331 }
332 }
333}
334
335// ActionAssertionsEq impl for slices - requires PartialEq + Debug
336impl<A: PartialEq + Debug> ActionAssertionsEq<A> for [A] {
337 fn assert_first(&self, expected: A) {
338 assert!(
339 !self.is_empty(),
340 "Expected first action to be {:?}, but no actions were emitted",
341 expected
342 );
343 assert_eq!(
344 &self[0], &expected,
345 "Expected first action to be {:?}, got {:?}",
346 expected, self[0]
347 );
348 }
349
350 fn assert_last(&self, expected: A) {
351 assert!(
352 !self.is_empty(),
353 "Expected last action to be {:?}, but no actions were emitted",
354 expected
355 );
356 let last = self.last().unwrap();
357 assert_eq!(
358 last, &expected,
359 "Expected last action to be {:?}, got {:?}",
360 expected, last
361 );
362 }
363
364 fn assert_contains(&self, expected: A) {
365 assert!(
366 self.iter().any(|a| a == &expected),
367 "Expected actions to contain {:?}, but got: {:?}",
368 expected,
369 self
370 );
371 }
372
373 fn assert_not_contains(&self, expected: A) {
374 assert!(
375 !self.iter().any(|a| a == &expected),
376 "Expected actions NOT to contain {:?}, but it was found in: {:?}",
377 expected,
378 self
379 );
380 }
381}
382
383// ============================================================================
384// Key Event Helpers
385// ============================================================================
386
387/// Create a `KeyEvent` from a key string.
388///
389/// This is a convenience wrapper around [`parse_key_string`] that panics
390/// if the key string is invalid, making it suitable for use in tests.
391///
392/// # Examples
393///
394/// ```
395/// use tui_dispatch_core::testing::key;
396/// use crossterm::event::{KeyCode, KeyModifiers};
397///
398/// let k = key("q");
399/// assert_eq!(k.code, KeyCode::Char('q'));
400///
401/// let k = key("ctrl+p");
402/// assert_eq!(k.code, KeyCode::Char('p'));
403/// assert!(k.modifiers.contains(KeyModifiers::CONTROL));
404///
405/// let k = key("shift+tab");
406/// assert_eq!(k.code, KeyCode::BackTab);
407/// ```
408///
409/// # Panics
410///
411/// Panics if the key string cannot be parsed.
412pub fn key(s: &str) -> KeyEvent {
413 parse_key_string(s).unwrap_or_else(|| panic!("Invalid key string: {:?}", s))
414}
415
416/// Create a `KeyEvent` for a character with no modifiers.
417///
418/// # Examples
419///
420/// ```
421/// use tui_dispatch_core::testing::char_key;
422/// use crossterm::event::KeyCode;
423///
424/// let k = char_key('x');
425/// assert_eq!(k.code, KeyCode::Char('x'));
426/// ```
427pub fn char_key(c: char) -> KeyEvent {
428 KeyEvent {
429 code: KeyCode::Char(c),
430 modifiers: KeyModifiers::empty(),
431 kind: crossterm::event::KeyEventKind::Press,
432 state: crossterm::event::KeyEventState::empty(),
433 }
434}
435
436/// Create a `KeyEvent` for a character with Ctrl modifier.
437pub fn ctrl_key(c: char) -> KeyEvent {
438 KeyEvent {
439 code: KeyCode::Char(c),
440 modifiers: KeyModifiers::CONTROL,
441 kind: crossterm::event::KeyEventKind::Press,
442 state: crossterm::event::KeyEventState::empty(),
443 }
444}
445
446/// Create a `KeyEvent` for a character with Alt modifier.
447pub fn alt_key(c: char) -> KeyEvent {
448 KeyEvent {
449 code: KeyCode::Char(c),
450 modifiers: KeyModifiers::ALT,
451 kind: crossterm::event::KeyEventKind::Press,
452 state: crossterm::event::KeyEventState::empty(),
453 }
454}
455
456/// Create an `Event<C>` containing a key event from a key string.
457///
458/// This is useful for testing component `handle_event` methods.
459///
460/// # Examples
461///
462/// ```ignore
463/// use tui_dispatch::testing::key_event;
464///
465/// let event = key_event::<MyComponentId>("ctrl+p");
466/// let actions = component.handle_event(&event, props);
467/// ```
468pub fn key_event<C: ComponentId>(s: &str) -> Event<C> {
469 Event {
470 kind: EventKind::Key(key(s)),
471 context: EventContext::default(),
472 }
473}
474
475/// Create an `Event<C>` from a `KeyEvent`.
476///
477/// # Examples
478///
479/// ```ignore
480/// use tui_dispatch::testing::{key, into_event};
481///
482/// let k = key("enter");
483/// let event = into_event::<MyComponentId>(k);
484/// ```
485pub fn into_event<C: ComponentId>(key_event: KeyEvent) -> Event<C> {
486 Event {
487 kind: EventKind::Key(key_event),
488 context: EventContext::default(),
489 }
490}
491
492/// Create multiple `Event<C>` from a space-separated key string.
493///
494/// This is useful for simulating key sequences in tests.
495///
496/// # Examples
497///
498/// ```ignore
499/// use tui_dispatch::testing::key_events;
500///
501/// // Single key
502/// let events = key_events::<MyComponentId>("ctrl+p");
503/// assert_eq!(events.len(), 1);
504///
505/// // Multiple keys separated by spaces
506/// let events = key_events::<MyComponentId>("ctrl+p down down enter");
507/// assert_eq!(events.len(), 4);
508///
509/// // Type characters
510/// let events = key_events::<MyComponentId>("h e l l o");
511/// assert_eq!(events.len(), 5);
512/// ```
513///
514/// # Panics
515///
516/// Panics if any key string in the sequence cannot be parsed.
517pub fn key_events<C: ComponentId>(keys: &str) -> Vec<Event<C>> {
518 keys.split_whitespace().map(|k| key_event::<C>(k)).collect()
519}
520
521/// Parse multiple key strings into `KeyEvent`s.
522///
523/// Similar to [`key_events`] but returns raw `KeyEvent`s instead of `Event<C>`.
524///
525/// # Examples
526///
527/// ```
528/// use tui_dispatch_core::testing::keys;
529/// use crossterm::event::KeyCode;
530///
531/// let key_events = keys("ctrl+c esc enter");
532/// assert_eq!(key_events.len(), 3);
533/// assert_eq!(key_events[0].code, KeyCode::Char('c'));
534/// assert_eq!(key_events[1].code, KeyCode::Esc);
535/// assert_eq!(key_events[2].code, KeyCode::Enter);
536/// ```
537pub fn keys(key_str: &str) -> Vec<KeyEvent> {
538 key_str.split_whitespace().map(key).collect()
539}
540
541/// Generic test harness for tui-dispatch applications.
542///
543/// Provides:
544/// - State management with a simple `state` field
545/// - Action channel for capturing emitted actions
546/// - Helper methods for dispatching and draining actions
547///
548/// # Type Parameters
549///
550/// - `S`: The state type
551/// - `A`: The action type (must implement [`Action`])
552///
553/// # Example
554///
555/// ```ignore
556/// use tui_dispatch::testing::TestHarness;
557///
558/// #[derive(Clone, Debug, PartialEq)]
559/// enum MyAction { Foo, Bar(i32) }
560///
561/// let mut harness = TestHarness::<MyState, MyAction>::new(MyState::default());
562///
563/// // Emit actions (simulating what handlers would do)
564/// harness.emit(MyAction::Foo);
565/// harness.emit(MyAction::Bar(42));
566///
567/// // Drain and verify
568/// let actions = harness.drain_emitted();
569/// assert_eq!(actions.len(), 2);
570/// ```
571pub struct TestHarness<S, A: Action> {
572 /// The application state under test
573 pub state: S,
574 /// Sender for emitting actions
575 tx: mpsc::UnboundedSender<A>,
576 /// Receiver for draining emitted actions
577 rx: mpsc::UnboundedReceiver<A>,
578}
579
580impl<S, A: Action> TestHarness<S, A> {
581 /// Create a new test harness with the given initial state.
582 pub fn new(state: S) -> Self {
583 let (tx, rx) = mpsc::unbounded_channel();
584 Self { state, tx, rx }
585 }
586
587 /// Get a clone of the action sender for passing to handlers.
588 pub fn sender(&self) -> mpsc::UnboundedSender<A> {
589 self.tx.clone()
590 }
591
592 /// Emit an action (simulates what a handler would do).
593 pub fn emit(&self, action: A) {
594 let _ = self.tx.send(action);
595 }
596
597 /// Drain all emitted actions from the channel.
598 pub fn drain_emitted(&mut self) -> Vec<A> {
599 let mut actions = Vec::new();
600 while let Ok(action) = self.rx.try_recv() {
601 actions.push(action);
602 }
603 actions
604 }
605
606 /// Check if any actions were emitted.
607 pub fn has_emitted(&mut self) -> bool {
608 !self.drain_emitted().is_empty()
609 }
610
611 /// Simulate async action completion (semantic alias for [`Self::emit`]).
612 ///
613 /// Use this when simulating backend responses or async operation results.
614 ///
615 /// # Example
616 ///
617 /// ```ignore
618 /// use tui_dispatch::testing::TestHarness;
619 ///
620 /// harness.complete_action(Action::DidConnect { id: "conn-1", .. });
621 /// harness.complete_action(Action::DidScanKeys { keys: vec!["foo", "bar"] });
622 /// ```
623 pub fn complete_action(&self, action: A) {
624 self.emit(action);
625 }
626
627 /// Simulate multiple async action completions.
628 ///
629 /// # Example
630 ///
631 /// ```ignore
632 /// harness.complete_actions([
633 /// Action::DidConnect { id: "conn-1" },
634 /// Action::DidLoadValue { key: "foo", value: "bar" },
635 /// ]);
636 /// ```
637 pub fn complete_actions(&self, actions: impl IntoIterator<Item = A>) {
638 for action in actions {
639 self.emit(action);
640 }
641 }
642
643 /// Send a sequence of key events and collect actions from a handler.
644 ///
645 /// Parses the space-separated key string and calls the handler for each event,
646 /// collecting all returned actions.
647 ///
648 /// # Example
649 ///
650 /// ```ignore
651 /// use tui_dispatch::testing::TestHarness;
652 ///
653 /// let mut harness = TestHarness::<AppState, Action>::new(AppState::default());
654 ///
655 /// // Send key sequence and collect actions
656 /// let actions = harness.send_keys("ctrl+p down down enter", |state, event| {
657 /// component.handle_event(&event.kind, ComponentProps { state })
658 /// });
659 ///
660 /// actions.assert_contains(Action::SelectItem(2));
661 /// ```
662 pub fn send_keys<C, H>(&mut self, keys: &str, mut handler: H) -> Vec<A>
663 where
664 C: ComponentId,
665 H: FnMut(&mut S, Event<C>) -> Vec<A>,
666 {
667 let events = key_events::<C>(keys);
668 let mut all_actions = Vec::new();
669 for event in events {
670 let actions = handler(&mut self.state, event);
671 all_actions.extend(actions);
672 }
673 all_actions
674 }
675
676 /// Send a sequence of key events, calling handler and emitting returned actions.
677 ///
678 /// Unlike [`Self::send_keys`], this method emits returned actions to the harness channel,
679 /// allowing you to drain them later.
680 ///
681 /// # Example
682 ///
683 /// ```ignore
684 /// harness.send_keys_emit("ctrl+p down enter", |state, event| {
685 /// component.handle_event(&event.kind, props)
686 /// });
687 ///
688 /// let actions = harness.drain_emitted();
689 /// actions.assert_contains(Action::Confirm);
690 /// ```
691 pub fn send_keys_emit<C, H>(&mut self, keys: &str, mut handler: H)
692 where
693 C: ComponentId,
694 H: FnMut(&mut S, Event<C>) -> Vec<A>,
695 {
696 let events = key_events::<C>(keys);
697 for event in events {
698 let actions = handler(&mut self.state, event);
699 for action in actions {
700 self.emit(action);
701 }
702 }
703 }
704}
705
706impl<S: Default, A: Action> Default for TestHarness<S, A> {
707 fn default() -> Self {
708 Self::new(S::default())
709 }
710}
711
712/// Category-aware methods for TestHarness.
713///
714/// These methods are available when the action type implements [`ActionCategory`],
715/// enabling filtering and assertions by action category.
716impl<S, A: ActionCategory> TestHarness<S, A> {
717 /// Drain all emitted actions that belong to a specific category.
718 ///
719 /// Actions not matching the category remain in the channel for later draining.
720 ///
721 /// # Example
722 ///
723 /// ```ignore
724 /// use tui_dispatch::testing::TestHarness;
725 ///
726 /// let mut harness = TestHarness::<MyState, MyAction>::new(MyState::default());
727 ///
728 /// // Emit various actions
729 /// harness.emit(MyAction::SearchStart);
730 /// harness.emit(MyAction::ConnectionFormOpen);
731 /// harness.emit(MyAction::SearchClear);
732 ///
733 /// // Drain only search-related actions
734 /// let search_actions = harness.drain_category("search");
735 /// assert_eq!(search_actions.len(), 2);
736 ///
737 /// // Other actions remain
738 /// let remaining = harness.drain_emitted();
739 /// assert_eq!(remaining.len(), 1);
740 /// ```
741 pub fn drain_category(&mut self, category: &str) -> Vec<A> {
742 let all = self.drain_emitted();
743 let mut matching = Vec::new();
744 let mut non_matching = Vec::new();
745
746 for action in all {
747 if action.category() == Some(category) {
748 matching.push(action);
749 } else {
750 non_matching.push(action);
751 }
752 }
753
754 // Re-emit non-matching actions
755 for action in non_matching {
756 let _ = self.tx.send(action);
757 }
758
759 matching
760 }
761
762 /// Check if any action of the given category was emitted.
763 ///
764 /// This drains only the matching category, leaving other actions in the channel.
765 pub fn has_category(&mut self, category: &str) -> bool {
766 !self.drain_category(category).is_empty()
767 }
768}
769
770/// Assert that a specific action was emitted.
771///
772/// # Example
773///
774/// ```ignore
775/// use tui_dispatch::testing::assert_emitted;
776///
777/// let actions = harness.drain_emitted();
778/// assert_emitted!(actions, Action::Increment);
779/// assert_emitted!(actions, Action::SetValue(42));
780/// ```
781#[macro_export]
782macro_rules! assert_emitted {
783 ($actions:expr, $pattern:pat $(if $guard:expr)?) => {
784 assert!(
785 $actions.iter().any(|a| matches!(a, $pattern $(if $guard)?)),
786 "Expected action matching `{}` to be emitted, but got: {:?}",
787 stringify!($pattern),
788 $actions
789 );
790 };
791}
792
793/// Assert that a specific action was NOT emitted.
794///
795/// # Example
796///
797/// ```ignore
798/// use tui_dispatch::testing::assert_not_emitted;
799///
800/// let actions = harness.drain_emitted();
801/// assert_not_emitted!(actions, Action::Quit);
802/// ```
803#[macro_export]
804macro_rules! assert_not_emitted {
805 ($actions:expr, $pattern:pat $(if $guard:expr)?) => {
806 assert!(
807 !$actions.iter().any(|a| matches!(a, $pattern $(if $guard)?)),
808 "Expected action matching `{}` NOT to be emitted, but it was: {:?}",
809 stringify!($pattern),
810 $actions
811 );
812 };
813}
814
815/// Find and return the first action matching a pattern.
816///
817/// # Example
818///
819/// ```ignore
820/// use tui_dispatch::testing::find_emitted;
821///
822/// let actions = harness.drain_emitted();
823/// if let Some(Action::SetValue(v)) = find_emitted!(actions, Action::SetValue(_)) {
824/// assert_eq!(*v, 42);
825/// }
826/// ```
827#[macro_export]
828macro_rules! find_emitted {
829 ($actions:expr, $pattern:pat $(if $guard:expr)?) => {
830 $actions.iter().find(|a| matches!(a, $pattern $(if $guard)?))
831 };
832}
833
834/// Count how many actions match a pattern.
835///
836/// # Example
837///
838/// ```ignore
839/// use tui_dispatch::testing::count_emitted;
840///
841/// let actions = harness.drain_emitted();
842/// assert_eq!(count_emitted!(actions, Action::Tick), 3);
843/// ```
844#[macro_export]
845macro_rules! count_emitted {
846 ($actions:expr, $pattern:pat $(if $guard:expr)?) => {
847 $actions.iter().filter(|a| matches!(a, $pattern $(if $guard)?)).count()
848 };
849}
850
851/// Assert that an action of a specific category was emitted.
852///
853/// This requires the action type to implement [`ActionCategory`].
854///
855/// # Example
856///
857/// ```ignore
858/// use tui_dispatch::testing::assert_category_emitted;
859///
860/// let actions = harness.drain_emitted();
861/// assert_category_emitted!(actions, "search");
862/// assert_category_emitted!(actions, "connection_form");
863/// ```
864#[macro_export]
865macro_rules! assert_category_emitted {
866 ($actions:expr, $category:expr) => {
867 assert!(
868 $actions.iter().any(|a| {
869 use $crate::ActionCategory;
870 a.category() == Some($category)
871 }),
872 "Expected action with category `{}` to be emitted, but got: {:?}",
873 $category,
874 $actions
875 );
876 };
877}
878
879/// Assert that NO action of a specific category was emitted.
880///
881/// This requires the action type to implement [`ActionCategory`].
882///
883/// # Example
884///
885/// ```ignore
886/// use tui_dispatch::testing::assert_category_not_emitted;
887///
888/// let actions = harness.drain_emitted();
889/// assert_category_not_emitted!(actions, "search");
890/// ```
891#[macro_export]
892macro_rules! assert_category_not_emitted {
893 ($actions:expr, $category:expr) => {
894 assert!(
895 !$actions.iter().any(|a| {
896 use $crate::ActionCategory;
897 a.category() == Some($category)
898 }),
899 "Expected NO action with category `{}` to be emitted, but found: {:?}",
900 $category,
901 $actions
902 .iter()
903 .filter(|a| {
904 use $crate::ActionCategory;
905 a.category() == Some($category)
906 })
907 .collect::<Vec<_>>()
908 );
909 };
910}
911
912/// Count how many actions belong to a specific category.
913///
914/// This requires the action type to implement [`ActionCategory`].
915///
916/// # Example
917///
918/// ```ignore
919/// use tui_dispatch::testing::count_category;
920///
921/// let actions = harness.drain_emitted();
922/// assert_eq!(count_category!(actions, "search"), 3);
923/// ```
924#[macro_export]
925macro_rules! count_category {
926 ($actions:expr, $category:expr) => {{
927 use $crate::ActionCategory;
928 $actions
929 .iter()
930 .filter(|a| a.category() == Some($category))
931 .count()
932 }};
933}
934
935// ============================================================================
936// State Assertions
937// ============================================================================
938
939/// Assert that a field of the harness state has an expected value.
940///
941/// # Example
942///
943/// ```ignore
944/// use tui_dispatch::testing::{TestHarness, assert_state};
945///
946/// let harness = TestHarness::<AppState, Action>::new(AppState::default());
947/// assert_state!(harness, counter, 0);
948/// assert_state!(harness, ui.focused_panel, Panel::Keys);
949/// ```
950#[macro_export]
951macro_rules! assert_state {
952 ($harness:expr, $($field:tt).+, $expected:expr) => {
953 assert_eq!(
954 $harness.state.$($field).+,
955 $expected,
956 "Expected state.{} = {:?}, got {:?}",
957 stringify!($($field).+),
958 $expected,
959 $harness.state.$($field).+
960 );
961 };
962}
963
964/// Assert that a field of the harness state matches a pattern.
965///
966/// # Example
967///
968/// ```ignore
969/// use tui_dispatch::testing::{TestHarness, assert_state_matches};
970///
971/// assert_state_matches!(harness, connection_status, ConnectionStatus::Connected { .. });
972/// ```
973#[macro_export]
974macro_rules! assert_state_matches {
975 ($harness:expr, $($field:tt).+, $pattern:pat $(if $guard:expr)?) => {
976 assert!(
977 matches!($harness.state.$($field).+, $pattern $(if $guard)?),
978 "Expected state.{} to match `{}`, got {:?}",
979 stringify!($($field).+),
980 stringify!($pattern),
981 $harness.state.$($field).+
982 );
983 };
984}
985
986// ============================================================================
987// Render Harness
988// ============================================================================
989
990use ratatui::backend::{Backend, TestBackend};
991use ratatui::buffer::Buffer;
992use ratatui::Terminal;
993
994/// Test harness for capturing rendered output.
995///
996/// Provides utilities for rendering components to a test buffer and
997/// converting the output to strings for snapshot testing.
998///
999/// # Example
1000///
1001/// ```ignore
1002/// use tui_dispatch::testing::RenderHarness;
1003///
1004/// let mut render = RenderHarness::new(80, 24);
1005///
1006/// // Render a component
1007/// let output = render.render_to_string(|frame| {
1008/// my_component.render(frame, frame.area(), props);
1009/// });
1010///
1011/// // Use with insta for snapshot testing
1012/// insta::assert_snapshot!(output);
1013/// ```
1014pub struct RenderHarness {
1015 terminal: Terminal<TestBackend>,
1016}
1017
1018impl RenderHarness {
1019 /// Create a new render harness with the specified dimensions.
1020 pub fn new(width: u16, height: u16) -> Self {
1021 let backend = TestBackend::new(width, height);
1022 let terminal = Terminal::new(backend).expect("Failed to create test terminal");
1023 Self { terminal }
1024 }
1025
1026 /// Render using the provided function and return the buffer.
1027 pub fn render<F>(&mut self, render_fn: F) -> &Buffer
1028 where
1029 F: FnOnce(&mut ratatui::Frame),
1030 {
1031 self.terminal
1032 .draw(render_fn)
1033 .expect("Failed to draw to test terminal");
1034 self.terminal.backend().buffer()
1035 }
1036
1037 /// Render and convert the buffer to a string representation.
1038 ///
1039 /// The output includes ANSI escape codes for colors and styles.
1040 pub fn render_to_string<F>(&mut self, render_fn: F) -> String
1041 where
1042 F: FnOnce(&mut ratatui::Frame),
1043 {
1044 let buffer = self.render(render_fn);
1045 buffer_to_string(buffer)
1046 }
1047
1048 /// Render and convert to a plain string (no ANSI codes).
1049 ///
1050 /// Useful for simple text assertions without style information.
1051 pub fn render_to_string_plain<F>(&mut self, render_fn: F) -> String
1052 where
1053 F: FnOnce(&mut ratatui::Frame),
1054 {
1055 let buffer = self.render(render_fn);
1056 buffer_to_string_plain(buffer)
1057 }
1058
1059 /// Get the current terminal size.
1060 pub fn size(&self) -> (u16, u16) {
1061 let area = self.terminal.backend().size().unwrap_or_default();
1062 (area.width, area.height)
1063 }
1064
1065 /// Resize the terminal.
1066 pub fn resize(&mut self, width: u16, height: u16) {
1067 self.terminal.backend_mut().resize(width, height);
1068 }
1069}
1070
1071/// Convert a ratatui Buffer to a string with ANSI escape codes.
1072///
1073/// Each cell's foreground color, background color, and modifiers are
1074/// converted to ANSI escape sequences.
1075pub fn buffer_to_string(buffer: &Buffer) -> String {
1076 use ratatui::style::{Color, Modifier};
1077 use std::fmt::Write;
1078
1079 let area = buffer.area();
1080 let mut result = String::new();
1081
1082 for y in area.top()..area.bottom() {
1083 for x in area.left()..area.right() {
1084 let cell = &buffer[(x, y)];
1085
1086 // Start with reset
1087 let _ = write!(result, "\x1b[0m");
1088
1089 // Foreground color
1090 match cell.fg {
1091 Color::Reset => {}
1092 Color::Black => result.push_str("\x1b[30m"),
1093 Color::Red => result.push_str("\x1b[31m"),
1094 Color::Green => result.push_str("\x1b[32m"),
1095 Color::Yellow => result.push_str("\x1b[33m"),
1096 Color::Blue => result.push_str("\x1b[34m"),
1097 Color::Magenta => result.push_str("\x1b[35m"),
1098 Color::Cyan => result.push_str("\x1b[36m"),
1099 Color::Gray => result.push_str("\x1b[37m"),
1100 Color::DarkGray => result.push_str("\x1b[90m"),
1101 Color::LightRed => result.push_str("\x1b[91m"),
1102 Color::LightGreen => result.push_str("\x1b[92m"),
1103 Color::LightYellow => result.push_str("\x1b[93m"),
1104 Color::LightBlue => result.push_str("\x1b[94m"),
1105 Color::LightMagenta => result.push_str("\x1b[95m"),
1106 Color::LightCyan => result.push_str("\x1b[96m"),
1107 Color::White => result.push_str("\x1b[97m"),
1108 Color::Rgb(r, g, b) => {
1109 let _ = write!(result, "\x1b[38;2;{};{};{}m", r, g, b);
1110 }
1111 Color::Indexed(i) => {
1112 let _ = write!(result, "\x1b[38;5;{}m", i);
1113 }
1114 }
1115
1116 // Background color
1117 match cell.bg {
1118 Color::Reset => {}
1119 Color::Black => result.push_str("\x1b[40m"),
1120 Color::Red => result.push_str("\x1b[41m"),
1121 Color::Green => result.push_str("\x1b[42m"),
1122 Color::Yellow => result.push_str("\x1b[43m"),
1123 Color::Blue => result.push_str("\x1b[44m"),
1124 Color::Magenta => result.push_str("\x1b[45m"),
1125 Color::Cyan => result.push_str("\x1b[46m"),
1126 Color::Gray => result.push_str("\x1b[47m"),
1127 Color::DarkGray => result.push_str("\x1b[100m"),
1128 Color::LightRed => result.push_str("\x1b[101m"),
1129 Color::LightGreen => result.push_str("\x1b[102m"),
1130 Color::LightYellow => result.push_str("\x1b[103m"),
1131 Color::LightBlue => result.push_str("\x1b[104m"),
1132 Color::LightMagenta => result.push_str("\x1b[105m"),
1133 Color::LightCyan => result.push_str("\x1b[106m"),
1134 Color::White => result.push_str("\x1b[107m"),
1135 Color::Rgb(r, g, b) => {
1136 let _ = write!(result, "\x1b[48;2;{};{};{}m", r, g, b);
1137 }
1138 Color::Indexed(i) => {
1139 let _ = write!(result, "\x1b[48;5;{}m", i);
1140 }
1141 }
1142
1143 // Modifiers
1144 if cell.modifier.contains(Modifier::BOLD) {
1145 result.push_str("\x1b[1m");
1146 }
1147 if cell.modifier.contains(Modifier::DIM) {
1148 result.push_str("\x1b[2m");
1149 }
1150 if cell.modifier.contains(Modifier::ITALIC) {
1151 result.push_str("\x1b[3m");
1152 }
1153 if cell.modifier.contains(Modifier::UNDERLINED) {
1154 result.push_str("\x1b[4m");
1155 }
1156 if cell.modifier.contains(Modifier::REVERSED) {
1157 result.push_str("\x1b[7m");
1158 }
1159 if cell.modifier.contains(Modifier::CROSSED_OUT) {
1160 result.push_str("\x1b[9m");
1161 }
1162
1163 result.push_str(cell.symbol());
1164 }
1165 result.push_str("\x1b[0m\n");
1166 }
1167
1168 result
1169}
1170
1171/// Convert a ratatui Buffer to a plain string (no ANSI codes).
1172///
1173/// Only extracts the text content, ignoring colors and styles.
1174pub fn buffer_to_string_plain(buffer: &Buffer) -> String {
1175 let area = buffer.area();
1176 let mut result = String::new();
1177
1178 for y in area.top()..area.bottom() {
1179 for x in area.left()..area.right() {
1180 let cell = &buffer[(x, y)];
1181 result.push_str(cell.symbol());
1182 }
1183 result.push('\n');
1184 }
1185
1186 result
1187}
1188
1189/// Convert a specific rect of a buffer to a plain string.
1190///
1191/// Useful for testing a specific region of the rendered output.
1192pub fn buffer_rect_to_string_plain(buffer: &Buffer, rect: ratatui::layout::Rect) -> String {
1193 let mut result = String::new();
1194
1195 for y in rect.top()..rect.bottom() {
1196 for x in rect.left()..rect.right() {
1197 if x < buffer.area().right() && y < buffer.area().bottom() {
1198 let cell = &buffer[(x, y)];
1199 result.push_str(cell.symbol());
1200 }
1201 }
1202 result.push('\n');
1203 }
1204
1205 result
1206}
1207
1208// ============================================================================
1209// Time Control (Feature-gated)
1210// ============================================================================
1211
1212/// Time control utilities for testing debounced actions.
1213///
1214/// These functions require the `testing-time` feature and must be used
1215/// within a `#[tokio::test]` context.
1216///
1217/// # Example
1218///
1219/// ```ignore
1220/// use tui_dispatch::testing::{pause_time, advance_time};
1221/// use std::time::Duration;
1222///
1223/// #[tokio::test]
1224/// async fn test_debounce() {
1225/// pause_time();
1226///
1227/// // Simulate typing with debounce
1228/// harness.send_keys("a b c", handler);
1229///
1230/// // Advance past debounce threshold
1231/// advance_time(Duration::from_millis(300)).await;
1232///
1233/// let actions = harness.drain_emitted();
1234/// assert_emitted!(actions, Action::DebouncedSearch { .. });
1235/// }
1236/// ```
1237#[cfg(feature = "testing-time")]
1238pub fn pause_time() {
1239 tokio::time::pause();
1240}
1241
1242/// Resume real-time execution after pausing.
1243#[cfg(feature = "testing-time")]
1244pub fn resume_time() {
1245 tokio::time::resume();
1246}
1247
1248/// Advance the paused clock by the specified duration.
1249///
1250/// Must be called after [`pause_time`].
1251#[cfg(feature = "testing-time")]
1252pub async fn advance_time(duration: std::time::Duration) {
1253 tokio::time::advance(duration).await;
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258 use super::*;
1259
1260 #[test]
1261 fn test_key_simple() {
1262 let k = key("q");
1263 assert_eq!(k.code, KeyCode::Char('q'));
1264 assert_eq!(k.modifiers, KeyModifiers::empty());
1265 }
1266
1267 #[test]
1268 fn test_key_with_ctrl() {
1269 let k = key("ctrl+p");
1270 assert_eq!(k.code, KeyCode::Char('p'));
1271 assert!(k.modifiers.contains(KeyModifiers::CONTROL));
1272 }
1273
1274 #[test]
1275 fn test_key_special() {
1276 let k = key("esc");
1277 assert_eq!(k.code, KeyCode::Esc);
1278
1279 let k = key("enter");
1280 assert_eq!(k.code, KeyCode::Enter);
1281
1282 let k = key("shift+tab");
1283 assert_eq!(k.code, KeyCode::BackTab);
1284 }
1285
1286 #[test]
1287 fn test_char_key() {
1288 let k = char_key('x');
1289 assert_eq!(k.code, KeyCode::Char('x'));
1290 assert_eq!(k.modifiers, KeyModifiers::empty());
1291 }
1292
1293 #[test]
1294 fn test_ctrl_key() {
1295 let k = ctrl_key('c');
1296 assert_eq!(k.code, KeyCode::Char('c'));
1297 assert!(k.modifiers.contains(KeyModifiers::CONTROL));
1298 }
1299
1300 #[derive(Clone, Debug, PartialEq)]
1301 enum TestAction {
1302 Foo,
1303 Bar(i32),
1304 }
1305
1306 impl crate::Action for TestAction {
1307 fn name(&self) -> &'static str {
1308 match self {
1309 TestAction::Foo => "Foo",
1310 TestAction::Bar(_) => "Bar",
1311 }
1312 }
1313 }
1314
1315 #[test]
1316 fn test_harness_emit_and_drain() {
1317 let mut harness = TestHarness::<(), TestAction>::new(());
1318
1319 harness.emit(TestAction::Foo);
1320 harness.emit(TestAction::Bar(42));
1321
1322 let actions = harness.drain_emitted();
1323 assert_eq!(actions.len(), 2);
1324 assert_eq!(actions[0], TestAction::Foo);
1325 assert_eq!(actions[1], TestAction::Bar(42));
1326
1327 // Drain again should be empty
1328 let actions = harness.drain_emitted();
1329 assert!(actions.is_empty());
1330 }
1331
1332 #[test]
1333 fn test_assert_macros() {
1334 let actions = vec![TestAction::Foo, TestAction::Bar(42)];
1335
1336 assert_emitted!(actions, TestAction::Foo);
1337 assert_emitted!(actions, TestAction::Bar(42));
1338 assert_emitted!(actions, TestAction::Bar(_));
1339
1340 assert_not_emitted!(actions, TestAction::Bar(99));
1341
1342 let found = find_emitted!(actions, TestAction::Bar(_));
1343 assert!(found.is_some());
1344
1345 let count = count_emitted!(actions, TestAction::Bar(_));
1346 assert_eq!(count, 1);
1347 }
1348
1349 // ActionAssertions trait tests
1350 #[test]
1351 fn test_action_assertions_first_last() {
1352 let actions = vec![TestAction::Foo, TestAction::Bar(42), TestAction::Bar(99)];
1353
1354 actions.assert_first(TestAction::Foo);
1355 actions.assert_last(TestAction::Bar(99));
1356 }
1357
1358 #[test]
1359 fn test_action_assertions_contains() {
1360 let actions = vec![TestAction::Foo, TestAction::Bar(42)];
1361
1362 actions.assert_contains(TestAction::Foo);
1363 actions.assert_contains(TestAction::Bar(42));
1364 actions.assert_not_contains(TestAction::Bar(99));
1365 }
1366
1367 #[test]
1368 fn test_action_assertions_empty() {
1369 let empty: Vec<TestAction> = vec![];
1370 let non_empty = vec![TestAction::Foo];
1371
1372 empty.assert_empty();
1373 non_empty.assert_not_empty();
1374 }
1375
1376 #[test]
1377 fn test_action_assertions_count() {
1378 let actions = vec![TestAction::Foo, TestAction::Bar(1), TestAction::Bar(2)];
1379 actions.assert_count(3);
1380 }
1381
1382 #[test]
1383 fn test_action_assertions_matches() {
1384 let actions = vec![TestAction::Foo, TestAction::Bar(42), TestAction::Bar(99)];
1385
1386 actions.assert_first_matches(|a| matches!(a, TestAction::Foo));
1387 actions.assert_any_matches(|a| matches!(a, TestAction::Bar(x) if *x > 50));
1388 actions.assert_all_match(|a| matches!(a, TestAction::Foo | TestAction::Bar(_)));
1389 actions.assert_none_match(|a| matches!(a, TestAction::Bar(0)));
1390 }
1391
1392 // key_events / keys tests
1393 #[test]
1394 fn test_keys_multiple() {
1395 let k = keys("a b c");
1396 assert_eq!(k.len(), 3);
1397 assert_eq!(k[0].code, KeyCode::Char('a'));
1398 assert_eq!(k[1].code, KeyCode::Char('b'));
1399 assert_eq!(k[2].code, KeyCode::Char('c'));
1400 }
1401
1402 #[test]
1403 fn test_keys_with_modifiers() {
1404 let k = keys("ctrl+c esc enter");
1405 assert_eq!(k.len(), 3);
1406 assert_eq!(k[0].code, KeyCode::Char('c'));
1407 assert!(k[0].modifiers.contains(KeyModifiers::CONTROL));
1408 assert_eq!(k[1].code, KeyCode::Esc);
1409 assert_eq!(k[2].code, KeyCode::Enter);
1410 }
1411
1412 // RenderHarness tests
1413 #[test]
1414 fn test_render_harness_new() {
1415 let harness = RenderHarness::new(80, 24);
1416 assert_eq!(harness.size(), (80, 24));
1417 }
1418
1419 #[test]
1420 fn test_render_harness_render_plain() {
1421 let mut harness = RenderHarness::new(10, 2);
1422 let output = harness.render_to_string_plain(|frame| {
1423 use ratatui::widgets::Paragraph;
1424 let p = Paragraph::new("Hello");
1425 frame.render_widget(p, frame.area());
1426 });
1427
1428 // Should contain "Hello" followed by spaces to fill the width
1429 assert!(output.starts_with("Hello"));
1430 }
1431
1432 #[test]
1433 fn test_render_harness_resize() {
1434 let mut harness = RenderHarness::new(80, 24);
1435 assert_eq!(harness.size(), (80, 24));
1436
1437 harness.resize(100, 30);
1438 assert_eq!(harness.size(), (100, 30));
1439 }
1440
1441 // complete_action tests
1442 #[test]
1443 fn test_complete_action() {
1444 let mut harness = TestHarness::<(), TestAction>::new(());
1445
1446 harness.complete_action(TestAction::Foo);
1447 harness.complete_actions([TestAction::Bar(1), TestAction::Bar(2)]);
1448
1449 let actions = harness.drain_emitted();
1450 assert_eq!(actions.len(), 3);
1451 actions.assert_first(TestAction::Foo);
1452 }
1453
1454 // assert_state! macro test
1455 #[derive(Default, Debug, PartialEq)]
1456 struct TestState {
1457 count: i32,
1458 name: String,
1459 }
1460
1461 #[test]
1462 fn test_assert_state_macro() {
1463 let harness = TestHarness::<TestState, TestAction>::new(TestState {
1464 count: 42,
1465 name: "test".to_string(),
1466 });
1467
1468 assert_state!(harness, count, 42);
1469 assert_state!(harness, name, "test".to_string());
1470 }
1471}