Skip to main content

rustview/testing/
mod.rs

1//! Testing utilities for RustView applications.
2//!
3//! The `TestUi` struct provides a mock-like harness that lets you
4//! unit-test your RustView app functions without starting the HTTP server.
5//!
6//! # Example
7//! ```rust
8//! use rustview::testing::TestUi;
9//! use rustview::ui::Ui;
10//!
11//! fn counter_app(ui: &mut Ui) {
12//!     let count = ui.get_state::<i64>("n", 0);
13//!     if ui.button("Inc") {
14//!         ui.set_state("n", count + 1);
15//!     }
16//!     ui.write(format!("Count: {}", ui.get_state::<i64>("n", 0)));
17//! }
18//!
19//! let mut tui = TestUi::new();
20//! tui.click_button("Inc");
21//! tui.run(counter_app);
22//! assert!(tui.contains_text("Count: 1"));
23//! ```
24
25use crate::session::Session;
26use crate::ui::Ui;
27use crate::vdom::VNode;
28use serde::Serialize;
29
30/// Maximum widget counter value to try when resolving label → widget ID.
31const MAX_WIDGET_COUNTER: u64 = 100;
32
33/// A test harness for RustView applications.
34///
35/// Allows simulating widget interactions and asserting on output
36/// without a browser or HTTP server.
37pub struct TestUi {
38    /// The backing session for state persistence across runs.
39    session: Session,
40    /// Pre-configured widget inputs (label → value).
41    /// These are resolved to widget IDs at run time.
42    pending_inputs: Vec<(String, serde_json::Value)>,
43    /// Button clicks to simulate (by label).
44    pending_clicks: Vec<String>,
45    /// Last rendered VNode tree (after `run()`).
46    last_tree: Option<VNode>,
47}
48
49impl TestUi {
50    /// Create a new test harness with an empty session.
51    pub fn new() -> Self {
52        TestUi {
53            session: Session::new(),
54            pending_inputs: Vec::new(),
55            pending_clicks: Vec::new(),
56            last_tree: None,
57        }
58    }
59
60    /// Simulate a button click by label.
61    ///
62    /// The button with this label will return `true` on the next `run()`.
63    pub fn click_button(&mut self, label: &str) {
64        self.pending_clicks.push(label.to_string());
65    }
66
67    /// Simulate setting a widget input value by label.
68    ///
69    /// Works for text_input, int_slider, number_input, checkbox, etc.
70    pub fn set_input<V: Serialize>(&mut self, label: &str, value: V) {
71        let json_val = serde_json::to_value(value).expect("Value must be serializable");
72        self.pending_inputs.push((label.to_string(), json_val));
73    }
74
75    /// Sanitize a label to match the widget ID format used by `Ui::next_widget_id`.
76    fn sanitize_label(label: &str) -> String {
77        label
78            .chars()
79            .map(|c| if c.is_alphanumeric() { c } else { '_' })
80            .collect()
81    }
82
83    /// Run the app function once, collecting all output into the VNode tree.
84    ///
85    /// All pending button clicks and input values are applied before the run.
86    /// After the run, query methods like `text_content()` reflect the output.
87    pub fn run(&mut self, app_fn: impl Fn(&mut Ui)) {
88        // Apply pending clicks: set matching widget IDs to true.
89        // Widget IDs follow the pattern: w-{sanitized_label}-{counter}
90        // We set all plausible counter values (1..=100) since we don't know
91        // the exact counter value without running the app first.
92        for label in &self.pending_clicks {
93            let sanitized = Self::sanitize_label(label);
94            for counter in 1..=MAX_WIDGET_COUNTER {
95                let widget_id = format!("w-{sanitized}-{counter}");
96                self.session
97                    .set_widget_value(&widget_id, serde_json::json!(true));
98            }
99        }
100
101        // Apply pending inputs
102        for (label, value) in &self.pending_inputs {
103            let sanitized = Self::sanitize_label(label);
104            for counter in 1..=MAX_WIDGET_COUNTER {
105                let widget_id = format!("w-{sanitized}-{counter}");
106                self.session.set_widget_value(&widget_id, value.clone());
107            }
108        }
109
110        // Run the app function
111        let mut ui = Ui::new(&mut self.session);
112        app_fn(&mut ui);
113        let tree = ui.build_tree();
114        self.last_tree = Some(tree);
115
116        // Clear pending actions after run
117        self.pending_clicks.clear();
118        self.pending_inputs.clear();
119
120        // Reset button states after run (buttons are one-shot)
121        if let Some(ref tree) = self.last_tree {
122            Self::reset_buttons(tree, &mut self.session);
123        }
124    }
125
126    /// Reset all button widget values to false after a run.
127    fn reset_buttons(node: &VNode, session: &mut Session) {
128        if let Some(wtype) = node.attrs.get("data-widget-type") {
129            if wtype == "button" {
130                if let Some(wid) = node.attrs.get("data-widget-id") {
131                    session.set_widget_value(wid, serde_json::json!(false));
132                }
133            }
134        }
135        for child in &node.children {
136            Self::reset_buttons(child, session);
137        }
138    }
139
140    /// Get the concatenated text content of all output widgets from the last run.
141    ///
142    /// Extracts text from `write`, `heading`, `subheading`, `caption`, `error`,
143    /// `success`, `warning`, `info`, and `metric` widgets.
144    pub fn text_content(&self) -> String {
145        let Some(ref tree) = self.last_tree else {
146            return String::new();
147        };
148        let mut texts = Vec::new();
149        Self::collect_texts(tree, &mut texts);
150        texts.join("\n")
151    }
152
153    /// Recursively collect text content from VNode tree.
154    fn collect_texts(node: &VNode, out: &mut Vec<String>) {
155        if let Some(ref text) = node.text {
156            if !text.is_empty() {
157                out.push(text.clone());
158            }
159        }
160        for child in &node.children {
161            Self::collect_texts(child, out);
162        }
163    }
164
165    /// Get the text content of widgets matching a specific CSS class substring.
166    ///
167    /// For example, `find_widget_text("rustview-write")` returns text from write widgets.
168    pub fn find_widget_text(&self, class_substr: &str) -> Vec<String> {
169        let Some(ref tree) = self.last_tree else {
170            return Vec::new();
171        };
172        let mut results = Vec::new();
173        Self::collect_widget_texts(tree, class_substr, &mut results);
174        results
175    }
176
177    fn collect_widget_texts(node: &VNode, class_substr: &str, out: &mut Vec<String>) {
178        let matches_class = node
179            .attrs
180            .get("class")
181            .is_some_and(|c| c.contains(class_substr));
182        if matches_class {
183            let mut texts = Vec::new();
184            Self::collect_texts(node, &mut texts);
185            if !texts.is_empty() {
186                out.push(texts.join(" "));
187            }
188        } else {
189            for child in &node.children {
190                Self::collect_widget_texts(child, class_substr, out);
191            }
192        }
193    }
194
195    /// Check if the output tree contains a specific text string.
196    pub fn contains_text(&self, needle: &str) -> bool {
197        self.text_content().contains(needle)
198    }
199
200    /// Get the number of top-level widgets rendered in the last run.
201    pub fn widget_count(&self) -> usize {
202        self.last_tree
203            .as_ref()
204            .map(|t| t.children.len())
205            .unwrap_or(0)
206    }
207
208    /// Get a typed user state value (mirrors `Ui::get_state`).
209    pub fn get_state<T>(&self, key: &str, default: T) -> T
210    where
211        T: serde::Serialize + serde::de::DeserializeOwned + Clone + 'static,
212    {
213        match self.session.get_state(key) {
214            Some(val) => serde_json::from_value(val.clone()).unwrap_or(default),
215            None => default,
216        }
217    }
218
219    /// Set a typed user state value (mirrors `Ui::set_state`).
220    pub fn set_state<T>(&mut self, key: &str, value: T)
221    where
222        T: serde::Serialize + 'static,
223    {
224        if let Ok(val) = serde_json::to_value(&value) {
225            self.session.set_state(key, val);
226        }
227    }
228
229    /// Get the full VNode tree from the last run (for advanced assertions).
230    pub fn tree(&self) -> Option<&VNode> {
231        self.last_tree.as_ref()
232    }
233
234    /// Check if a specific widget type exists in the output.
235    ///
236    /// Widget types match CSS classes like "rustview-write", "rustview-button", etc.
237    pub fn has_widget(&self, widget_class: &str) -> bool {
238        let Some(ref tree) = self.last_tree else {
239            return false;
240        };
241        Self::find_class(tree, widget_class)
242    }
243
244    fn find_class(node: &VNode, class_substr: &str) -> bool {
245        if node
246            .attrs
247            .get("class")
248            .is_some_and(|c| c.contains(class_substr))
249        {
250            return true;
251        }
252        node.children
253            .iter()
254            .any(|child| Self::find_class(child, class_substr))
255    }
256}
257
258impl Default for TestUi {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn hello_app(ui: &mut Ui) {
269        ui.write("Hello, World!");
270    }
271
272    fn counter_app(ui: &mut Ui) {
273        let count = ui.get_state::<i64>("n", 0);
274        if ui.button("Inc") {
275            ui.set_state("n", count + 1);
276        }
277        ui.write(format!("Count: {}", ui.get_state::<i64>("n", 0)));
278    }
279
280    fn input_app(ui: &mut Ui) {
281        let name = ui.text_input("Name", "World");
282        ui.write(format!("Hello, {}!", name));
283    }
284
285    fn slider_app(ui: &mut Ui) {
286        let val = ui.int_slider("Amount", 0..=100, 50);
287        ui.write(format!("Amount: {}", val));
288    }
289
290    fn checkbox_app(ui: &mut Ui) {
291        let checked = ui.checkbox("Enable", false);
292        if checked {
293            ui.write("Enabled!");
294        } else {
295            ui.write("Disabled.");
296        }
297    }
298
299    fn multi_widget_app(ui: &mut Ui) {
300        ui.heading("Dashboard");
301        ui.write("Welcome");
302        ui.progress(0.5);
303        if ui.button("Action") {
304            ui.success("Done!");
305        }
306    }
307
308    #[test]
309    fn test_testui_new() {
310        let tui = TestUi::new();
311        assert!(tui.last_tree.is_none());
312        assert_eq!(tui.widget_count(), 0);
313    }
314
315    #[test]
316    fn test_testui_run_hello() {
317        let mut tui = TestUi::new();
318        tui.run(hello_app);
319        assert!(tui.contains_text("Hello, World!"));
320        assert_eq!(tui.widget_count(), 1);
321    }
322
323    #[test]
324    fn test_testui_text_content() {
325        let mut tui = TestUi::new();
326        tui.run(hello_app);
327        let content = tui.text_content();
328        assert!(content.contains("Hello, World!"));
329    }
330
331    #[test]
332    fn test_testui_counter_default() {
333        let mut tui = TestUi::new();
334        tui.run(counter_app);
335        assert!(tui.contains_text("Count: 0"));
336    }
337
338    #[test]
339    fn test_testui_counter_increment() {
340        let mut tui = TestUi::new();
341        tui.click_button("Inc");
342        tui.run(counter_app);
343        assert!(tui.contains_text("Count: 1"));
344    }
345
346    #[test]
347    fn test_testui_counter_double_increment() {
348        let mut tui = TestUi::new();
349        tui.click_button("Inc");
350        tui.run(counter_app);
351        assert!(tui.contains_text("Count: 1"));
352
353        tui.click_button("Inc");
354        tui.run(counter_app);
355        assert!(tui.contains_text("Count: 2"));
356    }
357
358    #[test]
359    fn test_testui_text_input() {
360        let mut tui = TestUi::new();
361        tui.set_input("Name", "Alice");
362        tui.run(input_app);
363        assert!(tui.contains_text("Hello, Alice!"));
364    }
365
366    #[test]
367    fn test_testui_text_input_default() {
368        let mut tui = TestUi::new();
369        tui.run(input_app);
370        assert!(tui.contains_text("Hello, World!"));
371    }
372
373    #[test]
374    fn test_testui_slider_input() {
375        let mut tui = TestUi::new();
376        tui.set_input("Amount", 42);
377        tui.run(slider_app);
378        assert!(tui.contains_text("Amount: 42"));
379    }
380
381    #[test]
382    fn test_testui_checkbox() {
383        let mut tui = TestUi::new();
384        tui.run(checkbox_app);
385        assert!(tui.contains_text("Disabled."));
386
387        tui.set_input("Enable", true);
388        tui.run(checkbox_app);
389        assert!(tui.contains_text("Enabled!"));
390    }
391
392    #[test]
393    fn test_testui_has_widget() {
394        let mut tui = TestUi::new();
395        tui.run(multi_widget_app);
396        assert!(tui.has_widget("rustview-heading"));
397        assert!(tui.has_widget("rustview-write"));
398        assert!(tui.has_widget("rustview-progress"));
399        assert!(tui.has_widget("rustview-button"));
400    }
401
402    #[test]
403    fn test_testui_widget_count() {
404        let mut tui = TestUi::new();
405        tui.run(multi_widget_app);
406        // heading + write + progress + button = 4
407        assert_eq!(tui.widget_count(), 4);
408    }
409
410    #[test]
411    fn test_testui_button_one_shot() {
412        let mut tui = TestUi::new();
413        tui.click_button("Action");
414        tui.run(multi_widget_app);
415        assert!(tui.contains_text("Done!"));
416
417        // Without clicking again, action should not fire
418        tui.run(multi_widget_app);
419        assert!(!tui.contains_text("Done!"));
420    }
421
422    #[test]
423    fn test_testui_get_set_state() {
424        let mut tui = TestUi::new();
425        tui.set_state("key", 42i64);
426        assert_eq!(tui.get_state::<i64>("key", 0), 42);
427    }
428
429    #[test]
430    fn test_testui_state_persists_across_runs() {
431        let mut tui = TestUi::new();
432        tui.click_button("Inc");
433        tui.run(counter_app);
434        assert!(tui.contains_text("Count: 1"));
435
436        // State should persist, run again without clicking
437        tui.run(counter_app);
438        assert!(tui.contains_text("Count: 1"));
439    }
440
441    #[test]
442    fn test_testui_find_widget_text() {
443        let mut tui = TestUi::new();
444        tui.run(|ui: &mut Ui| {
445            ui.heading("Title");
446            ui.write("Body text");
447            ui.write("More text");
448        });
449        let writes = tui.find_widget_text("rustview-write");
450        assert_eq!(writes.len(), 2);
451        assert!(writes[0].contains("Body text"));
452        assert!(writes[1].contains("More text"));
453    }
454
455    #[test]
456    fn test_testui_tree_access() {
457        let mut tui = TestUi::new();
458        tui.run(hello_app);
459        let tree = tui.tree().unwrap();
460        assert_eq!(tree.tag, "div");
461        assert!(!tree.children.is_empty());
462    }
463
464    #[test]
465    fn test_testui_default() {
466        let tui = TestUi::default();
467        assert!(tui.last_tree.is_none());
468    }
469}