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}