Skip to main content

telex/
testing.rs

1//! Testing utilities for Telex components.
2//!
3//! Provides `TestApp` for testing components without a real terminal.
4//!
5//! # Example
6//! ```rust,ignore
7//! use telex::testing::TestApp;
8//! use telex::prelude::*;
9//!
10//! #[test]
11//! fn counter_increments() {
12//!     let mut app = TestApp::new(|cx| {
13//!         let count = state!(cx, || 0);
14//!         let c = count.clone();
15//!         View::vstack()
16//!             .child(View::text(format!("Count: {}", count.get())))
17//!             .child(View::button().label("+").on_press(move || c.update(|n| *n + 1)).build())
18//!             .build()
19//!     });
20//!
21//!     assert!(app.find_text("Count: 0").is_some());
22//!     app.press_button("+");
23//!     assert!(app.find_text("Count: 1").is_some());
24//! }
25//! ```
26
27use crate::buffer::Buffer;
28use crate::component::Component;
29use crate::focus::FocusManager;
30use crate::render::{render_view, RenderContext};
31use crate::scope::{Scope, StateStorage};
32use crate::terminal::Terminal;
33use crate::view::{ButtonNode, CheckboxNode, ListNode, TextInputNode, TextNode, View};
34use crate::EventSource;
35use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
36use std::cell::RefCell;
37use std::collections::VecDeque;
38use std::rc::Rc;
39use std::time::Duration;
40
41/// A test harness for Telex components.
42///
43/// Renders components to an in-memory buffer and provides methods
44/// for finding elements and simulating interactions.
45pub struct TestApp<C: Component> {
46    root: C,
47    storage: Rc<StateStorage>,
48    focus: FocusManager,
49    width: u16,
50    height: u16,
51}
52
53impl<C: Component> TestApp<C> {
54    /// Create a new test app with the given root component.
55    pub fn new(root: C) -> Self {
56        Self {
57            root,
58            storage: Rc::new(StateStorage::new()),
59            focus: FocusManager::new(),
60            width: 80,
61            height: 24,
62        }
63    }
64
65    /// Set the virtual terminal size.
66    pub fn with_size(mut self, width: u16, height: u16) -> Self {
67        self.width = width;
68        self.height = height;
69        self
70    }
71
72    /// Render the component and return the view tree.
73    fn render(&self) -> View {
74        let cx = Scope::with_storage(Rc::clone(&self.storage));
75        self.root.render(cx)
76    }
77
78    /// Render to a buffer and return the buffer contents as a string.
79    pub fn render_to_string(&mut self) -> String {
80        let view = self.render();
81        self.focus.collect_focusables(&view);
82
83        let mut buffer = Buffer::new(self.width, self.height);
84        let area = buffer.rect();
85
86        let scroll_offsets: Vec<(u16, u16)> = (0..self.focus.focus_index() + 10)
87            .map(|i| self.focus.scroll_offset(i))
88            .collect();
89        let cursor_offsets: Vec<usize> = (0..self.focus.focus_index() + 10)
90            .map(|i| self.focus.cursor_offset(i))
91            .collect();
92
93        // In tests, always show focus styling so we can verify focus behavior
94        let mut ctx = RenderContext::new(self.focus.focus_index(), true, scroll_offsets, cursor_offsets, area);
95        render_view(&mut buffer, &view, area, &mut ctx);
96        ctx.render_pending_dropdowns(&mut buffer);
97
98        // Run pending effects after render (mirrors lib.rs behavior)
99        self.storage.flush_effects();
100
101        buffer.to_string()
102    }
103
104    /// Find all text content in the view tree.
105    pub fn find_all_text(&self) -> Vec<String> {
106        let view = self.render();
107        let mut texts = Vec::new();
108        Self::collect_text(&view, &mut texts);
109        texts
110    }
111
112    /// Find text that contains the given substring.
113    pub fn find_text(&self, needle: &str) -> Option<String> {
114        self.find_all_text()
115            .into_iter()
116            .find(|t| t.contains(needle))
117    }
118
119    /// Check if text containing the given substring exists.
120    pub fn has_text(&self, needle: &str) -> bool {
121        self.find_text(needle).is_some()
122    }
123
124    /// Find all button labels in the view tree.
125    pub fn find_all_buttons(&self) -> Vec<String> {
126        let view = self.render();
127        let mut buttons = Vec::new();
128        Self::collect_buttons(&view, &mut buttons);
129        buttons
130    }
131
132    /// Find a button by its label.
133    pub fn find_button(&self, label: &str) -> Option<String> {
134        self.find_all_buttons().into_iter().find(|l| l == label)
135    }
136
137    /// Get the current focus index.
138    pub fn focus_index(&self) -> usize {
139        self.focus.focus_index()
140    }
141
142    /// Get the total number of focusable elements.
143    pub fn focusable_count(&mut self) -> usize {
144        let view = self.render();
145        self.focus.collect_focusables(&view);
146        self.focus.focusable_count()
147    }
148
149    /// Move focus to the next element.
150    pub fn focus_next(&mut self) {
151        let view = self.render();
152        self.focus.collect_focusables(&view);
153        self.focus.focus_next();
154    }
155
156    /// Move focus to the previous element.
157    pub fn focus_prev(&mut self) {
158        let view = self.render();
159        self.focus.collect_focusables(&view);
160        self.focus.focus_prev();
161    }
162
163    /// Activate the currently focused element (press button, toggle checkbox).
164    pub fn activate(&mut self) {
165        let view = self.render();
166        self.focus.collect_focusables(&view);
167        self.focus.activate();
168    }
169
170    /// Press a button by its label.
171    ///
172    /// Finds the button, focuses it, and activates it.
173    pub fn press_button(&mut self, label: &str) -> bool {
174        let view = self.render();
175        self.focus.collect_focusables(&view);
176
177        // Find the button index
178        if let Some(idx) = self.find_button_index(&view, label) {
179            // Focus it
180            while self.focus.focus_index() != idx {
181                self.focus.focus_next();
182            }
183            // Activate it
184            self.focus.activate();
185            true
186        } else {
187            false
188        }
189    }
190
191    /// Move list selection up.
192    pub fn list_up(&mut self) {
193        let view = self.render();
194        self.focus.collect_focusables(&view);
195        self.focus.list_select_prev();
196    }
197
198    /// Move list selection down.
199    pub fn list_down(&mut self) {
200        let view = self.render();
201        self.focus.collect_focusables(&view);
202        self.focus.list_select_next();
203    }
204
205    /// Type a character into the focused text input or text area.
206    pub fn type_char(&mut self, c: char) {
207        let view = self.render();
208        self.focus.collect_focusables(&view);
209        // Set wrap width for text areas (simulating lib.rs behavior)
210        self.focus
211            .set_default_textarea_wrap_width(self.width.saturating_sub(4));
212        if self.focus.is_focused_text_area() {
213            self.focus.text_area_key(c);
214        } else {
215            self.focus.text_input_key(c);
216        }
217    }
218
219    /// Type a string into the focused text input or text area.
220    pub fn type_str(&mut self, s: &str) {
221        for c in s.chars() {
222            self.type_char(c);
223        }
224    }
225
226    /// Press backspace in the focused text input or text area.
227    pub fn backspace(&mut self) {
228        let view = self.render();
229        self.focus.collect_focusables(&view);
230        if self.focus.is_focused_text_area() {
231            self.focus.text_area_backspace();
232        } else {
233            self.focus.text_input_backspace();
234        }
235    }
236
237    /// Press Enter in the focused text area (insert new line).
238    pub fn enter(&mut self) {
239        let view = self.render();
240        self.focus.collect_focusables(&view);
241        if self.focus.is_focused_text_area() {
242            self.focus.text_area_enter();
243        }
244    }
245
246    /// Scroll up in the focused scrollable.
247    pub fn scroll_up(&mut self, amount: u16) {
248        let view = self.render();
249        self.focus.collect_focusables(&view);
250        self.focus.scroll_up(amount);
251    }
252
253    /// Scroll down in the focused scrollable.
254    pub fn scroll_down(&mut self, amount: u16) {
255        let view = self.render();
256        self.focus.collect_focusables(&view);
257        self.focus.scroll_down(amount, 100);
258    }
259
260    // Helper: collect all text from view tree
261    fn collect_text(view: &View, texts: &mut Vec<String>) {
262        match view {
263            View::Text(TextNode { content, .. }) => {
264                texts.push(content.clone());
265            }
266            View::VStack(node) => {
267                for child in &node.children {
268                    Self::collect_text(child, texts);
269                }
270            }
271            View::HStack(node) => {
272                for child in &node.children {
273                    Self::collect_text(child, texts);
274                }
275            }
276            View::Box(node) => {
277                if let Some(child) = &node.child {
278                    Self::collect_text(child, texts);
279                }
280            }
281            View::Button(ButtonNode { label, .. }) => {
282                texts.push(label.clone());
283            }
284            View::List(ListNode { items, .. }) => {
285                texts.extend(items.clone());
286            }
287            View::TextInput(TextInputNode {
288                value, placeholder, ..
289            }) => {
290                if value.is_empty() {
291                    texts.push(placeholder.clone());
292                } else {
293                    texts.push(value.clone());
294                }
295            }
296            View::Checkbox(CheckboxNode { label, .. }) => {
297                texts.push(label.clone());
298            }
299            View::ErrorBoundary(node) => {
300                Self::collect_text(&node.child, texts);
301            }
302            _ => {}
303        }
304    }
305
306    // Helper: collect all button labels from view tree
307    fn collect_buttons(view: &View, buttons: &mut Vec<String>) {
308        match view {
309            View::Button(ButtonNode { label, .. }) => {
310                buttons.push(label.clone());
311            }
312            View::VStack(node) => {
313                for child in &node.children {
314                    Self::collect_buttons(child, buttons);
315                }
316            }
317            View::HStack(node) => {
318                for child in &node.children {
319                    Self::collect_buttons(child, buttons);
320                }
321            }
322            View::Box(node) => {
323                if let Some(child) = &node.child {
324                    Self::collect_buttons(child, buttons);
325                }
326            }
327            View::ErrorBoundary(node) => {
328                Self::collect_buttons(&node.child, buttons);
329            }
330            _ => {}
331        }
332    }
333
334    // Helper: find the focusable index of a button by label
335    fn find_button_index(&self, view: &View, label: &str) -> Option<usize> {
336        let mut index = 0;
337        Self::find_button_index_recursive(view, label, &mut index)
338    }
339
340    fn find_button_index_recursive(view: &View, label: &str, index: &mut usize) -> Option<usize> {
341        match view {
342            View::Button(ButtonNode {
343                label: btn_label, ..
344            }) => {
345                if btn_label == label {
346                    Some(*index)
347                } else {
348                    *index += 1;
349                    None
350                }
351            }
352            View::Box(node) => {
353                if node.scroll {
354                    *index += 1;
355                }
356                if let Some(child) = &node.child {
357                    Self::find_button_index_recursive(child, label, index)
358                } else {
359                    None
360                }
361            }
362            View::VStack(node) => {
363                for child in &node.children {
364                    if let Some(idx) = Self::find_button_index_recursive(child, label, index) {
365                        return Some(idx);
366                    }
367                }
368                None
369            }
370            View::HStack(node) => {
371                for child in &node.children {
372                    if let Some(idx) = Self::find_button_index_recursive(child, label, index) {
373                        return Some(idx);
374                    }
375                }
376                None
377            }
378            View::ErrorBoundary(node) => {
379                Self::find_button_index_recursive(&node.child, label, index)
380            }
381            View::List(_) | View::TextInput(_) | View::Checkbox(_) => {
382                *index += 1;
383                None
384            }
385            _ => None,
386        }
387    }
388
389    // ========== Visibility Assertions ==========
390
391    /// Assert that the given text is visible in the rendered output.
392    /// Panics with a helpful message showing the rendered output if not found.
393    pub fn assert_visible(&mut self, needle: &str) {
394        let rendered = self.render_to_string();
395        if !rendered.contains(needle) {
396            panic!(
397                "\n\nassertion failed: expected {:?} to be visible\n\nRendered output ({}x{}):\n{}\n",
398                needle, self.width, self.height, rendered
399            );
400        }
401    }
402
403    /// Assert that the given text is NOT visible in the rendered output.
404    /// Panics with a helpful message if the text is found.
405    pub fn assert_not_visible(&mut self, needle: &str) {
406        let rendered = self.render_to_string();
407        if rendered.contains(needle) {
408            panic!(
409                "\n\nassertion failed: expected {:?} to NOT be visible\n\nRendered output ({}x{}):\n{}\n",
410                needle, self.width, self.height, rendered
411            );
412        }
413    }
414
415    /// Check which items from the given list are visible in the rendered output.
416    /// Returns a Vec of the items that are visible.
417    pub fn visible_items(&mut self, items: &[&str]) -> Vec<String> {
418        let rendered = self.render_to_string();
419        items
420            .iter()
421            .filter(|item| rendered.contains(*item))
422            .map(|s| s.to_string())
423            .collect()
424    }
425
426    // ========== Rendered Output Helpers ==========
427
428    /// Get the rendered output as a Vec of lines.
429    pub fn rendered_lines(&mut self) -> Vec<String> {
430        self.render_to_string()
431            .lines()
432            .map(|s| s.to_string())
433            .collect()
434    }
435
436    /// Find the line number (0-indexed) containing the given text.
437    /// Returns None if not found.
438    pub fn find_line_containing(&mut self, needle: &str) -> Option<usize> {
439        self.rendered_lines()
440            .iter()
441            .position(|line| line.contains(needle))
442    }
443
444    // ========== Viewport Info ==========
445
446    /// Get the viewport height (visible area).
447    pub fn viewport_height(&self) -> u16 {
448        self.height
449    }
450
451    /// Get the viewport width (visible area).
452    pub fn viewport_width(&self) -> u16 {
453        self.width
454    }
455}
456
457/// Assert that the rendered output matches a snapshot.
458///
459/// On first run, creates the snapshot. On subsequent runs, compares.
460#[macro_export]
461macro_rules! assert_snapshot {
462    ($app:expr) => {
463        let rendered = $app.render_to_string();
464        // For now, just print - in real usage, compare to stored snapshot
465        println!("Snapshot:\n{}", rendered);
466    };
467    ($app:expr, $name:expr) => {
468        let rendered = $app.render_to_string();
469        println!("Snapshot [{}]:\n{}", $name, rendered);
470    };
471}
472
473// =============================================================================
474// TestEventSource - for headless event loop testing
475// =============================================================================
476
477/// A test event source that replays a scripted sequence of events.
478///
479/// Used by `run_headless()` to inject key events into the real event loop.
480/// When all events are consumed, returns Ctrl+Q to exit the loop.
481pub struct TestEventSource {
482    events: RefCell<VecDeque<Event>>,
483    exhausted: RefCell<bool>,
484    last_buffer: RefCell<String>,
485}
486
487impl TestEventSource {
488    /// Create a new test event source with the given events.
489    pub fn new(events: Vec<Event>) -> Self {
490        Self {
491            events: RefCell::new(events.into()),
492            exhausted: RefCell::new(false),
493            last_buffer: RefCell::new(String::new()),
494        }
495    }
496
497    /// Get the last rendered buffer string.
498    pub fn last_buffer(&self) -> String {
499        self.last_buffer.borrow().clone()
500    }
501}
502
503impl EventSource for TestEventSource {
504    fn poll_event(&self, _timeout: Duration) -> std::io::Result<Option<Event>> {
505        let mut events = self.events.borrow_mut();
506        if let Some(event) = events.pop_front() {
507            Ok(Some(event))
508        } else if !*self.exhausted.borrow() {
509            // First time exhausted: send Ctrl+Q to quit
510            *self.exhausted.borrow_mut() = true;
511            Ok(Some(Event::Key(KeyEvent::new(
512                KeyCode::Char('q'),
513                KeyModifiers::CONTROL,
514            ))))
515        } else {
516            // Already sent quit, return None
517            Ok(None)
518        }
519    }
520
521    fn on_frame_rendered(&self, terminal: &Terminal) {
522        *self.last_buffer.borrow_mut() = terminal.buffer_string();
523    }
524}
525
526// =============================================================================
527// StreamTestEventSource - for testing background stream wake behavior
528// =============================================================================
529
530/// A test event source that lets real time pass with no user input.
531///
532/// Unlike `TestEventSource` which fires events instantly, this source
533/// respects poll timeouts and waits until a deadline before sending Ctrl+Q.
534/// This allows background streams to wake the event loop and trigger
535/// re-renders — exactly what we need to test the wake mechanism.
536pub struct StreamTestEventSource {
537    deadline: std::time::Instant,
538    exhausted: RefCell<bool>,
539    /// Every rendered frame's buffer, in order.
540    frames: RefCell<Vec<String>>,
541}
542
543impl StreamTestEventSource {
544    /// Create a new stream test source that waits `duration` before quitting.
545    pub fn new(duration: Duration) -> Self {
546        Self {
547            deadline: std::time::Instant::now() + duration,
548            exhausted: RefCell::new(false),
549            frames: RefCell::new(Vec::new()),
550        }
551    }
552
553    /// Get all rendered frame buffers, in order.
554    pub fn frames(&self) -> Vec<String> {
555        self.frames.borrow().clone()
556    }
557}
558
559impl EventSource for StreamTestEventSource {
560    fn poll_event(&self, timeout: Duration) -> std::io::Result<Option<Event>> {
561        if std::time::Instant::now() >= self.deadline {
562            if !*self.exhausted.borrow() {
563                *self.exhausted.borrow_mut() = true;
564                return Ok(Some(Event::Key(KeyEvent::new(
565                    KeyCode::Char('q'),
566                    KeyModifiers::CONTROL,
567                ))));
568            }
569            return Ok(None);
570        }
571        // Actually sleep — let background threads send tokens and set wake flags
572        std::thread::sleep(timeout);
573        Ok(None)
574    }
575
576    fn on_frame_rendered(&self, terminal: &Terminal) {
577        self.frames.borrow_mut().push(terminal.buffer_string());
578    }
579}