1use crate::session::Session;
26use crate::ui::Ui;
27use crate::vdom::VNode;
28use serde::Serialize;
29
30const MAX_WIDGET_COUNTER: u64 = 100;
32
33pub struct TestUi {
38 session: Session,
40 pending_inputs: Vec<(String, serde_json::Value)>,
43 pending_clicks: Vec<String>,
45 last_tree: Option<VNode>,
47}
48
49impl TestUi {
50 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 pub fn click_button(&mut self, label: &str) {
64 self.pending_clicks.push(label.to_string());
65 }
66
67 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 fn sanitize_label(label: &str) -> String {
77 label
78 .chars()
79 .map(|c| if c.is_alphanumeric() { c } else { '_' })
80 .collect()
81 }
82
83 pub fn run(&mut self, app_fn: impl Fn(&mut Ui)) {
88 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 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 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 self.pending_clicks.clear();
118 self.pending_inputs.clear();
119
120 if let Some(ref tree) = self.last_tree {
122 Self::reset_buttons(tree, &mut self.session);
123 }
124 }
125
126 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 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 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 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 pub fn contains_text(&self, needle: &str) -> bool {
197 self.text_content().contains(needle)
198 }
199
200 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 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 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 pub fn tree(&self) -> Option<&VNode> {
231 self.last_tree.as_ref()
232 }
233
234 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 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 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 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}