Skip to main content

fastapi_core/
coverage.rs

1//! Code coverage integration for fastapi_rust.
2//!
3//! This module provides coverage tracking and reporting infrastructure for
4//! testing fastapi applications. It integrates with `cargo-llvm-cov` for
5//! line-level coverage and provides per-endpoint coverage tracking.
6//!
7//! # Features
8//!
9//! - **Endpoint coverage tracking**: Track which routes are tested
10//! - **Branch coverage hints**: Track error paths and edge cases
11//! - **Threshold enforcement**: Fail tests if coverage drops below threshold
12//! - **Report generation**: JSON, HTML, and badge formats
13//!
14//! # Example
15//!
16//! ```ignore
17//! use fastapi_core::coverage::{CoverageTracker, CoverageConfig};
18//!
19//! // Create a coverage tracker
20//! let tracker = CoverageTracker::new();
21//!
22//! // Run tests with tracking
23//! let client = TestClient::new(app).with_coverage(&tracker);
24//! client.get("/users").send();
25//! client.post("/users").json(&user).send();
26//!
27//! // Generate report
28//! let report = tracker.report();
29//! report.assert_threshold(0.80); // Fail if < 80% coverage
30//! report.write_json("coverage.json")?;
31//! report.write_html("coverage.html")?;
32//! ```
33//!
34//! # CI Integration
35//!
36//! Use with `cargo-llvm-cov` for full line-level coverage:
37//!
38//! ```bash
39//! # Install coverage tools
40//! cargo install cargo-llvm-cov
41//!
42//! # Run with coverage
43//! cargo llvm-cov --html --open
44//!
45//! # CI: Check threshold
46//! cargo llvm-cov --fail-under-lines 80
47//! ```
48
49use parking_lot::Mutex;
50use std::collections::{BTreeMap, HashMap};
51use std::fmt;
52use std::io;
53use std::sync::Arc;
54
55use crate::request::Method;
56
57/// Configuration for coverage tracking.
58#[derive(Debug, Clone)]
59pub struct CoverageConfig {
60    /// Minimum line coverage percentage (0.0 - 1.0).
61    pub line_threshold: f64,
62    /// Minimum branch coverage percentage (0.0 - 1.0).
63    pub branch_threshold: f64,
64    /// Minimum endpoint coverage percentage (0.0 - 1.0).
65    pub endpoint_threshold: f64,
66    /// Whether to fail tests below threshold.
67    pub fail_on_threshold: bool,
68    /// Output formats to generate.
69    pub output_formats: Vec<OutputFormat>,
70    /// Directory for coverage reports.
71    pub output_dir: String,
72}
73
74impl Default for CoverageConfig {
75    fn default() -> Self {
76        Self {
77            line_threshold: 0.80,
78            branch_threshold: 0.70,
79            endpoint_threshold: 0.90,
80            fail_on_threshold: true,
81            output_formats: vec![OutputFormat::Json, OutputFormat::Html],
82            output_dir: "target/coverage".into(),
83        }
84    }
85}
86
87impl CoverageConfig {
88    /// Create a new configuration with default values.
89    #[must_use]
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Set the line coverage threshold.
95    #[must_use]
96    pub fn line_threshold(mut self, threshold: f64) -> Self {
97        self.line_threshold = threshold.clamp(0.0, 1.0);
98        self
99    }
100
101    /// Set the branch coverage threshold.
102    #[must_use]
103    pub fn branch_threshold(mut self, threshold: f64) -> Self {
104        self.branch_threshold = threshold.clamp(0.0, 1.0);
105        self
106    }
107
108    /// Set the endpoint coverage threshold.
109    #[must_use]
110    pub fn endpoint_threshold(mut self, threshold: f64) -> Self {
111        self.endpoint_threshold = threshold.clamp(0.0, 1.0);
112        self
113    }
114
115    /// Disable failing on threshold violations.
116    #[must_use]
117    pub fn no_fail(mut self) -> Self {
118        self.fail_on_threshold = false;
119        self
120    }
121
122    /// Set output formats.
123    #[must_use]
124    pub fn output_formats(mut self, formats: Vec<OutputFormat>) -> Self {
125        self.output_formats = formats;
126        self
127    }
128
129    /// Set output directory.
130    #[must_use]
131    pub fn output_dir(mut self, dir: impl Into<String>) -> Self {
132        self.output_dir = dir.into();
133        self
134    }
135}
136
137/// Output format for coverage reports.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum OutputFormat {
140    /// JSON format for CI integration.
141    Json,
142    /// HTML format for human review.
143    Html,
144    /// SVG badge for README.
145    Badge,
146    /// LCOV format for external tools.
147    Lcov,
148}
149
150/// Tracks endpoint coverage during test execution.
151///
152/// Thread-safe and can be shared across multiple test clients.
153#[derive(Debug, Clone)]
154pub struct CoverageTracker {
155    inner: Arc<Mutex<CoverageTrackerInner>>,
156}
157
158#[derive(Debug, Default)]
159struct CoverageTrackerInner {
160    /// Registered endpoints (method, path pattern).
161    registered_endpoints: Vec<(Method, String)>,
162    /// Hit counts per endpoint.
163    endpoint_hits: HashMap<(Method, String), EndpointHits>,
164    /// Branch coverage hints.
165    branches: HashMap<String, BranchHits>,
166}
167
168/// Endpoint hit statistics.
169#[derive(Debug, Clone, Default)]
170pub struct EndpointHits {
171    /// Total number of times this endpoint was called.
172    pub total_calls: u64,
173    /// Number of successful responses (2xx).
174    pub success_count: u64,
175    /// Number of client error responses (4xx).
176    pub client_error_count: u64,
177    /// Number of server error responses (5xx).
178    pub server_error_count: u64,
179    /// Status codes observed.
180    pub status_codes: HashMap<u16, u64>,
181}
182
183/// Branch coverage for specific code paths.
184#[derive(Debug, Clone, Default)]
185pub struct BranchHits {
186    /// Number of times the branch was taken.
187    pub taken_count: u64,
188    /// Number of times the branch was not taken.
189    pub not_taken_count: u64,
190}
191
192impl CoverageTracker {
193    /// Create a new coverage tracker.
194    #[must_use]
195    pub fn new() -> Self {
196        Self {
197            inner: Arc::new(Mutex::new(CoverageTrackerInner::default())),
198        }
199    }
200
201    /// Register an endpoint for coverage tracking.
202    pub fn register_endpoint(&self, method: Method, path: impl Into<String>) {
203        let mut inner = self.inner.lock();
204        inner.registered_endpoints.push((method, path.into()));
205    }
206
207    /// Register multiple endpoints from a route table.
208    pub fn register_endpoints<'a>(&self, endpoints: impl IntoIterator<Item = (Method, &'a str)>) {
209        let mut inner = self.inner.lock();
210        for (method, path) in endpoints {
211            inner.registered_endpoints.push((method, path.to_string()));
212        }
213    }
214
215    /// Record a hit on an endpoint.
216    pub fn record_hit(&self, method: Method, path: &str, status_code: u16) {
217        let mut inner = self.inner.lock();
218
219        let key = (method, path.to_string());
220        let hits = inner.endpoint_hits.entry(key).or_default();
221
222        hits.total_calls += 1;
223        *hits.status_codes.entry(status_code).or_insert(0) += 1;
224
225        match status_code {
226            200..=299 => hits.success_count += 1,
227            400..=499 => hits.client_error_count += 1,
228            500..=599 => hits.server_error_count += 1,
229            _ => {}
230        }
231    }
232
233    /// Record a branch hit.
234    pub fn record_branch(&self, branch_id: impl Into<String>, taken: bool) {
235        let mut inner = self.inner.lock();
236
237        let branch = inner.branches.entry(branch_id.into()).or_default();
238        if taken {
239            branch.taken_count += 1;
240        } else {
241            branch.not_taken_count += 1;
242        }
243    }
244
245    /// Generate a coverage report.
246    #[must_use]
247    pub fn report(&self) -> CoverageReport {
248        let inner = self.inner.lock();
249
250        let mut endpoints = BTreeMap::new();
251        for (method, path) in &inner.registered_endpoints {
252            let key = (*method, path.clone());
253            let hits = inner.endpoint_hits.get(&key).cloned().unwrap_or_default();
254            endpoints.insert((method.as_str().to_string(), path.clone()), hits);
255        }
256
257        // Find endpoints that were hit but not registered
258        for ((method, path), hits) in &inner.endpoint_hits {
259            let key = (method.as_str().to_string(), path.clone());
260            endpoints.entry(key).or_insert_with(|| hits.clone());
261        }
262
263        let branches = inner.branches.clone();
264
265        CoverageReport {
266            endpoints,
267            branches,
268        }
269    }
270
271    /// Reset all coverage data.
272    pub fn reset(&self) {
273        let mut inner = self.inner.lock();
274        inner.endpoint_hits.clear();
275        inner.branches.clear();
276    }
277}
278
279impl Default for CoverageTracker {
280    fn default() -> Self {
281        Self::new()
282    }
283}
284
285/// Coverage report with statistics and utilities.
286#[derive(Debug, Clone)]
287pub struct CoverageReport {
288    /// Endpoint coverage: (method, path) -> hits.
289    pub endpoints: BTreeMap<(String, String), EndpointHits>,
290    /// Branch coverage by identifier.
291    pub branches: HashMap<String, BranchHits>,
292}
293
294impl CoverageReport {
295    /// Calculate endpoint coverage percentage.
296    #[must_use]
297    #[allow(clippy::cast_precision_loss)]
298    pub fn endpoint_coverage(&self) -> f64 {
299        if self.endpoints.is_empty() {
300            return 1.0;
301        }
302
303        let covered = self
304            .endpoints
305            .values()
306            .filter(|h| h.total_calls > 0)
307            .count();
308
309        covered as f64 / self.endpoints.len() as f64
310    }
311
312    /// Calculate branch coverage percentage.
313    #[must_use]
314    #[allow(clippy::cast_precision_loss)]
315    pub fn branch_coverage(&self) -> f64 {
316        if self.branches.is_empty() {
317            return 1.0;
318        }
319
320        let fully_covered = self
321            .branches
322            .values()
323            .filter(|b| b.taken_count > 0 && b.not_taken_count > 0)
324            .count();
325
326        fully_covered as f64 / self.branches.len() as f64
327    }
328
329    /// Get endpoints that have not been tested.
330    #[must_use]
331    pub fn untested_endpoints(&self) -> Vec<(&str, &str)> {
332        self.endpoints
333            .iter()
334            .filter(|(_, hits)| hits.total_calls == 0)
335            .map(|((method, path), _)| (method.as_str(), path.as_str()))
336            .collect()
337    }
338
339    /// Get endpoints with only success responses (no error testing).
340    #[must_use]
341    pub fn untested_error_paths(&self) -> Vec<(&str, &str)> {
342        self.endpoints
343            .iter()
344            .filter(|(_, hits)| {
345                hits.total_calls > 0 && hits.client_error_count == 0 && hits.server_error_count == 0
346            })
347            .map(|((method, path), _)| (method.as_str(), path.as_str()))
348            .collect()
349    }
350
351    /// Assert that endpoint coverage meets threshold.
352    ///
353    /// # Panics
354    ///
355    /// Panics if coverage is below threshold with a detailed message.
356    pub fn assert_threshold(&self, threshold: f64) {
357        let coverage = self.endpoint_coverage();
358        if coverage < threshold {
359            let untested = self.untested_endpoints();
360            panic!(
361                "Endpoint coverage {:.1}% is below threshold {:.1}%.\n\
362                 Untested endpoints ({}):\n{}",
363                coverage * 100.0,
364                threshold * 100.0,
365                untested.len(),
366                untested
367                    .iter()
368                    .map(|(m, p)| format!("  - {} {}", m, p))
369                    .collect::<Vec<_>>()
370                    .join("\n")
371            );
372        }
373    }
374
375    /// Write coverage report as JSON.
376    ///
377    /// # Errors
378    ///
379    /// Returns error if file cannot be written.
380    pub fn write_json(&self, path: &str) -> io::Result<()> {
381        let json = self.to_json();
382        std::fs::write(path, json)
383    }
384
385    /// Generate JSON representation.
386    #[must_use]
387    pub fn to_json(&self) -> String {
388        let mut json = String::from("{\n");
389        json.push_str("  \"summary\": {\n");
390        json.push_str(&format!(
391            "    \"endpoint_coverage\": {:.4},\n",
392            self.endpoint_coverage()
393        ));
394        json.push_str(&format!(
395            "    \"branch_coverage\": {:.4},\n",
396            self.branch_coverage()
397        ));
398        json.push_str(&format!(
399            "    \"total_endpoints\": {},\n",
400            self.endpoints.len()
401        ));
402        json.push_str(&format!(
403            "    \"tested_endpoints\": {}\n",
404            self.endpoints
405                .values()
406                .filter(|h| h.total_calls > 0)
407                .count()
408        ));
409        json.push_str("  },\n");
410
411        json.push_str("  \"endpoints\": [\n");
412        let endpoint_entries: Vec<_> = self
413            .endpoints
414            .iter()
415            .map(|((method, path), hits)| {
416                format!(
417                    "    {{\n\
418                     \"method\": \"{method}\",\n\
419                     \"path\": \"{path}\",\n\
420                     \"calls\": {},\n\
421                     \"success\": {},\n\
422                     \"client_errors\": {},\n\
423                     \"server_errors\": {}\n\
424                     }}",
425                    hits.total_calls,
426                    hits.success_count,
427                    hits.client_error_count,
428                    hits.server_error_count
429                )
430                .replace('\n', "\n    ")
431            })
432            .collect();
433        json.push_str(&endpoint_entries.join(",\n"));
434        json.push_str("\n  ]\n");
435        json.push('}');
436
437        json
438    }
439
440    /// Write coverage report as HTML.
441    ///
442    /// # Errors
443    ///
444    /// Returns error if file cannot be written.
445    pub fn write_html(&self, path: &str) -> io::Result<()> {
446        let html = self.to_html();
447        std::fs::write(path, html)
448    }
449
450    /// Generate HTML representation.
451    #[must_use]
452    #[allow(clippy::too_many_lines)]
453    pub fn to_html(&self) -> String {
454        let coverage_pct = self.endpoint_coverage() * 100.0;
455        let coverage_class = if coverage_pct >= 80.0 {
456            "good"
457        } else if coverage_pct >= 60.0 {
458            "warning"
459        } else {
460            "poor"
461        };
462
463        let mut html = format!(
464            r#"<!DOCTYPE html>
465<html lang="en">
466<head>
467    <meta charset="UTF-8">
468    <meta name="viewport" content="width=device-width, initial-scale=1.0">
469    <title>fastapi_rust Coverage Report</title>
470    <style>
471        body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; background: #f5f5f5; }}
472        .container {{ max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
473        h1 {{ color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px; }}
474        .summary {{ display: flex; gap: 20px; margin-bottom: 30px; }}
475        .metric {{ flex: 1; padding: 20px; border-radius: 8px; text-align: center; }}
476        .metric.good {{ background: #d4edda; color: #155724; }}
477        .metric.warning {{ background: #fff3cd; color: #856404; }}
478        .metric.poor {{ background: #f8d7da; color: #721c24; }}
479        .metric h2 {{ margin: 0 0 10px 0; font-size: 2.5em; }}
480        .metric p {{ margin: 0; font-size: 0.9em; }}
481        table {{ width: 100%; border-collapse: collapse; }}
482        th, td {{ padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }}
483        th {{ background: #f8f9fa; font-weight: 600; }}
484        tr:hover {{ background: #f5f5f5; }}
485        .method {{ font-family: monospace; padding: 2px 6px; border-radius: 4px; font-weight: 600; }}
486        .method.GET {{ background: #28a745; color: white; }}
487        .method.POST {{ background: #ffc107; color: black; }}
488        .method.PUT {{ background: #17a2b8; color: white; }}
489        .method.DELETE {{ background: #dc3545; color: white; }}
490        .method.PATCH {{ background: #6f42c1; color: white; }}
491        .untested {{ color: #dc3545; font-weight: 600; }}
492        .path {{ font-family: monospace; }}
493        .count {{ text-align: right; }}
494    </style>
495</head>
496<body>
497    <div class="container">
498        <h1>fastapi_rust Coverage Report</h1>
499
500        <div class="summary">
501            <div class="metric {coverage_class}">
502                <h2>{coverage_pct:.1}%</h2>
503                <p>Endpoint Coverage</p>
504            </div>
505            <div class="metric">
506                <h2>{}</h2>
507                <p>Total Endpoints</p>
508            </div>
509            <div class="metric">
510                <h2>{}</h2>
511                <p>Tested Endpoints</p>
512            </div>
513        </div>
514
515        <h2>Endpoint Details</h2>
516        <table>
517            <thead>
518                <tr>
519                    <th>Method</th>
520                    <th>Path</th>
521                    <th class="count">Calls</th>
522                    <th class="count">Success</th>
523                    <th class="count">4xx</th>
524                    <th class="count">5xx</th>
525                </tr>
526            </thead>
527            <tbody>
528"#,
529            self.endpoints.len(),
530            self.endpoints
531                .values()
532                .filter(|h| h.total_calls > 0)
533                .count()
534        );
535
536        for ((method, path), hits) in &self.endpoints {
537            let tested_class = if hits.total_calls == 0 {
538                " class=\"untested\""
539            } else {
540                ""
541            };
542            let method_escaped = escape_html(method);
543            let path_escaped = escape_html(path);
544            html.push_str(&format!(
545                r#"                <tr{tested_class}>
546                    <td><span class="method {method_escaped}">{method_escaped}</span></td>
547                    <td class="path">{path_escaped}</td>
548                    <td class="count">{}</td>
549                    <td class="count">{}</td>
550                    <td class="count">{}</td>
551                    <td class="count">{}</td>
552                </tr>
553"#,
554                hits.total_calls,
555                hits.success_count,
556                hits.client_error_count,
557                hits.server_error_count
558            ));
559        }
560
561        html.push_str(
562            r"            </tbody>
563        </table>
564    </div>
565</body>
566</html>",
567        );
568
569        html
570    }
571
572    /// Generate SVG badge.
573    #[must_use]
574    pub fn to_badge(&self) -> String {
575        let coverage_pct = self.endpoint_coverage() * 100.0;
576        let color = if coverage_pct >= 80.0 {
577            "4c1"
578        } else if coverage_pct >= 60.0 {
579            "dfb317"
580        } else {
581            "e05d44"
582        };
583
584        // Build SVG programmatically to avoid raw string issues with hex colors
585        let mut svg = String::new();
586        svg.push_str(r#"<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20">"#);
587        svg.push_str("\n  <linearGradient id=\"b\" x2=\"0\" y2=\"100%\">");
588        svg.push_str("\n    <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>");
589        svg.push_str("\n    <stop offset=\"1\" stop-opacity=\".1\"/>");
590        svg.push_str("\n  </linearGradient>");
591        svg.push_str(
592            "\n  <mask id=\"a\"><rect width=\"106\" height=\"20\" rx=\"3\" fill=\"#fff\"/></mask>",
593        );
594        svg.push_str("\n  <g mask=\"url(#a)\">");
595        svg.push_str("\n    <rect width=\"61\" height=\"20\" fill=\"#555\"/>");
596        svg.push_str(&format!(
597            "\n    <rect x=\"61\" width=\"45\" height=\"20\" fill=\"#{color}\"/>"
598        ));
599        svg.push_str("\n    <rect width=\"106\" height=\"20\" fill=\"url(#b)\"/>");
600        svg.push_str("\n  </g>");
601        svg.push_str("\n  <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\">");
602        svg.push_str(
603            "\n    <text x=\"31.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">coverage</text>",
604        );
605        svg.push_str("\n    <text x=\"31.5\" y=\"14\" fill=\"#fff\">coverage</text>");
606        svg.push_str(&format!("\n    <text x=\"82.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{coverage_pct:.0}%</text>"));
607        svg.push_str(&format!(
608            "\n    <text x=\"82.5\" y=\"14\" fill=\"#fff\">{coverage_pct:.0}%</text>"
609        ));
610        svg.push_str("\n  </g>");
611        svg.push_str("\n</svg>");
612
613        svg
614    }
615
616    /// Write SVG badge to file.
617    ///
618    /// # Errors
619    ///
620    /// Returns error if file cannot be written.
621    pub fn write_badge(&self, path: &str) -> io::Result<()> {
622        std::fs::write(path, self.to_badge())
623    }
624}
625
626impl fmt::Display for CoverageReport {
627    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
628        writeln!(f, "Coverage Report")?;
629        writeln!(f, "===============")?;
630        writeln!(f)?;
631        writeln!(
632            f,
633            "Endpoint Coverage: {:.1}%",
634            self.endpoint_coverage() * 100.0
635        )?;
636        writeln!(
637            f,
638            "Branch Coverage:   {:.1}%",
639            self.branch_coverage() * 100.0
640        )?;
641        writeln!(f)?;
642
643        let untested = self.untested_endpoints();
644        if !untested.is_empty() {
645            writeln!(f, "Untested Endpoints ({}):", untested.len())?;
646            for (method, path) in untested {
647                writeln!(f, "  - {} {}", method, path)?;
648            }
649        }
650
651        let untested_errors = self.untested_error_paths();
652        if !untested_errors.is_empty() {
653            writeln!(f)?;
654            writeln!(f, "Missing Error Path Tests ({}):", untested_errors.len())?;
655            for (method, path) in untested_errors {
656                writeln!(f, "  - {} {}", method, path)?;
657            }
658        }
659
660        Ok(())
661    }
662}
663
664/// Helper macro for recording branch coverage.
665///
666/// # Example
667///
668/// ```ignore
669/// use fastapi_core::record_branch;
670///
671/// let tracker = CoverageTracker::new();
672///
673/// if some_condition {
674///     record_branch!(tracker, "auth_check", true);
675///     // handle authenticated
676/// } else {
677///     record_branch!(tracker, "auth_check", false);
678///     // handle unauthenticated
679/// }
680/// ```
681#[macro_export]
682macro_rules! record_branch {
683    ($tracker:expr, $branch_id:expr, $taken:expr) => {
684        $tracker.record_branch($branch_id, $taken)
685    };
686}
687
688/// Escape HTML special characters to prevent XSS in generated reports.
689fn escape_html(s: &str) -> String {
690    let mut out = String::with_capacity(s.len());
691    for c in s.chars() {
692        match c {
693            '&' => out.push_str("&amp;"),
694            '<' => out.push_str("&lt;"),
695            '>' => out.push_str("&gt;"),
696            '"' => out.push_str("&quot;"),
697            '\'' => out.push_str("&#x27;"),
698            _ => out.push(c),
699        }
700    }
701    out
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    #[test]
709    fn test_tracker_basic() {
710        let tracker = CoverageTracker::new();
711
712        // Register endpoints
713        tracker.register_endpoint(Method::Get, "/users");
714        tracker.register_endpoint(Method::Post, "/users");
715        tracker.register_endpoint(Method::Get, "/users/{id}");
716
717        // Record some hits
718        tracker.record_hit(Method::Get, "/users", 200);
719        tracker.record_hit(Method::Get, "/users", 200);
720        tracker.record_hit(Method::Post, "/users", 201);
721        tracker.record_hit(Method::Post, "/users", 400); // Error case
722
723        let report = tracker.report();
724
725        // 3 endpoints, 2 tested
726        assert_eq!(report.endpoints.len(), 3);
727        assert!((report.endpoint_coverage() - 2.0 / 3.0).abs() < 0.001);
728
729        // Check untested
730        let untested = report.untested_endpoints();
731        assert_eq!(untested.len(), 1);
732        assert_eq!(untested[0], ("GET", "/users/{id}"));
733    }
734
735    #[test]
736    fn test_tracker_error_paths() {
737        let tracker = CoverageTracker::new();
738
739        tracker.register_endpoint(Method::Get, "/users");
740        tracker.register_endpoint(Method::Post, "/users");
741
742        // Only success for GET
743        tracker.record_hit(Method::Get, "/users", 200);
744
745        // Both success and error for POST
746        tracker.record_hit(Method::Post, "/users", 201);
747        tracker.record_hit(Method::Post, "/users", 400);
748
749        let report = tracker.report();
750        let untested_errors = report.untested_error_paths();
751
752        assert_eq!(untested_errors.len(), 1);
753        assert_eq!(untested_errors[0], ("GET", "/users"));
754    }
755
756    #[test]
757    fn test_branch_coverage() {
758        let tracker = CoverageTracker::new();
759
760        // Fully covered branch
761        tracker.record_branch("auth", true);
762        tracker.record_branch("auth", false);
763
764        // Partially covered branch (only true)
765        tracker.record_branch("admin", true);
766
767        let report = tracker.report();
768
769        // 2 branches, 1 fully covered
770        assert_eq!(report.branches.len(), 2);
771        assert!((report.branch_coverage() - 0.5).abs() < 0.001);
772    }
773
774    #[test]
775    fn test_report_json() {
776        let tracker = CoverageTracker::new();
777        tracker.register_endpoint(Method::Get, "/test");
778        tracker.record_hit(Method::Get, "/test", 200);
779
780        let report = tracker.report();
781        let json = report.to_json();
782
783        assert!(json.contains("\"endpoint_coverage\""));
784        assert!(json.contains("\"/test\""));
785    }
786
787    #[test]
788    fn test_report_html() {
789        let tracker = CoverageTracker::new();
790        tracker.register_endpoint(Method::Get, "/test");
791
792        let report = tracker.report();
793        let html = report.to_html();
794
795        assert!(html.contains("<!DOCTYPE html>"));
796        assert!(html.contains("Coverage Report"));
797        assert!(html.contains("/test"));
798    }
799
800    #[test]
801    fn test_report_badge() {
802        let tracker = CoverageTracker::new();
803        tracker.register_endpoint(Method::Get, "/test");
804        tracker.record_hit(Method::Get, "/test", 200);
805
806        let report = tracker.report();
807        let badge = report.to_badge();
808
809        assert!(badge.contains("<svg"));
810        assert!(badge.contains("coverage"));
811        assert!(badge.contains("100%"));
812    }
813
814    #[test]
815    fn test_config_builder() {
816        let config = CoverageConfig::new()
817            .line_threshold(0.90)
818            .branch_threshold(0.85)
819            .endpoint_threshold(0.95)
820            .no_fail()
821            .output_dir("custom/path");
822
823        assert!((config.line_threshold - 0.90).abs() < 0.001);
824        assert!((config.branch_threshold - 0.85).abs() < 0.001);
825        assert!((config.endpoint_threshold - 0.95).abs() < 0.001);
826        assert!(!config.fail_on_threshold);
827        assert_eq!(config.output_dir, "custom/path");
828    }
829
830    #[test]
831    fn test_threshold_clamp() {
832        let config = CoverageConfig::new()
833            .line_threshold(1.5) // Over 1.0
834            .branch_threshold(-0.5); // Under 0.0
835
836        assert!((config.line_threshold - 1.0).abs() < 0.001);
837        assert!((config.branch_threshold - 0.0).abs() < 0.001);
838    }
839
840    #[test]
841    #[should_panic(expected = "coverage")]
842    fn test_assert_threshold_panics() {
843        let tracker = CoverageTracker::new();
844        tracker.register_endpoint(Method::Get, "/a");
845        tracker.register_endpoint(Method::Get, "/b");
846        // Only test one endpoint
847        tracker.record_hit(Method::Get, "/a", 200);
848
849        let report = tracker.report();
850        report.assert_threshold(0.90); // Should panic, only 50% coverage
851    }
852
853    #[test]
854    #[allow(clippy::float_cmp)]
855    fn test_reset() {
856        let tracker = CoverageTracker::new();
857        tracker.register_endpoint(Method::Get, "/test");
858        tracker.record_hit(Method::Get, "/test", 200);
859
860        let report1 = tracker.report();
861        assert_eq!(report1.endpoint_coverage(), 1.0);
862
863        tracker.reset();
864
865        let report2 = tracker.report();
866        // Endpoint still registered but no hits
867        assert_eq!(report2.endpoints.len(), 1);
868        let hits = report2
869            .endpoints
870            .get(&("GET".to_string(), "/test".to_string()))
871            .unwrap();
872        assert_eq!(hits.total_calls, 0);
873    }
874}