Skip to main content

jugar_probar/
cdp_coverage.rs

1//! CDP Profiler-based Code Coverage (Issue #10)
2//!
3//! Provides line-level coverage tracking for browser-executed code (JS/WASM)
4//! using Chrome DevTools Protocol's Profiler domain.
5//!
6//! ## Usage
7//!
8//! ```ignore
9//! // Enable coverage collection
10//! page.start_coverage().await?;
11//!
12//! // Navigate and interact
13//! page.goto("http://localhost:8080/demo.html").await?;
14//! page.click("#start_button").await?;
15//!
16//! // Get coverage data
17//! let coverage = page.take_coverage().await?;
18//! println!("Functions covered: {}", coverage.functions_covered());
19//! ```
20
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23
24/// Coverage configuration
25#[derive(Debug, Clone)]
26pub struct CoverageConfig {
27    /// Include call counts for each function
28    pub call_count: bool,
29    /// Include detailed range information
30    pub detailed: bool,
31    /// Allow coverage to be collected multiple times
32    pub allow_triggered_updates: bool,
33}
34
35impl Default for CoverageConfig {
36    fn default() -> Self {
37        Self {
38            call_count: true,
39            detailed: true,
40            allow_triggered_updates: false,
41        }
42    }
43}
44
45impl CoverageConfig {
46    /// Create a new coverage config with defaults
47    #[must_use]
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    /// Enable call count tracking
53    #[must_use]
54    pub const fn with_call_count(mut self, enabled: bool) -> Self {
55        self.call_count = enabled;
56        self
57    }
58
59    /// Enable detailed range information
60    #[must_use]
61    pub const fn with_detailed(mut self, enabled: bool) -> Self {
62        self.detailed = enabled;
63        self
64    }
65}
66
67/// A range of bytes/characters in a script that was covered
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct CoverageRange {
70    /// Start offset (byte position)
71    pub start_offset: u32,
72    /// End offset (byte position)
73    pub end_offset: u32,
74    /// Number of times this range was executed
75    pub count: u32,
76}
77
78/// Coverage data for a single function
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct FunctionCoverage {
81    /// Function name (may be empty for anonymous functions)
82    pub function_name: String,
83    /// Ranges within this function that were covered
84    pub ranges: Vec<CoverageRange>,
85    /// Whether this function was called at all
86    pub is_block_coverage: bool,
87}
88
89impl FunctionCoverage {
90    /// Check if the function was executed at least once
91    #[must_use]
92    pub fn was_executed(&self) -> bool {
93        self.ranges.iter().any(|r| r.count > 0)
94    }
95
96    /// Get total execution count across all ranges
97    #[must_use]
98    pub fn total_count(&self) -> u32 {
99        self.ranges.iter().map(|r| r.count).sum()
100    }
101
102    /// Get the byte range covered
103    #[must_use]
104    pub fn byte_range(&self) -> Option<(u32, u32)> {
105        if self.ranges.is_empty() {
106            return None;
107        }
108        let start = self.ranges.iter().map(|r| r.start_offset).min()?;
109        let end = self.ranges.iter().map(|r| r.end_offset).max()?;
110        Some((start, end))
111    }
112}
113
114/// Coverage data for a single script (JS file or WASM module)
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ScriptCoverage {
117    /// Script ID from CDP
118    pub script_id: String,
119    /// Script URL
120    pub url: String,
121    /// Functions in this script
122    pub functions: Vec<FunctionCoverage>,
123}
124
125impl ScriptCoverage {
126    /// Count functions that were executed
127    #[must_use]
128    pub fn functions_executed(&self) -> usize {
129        self.functions.iter().filter(|f| f.was_executed()).count()
130    }
131
132    /// Count total functions
133    #[must_use]
134    pub fn functions_total(&self) -> usize {
135        self.functions.len()
136    }
137
138    /// Calculate coverage percentage
139    #[must_use]
140    pub fn coverage_percent(&self) -> f64 {
141        if self.functions.is_empty() {
142            return 100.0;
143        }
144        (self.functions_executed() as f64 / self.functions_total() as f64) * 100.0
145    }
146
147    /// Check if this is a WASM module
148    #[must_use]
149    pub fn is_wasm(&self) -> bool {
150        std::path::Path::new(&self.url)
151            .extension()
152            .is_some_and(|ext| ext.eq_ignore_ascii_case("wasm"))
153            || self.url.contains("wasm")
154    }
155}
156
157/// Complete coverage report from a test session
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159pub struct CoverageReport {
160    /// Coverage data per script
161    pub scripts: Vec<ScriptCoverage>,
162    /// Timestamp when coverage was taken
163    pub timestamp_ms: u64,
164}
165
166impl CoverageReport {
167    /// Create empty report
168    #[must_use]
169    pub fn new() -> Self {
170        Self::default()
171    }
172
173    /// Add script coverage
174    pub fn add_script(&mut self, script: ScriptCoverage) {
175        self.scripts.push(script);
176    }
177
178    /// Get total functions covered across all scripts
179    #[must_use]
180    pub fn functions_covered(&self) -> usize {
181        self.scripts.iter().map(|s| s.functions_executed()).sum()
182    }
183
184    /// Get total functions across all scripts
185    #[must_use]
186    pub fn functions_total(&self) -> usize {
187        self.scripts.iter().map(|s| s.functions_total()).sum()
188    }
189
190    /// Calculate overall coverage percentage
191    #[must_use]
192    pub fn coverage_percent(&self) -> f64 {
193        let total = self.functions_total();
194        if total == 0 {
195            return 100.0;
196        }
197        (self.functions_covered() as f64 / total as f64) * 100.0
198    }
199
200    /// Get WASM-only coverage
201    #[must_use]
202    pub fn wasm_coverage(&self) -> WasmCoverage {
203        let wasm_scripts: Vec<_> = self.scripts.iter().filter(|s| s.is_wasm()).collect();
204
205        let functions_covered = wasm_scripts.iter().map(|s| s.functions_executed()).sum();
206        let functions_total = wasm_scripts.iter().map(|s| s.functions_total()).sum();
207
208        WasmCoverage {
209            functions_covered,
210            functions_total,
211            scripts: wasm_scripts.into_iter().cloned().collect(),
212        }
213    }
214
215    /// Get JS-only coverage (excluding WASM)
216    #[must_use]
217    pub fn js_coverage(&self) -> JsCoverage {
218        let js_scripts: Vec<_> = self.scripts.iter().filter(|s| !s.is_wasm()).collect();
219
220        let functions_covered = js_scripts.iter().map(|s| s.functions_executed()).sum();
221        let functions_total = js_scripts.iter().map(|s| s.functions_total()).sum();
222
223        JsCoverage {
224            functions_covered,
225            functions_total,
226            scripts: js_scripts.into_iter().cloned().collect(),
227        }
228    }
229
230    /// Filter to specific URL pattern
231    #[must_use]
232    pub fn filter_by_url(&self, pattern: &str) -> Self {
233        Self {
234            scripts: self
235                .scripts
236                .iter()
237                .filter(|s| s.url.contains(pattern))
238                .cloned()
239                .collect(),
240            timestamp_ms: self.timestamp_ms,
241        }
242    }
243
244    /// Generate a summary string
245    #[must_use]
246    pub fn summary(&self) -> String {
247        let mut s = String::new();
248        s.push_str(&format!(
249            "Coverage: {:.1}% ({}/{})\n",
250            self.coverage_percent(),
251            self.functions_covered(),
252            self.functions_total()
253        ));
254
255        for script in &self.scripts {
256            let icon = if script.is_wasm() { "🦀" } else { "📜" };
257            s.push_str(&format!(
258                "  {} {} - {:.1}% ({}/{})\n",
259                icon,
260                script.url,
261                script.coverage_percent(),
262                script.functions_executed(),
263                script.functions_total()
264            ));
265        }
266
267        s
268    }
269
270    /// Get uncovered functions (useful for debugging)
271    #[must_use]
272    pub fn uncovered_functions(&self) -> Vec<(&str, &str)> {
273        let mut result = Vec::new();
274        for script in &self.scripts {
275            for func in &script.functions {
276                if !func.was_executed() && !func.function_name.is_empty() {
277                    result.push((script.url.as_str(), func.function_name.as_str()));
278                }
279            }
280        }
281        result
282    }
283
284    /// Get covered functions with call counts
285    #[must_use]
286    pub fn covered_functions(&self) -> Vec<CoveredFunction> {
287        let mut result = Vec::new();
288        for script in &self.scripts {
289            for func in &script.functions {
290                if func.was_executed() {
291                    result.push(CoveredFunction {
292                        script_url: script.url.clone(),
293                        function_name: func.function_name.clone(),
294                        call_count: func.total_count(),
295                    });
296                }
297            }
298        }
299        result
300    }
301}
302
303/// A function that was covered during execution
304#[derive(Debug, Clone)]
305pub struct CoveredFunction {
306    /// Script URL
307    pub script_url: String,
308    /// Function name
309    pub function_name: String,
310    /// Number of times called
311    pub call_count: u32,
312}
313
314/// WASM-specific coverage data
315#[derive(Debug, Clone)]
316pub struct WasmCoverage {
317    /// Functions covered in WASM modules
318    pub functions_covered: usize,
319    /// Total functions in WASM modules
320    pub functions_total: usize,
321    /// WASM scripts
322    pub scripts: Vec<ScriptCoverage>,
323}
324
325impl WasmCoverage {
326    /// Calculate coverage percentage
327    #[must_use]
328    pub fn coverage_percent(&self) -> f64 {
329        if self.functions_total == 0 {
330            return 100.0;
331        }
332        (self.functions_covered as f64 / self.functions_total as f64) * 100.0
333    }
334}
335
336/// JS-specific coverage data
337#[derive(Debug, Clone)]
338pub struct JsCoverage {
339    /// Functions covered in JS files
340    pub functions_covered: usize,
341    /// Total functions in JS files
342    pub functions_total: usize,
343    /// JS scripts
344    pub scripts: Vec<ScriptCoverage>,
345}
346
347impl JsCoverage {
348    /// Calculate coverage percentage
349    #[must_use]
350    pub fn coverage_percent(&self) -> f64 {
351        if self.functions_total == 0 {
352            return 100.0;
353        }
354        (self.functions_covered as f64 / self.functions_total as f64) * 100.0
355    }
356}
357
358/// Source map entry for mapping WASM offsets to Rust source
359#[derive(Debug, Clone)]
360pub struct SourceMapEntry {
361    /// WASM byte offset
362    pub wasm_offset: u32,
363    /// Source file path
364    pub source_file: String,
365    /// Line number (1-indexed)
366    pub line: u32,
367    /// Column number (0-indexed)
368    pub column: u32,
369}
370
371/// Source map for WASM to Rust mapping
372#[derive(Debug, Clone, Default)]
373pub struct WasmSourceMap {
374    /// Mappings from WASM offset to source location
375    pub entries: Vec<SourceMapEntry>,
376    /// Source file contents (for line extraction)
377    pub sources: HashMap<String, Vec<String>>,
378}
379
380impl WasmSourceMap {
381    /// Create empty source map
382    #[must_use]
383    pub fn new() -> Self {
384        Self::default()
385    }
386
387    /// Look up source location for a WASM offset
388    #[must_use]
389    pub fn lookup(&self, offset: u32) -> Option<&SourceMapEntry> {
390        // Find the entry with the largest offset <= the target
391        self.entries
392            .iter()
393            .filter(|e| e.wasm_offset <= offset)
394            .max_by_key(|e| e.wasm_offset)
395    }
396
397    /// Map coverage ranges to source lines
398    pub fn map_coverage(&self, coverage: &CoverageReport) -> LineCoverage {
399        let mut line_coverage = LineCoverage::new();
400
401        for script in &coverage.scripts {
402            if !script.is_wasm() {
403                continue;
404            }
405
406            for func in &script.functions {
407                for range in &func.ranges {
408                    // Map start and end offsets to source lines
409                    if let Some(start_entry) = self.lookup(range.start_offset) {
410                        line_coverage.mark_covered(
411                            &start_entry.source_file,
412                            start_entry.line,
413                            range.count,
414                        );
415                    }
416                    if let Some(end_entry) = self.lookup(range.end_offset) {
417                        line_coverage.mark_covered(
418                            &end_entry.source_file,
419                            end_entry.line,
420                            range.count,
421                        );
422                    }
423                }
424            }
425        }
426
427        line_coverage
428    }
429}
430
431/// Line-level coverage data
432#[derive(Debug, Clone, Default)]
433pub struct LineCoverage {
434    /// Coverage per file: file -> (line -> count)
435    pub files: HashMap<String, HashMap<u32, u32>>,
436}
437
438impl LineCoverage {
439    /// Create empty line coverage
440    #[must_use]
441    pub fn new() -> Self {
442        Self::default()
443    }
444
445    /// Mark a line as covered
446    pub fn mark_covered(&mut self, file: &str, line: u32, count: u32) {
447        let file_coverage = self.files.entry(file.to_string()).or_default();
448        let current = file_coverage.entry(line).or_insert(0);
449        *current = (*current).saturating_add(count);
450    }
451
452    /// Check if a line was covered
453    #[must_use]
454    pub fn is_covered(&self, file: &str, line: u32) -> bool {
455        self.files
456            .get(file)
457            .and_then(|f| f.get(&line))
458            .is_some_and(|&count| count > 0)
459    }
460
461    /// Get coverage count for a line
462    #[must_use]
463    pub fn get_count(&self, file: &str, line: u32) -> u32 {
464        self.files
465            .get(file)
466            .and_then(|f| f.get(&line))
467            .copied()
468            .unwrap_or(0)
469    }
470
471    /// Get covered lines for a file
472    #[must_use]
473    pub fn covered_lines(&self, file: &str) -> Vec<u32> {
474        self.files
475            .get(file)
476            .map(|f| f.keys().copied().collect())
477            .unwrap_or_default()
478    }
479}
480
481// ============================================================================
482// Tests
483// ============================================================================
484
485#[cfg(test)]
486#[allow(clippy::unwrap_used, clippy::expect_used)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn test_coverage_config_default() {
492        let config = CoverageConfig::default();
493        assert!(config.call_count);
494        assert!(config.detailed);
495        assert!(!config.allow_triggered_updates);
496    }
497
498    #[test]
499    fn test_coverage_config_builder() {
500        let config = CoverageConfig::new()
501            .with_call_count(false)
502            .with_detailed(true);
503        assert!(!config.call_count);
504        assert!(config.detailed);
505    }
506
507    #[test]
508    fn test_coverage_range() {
509        let range = CoverageRange {
510            start_offset: 0,
511            end_offset: 100,
512            count: 5,
513        };
514        assert_eq!(range.count, 5);
515    }
516
517    #[test]
518    fn test_function_coverage_executed() {
519        let func = FunctionCoverage {
520            function_name: "test_fn".to_string(),
521            ranges: vec![CoverageRange {
522                start_offset: 0,
523                end_offset: 50,
524                count: 3,
525            }],
526            is_block_coverage: false,
527        };
528        assert!(func.was_executed());
529        assert_eq!(func.total_count(), 3);
530    }
531
532    #[test]
533    fn test_function_coverage_not_executed() {
534        let func = FunctionCoverage {
535            function_name: "unused_fn".to_string(),
536            ranges: vec![CoverageRange {
537                start_offset: 0,
538                end_offset: 50,
539                count: 0,
540            }],
541            is_block_coverage: false,
542        };
543        assert!(!func.was_executed());
544        assert_eq!(func.total_count(), 0);
545    }
546
547    #[test]
548    fn test_function_byte_range() {
549        let func = FunctionCoverage {
550            function_name: "test".to_string(),
551            ranges: vec![
552                CoverageRange {
553                    start_offset: 10,
554                    end_offset: 30,
555                    count: 1,
556                },
557                CoverageRange {
558                    start_offset: 50,
559                    end_offset: 100,
560                    count: 1,
561                },
562            ],
563            is_block_coverage: false,
564        };
565        assert_eq!(func.byte_range(), Some((10, 100)));
566    }
567
568    #[test]
569    fn test_script_coverage() {
570        let script = ScriptCoverage {
571            script_id: "1".to_string(),
572            url: "http://localhost/test.js".to_string(),
573            functions: vec![
574                FunctionCoverage {
575                    function_name: "covered".to_string(),
576                    ranges: vec![CoverageRange {
577                        start_offset: 0,
578                        end_offset: 50,
579                        count: 1,
580                    }],
581                    is_block_coverage: false,
582                },
583                FunctionCoverage {
584                    function_name: "uncovered".to_string(),
585                    ranges: vec![CoverageRange {
586                        start_offset: 50,
587                        end_offset: 100,
588                        count: 0,
589                    }],
590                    is_block_coverage: false,
591                },
592            ],
593        };
594        assert_eq!(script.functions_executed(), 1);
595        assert_eq!(script.functions_total(), 2);
596        assert!((script.coverage_percent() - 50.0).abs() < 0.01);
597        assert!(!script.is_wasm());
598    }
599
600    #[test]
601    fn test_script_is_wasm() {
602        let wasm_script = ScriptCoverage {
603            script_id: "1".to_string(),
604            url: "http://localhost/app.wasm".to_string(),
605            functions: vec![],
606        };
607        assert!(wasm_script.is_wasm());
608
609        let js_script = ScriptCoverage {
610            script_id: "2".to_string(),
611            url: "http://localhost/app.js".to_string(),
612            functions: vec![],
613        };
614        assert!(!js_script.is_wasm());
615    }
616
617    #[test]
618    fn test_coverage_report_summary() {
619        let mut report = CoverageReport::new();
620        report.add_script(ScriptCoverage {
621            script_id: "1".to_string(),
622            url: "http://localhost/app.wasm".to_string(),
623            functions: vec![FunctionCoverage {
624                function_name: "main".to_string(),
625                ranges: vec![CoverageRange {
626                    start_offset: 0,
627                    end_offset: 100,
628                    count: 1,
629                }],
630                is_block_coverage: false,
631            }],
632        });
633
634        assert_eq!(report.functions_covered(), 1);
635        assert_eq!(report.functions_total(), 1);
636        assert!((report.coverage_percent() - 100.0).abs() < 0.01);
637    }
638
639    #[test]
640    fn test_coverage_report_wasm_only() {
641        let mut report = CoverageReport::new();
642        report.add_script(ScriptCoverage {
643            script_id: "1".to_string(),
644            url: "http://localhost/app.wasm".to_string(),
645            functions: vec![FunctionCoverage {
646                function_name: "wasm_fn".to_string(),
647                ranges: vec![CoverageRange {
648                    start_offset: 0,
649                    end_offset: 100,
650                    count: 1,
651                }],
652                is_block_coverage: false,
653            }],
654        });
655        report.add_script(ScriptCoverage {
656            script_id: "2".to_string(),
657            url: "http://localhost/app.js".to_string(),
658            functions: vec![FunctionCoverage {
659                function_name: "js_fn".to_string(),
660                ranges: vec![CoverageRange {
661                    start_offset: 0,
662                    end_offset: 50,
663                    count: 1,
664                }],
665                is_block_coverage: false,
666            }],
667        });
668
669        let wasm = report.wasm_coverage();
670        assert_eq!(wasm.functions_covered, 1);
671        assert_eq!(wasm.functions_total, 1);
672
673        let js = report.js_coverage();
674        assert_eq!(js.functions_covered, 1);
675        assert_eq!(js.functions_total, 1);
676    }
677
678    #[test]
679    fn test_coverage_report_filter() {
680        let mut report = CoverageReport::new();
681        report.add_script(ScriptCoverage {
682            script_id: "1".to_string(),
683            url: "http://localhost/myapp.wasm".to_string(),
684            functions: vec![],
685        });
686        report.add_script(ScriptCoverage {
687            script_id: "2".to_string(),
688            url: "http://localhost/vendor.js".to_string(),
689            functions: vec![],
690        });
691
692        let filtered = report.filter_by_url("myapp");
693        assert_eq!(filtered.scripts.len(), 1);
694        assert!(filtered.scripts[0].url.contains("myapp"));
695    }
696
697    #[test]
698    fn test_uncovered_functions() {
699        let mut report = CoverageReport::new();
700        report.add_script(ScriptCoverage {
701            script_id: "1".to_string(),
702            url: "test.js".to_string(),
703            functions: vec![
704                FunctionCoverage {
705                    function_name: "covered".to_string(),
706                    ranges: vec![CoverageRange {
707                        start_offset: 0,
708                        end_offset: 50,
709                        count: 1,
710                    }],
711                    is_block_coverage: false,
712                },
713                FunctionCoverage {
714                    function_name: "uncovered".to_string(),
715                    ranges: vec![CoverageRange {
716                        start_offset: 50,
717                        end_offset: 100,
718                        count: 0,
719                    }],
720                    is_block_coverage: false,
721                },
722            ],
723        });
724
725        let uncovered = report.uncovered_functions();
726        assert_eq!(uncovered.len(), 1);
727        assert_eq!(uncovered[0].1, "uncovered");
728    }
729
730    #[test]
731    fn test_covered_functions() {
732        let mut report = CoverageReport::new();
733        report.add_script(ScriptCoverage {
734            script_id: "1".to_string(),
735            url: "test.js".to_string(),
736            functions: vec![FunctionCoverage {
737                function_name: "my_fn".to_string(),
738                ranges: vec![CoverageRange {
739                    start_offset: 0,
740                    end_offset: 50,
741                    count: 5,
742                }],
743                is_block_coverage: false,
744            }],
745        });
746
747        let covered = report.covered_functions();
748        assert_eq!(covered.len(), 1);
749        assert_eq!(covered[0].function_name, "my_fn");
750        assert_eq!(covered[0].call_count, 5);
751    }
752
753    #[test]
754    fn test_line_coverage() {
755        let mut lc = LineCoverage::new();
756        lc.mark_covered("src/lib.rs", 10, 1);
757        lc.mark_covered("src/lib.rs", 10, 2);
758        lc.mark_covered("src/lib.rs", 20, 1);
759
760        assert!(lc.is_covered("src/lib.rs", 10));
761        assert!(lc.is_covered("src/lib.rs", 20));
762        assert!(!lc.is_covered("src/lib.rs", 30));
763        assert_eq!(lc.get_count("src/lib.rs", 10), 3);
764    }
765
766    #[test]
767    fn test_wasm_source_map_lookup() {
768        let mut sm = WasmSourceMap::new();
769        sm.entries.push(SourceMapEntry {
770            wasm_offset: 0,
771            source_file: "src/lib.rs".to_string(),
772            line: 1,
773            column: 0,
774        });
775        sm.entries.push(SourceMapEntry {
776            wasm_offset: 100,
777            source_file: "src/lib.rs".to_string(),
778            line: 10,
779            column: 0,
780        });
781
782        let entry = sm.lookup(50).unwrap();
783        assert_eq!(entry.line, 1);
784
785        let entry = sm.lookup(150).unwrap();
786        assert_eq!(entry.line, 10);
787    }
788
789    // =========================================================================
790    // Additional tests for 95% coverage
791    // =========================================================================
792
793    #[test]
794    fn test_function_byte_range_empty() {
795        let func = FunctionCoverage {
796            function_name: "empty".to_string(),
797            ranges: vec![],
798            is_block_coverage: false,
799        };
800        assert!(func.byte_range().is_none());
801    }
802
803    #[test]
804    fn test_function_byte_range_single_range() {
805        let func = FunctionCoverage {
806            function_name: "single".to_string(),
807            ranges: vec![CoverageRange {
808                start_offset: 10,
809                end_offset: 50,
810                count: 1,
811            }],
812            is_block_coverage: false,
813        };
814        assert_eq!(func.byte_range(), Some((10, 50)));
815    }
816
817    #[test]
818    fn test_script_coverage_percent_empty_functions() {
819        let script = ScriptCoverage {
820            script_id: "1".to_string(),
821            url: "test.js".to_string(),
822            functions: vec![],
823        };
824        // Empty functions should return 100%
825        assert!((script.coverage_percent() - 100.0).abs() < 0.01);
826    }
827
828    #[test]
829    fn test_coverage_report_coverage_percent_zero_total() {
830        let report = CoverageReport::new();
831        // Empty report should return 100%
832        assert!((report.coverage_percent() - 100.0).abs() < 0.01);
833    }
834
835    #[test]
836    fn test_wasm_coverage_percent_zero_total() {
837        let wasm = WasmCoverage {
838            functions_covered: 0,
839            functions_total: 0,
840            scripts: vec![],
841        };
842        // Zero total should return 100%
843        assert!((wasm.coverage_percent() - 100.0).abs() < 0.01);
844    }
845
846    #[test]
847    fn test_js_coverage_percent_zero_total() {
848        let js = JsCoverage {
849            functions_covered: 0,
850            functions_total: 0,
851            scripts: vec![],
852        };
853        // Zero total should return 100%
854        assert!((js.coverage_percent() - 100.0).abs() < 0.01);
855    }
856
857    #[test]
858    fn test_wasm_coverage_percent_partial() {
859        let wasm = WasmCoverage {
860            functions_covered: 3,
861            functions_total: 10,
862            scripts: vec![],
863        };
864        assert!((wasm.coverage_percent() - 30.0).abs() < 0.01);
865    }
866
867    #[test]
868    fn test_js_coverage_percent_partial() {
869        let js = JsCoverage {
870            functions_covered: 7,
871            functions_total: 10,
872            scripts: vec![],
873        };
874        assert!((js.coverage_percent() - 70.0).abs() < 0.01);
875    }
876
877    #[test]
878    fn test_line_coverage_covered_lines() {
879        let mut lc = LineCoverage::new();
880        lc.mark_covered("src/lib.rs", 10, 1);
881        lc.mark_covered("src/lib.rs", 20, 2);
882        lc.mark_covered("src/lib.rs", 30, 3);
883
884        let lines = lc.covered_lines("src/lib.rs");
885        assert_eq!(lines.len(), 3);
886        assert!(lines.contains(&10));
887        assert!(lines.contains(&20));
888        assert!(lines.contains(&30));
889    }
890
891    #[test]
892    fn test_line_coverage_covered_lines_nonexistent_file() {
893        let lc = LineCoverage::new();
894        let lines = lc.covered_lines("nonexistent.rs");
895        assert!(lines.is_empty());
896    }
897
898    #[test]
899    fn test_line_coverage_get_count_nonexistent() {
900        let lc = LineCoverage::new();
901        assert_eq!(lc.get_count("src/lib.rs", 10), 0);
902    }
903
904    #[test]
905    fn test_line_coverage_is_covered_false() {
906        let lc = LineCoverage::new();
907        assert!(!lc.is_covered("src/lib.rs", 10));
908    }
909
910    #[test]
911    fn test_coverage_report_summary_format() {
912        let mut report = CoverageReport::new();
913        report.add_script(ScriptCoverage {
914            script_id: "1".to_string(),
915            url: "http://localhost/app.wasm".to_string(),
916            functions: vec![
917                FunctionCoverage {
918                    function_name: "covered".to_string(),
919                    ranges: vec![CoverageRange {
920                        start_offset: 0,
921                        end_offset: 50,
922                        count: 1,
923                    }],
924                    is_block_coverage: false,
925                },
926                FunctionCoverage {
927                    function_name: "uncovered".to_string(),
928                    ranges: vec![CoverageRange {
929                        start_offset: 50,
930                        end_offset: 100,
931                        count: 0,
932                    }],
933                    is_block_coverage: false,
934                },
935            ],
936        });
937
938        let summary = report.summary();
939        assert!(summary.contains("Coverage:"));
940        assert!(summary.contains("50.0%"));
941        assert!(summary.contains("1/2"));
942    }
943
944    #[test]
945    fn test_wasm_source_map_map_coverage() {
946        let mut sm = WasmSourceMap::new();
947        sm.entries.push(SourceMapEntry {
948            wasm_offset: 0,
949            source_file: "src/lib.rs".to_string(),
950            line: 1,
951            column: 0,
952        });
953        sm.entries.push(SourceMapEntry {
954            wasm_offset: 100,
955            source_file: "src/lib.rs".to_string(),
956            line: 10,
957            column: 0,
958        });
959
960        let mut report = CoverageReport::new();
961        report.add_script(ScriptCoverage {
962            script_id: "1".to_string(),
963            url: "http://localhost/app.wasm".to_string(),
964            functions: vec![FunctionCoverage {
965                function_name: "test_fn".to_string(),
966                ranges: vec![CoverageRange {
967                    start_offset: 50,
968                    end_offset: 150,
969                    count: 3,
970                }],
971                is_block_coverage: false,
972            }],
973        });
974
975        let line_coverage = sm.map_coverage(&report);
976        // Start offset 50 maps to line 1, end offset 150 maps to line 10
977        assert!(line_coverage.is_covered("src/lib.rs", 1));
978        assert!(line_coverage.is_covered("src/lib.rs", 10));
979    }
980
981    #[test]
982    fn test_wasm_source_map_map_coverage_skips_js() {
983        let sm = WasmSourceMap::new();
984
985        let mut report = CoverageReport::new();
986        report.add_script(ScriptCoverage {
987            script_id: "1".to_string(),
988            url: "http://localhost/app.js".to_string(), // Not WASM
989            functions: vec![FunctionCoverage {
990                function_name: "js_fn".to_string(),
991                ranges: vec![CoverageRange {
992                    start_offset: 0,
993                    end_offset: 100,
994                    count: 1,
995                }],
996                is_block_coverage: false,
997            }],
998        });
999
1000        let line_coverage = sm.map_coverage(&report);
1001        // Should not map JS files
1002        assert!(line_coverage.files.is_empty());
1003    }
1004
1005    #[test]
1006    fn test_wasm_source_map_lookup_no_match() {
1007        let sm = WasmSourceMap::new();
1008        // Empty source map should return None
1009        assert!(sm.lookup(50).is_none());
1010    }
1011
1012    #[test]
1013    fn test_wasm_source_map_lookup_exact_match() {
1014        let mut sm = WasmSourceMap::new();
1015        sm.entries.push(SourceMapEntry {
1016            wasm_offset: 100,
1017            source_file: "src/lib.rs".to_string(),
1018            line: 10,
1019            column: 5,
1020        });
1021
1022        let entry = sm.lookup(100).unwrap();
1023        assert_eq!(entry.wasm_offset, 100);
1024        assert_eq!(entry.line, 10);
1025    }
1026
1027    #[test]
1028    fn test_script_is_wasm_case_insensitive() {
1029        let wasm_script = ScriptCoverage {
1030            script_id: "1".to_string(),
1031            url: "http://localhost/app.WASM".to_string(),
1032            functions: vec![],
1033        };
1034        assert!(wasm_script.is_wasm());
1035    }
1036
1037    #[test]
1038    fn test_script_is_wasm_url_contains() {
1039        let wasm_script = ScriptCoverage {
1040            script_id: "1".to_string(),
1041            url: "http://localhost/wasm/module".to_string(),
1042            functions: vec![],
1043        };
1044        assert!(wasm_script.is_wasm());
1045    }
1046
1047    #[test]
1048    fn test_coverage_config_new() {
1049        let config = CoverageConfig::new();
1050        assert!(config.call_count);
1051        assert!(config.detailed);
1052        assert!(!config.allow_triggered_updates);
1053    }
1054
1055    #[test]
1056    fn test_coverage_report_timestamp() {
1057        let mut report = CoverageReport::new();
1058        report.timestamp_ms = 1234567890;
1059        assert_eq!(report.timestamp_ms, 1234567890);
1060    }
1061
1062    #[test]
1063    fn test_coverage_report_filter_by_url_no_match() {
1064        let mut report = CoverageReport::new();
1065        report.add_script(ScriptCoverage {
1066            script_id: "1".to_string(),
1067            url: "http://localhost/app.js".to_string(),
1068            functions: vec![],
1069        });
1070
1071        let filtered = report.filter_by_url("nonexistent");
1072        assert!(filtered.scripts.is_empty());
1073    }
1074
1075    #[test]
1076    fn test_uncovered_functions_anonymous() {
1077        let mut report = CoverageReport::new();
1078        report.add_script(ScriptCoverage {
1079            script_id: "1".to_string(),
1080            url: "test.js".to_string(),
1081            functions: vec![
1082                FunctionCoverage {
1083                    function_name: String::new(), // Anonymous
1084                    ranges: vec![CoverageRange {
1085                        start_offset: 0,
1086                        end_offset: 50,
1087                        count: 0, // Not executed
1088                    }],
1089                    is_block_coverage: false,
1090                },
1091                FunctionCoverage {
1092                    function_name: "named".to_string(),
1093                    ranges: vec![CoverageRange {
1094                        start_offset: 50,
1095                        end_offset: 100,
1096                        count: 0, // Not executed
1097                    }],
1098                    is_block_coverage: false,
1099                },
1100            ],
1101        });
1102
1103        let uncovered = report.uncovered_functions();
1104        // Should only include named function
1105        assert_eq!(uncovered.len(), 1);
1106        assert_eq!(uncovered[0].1, "named");
1107    }
1108
1109    #[test]
1110    fn test_covered_function_struct() {
1111        let func = CoveredFunction {
1112            script_url: "test.js".to_string(),
1113            function_name: "my_func".to_string(),
1114            call_count: 5,
1115        };
1116        assert_eq!(func.script_url, "test.js");
1117        assert_eq!(func.function_name, "my_func");
1118        assert_eq!(func.call_count, 5);
1119    }
1120
1121    #[test]
1122    fn test_source_map_entry_fields() {
1123        let entry = SourceMapEntry {
1124            wasm_offset: 100,
1125            source_file: "src/main.rs".to_string(),
1126            line: 42,
1127            column: 8,
1128        };
1129        assert_eq!(entry.wasm_offset, 100);
1130        assert_eq!(entry.source_file, "src/main.rs");
1131        assert_eq!(entry.line, 42);
1132        assert_eq!(entry.column, 8);
1133    }
1134
1135    #[test]
1136    fn test_line_coverage_mark_covered_saturating() {
1137        let mut lc = LineCoverage::new();
1138        // Test saturating_add doesn't overflow
1139        lc.mark_covered("src/lib.rs", 10, u32::MAX);
1140        lc.mark_covered("src/lib.rs", 10, 1);
1141        assert_eq!(lc.get_count("src/lib.rs", 10), u32::MAX);
1142    }
1143
1144    #[test]
1145    fn test_function_coverage_total_count_multiple_ranges() {
1146        let func = FunctionCoverage {
1147            function_name: "multi".to_string(),
1148            ranges: vec![
1149                CoverageRange {
1150                    start_offset: 0,
1151                    end_offset: 10,
1152                    count: 5,
1153                },
1154                CoverageRange {
1155                    start_offset: 10,
1156                    end_offset: 20,
1157                    count: 3,
1158                },
1159                CoverageRange {
1160                    start_offset: 20,
1161                    end_offset: 30,
1162                    count: 2,
1163                },
1164            ],
1165            is_block_coverage: true,
1166        };
1167        assert_eq!(func.total_count(), 10);
1168        assert!(func.is_block_coverage);
1169    }
1170
1171    #[test]
1172    fn test_wasm_source_map_sources_field() {
1173        let mut sm = WasmSourceMap::new();
1174        sm.sources
1175            .insert("src/lib.rs".to_string(), vec!["fn main() {}".to_string()]);
1176
1177        assert!(sm.sources.contains_key("src/lib.rs"));
1178        assert_eq!(sm.sources["src/lib.rs"].len(), 1);
1179    }
1180
1181    #[test]
1182    fn test_coverage_report_serialize_deserialize() {
1183        let mut report = CoverageReport::new();
1184        report.timestamp_ms = 1000;
1185        report.add_script(ScriptCoverage {
1186            script_id: "1".to_string(),
1187            url: "test.js".to_string(),
1188            functions: vec![FunctionCoverage {
1189                function_name: "test".to_string(),
1190                ranges: vec![CoverageRange {
1191                    start_offset: 0,
1192                    end_offset: 50,
1193                    count: 1,
1194                }],
1195                is_block_coverage: false,
1196            }],
1197        });
1198
1199        let json = serde_json::to_string(&report).unwrap();
1200        let deserialized: CoverageReport = serde_json::from_str(&json).unwrap();
1201
1202        assert_eq!(deserialized.timestamp_ms, 1000);
1203        assert_eq!(deserialized.scripts.len(), 1);
1204        assert_eq!(deserialized.functions_covered(), 1);
1205    }
1206
1207    #[test]
1208    fn test_line_coverage_default() {
1209        let lc = LineCoverage::default();
1210        assert!(lc.files.is_empty());
1211    }
1212
1213    #[test]
1214    fn test_wasm_source_map_default() {
1215        let sm = WasmSourceMap::default();
1216        assert!(sm.entries.is_empty());
1217        assert!(sm.sources.is_empty());
1218    }
1219}