Skip to main content

simular/edd/
gui_coverage.rs

1//! GUI/UX Coverage Tracking for E2E Tests (Probar)
2//!
3//! This module provides coverage tracking for GUI elements and user journeys,
4//! enabling comprehensive E2E testing of TUI and WASM interfaces.
5//!
6//! # Toyota Production System Alignment
7//!
8//! - **Jidoka**: Stop tests when coverage drops below threshold
9//! - **Poka-Yoke**: Compile-time element registration prevents missing tests
10//! - **Visual Control**: Coverage reports show exactly what's tested
11//!
12//! # Example
13//!
14//! ```rust
15//! use simular::edd::gui_coverage::GuiCoverage;
16//!
17//! let mut coverage = GuiCoverage::new("TSP TUI Demo");
18//!
19//! // Register elements to track
20//! coverage.register_element("tour_length_display");
21//! coverage.register_element("convergence_graph");
22//! coverage.register_element("city_plot");
23//!
24//! // Register screens
25//! coverage.register_screen("main_view");
26//! coverage.register_screen("controls_panel");
27//!
28//! // Mark as covered during tests
29//! coverage.cover_element("tour_length_display");
30//! coverage.cover_screen("main_view");
31//!
32//! // Check coverage
33//! assert!(coverage.element_coverage() >= 0.33);
34//! ```
35//!
36//! # References
37//!
38//! - [57] Nielsen, J. (1994). Usability Engineering. Morgan Kaufmann.
39//! - [58] Krug, S. (2014). Don't Make Me Think. New Riders.
40
41use std::collections::{HashMap, HashSet};
42
43/// GUI coverage tracker for E2E testing.
44#[derive(Debug, Clone)]
45pub struct GuiCoverage {
46    /// Name of the component being tested.
47    name: String,
48    /// All registered elements (buttons, displays, labels, etc.).
49    elements: HashSet<String>,
50    /// Elements that have been covered by tests.
51    covered_elements: HashSet<String>,
52    /// All registered screens/views.
53    screens: HashSet<String>,
54    /// Screens that have been visited by tests.
55    covered_screens: HashSet<String>,
56    /// User journeys (named sequences of interactions).
57    journeys: HashMap<String, Vec<String>>,
58    /// Completed user journeys.
59    completed_journeys: HashSet<String>,
60    /// Interaction log for debugging.
61    interaction_log: Vec<Interaction>,
62}
63
64/// A single user interaction.
65#[derive(Debug, Clone)]
66pub struct Interaction {
67    /// Type of interaction.
68    pub kind: InteractionKind,
69    /// Target element or screen.
70    pub target: String,
71    /// Optional value (for inputs).
72    pub value: Option<String>,
73    /// Timestamp (frame number).
74    pub frame: u64,
75}
76
77/// Types of user interactions.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum InteractionKind {
80    /// Key press.
81    KeyPress,
82    /// Click (mouse or touch).
83    Click,
84    /// Text input.
85    Input,
86    /// Screen navigation.
87    Navigate,
88    /// Element view/render.
89    View,
90}
91
92impl GuiCoverage {
93    /// Create a new GUI coverage tracker.
94    #[must_use]
95    pub fn new(name: &str) -> Self {
96        Self {
97            name: name.to_string(),
98            elements: HashSet::new(),
99            covered_elements: HashSet::new(),
100            screens: HashSet::new(),
101            covered_screens: HashSet::new(),
102            journeys: HashMap::new(),
103            completed_journeys: HashSet::new(),
104            interaction_log: Vec::new(),
105        }
106    }
107
108    /// Create a TUI coverage tracker with common elements pre-registered.
109    #[must_use]
110    pub fn tui_preset(name: &str) -> Self {
111        let mut coverage = Self::new(name);
112
113        // Common TUI elements
114        coverage.register_element("title_bar");
115        coverage.register_element("status_bar");
116        coverage.register_element("help_text");
117        coverage.register_element("statistics_panel");
118        coverage.register_element("controls_panel");
119
120        // Common TUI screens
121        coverage.register_screen("main_view");
122        coverage.register_screen("help_screen");
123
124        coverage
125    }
126
127    /// Create a TSP TUI coverage tracker with all elements.
128    #[must_use]
129    pub fn tsp_tui() -> Self {
130        let mut coverage = Self::new("TSP GRASP TUI");
131
132        // TSP-specific elements
133        coverage.register_element("title_bar");
134        coverage.register_element("equations_panel");
135        coverage.register_element("city_plot");
136        coverage.register_element("convergence_graph");
137        coverage.register_element("statistics_panel");
138        coverage.register_element("controls_panel");
139        coverage.register_element("status_bar");
140
141        // Display elements
142        coverage.register_element("tour_length_display");
143        coverage.register_element("best_tour_display");
144        coverage.register_element("lower_bound_display");
145        coverage.register_element("gap_display");
146        coverage.register_element("crossings_display");
147        coverage.register_element("restarts_display");
148        coverage.register_element("method_display");
149        coverage.register_element("rcl_display");
150
151        // Interactive elements
152        coverage.register_element("space_toggle");
153        coverage.register_element("g_step");
154        coverage.register_element("r_reset");
155        coverage.register_element("plus_rcl");
156        coverage.register_element("minus_rcl");
157        coverage.register_element("m_method");
158        coverage.register_element("q_quit");
159
160        // Screens
161        coverage.register_screen("main_view");
162        coverage.register_screen("running_state");
163        coverage.register_screen("paused_state");
164        coverage.register_screen("converged_state");
165
166        // User journeys
167        coverage.register_journey(
168            "basic_run",
169            vec!["main_view", "space_toggle", "running_state"],
170        );
171        coverage.register_journey(
172            "single_step",
173            vec!["main_view", "g_step", "tour_length_display"],
174        );
175        coverage.register_journey(
176            "change_method",
177            vec!["main_view", "m_method", "method_display"],
178        );
179        coverage.register_journey("adjust_rcl", vec!["main_view", "plus_rcl", "rcl_display"]);
180        coverage.register_journey(
181            "full_convergence",
182            vec![
183                "main_view",
184                "space_toggle",
185                "running_state",
186                "converged_state",
187            ],
188        );
189
190        coverage
191    }
192
193    /// Create a TSP WASM coverage tracker with all elements.
194    ///
195    /// Matches TUI's 22 elements, 4 screens, 5 journeys for full parity.
196    #[must_use]
197    pub fn tsp_wasm() -> Self {
198        let mut coverage = Self::new("TSP GRASP WASM");
199
200        // === Panel Elements (matches TUI) ===
201        coverage.register_element("header"); // TUI: title_bar
202        coverage.register_element("equations_panel"); // Same
203        coverage.register_element("tsp_canvas"); // TUI: city_plot
204        coverage.register_element("sparkline"); // TUI: convergence_graph
205        coverage.register_element("statistics_panel"); // Same
206        coverage.register_element("controls_panel"); // Same
207        coverage.register_element("footer"); // TUI: status_bar
208
209        // === Display Elements (matches TUI stat-* IDs) ===
210        coverage.register_element("stat_best"); // TUI: tour_length_display
211        coverage.register_element("eq_tour"); // TUI: best_tour_display
212        coverage.register_element("stat_lb"); // TUI: lower_bound_display
213        coverage.register_element("stat_gap"); // TUI: gap_display
214        coverage.register_element("fals_status"); // TUI: crossings_display (falsification)
215        coverage.register_element("stat_restarts"); // TUI: restarts_display
216        coverage.register_element("select_method"); // TUI: method_display
217        coverage.register_element("slider_n"); // TUI: rcl_display (city count)
218
219        // === Interactive Elements (matches TUI keybinds) ===
220        coverage.register_element("btn_play"); // TUI: space_toggle
221        coverage.register_element("btn_step"); // TUI: g_step
222        coverage.register_element("btn_reset"); // TUI: r_reset
223        coverage.register_element("btn_run10"); // TUI: plus_rcl (run more)
224        coverage.register_element("btn_run100"); // TUI: minus_rcl (run lots)
225        coverage.register_element("select_method"); // TUI: m_method
226        coverage.register_element("btn_run_tests"); // Probar: test suite
227
228        // === Screens (matches TUI states) ===
229        coverage.register_screen("main_view"); // Initial view
230        coverage.register_screen("running_state"); // Animation running
231        coverage.register_screen("paused_state"); // Paused
232        coverage.register_screen("converged_state"); // Algorithm converged
233
234        // === User Journeys (matches TUI journeys) ===
235        coverage.register_journey("basic_run", vec!["main_view", "btn_play", "running_state"]);
236        coverage.register_journey("single_step", vec!["main_view", "btn_step", "stat_best"]);
237        coverage.register_journey(
238            "change_method",
239            vec!["main_view", "select_method", "stat_best"],
240        );
241        coverage.register_journey("adjust_cities", vec!["main_view", "slider_n", "tsp_canvas"]);
242        coverage.register_journey(
243            "full_convergence",
244            vec!["main_view", "btn_play", "running_state", "converged_state"],
245        );
246
247        coverage
248    }
249
250    // =========================================================================
251    // Registration
252    // =========================================================================
253
254    /// Register an element to track.
255    pub fn register_element(&mut self, name: &str) {
256        self.elements.insert(name.to_string());
257    }
258
259    /// Register a screen/view to track.
260    pub fn register_screen(&mut self, name: &str) {
261        self.screens.insert(name.to_string());
262    }
263
264    /// Register a user journey.
265    pub fn register_journey(&mut self, name: &str, steps: Vec<&str>) {
266        contract_pre_iterator!(name);
267        self.journeys.insert(
268            name.to_string(),
269            steps.into_iter().map(String::from).collect(),
270        );
271    }
272
273    // =========================================================================
274    // Coverage Recording
275    // =========================================================================
276
277    /// Mark an element as covered.
278    pub fn cover_element(&mut self, name: &str) {
279        if self.elements.contains(name) {
280            self.covered_elements.insert(name.to_string());
281        }
282    }
283
284    /// Mark a screen as covered.
285    pub fn cover_screen(&mut self, name: &str) {
286        if self.screens.contains(name) {
287            self.covered_screens.insert(name.to_string());
288        }
289    }
290
291    /// Mark a journey as completed.
292    pub fn complete_journey(&mut self, name: &str) {
293        if self.journeys.contains_key(name) {
294            self.completed_journeys.insert(name.to_string());
295            // Also cover all elements/screens in the journey
296            if let Some(steps) = self.journeys.get(name).cloned() {
297                for step in steps {
298                    self.cover_element(&step);
299                    self.cover_screen(&step);
300                }
301            }
302        }
303    }
304
305    /// Log an interaction.
306    pub fn log_interaction(
307        &mut self,
308        kind: InteractionKind,
309        target: &str,
310        value: Option<&str>,
311        frame: u64,
312    ) {
313        self.interaction_log.push(Interaction {
314            kind,
315            target: target.to_string(),
316            value: value.map(String::from),
317            frame,
318        });
319
320        // Auto-cover based on interaction type
321        match kind {
322            InteractionKind::Navigate => {
323                self.cover_screen(target);
324            }
325            InteractionKind::KeyPress
326            | InteractionKind::Click
327            | InteractionKind::Input
328            | InteractionKind::View => {
329                self.cover_element(target);
330            }
331        }
332    }
333
334    // =========================================================================
335    // Coverage Metrics
336    // =========================================================================
337
338    /// Get element coverage percentage (0.0 to 1.0).
339    #[must_use]
340    pub fn element_coverage(&self) -> f64 {
341        if self.elements.is_empty() {
342            return 1.0;
343        }
344        self.covered_elements.len() as f64 / self.elements.len() as f64
345    }
346
347    /// Get screen coverage percentage (0.0 to 1.0).
348    #[must_use]
349    pub fn screen_coverage(&self) -> f64 {
350        if self.screens.is_empty() {
351            return 1.0;
352        }
353        self.covered_screens.len() as f64 / self.screens.len() as f64
354    }
355
356    /// Get journey coverage percentage (0.0 to 1.0).
357    #[must_use]
358    pub fn journey_coverage(&self) -> f64 {
359        if self.journeys.is_empty() {
360            return 1.0;
361        }
362        self.completed_journeys.len() as f64 / self.journeys.len() as f64
363    }
364
365    /// Get overall GUI coverage percentage (0.0 to 1.0).
366    #[must_use]
367    pub fn total_coverage(&self) -> f64 {
368        let element = self.element_coverage();
369        let screen = self.screen_coverage();
370        let journey = self.journey_coverage();
371
372        // Weighted average: elements 50%, screens 30%, journeys 20%
373        element * 0.5 + screen * 0.3 + journey * 0.2
374    }
375
376    /// Check if coverage meets threshold.
377    #[must_use]
378    pub fn meets_threshold(&self, threshold: f64) -> bool {
379        self.total_coverage() >= threshold
380    }
381
382    /// Check if 100% coverage is achieved.
383    #[must_use]
384    pub fn is_complete(&self) -> bool {
385        self.element_coverage() >= 1.0
386            && self.screen_coverage() >= 1.0
387            && self.journey_coverage() >= 1.0
388    }
389
390    // =========================================================================
391    // Reporting
392    // =========================================================================
393
394    /// Get a summary string.
395    #[must_use]
396    pub fn summary(&self) -> String {
397        format!(
398            "GUI: {:.0}% ({}/{} elements, {}/{} screens)",
399            self.total_coverage() * 100.0,
400            self.covered_elements.len(),
401            self.elements.len(),
402            self.covered_screens.len(),
403            self.screens.len()
404        )
405    }
406
407    /// Get uncovered elements.
408    #[must_use]
409    pub fn uncovered_elements(&self) -> Vec<&str> {
410        self.elements
411            .iter()
412            .filter(|e| !self.covered_elements.contains(*e))
413            .map(String::as_str)
414            .collect()
415    }
416
417    /// Get uncovered screens.
418    #[must_use]
419    pub fn uncovered_screens(&self) -> Vec<&str> {
420        self.screens
421            .iter()
422            .filter(|s| !self.covered_screens.contains(*s))
423            .map(String::as_str)
424            .collect()
425    }
426
427    /// Get incomplete journeys.
428    #[must_use]
429    pub fn incomplete_journeys(&self) -> Vec<&str> {
430        self.journeys
431            .keys()
432            .filter(|j| !self.completed_journeys.contains(*j))
433            .map(String::as_str)
434            .collect()
435    }
436
437    /// Get total element count.
438    #[must_use]
439    pub fn element_count(&self) -> usize {
440        self.elements.len()
441    }
442
443    /// Get total screen count.
444    #[must_use]
445    pub fn screen_count(&self) -> usize {
446        self.screens.len()
447    }
448
449    /// Get total journey count.
450    #[must_use]
451    pub fn journey_count(&self) -> usize {
452        self.journeys.len()
453    }
454
455    /// Check if an element is registered.
456    #[must_use]
457    pub fn has_element(&self, name: &str) -> bool {
458        self.elements.contains(name)
459    }
460
461    /// Check if a screen is registered.
462    #[must_use]
463    pub fn has_screen(&self, name: &str) -> bool {
464        self.screens.contains(name)
465    }
466
467    /// Generate a detailed coverage report.
468    #[must_use]
469    pub fn detailed_report(&self) -> String {
470        use std::fmt::Write;
471        let mut report = String::new();
472
473        let _ = writeln!(report, "=== GUI Coverage Report: {} ===\n", self.name);
474        let _ = writeln!(report, "Overall: {:.1}%", self.total_coverage() * 100.0);
475        let _ = writeln!(
476            report,
477            "  Elements: {:.1}% ({}/{})",
478            self.element_coverage() * 100.0,
479            self.covered_elements.len(),
480            self.elements.len()
481        );
482        let _ = writeln!(
483            report,
484            "  Screens:  {:.1}% ({}/{})",
485            self.screen_coverage() * 100.0,
486            self.covered_screens.len(),
487            self.screens.len()
488        );
489        let _ = writeln!(
490            report,
491            "  Journeys: {:.1}% ({}/{})",
492            self.journey_coverage() * 100.0,
493            self.completed_journeys.len(),
494            self.journeys.len()
495        );
496
497        let uncovered_elements = self.uncovered_elements();
498        if !uncovered_elements.is_empty() {
499            report.push_str("\nUncovered Elements:\n");
500            for elem in uncovered_elements {
501                let _ = writeln!(report, "  - {elem}");
502            }
503        }
504
505        let uncovered_screens = self.uncovered_screens();
506        if !uncovered_screens.is_empty() {
507            report.push_str("\nUncovered Screens:\n");
508            for screen in uncovered_screens {
509                let _ = writeln!(report, "  - {screen}");
510            }
511        }
512
513        let incomplete = self.incomplete_journeys();
514        if !incomplete.is_empty() {
515            report.push_str("\nIncomplete Journeys:\n");
516            for journey in incomplete {
517                let _ = writeln!(report, "  - {journey}");
518            }
519        }
520
521        report
522    }
523
524    /// Get interaction count.
525    #[must_use]
526    pub fn interaction_count(&self) -> usize {
527        self.interaction_log.len()
528    }
529
530    /// Get name.
531    #[must_use]
532    pub fn name(&self) -> &str {
533        &self.name
534    }
535}
536
537/// Macro for quick GUI coverage setup.
538#[macro_export]
539macro_rules! gui_coverage {
540    ($name:expr => elements: [$($elem:expr),* $(,)?], screens: [$($screen:expr),* $(,)?]) => {{
541        let mut coverage = $crate::edd::gui_coverage::GuiCoverage::new($name);
542        $(coverage.register_element($elem);)*
543        $(coverage.register_screen($screen);)*
544        coverage
545    }};
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn test_new_coverage() {
554        let coverage = GuiCoverage::new("Test");
555        assert_eq!(coverage.name(), "Test");
556        assert!(coverage.elements.is_empty());
557        assert!(coverage.screens.is_empty());
558    }
559
560    #[test]
561    fn test_register_and_cover_elements() {
562        let mut coverage = GuiCoverage::new("Test");
563        coverage.register_element("button1");
564        coverage.register_element("button2");
565
566        assert_eq!(coverage.element_coverage(), 0.0);
567
568        coverage.cover_element("button1");
569        assert!((coverage.element_coverage() - 0.5).abs() < f64::EPSILON);
570
571        coverage.cover_element("button2");
572        assert!((coverage.element_coverage() - 1.0).abs() < f64::EPSILON);
573    }
574
575    #[test]
576    fn test_register_and_cover_screens() {
577        let mut coverage = GuiCoverage::new("Test");
578        coverage.register_screen("main");
579        coverage.register_screen("settings");
580
581        assert_eq!(coverage.screen_coverage(), 0.0);
582
583        coverage.cover_screen("main");
584        assert!((coverage.screen_coverage() - 0.5).abs() < f64::EPSILON);
585    }
586
587    #[test]
588    fn test_journey_completion() {
589        let mut coverage = GuiCoverage::new("Test");
590        coverage.register_element("btn");
591        coverage.register_screen("main");
592        coverage.register_journey("flow", vec!["main", "btn"]);
593
594        coverage.complete_journey("flow");
595
596        assert!(coverage.completed_journeys.contains("flow"));
597        assert!(coverage.covered_elements.contains("btn"));
598        assert!(coverage.covered_screens.contains("main"));
599    }
600
601    #[test]
602    fn test_tsp_tui_preset() {
603        let coverage = GuiCoverage::tsp_tui();
604
605        // Should have TSP-specific elements
606        assert!(coverage.elements.contains("city_plot"));
607        assert!(coverage.elements.contains("convergence_graph"));
608        assert!(coverage.elements.contains("equations_panel"));
609
610        // Should have screens
611        assert!(coverage.screens.contains("main_view"));
612        assert!(coverage.screens.contains("converged_state"));
613
614        // Should have journeys
615        assert!(coverage.journeys.contains_key("basic_run"));
616        assert!(coverage.journeys.contains_key("full_convergence"));
617    }
618
619    #[test]
620    fn test_tsp_wasm_preset() {
621        let coverage = GuiCoverage::tsp_wasm();
622
623        // Should have DOM elements matching TUI
624        assert!(coverage.elements.contains("tsp_canvas"));
625        assert!(coverage.elements.contains("sparkline"));
626        assert!(coverage.elements.contains("btn_play"));
627        assert!(coverage.elements.contains("btn_step"));
628        assert!(coverage.elements.contains("stat_best"));
629
630        // Should have 4 screens (matches TUI)
631        assert!(coverage.screens.contains("main_view"));
632        assert!(coverage.screens.contains("running_state"));
633        assert!(coverage.screens.contains("paused_state"));
634        assert!(coverage.screens.contains("converged_state"));
635
636        // Should have 5 journeys (matches TUI)
637        assert!(coverage.journeys.contains_key("basic_run"));
638        assert!(coverage.journeys.contains_key("full_convergence"));
639        assert!(coverage.journeys.contains_key("single_step"));
640    }
641
642    #[test]
643    fn test_summary() {
644        let mut coverage = GuiCoverage::new("Test");
645        coverage.register_element("e1");
646        coverage.register_element("e2");
647        coverage.register_screen("s1");
648
649        coverage.cover_element("e1");
650
651        let summary = coverage.summary();
652        assert!(summary.contains("1/2 elements"));
653        assert!(summary.contains("0/1 screens"));
654    }
655
656    #[test]
657    fn test_detailed_report() {
658        let mut coverage = GuiCoverage::new("Test");
659        coverage.register_element("covered");
660        coverage.register_element("uncovered");
661        coverage.cover_element("covered");
662
663        let report = coverage.detailed_report();
664        assert!(report.contains("Test"));
665        assert!(report.contains("uncovered"));
666    }
667
668    #[test]
669    fn test_meets_threshold() {
670        let mut coverage = GuiCoverage::new("Test");
671        coverage.register_element("e1");
672        coverage.register_element("e2");
673        coverage.cover_element("e1");
674        coverage.cover_element("e2");
675
676        assert!(coverage.meets_threshold(0.5));
677    }
678
679    #[test]
680    fn test_interaction_logging() {
681        let mut coverage = GuiCoverage::new("Test");
682        coverage.register_element("btn");
683
684        coverage.log_interaction(InteractionKind::Click, "btn", None, 1);
685
686        assert_eq!(coverage.interaction_count(), 1);
687        assert!(coverage.covered_elements.contains("btn"));
688    }
689
690    #[test]
691    fn test_gui_coverage_macro() {
692        let coverage = gui_coverage!("Test" =>
693            elements: ["btn1", "btn2"],
694            screens: ["main"]
695        );
696
697        assert!(coverage.elements.contains("btn1"));
698        assert!(coverage.elements.contains("btn2"));
699        assert!(coverage.screens.contains("main"));
700    }
701}