Skip to main content

telex/
testing.rs

1//! Testing utilities for RTE components.
2//!
3//! Provides `TestApp` for testing components without a real terminal.
4//!
5//! # Example
6//! ```rust,ignore
7//! use rte::testing::TestApp;
8//! use rte::prelude::*;
9//!
10//! #[test]
11//! fn counter_increments() {
12//!     let mut app = TestApp::new(|cx| {
13//!         let count = cx.use_state(|| 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::view::{ButtonNode, CheckboxNode, ListNode, TextInputNode, TextNode, View};
33use std::rc::Rc;
34
35/// A test harness for RTE components.
36///
37/// Renders components to an in-memory buffer and provides methods
38/// for finding elements and simulating interactions.
39pub struct TestApp<C: Component> {
40    root: C,
41    storage: Rc<StateStorage>,
42    focus: FocusManager,
43    width: u16,
44    height: u16,
45}
46
47impl<C: Component> TestApp<C> {
48    /// Create a new test app with the given root component.
49    pub fn new(root: C) -> Self {
50        Self {
51            root,
52            storage: Rc::new(StateStorage::new()),
53            focus: FocusManager::new(),
54            width: 80,
55            height: 24,
56        }
57    }
58
59    /// Set the virtual terminal size.
60    pub fn with_size(mut self, width: u16, height: u16) -> Self {
61        self.width = width;
62        self.height = height;
63        self
64    }
65
66    /// Render the component and return the view tree.
67    fn render(&self) -> View {
68        let cx = Scope::with_storage(Rc::clone(&self.storage));
69        self.root.render(cx)
70    }
71
72    /// Render to a buffer and return the buffer contents as a string.
73    pub fn render_to_string(&mut self) -> String {
74        let view = self.render();
75        self.focus.collect_focusables(&view);
76
77        let mut buffer = Buffer::new(self.width, self.height);
78        let area = buffer.rect();
79
80        let scroll_offsets: Vec<(u16, u16)> = (0..self.focus.focus_index() + 10)
81            .map(|i| self.focus.scroll_offset(i))
82            .collect();
83        let cursor_offsets: Vec<usize> = (0..self.focus.focus_index() + 10)
84            .map(|i| self.focus.cursor_offset(i))
85            .collect();
86
87        // In tests, always show focus styling so we can verify focus behavior
88        let mut ctx = RenderContext::new(self.focus.focus_index(), true, scroll_offsets, cursor_offsets, area);
89        render_view(&mut buffer, &view, area, &mut ctx);
90        ctx.render_pending_dropdowns(&mut buffer);
91
92        // Run pending effects after render (mirrors lib.rs behavior)
93        self.storage.flush_effects();
94
95        buffer.to_string()
96    }
97
98    /// Find all text content in the view tree.
99    pub fn find_all_text(&self) -> Vec<String> {
100        let view = self.render();
101        let mut texts = Vec::new();
102        Self::collect_text(&view, &mut texts);
103        texts
104    }
105
106    /// Find text that contains the given substring.
107    pub fn find_text(&self, needle: &str) -> Option<String> {
108        self.find_all_text()
109            .into_iter()
110            .find(|t| t.contains(needle))
111    }
112
113    /// Check if text containing the given substring exists.
114    pub fn has_text(&self, needle: &str) -> bool {
115        self.find_text(needle).is_some()
116    }
117
118    /// Find all button labels in the view tree.
119    pub fn find_all_buttons(&self) -> Vec<String> {
120        let view = self.render();
121        let mut buttons = Vec::new();
122        Self::collect_buttons(&view, &mut buttons);
123        buttons
124    }
125
126    /// Find a button by its label.
127    pub fn find_button(&self, label: &str) -> Option<String> {
128        self.find_all_buttons().into_iter().find(|l| l == label)
129    }
130
131    /// Get the current focus index.
132    pub fn focus_index(&self) -> usize {
133        self.focus.focus_index()
134    }
135
136    /// Get the total number of focusable elements.
137    pub fn focusable_count(&mut self) -> usize {
138        let view = self.render();
139        self.focus.collect_focusables(&view);
140        self.focus.focusable_count()
141    }
142
143    /// Move focus to the next element.
144    pub fn focus_next(&mut self) {
145        let view = self.render();
146        self.focus.collect_focusables(&view);
147        self.focus.focus_next();
148    }
149
150    /// Move focus to the previous element.
151    pub fn focus_prev(&mut self) {
152        let view = self.render();
153        self.focus.collect_focusables(&view);
154        self.focus.focus_prev();
155    }
156
157    /// Activate the currently focused element (press button, toggle checkbox).
158    pub fn activate(&mut self) {
159        let view = self.render();
160        self.focus.collect_focusables(&view);
161        self.focus.activate();
162    }
163
164    /// Press a button by its label.
165    ///
166    /// Finds the button, focuses it, and activates it.
167    pub fn press_button(&mut self, label: &str) -> bool {
168        let view = self.render();
169        self.focus.collect_focusables(&view);
170
171        // Find the button index
172        if let Some(idx) = self.find_button_index(&view, label) {
173            // Focus it
174            while self.focus.focus_index() != idx {
175                self.focus.focus_next();
176            }
177            // Activate it
178            self.focus.activate();
179            true
180        } else {
181            false
182        }
183    }
184
185    /// Move list selection up.
186    pub fn list_up(&mut self) {
187        let view = self.render();
188        self.focus.collect_focusables(&view);
189        self.focus.list_select_prev();
190    }
191
192    /// Move list selection down.
193    pub fn list_down(&mut self) {
194        let view = self.render();
195        self.focus.collect_focusables(&view);
196        self.focus.list_select_next();
197    }
198
199    /// Type a character into the focused text input or text area.
200    pub fn type_char(&mut self, c: char) {
201        let view = self.render();
202        self.focus.collect_focusables(&view);
203        // Set wrap width for text areas (simulating lib.rs behavior)
204        self.focus
205            .set_default_textarea_wrap_width(self.width.saturating_sub(4));
206        if self.focus.is_focused_text_area() {
207            self.focus.text_area_key(c);
208        } else {
209            self.focus.text_input_key(c);
210        }
211    }
212
213    /// Type a string into the focused text input or text area.
214    pub fn type_str(&mut self, s: &str) {
215        for c in s.chars() {
216            self.type_char(c);
217        }
218    }
219
220    /// Press backspace in the focused text input or text area.
221    pub fn backspace(&mut self) {
222        let view = self.render();
223        self.focus.collect_focusables(&view);
224        if self.focus.is_focused_text_area() {
225            self.focus.text_area_backspace();
226        } else {
227            self.focus.text_input_backspace();
228        }
229    }
230
231    /// Press Enter in the focused text area (insert new line).
232    pub fn enter(&mut self) {
233        let view = self.render();
234        self.focus.collect_focusables(&view);
235        if self.focus.is_focused_text_area() {
236            self.focus.text_area_enter();
237        }
238    }
239
240    /// Scroll up in the focused scrollable.
241    pub fn scroll_up(&mut self, amount: u16) {
242        let view = self.render();
243        self.focus.collect_focusables(&view);
244        self.focus.scroll_up(amount);
245    }
246
247    /// Scroll down in the focused scrollable.
248    pub fn scroll_down(&mut self, amount: u16) {
249        let view = self.render();
250        self.focus.collect_focusables(&view);
251        self.focus.scroll_down(amount, 100);
252    }
253
254    // Helper: collect all text from view tree
255    fn collect_text(view: &View, texts: &mut Vec<String>) {
256        match view {
257            View::Text(TextNode { content, .. }) => {
258                texts.push(content.clone());
259            }
260            View::VStack(node) => {
261                for child in &node.children {
262                    Self::collect_text(child, texts);
263                }
264            }
265            View::HStack(node) => {
266                for child in &node.children {
267                    Self::collect_text(child, texts);
268                }
269            }
270            View::Box(node) => {
271                if let Some(child) = &node.child {
272                    Self::collect_text(child, texts);
273                }
274            }
275            View::Button(ButtonNode { label, .. }) => {
276                texts.push(label.clone());
277            }
278            View::List(ListNode { items, .. }) => {
279                texts.extend(items.clone());
280            }
281            View::TextInput(TextInputNode {
282                value, placeholder, ..
283            }) => {
284                if value.is_empty() {
285                    texts.push(placeholder.clone());
286                } else {
287                    texts.push(value.clone());
288                }
289            }
290            View::Checkbox(CheckboxNode { label, .. }) => {
291                texts.push(label.clone());
292            }
293            _ => {}
294        }
295    }
296
297    // Helper: collect all button labels from view tree
298    fn collect_buttons(view: &View, buttons: &mut Vec<String>) {
299        match view {
300            View::Button(ButtonNode { label, .. }) => {
301                buttons.push(label.clone());
302            }
303            View::VStack(node) => {
304                for child in &node.children {
305                    Self::collect_buttons(child, buttons);
306                }
307            }
308            View::HStack(node) => {
309                for child in &node.children {
310                    Self::collect_buttons(child, buttons);
311                }
312            }
313            View::Box(node) => {
314                if let Some(child) = &node.child {
315                    Self::collect_buttons(child, buttons);
316                }
317            }
318            _ => {}
319        }
320    }
321
322    // Helper: find the focusable index of a button by label
323    fn find_button_index(&self, view: &View, label: &str) -> Option<usize> {
324        let mut index = 0;
325        Self::find_button_index_recursive(view, label, &mut index)
326    }
327
328    fn find_button_index_recursive(view: &View, label: &str, index: &mut usize) -> Option<usize> {
329        match view {
330            View::Button(ButtonNode {
331                label: btn_label, ..
332            }) => {
333                if btn_label == label {
334                    Some(*index)
335                } else {
336                    *index += 1;
337                    None
338                }
339            }
340            View::Box(node) => {
341                if node.scroll {
342                    *index += 1;
343                }
344                if let Some(child) = &node.child {
345                    Self::find_button_index_recursive(child, label, index)
346                } else {
347                    None
348                }
349            }
350            View::VStack(node) => {
351                for child in &node.children {
352                    if let Some(idx) = Self::find_button_index_recursive(child, label, index) {
353                        return Some(idx);
354                    }
355                }
356                None
357            }
358            View::HStack(node) => {
359                for child in &node.children {
360                    if let Some(idx) = Self::find_button_index_recursive(child, label, index) {
361                        return Some(idx);
362                    }
363                }
364                None
365            }
366            View::List(_) | View::TextInput(_) | View::Checkbox(_) => {
367                *index += 1;
368                None
369            }
370            _ => None,
371        }
372    }
373
374    // ========== Visibility Assertions ==========
375
376    /// Assert that the given text is visible in the rendered output.
377    /// Panics with a helpful message showing the rendered output if not found.
378    pub fn assert_visible(&mut self, needle: &str) {
379        let rendered = self.render_to_string();
380        if !rendered.contains(needle) {
381            panic!(
382                "\n\nassertion failed: expected {:?} to be visible\n\nRendered output ({}x{}):\n{}\n",
383                needle, self.width, self.height, rendered
384            );
385        }
386    }
387
388    /// Assert that the given text is NOT visible in the rendered output.
389    /// Panics with a helpful message if the text is found.
390    pub fn assert_not_visible(&mut self, needle: &str) {
391        let rendered = self.render_to_string();
392        if rendered.contains(needle) {
393            panic!(
394                "\n\nassertion failed: expected {:?} to NOT be visible\n\nRendered output ({}x{}):\n{}\n",
395                needle, self.width, self.height, rendered
396            );
397        }
398    }
399
400    /// Check which items from the given list are visible in the rendered output.
401    /// Returns a Vec of the items that are visible.
402    pub fn visible_items(&mut self, items: &[&str]) -> Vec<String> {
403        let rendered = self.render_to_string();
404        items
405            .iter()
406            .filter(|item| rendered.contains(*item))
407            .map(|s| s.to_string())
408            .collect()
409    }
410
411    // ========== Rendered Output Helpers ==========
412
413    /// Get the rendered output as a Vec of lines.
414    pub fn rendered_lines(&mut self) -> Vec<String> {
415        self.render_to_string()
416            .lines()
417            .map(|s| s.to_string())
418            .collect()
419    }
420
421    /// Find the line number (0-indexed) containing the given text.
422    /// Returns None if not found.
423    pub fn find_line_containing(&mut self, needle: &str) -> Option<usize> {
424        self.rendered_lines()
425            .iter()
426            .position(|line| line.contains(needle))
427    }
428
429    // ========== Viewport Info ==========
430
431    /// Get the viewport height (visible area).
432    pub fn viewport_height(&self) -> u16 {
433        self.height
434    }
435
436    /// Get the viewport width (visible area).
437    pub fn viewport_width(&self) -> u16 {
438        self.width
439    }
440}
441
442/// Assert that the rendered output matches a snapshot.
443///
444/// On first run, creates the snapshot. On subsequent runs, compares.
445#[macro_export]
446macro_rules! assert_snapshot {
447    ($app:expr) => {
448        let rendered = $app.render_to_string();
449        // For now, just print - in real usage, compare to stored snapshot
450        println!("Snapshot:\n{}", rendered);
451    };
452    ($app:expr, $name:expr) => {
453        let rendered = $app.render_to_string();
454        println!("Snapshot [{}]:\n{}", $name, rendered);
455    };
456}