Skip to main content

ipfrs_network/
connection_health_monitor.rs

1//! Connection Health Monitor
2//!
3//! Tracks connection quality metrics per peer with health scoring, anomaly
4//! detection, and alerting. Each peer maintains a sliding window of the last
5//! 50 samples per metric type. A composite health score in `[0.0, 1.0]` is
6//! computed from latency, bandwidth, packet loss, jitter, and error rate.
7//! Threshold crossings trigger `HealthAlert` values that are returned from
8//! `record()` and stored internally.
9//!
10//! # Example
11//!
12//! ```rust
13//! use ipfrs_network::connection_health_monitor::{
14//!     AlertThresholds, ChmHealthMetric, ChmHealthSample, ConnectionHealthMonitor,
15//! };
16//!
17//! let thresholds = AlertThresholds::default();
18//! let mut monitor = ConnectionHealthMonitor::new(thresholds, 1_000);
19//!
20//! let sample = ChmHealthSample {
21//!     peer_id: "peer-1".to_string(),
22//!     metric: ChmHealthMetric::Latency(120.0),
23//!     timestamp: 1_000_000,
24//! };
25//! let alerts = monitor.record(sample);
26//! assert!(alerts.is_empty());
27//!
28//! let score = monitor.health_score("peer-1");
29//! assert!(score.is_some());
30//! ```
31
32use std::collections::{HashMap, VecDeque};
33
34// ---------------------------------------------------------------------------
35// Constants
36// ---------------------------------------------------------------------------
37
38/// Number of samples retained per metric type for each peer.
39const WINDOW_SIZE: usize = 50;
40
41// ---------------------------------------------------------------------------
42// HealthMetric
43// ---------------------------------------------------------------------------
44
45/// A single connection quality observation.
46///
47/// All values are non-negative. `PacketLoss` and `ErrorRate` are in `[0.0, 1.0]`.
48#[derive(Clone, Debug, PartialEq)]
49pub enum ChmHealthMetric {
50    /// Round-trip latency in milliseconds.
51    Latency(f64),
52    /// Fraction of lost packets in `[0.0, 1.0]`.
53    PacketLoss(f64),
54    /// Available bandwidth in bits-per-second.
55    Bandwidth(f64),
56    /// Packet arrival jitter in milliseconds.
57    Jitter(f64),
58    /// Fraction of requests that resulted in an error in `[0.0, 1.0]`.
59    ErrorRate(f64),
60}
61
62impl ChmHealthMetric {
63    /// Return the inner `f64` value regardless of variant.
64    pub fn value(&self) -> f64 {
65        match self {
66            Self::Latency(v)
67            | Self::PacketLoss(v)
68            | Self::Bandwidth(v)
69            | Self::Jitter(v)
70            | Self::ErrorRate(v) => *v,
71        }
72    }
73
74    /// Short string tag used for categorisation.
75    pub fn kind(&self) -> &'static str {
76        match self {
77            Self::Latency(_) => "latency",
78            Self::PacketLoss(_) => "packet_loss",
79            Self::Bandwidth(_) => "bandwidth",
80            Self::Jitter(_) => "jitter",
81            Self::ErrorRate(_) => "error_rate",
82        }
83    }
84}
85
86// ---------------------------------------------------------------------------
87// HealthSample
88// ---------------------------------------------------------------------------
89
90/// A single observation recorded for a specific peer.
91#[derive(Clone, Debug)]
92pub struct ChmHealthSample {
93    /// Identifier of the peer being observed.
94    pub peer_id: String,
95    /// The metric value captured in this observation.
96    pub metric: ChmHealthMetric,
97    /// Unix timestamp in milliseconds when the sample was captured.
98    pub timestamp: u64,
99}
100
101// ---------------------------------------------------------------------------
102// AlertSeverity
103// ---------------------------------------------------------------------------
104
105/// Severity level of a `ChmHealthAlert`.
106#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
107pub enum ChmAlertSeverity {
108    /// Informational — within normal parameters.
109    Info,
110    /// Warning — 80 % of the critical threshold.
111    Warning,
112    /// Critical — at or beyond the threshold limit.
113    Critical,
114}
115
116// ---------------------------------------------------------------------------
117// HealthAlert
118// ---------------------------------------------------------------------------
119
120/// An alert generated when a metric crosses a threshold.
121#[derive(Clone, Debug)]
122pub struct ChmHealthAlert {
123    /// Peer for which the alert was raised.
124    pub peer_id: String,
125    /// The metric that triggered the alert.
126    pub metric: ChmHealthMetric,
127    /// Severity classification.
128    pub severity: ChmAlertSeverity,
129    /// The observed value that crossed the threshold.
130    pub value: f64,
131    /// The threshold that was crossed.
132    pub threshold: f64,
133    /// Unix timestamp in milliseconds when the alert was generated.
134    pub timestamp: u64,
135}
136
137// ---------------------------------------------------------------------------
138// AlertThresholds
139// ---------------------------------------------------------------------------
140
141/// Per-metric thresholds used to generate `ChmHealthAlert` values.
142///
143/// Warning alerts fire at 80 % of the configured threshold; critical alerts
144/// fire at 100 %.
145#[derive(Clone, Debug)]
146pub struct AlertThresholds {
147    /// Maximum acceptable latency in milliseconds (default: 500 ms).
148    pub max_latency_ms: f64,
149    /// Maximum acceptable packet-loss fraction (default: 0.05).
150    pub max_packet_loss: f64,
151    /// Minimum acceptable bandwidth in bits-per-second (default: 100 000 bps).
152    pub min_bandwidth_bps: f64,
153    /// Maximum acceptable jitter in milliseconds (default: 100 ms).
154    pub max_jitter_ms: f64,
155    /// Maximum acceptable error-rate fraction (default: 0.10).
156    pub max_error_rate: f64,
157}
158
159impl Default for AlertThresholds {
160    fn default() -> Self {
161        Self {
162            max_latency_ms: 500.0,
163            max_packet_loss: 0.05,
164            min_bandwidth_bps: 100_000.0,
165            max_jitter_ms: 100.0,
166            max_error_rate: 0.1,
167        }
168    }
169}
170
171impl AlertThresholds {
172    /// Warning level — 80 % of the critical threshold.
173    const WARNING_FACTOR: f64 = 0.8;
174
175    /// Returns the warning threshold for latency.
176    pub fn warning_latency_ms(&self) -> f64 {
177        self.max_latency_ms * Self::WARNING_FACTOR
178    }
179
180    /// Returns the warning threshold for packet loss.
181    pub fn warning_packet_loss(&self) -> f64 {
182        self.max_packet_loss * Self::WARNING_FACTOR
183    }
184
185    /// Returns the warning threshold for bandwidth (inverted — lower is worse).
186    pub fn warning_bandwidth_bps(&self) -> f64 {
187        self.min_bandwidth_bps / Self::WARNING_FACTOR
188    }
189
190    /// Returns the warning threshold for jitter.
191    pub fn warning_jitter_ms(&self) -> f64 {
192        self.max_jitter_ms * Self::WARNING_FACTOR
193    }
194
195    /// Returns the warning threshold for error rate.
196    pub fn warning_error_rate(&self) -> f64 {
197        self.max_error_rate * Self::WARNING_FACTOR
198    }
199}
200
201// ---------------------------------------------------------------------------
202// ConnectionHealth
203// ---------------------------------------------------------------------------
204
205/// Per-peer health state maintained by the monitor.
206#[derive(Clone, Debug)]
207pub struct ConnectionHealth {
208    /// Peer identifier.
209    pub peer_id: String,
210    /// Composite health score in `[0.0, 1.0]`.
211    pub health_score: f64,
212    /// Sliding window of recent samples (across all metric types).
213    pub samples: VecDeque<ChmHealthSample>,
214    /// Unix timestamp in milliseconds of the most recent update.
215    pub last_updated: u64,
216    /// Number of alerts that have been raised for this peer.
217    pub alert_count: u32,
218}
219
220impl ConnectionHealth {
221    fn new(peer_id: String) -> Self {
222        Self {
223            peer_id,
224            health_score: 1.0,
225            samples: VecDeque::new(),
226            last_updated: 0,
227            alert_count: 0,
228        }
229    }
230
231    /// Collect the values for a particular metric kind from the sample window.
232    fn values_for_kind(&self, kind: &str) -> Vec<f64> {
233        self.samples
234            .iter()
235            .filter(|s| s.metric.kind() == kind)
236            .map(|s| s.metric.value())
237            .collect()
238    }
239
240    /// Arithmetic mean of a slice, or `None` if empty.
241    fn mean(values: &[f64]) -> Option<f64> {
242        if values.is_empty() {
243            return None;
244        }
245        Some(values.iter().sum::<f64>() / values.len() as f64)
246    }
247}
248
249// ---------------------------------------------------------------------------
250// MonitorStats
251// ---------------------------------------------------------------------------
252
253/// Aggregate statistics snapshot for the whole monitor.
254#[derive(Clone, Debug, Default)]
255pub struct ChmMonitorStats {
256    /// Total number of peers currently tracked.
257    pub total_peers: usize,
258    /// Number of peers with `health_score >= 0.8`.
259    pub healthy_peers: usize,
260    /// Number of peers with `0.5 <= health_score < 0.8`.
261    pub degraded_peers: usize,
262    /// Number of peers with `health_score < 0.5`.
263    pub critical_peers: usize,
264    /// Total number of alerts stored in the internal deque.
265    pub total_alerts: usize,
266    /// Average health score across all tracked peers (0.0 if none).
267    pub avg_health_score: f64,
268}
269
270// ---------------------------------------------------------------------------
271// ConnectionHealthMonitor
272// ---------------------------------------------------------------------------
273
274/// Tracks connection quality metrics per peer with health scoring, anomaly
275/// detection, and alerting.
276pub struct ConnectionHealthMonitor {
277    /// Threshold configuration for generating alerts.
278    pub thresholds: AlertThresholds,
279    /// Per-peer health records.
280    pub connections: HashMap<String, ConnectionHealth>,
281    /// Ring buffer of recent alerts.
282    pub alerts: VecDeque<ChmHealthAlert>,
283    /// Maximum number of alerts to retain.
284    pub max_alerts: usize,
285}
286
287impl ConnectionHealthMonitor {
288    /// Create a new monitor with the given thresholds and alert capacity.
289    pub fn new(thresholds: AlertThresholds, max_alerts: usize) -> Self {
290        Self {
291            thresholds,
292            connections: HashMap::new(),
293            alerts: VecDeque::new(),
294            max_alerts: max_alerts.max(1),
295        }
296    }
297
298    // -----------------------------------------------------------------------
299    // Public API
300    // -----------------------------------------------------------------------
301
302    /// Record a new sample for a peer, recompute its health score, and return
303    /// any new alerts that were triggered.
304    pub fn record(&mut self, sample: ChmHealthSample) -> Vec<ChmHealthAlert> {
305        let peer_id = sample.peer_id.clone();
306        let value = sample.metric.value();
307        let now = sample.timestamp;
308        let metric_clone = sample.metric.clone();
309
310        // Insert/update connection record.
311        let conn = self
312            .connections
313            .entry(peer_id.clone())
314            .or_insert_with(|| ConnectionHealth::new(peer_id.clone()));
315
316        // Add sample; evict oldest sample of the same metric kind if window full.
317        let kind = sample.metric.kind();
318        let same_kind_count = conn
319            .samples
320            .iter()
321            .filter(|s| s.metric.kind() == kind)
322            .count();
323        if same_kind_count >= WINDOW_SIZE {
324            // Remove the oldest sample of this kind.
325            if let Some(pos) = conn.samples.iter().position(|s| s.metric.kind() == kind) {
326                conn.samples.remove(pos);
327            }
328        }
329        conn.samples.push_back(sample);
330        conn.last_updated = now;
331
332        // Recompute health score.
333        let new_score = Self::compute_health_score(conn);
334        conn.health_score = new_score;
335
336        // Check thresholds.
337        let new_alerts = self.check_thresholds(&peer_id, &metric_clone, value, now);
338
339        // Push alerts and update alert_count.
340        if let Some(conn) = self.connections.get_mut(&peer_id) {
341            conn.alert_count = conn.alert_count.saturating_add(new_alerts.len() as u32);
342        }
343        for alert in &new_alerts {
344            self.alerts.push_back(alert.clone());
345            while self.alerts.len() > self.max_alerts {
346                self.alerts.pop_front();
347            }
348        }
349
350        new_alerts
351    }
352
353    /// Return the current health score for a peer, if tracked.
354    pub fn health_score(&self, peer_id: &str) -> Option<f64> {
355        self.connections.get(peer_id).map(|c| c.health_score)
356    }
357
358    /// Compute the composite health score from a peer's sample window.
359    ///
360    /// Components and weights:
361    /// - Latency   (0.30): `exp(-avg_latency / 1000.0)`
362    /// - Bandwidth (0.20): `(avg_bandwidth / 1_000_000.0).min(1.0)`
363    /// - Availability (0.20): `1.0 - avg_packet_loss`
364    /// - Jitter    (0.15): `exp(-avg_jitter / 200.0)`
365    /// - Reliability (0.15): `1.0 - avg_error_rate`
366    ///
367    /// Missing metrics default to 0.5.
368    pub fn compute_health_score(conn: &ConnectionHealth) -> f64 {
369        let latency_score = ConnectionHealth::mean(&conn.values_for_kind("latency"))
370            .map(|v| (-v / 1_000.0_f64).exp())
371            .unwrap_or(0.5);
372
373        let bandwidth_score = ConnectionHealth::mean(&conn.values_for_kind("bandwidth"))
374            .map(|v| (v / 1_000_000.0).min(1.0))
375            .unwrap_or(0.5);
376
377        let availability_score = ConnectionHealth::mean(&conn.values_for_kind("packet_loss"))
378            .map(|v| 1.0 - v)
379            .unwrap_or(0.5);
380
381        let jitter_score = ConnectionHealth::mean(&conn.values_for_kind("jitter"))
382            .map(|v| (-v / 200.0_f64).exp())
383            .unwrap_or(0.5);
384
385        let reliability_score = ConnectionHealth::mean(&conn.values_for_kind("error_rate"))
386            .map(|v| 1.0 - v)
387            .unwrap_or(0.5);
388
389        let score = 0.30 * latency_score
390            + 0.20 * bandwidth_score
391            + 0.20 * availability_score
392            + 0.15 * jitter_score
393            + 0.15 * reliability_score;
394
395        score.clamp(0.0, 1.0)
396    }
397
398    /// Check whether `value` crosses warning or critical thresholds for the
399    /// given metric, and return any generated alerts.
400    pub fn check_thresholds(
401        &self,
402        peer_id: &str,
403        metric: &ChmHealthMetric,
404        value: f64,
405        now: u64,
406    ) -> Vec<ChmHealthAlert> {
407        let mut alerts = Vec::new();
408
409        match metric {
410            ChmHealthMetric::Latency(_) => {
411                let critical = self.thresholds.max_latency_ms;
412                let warning = self.thresholds.warning_latency_ms();
413                if value >= critical {
414                    alerts.push(self.make_alert(
415                        peer_id,
416                        metric.clone(),
417                        ChmAlertSeverity::Critical,
418                        value,
419                        critical,
420                        now,
421                    ));
422                } else if value >= warning {
423                    alerts.push(self.make_alert(
424                        peer_id,
425                        metric.clone(),
426                        ChmAlertSeverity::Warning,
427                        value,
428                        critical,
429                        now,
430                    ));
431                }
432            }
433            ChmHealthMetric::PacketLoss(_) => {
434                let critical = self.thresholds.max_packet_loss;
435                let warning = self.thresholds.warning_packet_loss();
436                if value >= critical {
437                    alerts.push(self.make_alert(
438                        peer_id,
439                        metric.clone(),
440                        ChmAlertSeverity::Critical,
441                        value,
442                        critical,
443                        now,
444                    ));
445                } else if value >= warning {
446                    alerts.push(self.make_alert(
447                        peer_id,
448                        metric.clone(),
449                        ChmAlertSeverity::Warning,
450                        value,
451                        critical,
452                        now,
453                    ));
454                }
455            }
456            ChmHealthMetric::Bandwidth(_) => {
457                // Low bandwidth is bad — invert the comparison.
458                let critical = self.thresholds.min_bandwidth_bps;
459                let warning = self.thresholds.warning_bandwidth_bps();
460                if value <= critical {
461                    alerts.push(self.make_alert(
462                        peer_id,
463                        metric.clone(),
464                        ChmAlertSeverity::Critical,
465                        value,
466                        critical,
467                        now,
468                    ));
469                } else if value <= warning {
470                    alerts.push(self.make_alert(
471                        peer_id,
472                        metric.clone(),
473                        ChmAlertSeverity::Warning,
474                        value,
475                        critical,
476                        now,
477                    ));
478                }
479            }
480            ChmHealthMetric::Jitter(_) => {
481                let critical = self.thresholds.max_jitter_ms;
482                let warning = self.thresholds.warning_jitter_ms();
483                if value >= critical {
484                    alerts.push(self.make_alert(
485                        peer_id,
486                        metric.clone(),
487                        ChmAlertSeverity::Critical,
488                        value,
489                        critical,
490                        now,
491                    ));
492                } else if value >= warning {
493                    alerts.push(self.make_alert(
494                        peer_id,
495                        metric.clone(),
496                        ChmAlertSeverity::Warning,
497                        value,
498                        critical,
499                        now,
500                    ));
501                }
502            }
503            ChmHealthMetric::ErrorRate(_) => {
504                let critical = self.thresholds.max_error_rate;
505                let warning = self.thresholds.warning_error_rate();
506                if value >= critical {
507                    alerts.push(self.make_alert(
508                        peer_id,
509                        metric.clone(),
510                        ChmAlertSeverity::Critical,
511                        value,
512                        critical,
513                        now,
514                    ));
515                } else if value >= warning {
516                    alerts.push(self.make_alert(
517                        peer_id,
518                        metric.clone(),
519                        ChmAlertSeverity::Warning,
520                        value,
521                        critical,
522                        now,
523                    ));
524                }
525            }
526        }
527
528        alerts
529    }
530
531    /// Return the IDs of peers whose health score is below `min_score`,
532    /// sorted lexicographically.
533    pub fn peers_below_threshold(&self, min_score: f64) -> Vec<&str> {
534        let mut result: Vec<&str> = self
535            .connections
536            .iter()
537            .filter(|(_, c)| c.health_score < min_score)
538            .map(|(id, _)| id.as_str())
539            .collect();
540        result.sort_unstable();
541        result
542    }
543
544    /// Return all alerts with timestamp >= `since`.
545    pub fn recent_alerts(&self, since: u64) -> Vec<&ChmHealthAlert> {
546        self.alerts
547            .iter()
548            .filter(|a| a.timestamp >= since)
549            .collect()
550    }
551
552    /// Remove all health data for a peer. Returns `true` if the peer existed.
553    pub fn clear_peer(&mut self, peer_id: &str) -> bool {
554        self.connections.remove(peer_id).is_some()
555    }
556
557    /// Evict peers that have not been updated within `max_age_ms` milliseconds.
558    ///
559    /// Returns the number of peers evicted.
560    pub fn evict_stale(&mut self, max_age_ms: u64, now: u64) -> usize {
561        let cutoff = now.saturating_sub(max_age_ms);
562        let before = self.connections.len();
563        self.connections
564            .retain(|_, c| c.last_updated >= cutoff || c.last_updated == 0);
565        before - self.connections.len()
566    }
567
568    /// Return the top `n` peers by health score in descending order.
569    pub fn top_peers(&self, n: usize) -> Vec<(&str, f64)> {
570        let mut peers: Vec<(&str, f64)> = self
571            .connections
572            .iter()
573            .map(|(id, c)| (id.as_str(), c.health_score))
574            .collect();
575        // Sort descending by score, then ascending by peer_id for determinism.
576        peers.sort_by(|a, b| {
577            b.1.partial_cmp(&a.1)
578                .unwrap_or(std::cmp::Ordering::Equal)
579                .then_with(|| a.0.cmp(b.0))
580        });
581        peers.truncate(n);
582        peers
583    }
584
585    /// Compute aggregate statistics across all tracked peers.
586    pub fn stats(&self) -> ChmMonitorStats {
587        let total_peers = self.connections.len();
588        let mut healthy_peers = 0usize;
589        let mut degraded_peers = 0usize;
590        let mut critical_peers = 0usize;
591        let mut score_sum = 0.0f64;
592
593        for conn in self.connections.values() {
594            let s = conn.health_score;
595            score_sum += s;
596            if s >= 0.8 {
597                healthy_peers += 1;
598            } else if s >= 0.5 {
599                degraded_peers += 1;
600            } else {
601                critical_peers += 1;
602            }
603        }
604
605        let avg_health_score = if total_peers == 0 {
606            0.0
607        } else {
608            score_sum / total_peers as f64
609        };
610
611        ChmMonitorStats {
612            total_peers,
613            healthy_peers,
614            degraded_peers,
615            critical_peers,
616            total_alerts: self.alerts.len(),
617            avg_health_score,
618        }
619    }
620
621    // -----------------------------------------------------------------------
622    // Private helpers
623    // -----------------------------------------------------------------------
624
625    fn make_alert(
626        &self,
627        peer_id: &str,
628        metric: ChmHealthMetric,
629        severity: ChmAlertSeverity,
630        value: f64,
631        threshold: f64,
632        timestamp: u64,
633    ) -> ChmHealthAlert {
634        ChmHealthAlert {
635            peer_id: peer_id.to_string(),
636            metric,
637            severity,
638            value,
639            threshold,
640            timestamp,
641        }
642    }
643}
644
645// ---------------------------------------------------------------------------
646// Tests
647// ---------------------------------------------------------------------------
648
649#[cfg(test)]
650mod tests {
651    use super::{
652        AlertThresholds, ChmAlertSeverity, ChmHealthAlert, ChmHealthMetric, ChmHealthSample,
653        ChmMonitorStats, ConnectionHealth, ConnectionHealthMonitor, WINDOW_SIZE,
654    };
655
656    // -----------------------------------------------------------------------
657    // Helpers
658    // -----------------------------------------------------------------------
659
660    fn default_monitor() -> ConnectionHealthMonitor {
661        ConnectionHealthMonitor::new(AlertThresholds::default(), 1_000)
662    }
663
664    fn latency_sample(peer_id: &str, ms: f64, ts: u64) -> ChmHealthSample {
665        ChmHealthSample {
666            peer_id: peer_id.to_string(),
667            metric: ChmHealthMetric::Latency(ms),
668            timestamp: ts,
669        }
670    }
671
672    fn bandwidth_sample(peer_id: &str, bps: f64, ts: u64) -> ChmHealthSample {
673        ChmHealthSample {
674            peer_id: peer_id.to_string(),
675            metric: ChmHealthMetric::Bandwidth(bps),
676            timestamp: ts,
677        }
678    }
679
680    fn packet_loss_sample(peer_id: &str, loss: f64, ts: u64) -> ChmHealthSample {
681        ChmHealthSample {
682            peer_id: peer_id.to_string(),
683            metric: ChmHealthMetric::PacketLoss(loss),
684            timestamp: ts,
685        }
686    }
687
688    fn jitter_sample(peer_id: &str, ms: f64, ts: u64) -> ChmHealthSample {
689        ChmHealthSample {
690            peer_id: peer_id.to_string(),
691            metric: ChmHealthMetric::Jitter(ms),
692            timestamp: ts,
693        }
694    }
695
696    fn error_rate_sample(peer_id: &str, rate: f64, ts: u64) -> ChmHealthSample {
697        ChmHealthSample {
698            peer_id: peer_id.to_string(),
699            metric: ChmHealthMetric::ErrorRate(rate),
700            timestamp: ts,
701        }
702    }
703
704    // -----------------------------------------------------------------------
705    // 1. Basic record / health_score
706    // -----------------------------------------------------------------------
707
708    #[test]
709    fn test_record_new_peer_creates_entry() {
710        let mut m = default_monitor();
711        m.record(latency_sample("p1", 50.0, 1000));
712        assert!(m.health_score("p1").is_some());
713    }
714
715    #[test]
716    fn test_health_score_unknown_peer_returns_none() {
717        let m = default_monitor();
718        assert!(m.health_score("nobody").is_none());
719    }
720
721    #[test]
722    fn test_record_returns_empty_alerts_for_good_latency() {
723        let mut m = default_monitor();
724        let alerts = m.record(latency_sample("p1", 50.0, 1000));
725        assert!(alerts.is_empty());
726    }
727
728    #[test]
729    fn test_record_multiple_samples_updates_score() {
730        let mut m = default_monitor();
731        m.record(latency_sample("p1", 100.0, 1000));
732        let score1 = m.health_score("p1").unwrap_or(0.0);
733        m.record(latency_sample("p1", 200.0, 2000));
734        let score2 = m.health_score("p1").unwrap_or(0.0);
735        // Higher avg latency → lower latency score → lower composite.
736        assert!(score2 < score1 || (score2 - score1).abs() < 1e-9);
737    }
738
739    // -----------------------------------------------------------------------
740    // 2. Health score formula
741    // -----------------------------------------------------------------------
742
743    #[test]
744    fn test_compute_health_score_all_default_missing() {
745        let conn = ConnectionHealth::new("p".to_string());
746        let score = ConnectionHealthMonitor::compute_health_score(&conn);
747        // All metrics missing → all default to 0.5 → composite = 0.5.
748        assert!((score - 0.5).abs() < 1e-9, "expected 0.5 got {}", score);
749    }
750
751    #[test]
752    fn test_compute_health_score_perfect_latency() {
753        let mut conn = ConnectionHealth::new("p".to_string());
754        // Very low latency → exp(0) ≈ 1.0
755        conn.samples.push_back(ChmHealthSample {
756            peer_id: "p".to_string(),
757            metric: ChmHealthMetric::Latency(0.0),
758            timestamp: 1,
759        });
760        let score = ConnectionHealthMonitor::compute_health_score(&conn);
761        // latency_score=1.0, rest default 0.5
762        let expected = 0.30 * 1.0 + 0.20 * 0.5 + 0.20 * 0.5 + 0.15 * 0.5 + 0.15 * 0.5;
763        assert!((score - expected).abs() < 1e-9);
764    }
765
766    #[test]
767    fn test_compute_health_score_zero_bandwidth() {
768        let mut conn = ConnectionHealth::new("p".to_string());
769        conn.samples.push_back(ChmHealthSample {
770            peer_id: "p".to_string(),
771            metric: ChmHealthMetric::Bandwidth(0.0),
772            timestamp: 1,
773        });
774        let score = ConnectionHealthMonitor::compute_health_score(&conn);
775        // bandwidth_score = 0.0; rest default 0.5
776        let expected = 0.30 * 0.5 + 0.20 * 0.0 + 0.20 * 0.5 + 0.15 * 0.5 + 0.15 * 0.5;
777        assert!((score - expected).abs() < 1e-9);
778    }
779
780    #[test]
781    fn test_compute_health_score_full_packet_loss() {
782        let mut conn = ConnectionHealth::new("p".to_string());
783        conn.samples.push_back(ChmHealthSample {
784            peer_id: "p".to_string(),
785            metric: ChmHealthMetric::PacketLoss(1.0),
786            timestamp: 1,
787        });
788        let score = ConnectionHealthMonitor::compute_health_score(&conn);
789        // availability_score = 0.0; rest default 0.5
790        let expected = 0.30 * 0.5 + 0.20 * 0.5 + 0.20 * 0.0 + 0.15 * 0.5 + 0.15 * 0.5;
791        assert!((score - expected).abs() < 1e-9);
792    }
793
794    #[test]
795    fn test_compute_health_score_all_perfect() {
796        let mut conn = ConnectionHealth::new("p".to_string());
797        conn.samples.push_back(ChmHealthSample {
798            peer_id: "p".to_string(),
799            metric: ChmHealthMetric::Latency(0.0),
800            timestamp: 1,
801        });
802        conn.samples.push_back(ChmHealthSample {
803            peer_id: "p".to_string(),
804            metric: ChmHealthMetric::Bandwidth(1_000_000.0),
805            timestamp: 2,
806        });
807        conn.samples.push_back(ChmHealthSample {
808            peer_id: "p".to_string(),
809            metric: ChmHealthMetric::PacketLoss(0.0),
810            timestamp: 3,
811        });
812        conn.samples.push_back(ChmHealthSample {
813            peer_id: "p".to_string(),
814            metric: ChmHealthMetric::Jitter(0.0),
815            timestamp: 4,
816        });
817        conn.samples.push_back(ChmHealthSample {
818            peer_id: "p".to_string(),
819            metric: ChmHealthMetric::ErrorRate(0.0),
820            timestamp: 5,
821        });
822        let score = ConnectionHealthMonitor::compute_health_score(&conn);
823        assert!((score - 1.0).abs() < 1e-9, "expected 1.0 got {score}");
824    }
825
826    #[test]
827    fn test_health_score_clamped_to_one() {
828        let mut conn = ConnectionHealth::new("p".to_string());
829        // Latency of 0 gives exp(0)=1.0; bandwidth 10Mbps gives 1.0; rest 0 → score=1.0.
830        conn.samples.push_back(ChmHealthSample {
831            peer_id: "p".to_string(),
832            metric: ChmHealthMetric::Latency(0.0),
833            timestamp: 1,
834        });
835        conn.samples.push_back(ChmHealthSample {
836            peer_id: "p".to_string(),
837            metric: ChmHealthMetric::Bandwidth(10_000_000.0),
838            timestamp: 2,
839        });
840        conn.samples.push_back(ChmHealthSample {
841            peer_id: "p".to_string(),
842            metric: ChmHealthMetric::PacketLoss(0.0),
843            timestamp: 3,
844        });
845        conn.samples.push_back(ChmHealthSample {
846            peer_id: "p".to_string(),
847            metric: ChmHealthMetric::Jitter(0.0),
848            timestamp: 4,
849        });
850        conn.samples.push_back(ChmHealthSample {
851            peer_id: "p".to_string(),
852            metric: ChmHealthMetric::ErrorRate(0.0),
853            timestamp: 5,
854        });
855        let score = ConnectionHealthMonitor::compute_health_score(&conn);
856        assert!(score <= 1.0);
857    }
858
859    // -----------------------------------------------------------------------
860    // 3. Alert generation — latency
861    // -----------------------------------------------------------------------
862
863    #[test]
864    fn test_latency_warning_alert() {
865        let mut m = default_monitor();
866        // 80 % of 500 ms = 400 ms
867        let alerts = m.record(latency_sample("p1", 400.0, 1000));
868        assert_eq!(alerts.len(), 1);
869        assert_eq!(alerts[0].severity, ChmAlertSeverity::Warning);
870    }
871
872    #[test]
873    fn test_latency_critical_alert() {
874        let mut m = default_monitor();
875        let alerts = m.record(latency_sample("p1", 500.0, 1000));
876        assert_eq!(alerts.len(), 1);
877        assert_eq!(alerts[0].severity, ChmAlertSeverity::Critical);
878    }
879
880    #[test]
881    fn test_latency_above_critical_is_critical() {
882        let mut m = default_monitor();
883        let alerts = m.record(latency_sample("p1", 800.0, 1000));
884        assert_eq!(alerts.len(), 1);
885        assert_eq!(alerts[0].severity, ChmAlertSeverity::Critical);
886    }
887
888    #[test]
889    fn test_latency_below_warning_no_alert() {
890        let mut m = default_monitor();
891        let alerts = m.record(latency_sample("p1", 100.0, 1000));
892        assert!(alerts.is_empty());
893    }
894
895    // -----------------------------------------------------------------------
896    // 4. Alert generation — packet loss
897    // -----------------------------------------------------------------------
898
899    #[test]
900    fn test_packet_loss_warning_alert() {
901        let mut m = default_monitor();
902        // Warning threshold = 80% of 0.05 = 0.04 (computed as 0.05 * 0.8 ≈ 0.04000000000000001).
903        // Use 0.045 to safely land above the floating-point result without reaching critical.
904        let alerts = m.record(packet_loss_sample("p1", 0.045, 1000));
905        assert_eq!(alerts.len(), 1);
906        assert_eq!(alerts[0].severity, ChmAlertSeverity::Warning);
907    }
908
909    #[test]
910    fn test_packet_loss_critical_alert() {
911        let mut m = default_monitor();
912        let alerts = m.record(packet_loss_sample("p1", 0.05, 1000));
913        assert_eq!(alerts.len(), 1);
914        assert_eq!(alerts[0].severity, ChmAlertSeverity::Critical);
915    }
916
917    // -----------------------------------------------------------------------
918    // 5. Alert generation — bandwidth (inverted threshold)
919    // -----------------------------------------------------------------------
920
921    #[test]
922    fn test_bandwidth_critical_alert_when_too_low() {
923        let mut m = default_monitor();
924        // Below min_bandwidth_bps (100_000)
925        let alerts = m.record(bandwidth_sample("p1", 50_000.0, 1000));
926        assert_eq!(alerts.len(), 1);
927        assert_eq!(alerts[0].severity, ChmAlertSeverity::Critical);
928    }
929
930    #[test]
931    fn test_bandwidth_warning_alert_between_thresholds() {
932        let mut m = default_monitor();
933        // warning = 100_000 / 0.8 = 125_000 bps; value between 100_000 and 125_000
934        let alerts = m.record(bandwidth_sample("p1", 110_000.0, 1000));
935        assert_eq!(alerts.len(), 1);
936        assert_eq!(alerts[0].severity, ChmAlertSeverity::Warning);
937    }
938
939    #[test]
940    fn test_bandwidth_no_alert_when_high() {
941        let mut m = default_monitor();
942        let alerts = m.record(bandwidth_sample("p1", 1_000_000.0, 1000));
943        assert!(alerts.is_empty());
944    }
945
946    // -----------------------------------------------------------------------
947    // 6. Alert generation — jitter
948    // -----------------------------------------------------------------------
949
950    #[test]
951    fn test_jitter_warning_alert() {
952        let mut m = default_monitor();
953        // 80 % of 100 ms = 80 ms
954        let alerts = m.record(jitter_sample("p1", 80.0, 1000));
955        assert_eq!(alerts.len(), 1);
956        assert_eq!(alerts[0].severity, ChmAlertSeverity::Warning);
957    }
958
959    #[test]
960    fn test_jitter_critical_alert() {
961        let mut m = default_monitor();
962        let alerts = m.record(jitter_sample("p1", 100.0, 1000));
963        assert_eq!(alerts.len(), 1);
964        assert_eq!(alerts[0].severity, ChmAlertSeverity::Critical);
965    }
966
967    // -----------------------------------------------------------------------
968    // 7. Alert generation — error rate
969    // -----------------------------------------------------------------------
970
971    #[test]
972    fn test_error_rate_warning_alert() {
973        let mut m = default_monitor();
974        // Warning threshold = 80% of 0.1 = 0.08 (computed as 0.1 * 0.8).
975        // Use 0.09 to safely land above the floating-point result without reaching critical.
976        let alerts = m.record(error_rate_sample("p1", 0.09, 1000));
977        assert_eq!(alerts.len(), 1);
978        assert_eq!(alerts[0].severity, ChmAlertSeverity::Warning);
979    }
980
981    #[test]
982    fn test_error_rate_critical_alert() {
983        let mut m = default_monitor();
984        let alerts = m.record(error_rate_sample("p1", 0.1, 1000));
985        assert_eq!(alerts.len(), 1);
986        assert_eq!(alerts[0].severity, ChmAlertSeverity::Critical);
987    }
988
989    // -----------------------------------------------------------------------
990    // 8. Alert storage and retrieval
991    // -----------------------------------------------------------------------
992
993    #[test]
994    fn test_alerts_stored_internally() {
995        let mut m = default_monitor();
996        m.record(latency_sample("p1", 500.0, 1000));
997        assert_eq!(m.alerts.len(), 1);
998    }
999
1000    #[test]
1001    fn test_max_alerts_eviction() {
1002        let mut m = ConnectionHealthMonitor::new(AlertThresholds::default(), 3);
1003        for i in 0..10u64 {
1004            m.record(latency_sample("p1", 600.0, i * 1000));
1005        }
1006        assert_eq!(m.alerts.len(), 3);
1007    }
1008
1009    #[test]
1010    fn test_recent_alerts_filter_by_timestamp() {
1011        let mut m = default_monitor();
1012        m.record(latency_sample("p1", 600.0, 1_000));
1013        m.record(latency_sample("p1", 600.0, 5_000));
1014        m.record(latency_sample("p1", 600.0, 10_000));
1015        let recent = m.recent_alerts(5_000);
1016        assert_eq!(recent.len(), 2);
1017    }
1018
1019    #[test]
1020    fn test_recent_alerts_returns_all_when_since_zero() {
1021        let mut m = default_monitor();
1022        m.record(latency_sample("p1", 600.0, 1_000));
1023        m.record(latency_sample("p1", 600.0, 2_000));
1024        let recent = m.recent_alerts(0);
1025        assert_eq!(recent.len(), 2);
1026    }
1027
1028    // -----------------------------------------------------------------------
1029    // 9. alert_count on ConnectionHealth
1030    // -----------------------------------------------------------------------
1031
1032    #[test]
1033    fn test_alert_count_increments_per_peer() {
1034        let mut m = default_monitor();
1035        m.record(latency_sample("p1", 600.0, 1_000));
1036        m.record(latency_sample("p1", 600.0, 2_000));
1037        let conn = m.connections.get("p1").expect("peer exists");
1038        assert_eq!(conn.alert_count, 2);
1039    }
1040
1041    // -----------------------------------------------------------------------
1042    // 10. peers_below_threshold
1043    // -----------------------------------------------------------------------
1044
1045    #[test]
1046    fn test_peers_below_threshold_sorted() {
1047        let mut m = default_monitor();
1048        // Insert peers with varying latencies.
1049        m.record(latency_sample("b_peer", 900.0, 1000));
1050        m.record(latency_sample("a_peer", 950.0, 1000));
1051        m.record(latency_sample("c_peer", 10.0, 1000));
1052        // c_peer should have a good score; a_peer and b_peer poor scores.
1053        let below = m.peers_below_threshold(0.9);
1054        // At least a_peer and b_peer should be there; check sort.
1055        for i in 1..below.len() {
1056            assert!(below[i - 1] <= below[i]);
1057        }
1058    }
1059
1060    #[test]
1061    fn test_peers_below_threshold_none_when_all_healthy() {
1062        let mut m = default_monitor();
1063        m.record(latency_sample("p1", 1.0, 1000));
1064        let below = m.peers_below_threshold(0.0);
1065        assert!(below.is_empty());
1066    }
1067
1068    // -----------------------------------------------------------------------
1069    // 11. clear_peer
1070    // -----------------------------------------------------------------------
1071
1072    #[test]
1073    fn test_clear_peer_removes_existing() {
1074        let mut m = default_monitor();
1075        m.record(latency_sample("p1", 50.0, 1000));
1076        assert!(m.clear_peer("p1"));
1077        assert!(m.health_score("p1").is_none());
1078    }
1079
1080    #[test]
1081    fn test_clear_peer_returns_false_for_unknown() {
1082        let mut m = default_monitor();
1083        assert!(!m.clear_peer("ghost"));
1084    }
1085
1086    // -----------------------------------------------------------------------
1087    // 12. evict_stale
1088    // -----------------------------------------------------------------------
1089
1090    #[test]
1091    fn test_evict_stale_removes_old_peers() {
1092        let mut m = default_monitor();
1093        m.record(latency_sample("old", 50.0, 1_000));
1094        // "new" peer has ts=180_000 which is >= cutoff (200_000 - 50_000 = 150_000).
1095        m.record(latency_sample("new", 50.0, 180_000));
1096        let count = m.evict_stale(50_000, 200_000);
1097        assert_eq!(count, 1);
1098        assert!(m.health_score("old").is_none());
1099        assert!(m.health_score("new").is_some());
1100    }
1101
1102    #[test]
1103    fn test_evict_stale_returns_zero_when_nothing_stale() {
1104        let mut m = default_monitor();
1105        m.record(latency_sample("p1", 50.0, 190_000));
1106        let count = m.evict_stale(50_000, 200_000);
1107        assert_eq!(count, 0);
1108    }
1109
1110    // -----------------------------------------------------------------------
1111    // 13. top_peers
1112    // -----------------------------------------------------------------------
1113
1114    #[test]
1115    fn test_top_peers_returns_n_best() {
1116        let mut m = default_monitor();
1117        m.record(bandwidth_sample("p1", 1_000_000.0, 1000));
1118        m.record(bandwidth_sample("p2", 500_000.0, 1000));
1119        m.record(bandwidth_sample("p3", 10_000.0, 1000));
1120        let top = m.top_peers(2);
1121        assert_eq!(top.len(), 2);
1122        // Descending order.
1123        assert!(top[0].1 >= top[1].1);
1124    }
1125
1126    #[test]
1127    fn test_top_peers_handles_n_larger_than_count() {
1128        let mut m = default_monitor();
1129        m.record(latency_sample("p1", 50.0, 1000));
1130        let top = m.top_peers(100);
1131        assert_eq!(top.len(), 1);
1132    }
1133
1134    #[test]
1135    fn test_top_peers_empty_monitor() {
1136        let m = default_monitor();
1137        assert!(m.top_peers(5).is_empty());
1138    }
1139
1140    // -----------------------------------------------------------------------
1141    // 14. stats
1142    // -----------------------------------------------------------------------
1143
1144    #[test]
1145    fn test_stats_default_empty() {
1146        let m = default_monitor();
1147        let s = m.stats();
1148        assert_eq!(s.total_peers, 0);
1149        assert_eq!(s.avg_health_score, 0.0);
1150    }
1151
1152    #[test]
1153    fn test_stats_counts_healthy_degraded_critical() {
1154        let mut m = default_monitor();
1155        // Near-perfect peer → healthy.
1156        m.record(latency_sample("healthy", 1.0, 1));
1157        m.record(bandwidth_sample("healthy", 1_000_000.0, 2));
1158        m.record(packet_loss_sample("healthy", 0.0, 3));
1159        m.record(jitter_sample("healthy", 0.0, 4));
1160        m.record(error_rate_sample("healthy", 0.0, 5));
1161
1162        // High latency peer → critical or degraded.
1163        m.record(latency_sample("bad", 10_000.0, 1));
1164        m.record(bandwidth_sample("bad", 1.0, 2));
1165        m.record(packet_loss_sample("bad", 0.9, 3));
1166        m.record(jitter_sample("bad", 5_000.0, 4));
1167        m.record(error_rate_sample("bad", 0.9, 5));
1168
1169        let s = m.stats();
1170        assert_eq!(s.total_peers, 2);
1171        assert_eq!(s.healthy_peers + s.degraded_peers + s.critical_peers, 2);
1172    }
1173
1174    #[test]
1175    fn test_stats_avg_health_score() {
1176        let mut m = default_monitor();
1177        // Only latency samples with identical value → scores equal.
1178        m.record(latency_sample("p1", 1.0, 1));
1179        m.record(latency_sample("p2", 1.0, 1));
1180        let s = m.stats();
1181        let score_p1 = m.health_score("p1").unwrap_or(0.0);
1182        let score_p2 = m.health_score("p2").unwrap_or(0.0);
1183        let expected_avg = (score_p1 + score_p2) / 2.0;
1184        assert!((s.avg_health_score - expected_avg).abs() < 1e-9);
1185    }
1186
1187    // -----------------------------------------------------------------------
1188    // 15. Window eviction
1189    // -----------------------------------------------------------------------
1190
1191    #[test]
1192    fn test_window_evicts_oldest_same_metric() {
1193        let mut m = default_monitor();
1194        // Fill window with 50 latency samples.
1195        for i in 0..WINDOW_SIZE {
1196            m.record(latency_sample("p1", 10.0, i as u64 * 100));
1197        }
1198        let conn = m.connections.get("p1").expect("exists");
1199        let latency_count = conn
1200            .samples
1201            .iter()
1202            .filter(|s| s.metric.kind() == "latency")
1203            .count();
1204        assert_eq!(latency_count, WINDOW_SIZE);
1205
1206        // Add one more latency sample — oldest should be evicted.
1207        m.record(latency_sample("p1", 10.0, WINDOW_SIZE as u64 * 100));
1208        let conn = m.connections.get("p1").expect("exists");
1209        let latency_count_after = conn
1210            .samples
1211            .iter()
1212            .filter(|s| s.metric.kind() == "latency")
1213            .count();
1214        assert_eq!(latency_count_after, WINDOW_SIZE);
1215    }
1216
1217    #[test]
1218    fn test_window_different_metrics_independent() {
1219        let mut m = default_monitor();
1220        // Fill window with both latency and bandwidth.
1221        for i in 0..WINDOW_SIZE {
1222            m.record(latency_sample("p1", 10.0, i as u64 * 100));
1223            m.record(bandwidth_sample("p1", 1_000_000.0, i as u64 * 100 + 1));
1224        }
1225        let conn = m.connections.get("p1").expect("exists");
1226        let latency_count = conn
1227            .samples
1228            .iter()
1229            .filter(|s| s.metric.kind() == "latency")
1230            .count();
1231        let bandwidth_count = conn
1232            .samples
1233            .iter()
1234            .filter(|s| s.metric.kind() == "bandwidth")
1235            .count();
1236        assert_eq!(latency_count, WINDOW_SIZE);
1237        assert_eq!(bandwidth_count, WINDOW_SIZE);
1238    }
1239
1240    // -----------------------------------------------------------------------
1241    // 16. HealthMetric helpers
1242    // -----------------------------------------------------------------------
1243
1244    #[test]
1245    fn test_health_metric_value() {
1246        assert_eq!(ChmHealthMetric::Latency(42.0).value(), 42.0);
1247        assert_eq!(ChmHealthMetric::PacketLoss(0.03).value(), 0.03);
1248        assert_eq!(ChmHealthMetric::Bandwidth(1e6).value(), 1e6);
1249        assert_eq!(ChmHealthMetric::Jitter(15.0).value(), 15.0);
1250        assert_eq!(ChmHealthMetric::ErrorRate(0.07).value(), 0.07);
1251    }
1252
1253    #[test]
1254    fn test_health_metric_kind() {
1255        assert_eq!(ChmHealthMetric::Latency(0.0).kind(), "latency");
1256        assert_eq!(ChmHealthMetric::PacketLoss(0.0).kind(), "packet_loss");
1257        assert_eq!(ChmHealthMetric::Bandwidth(0.0).kind(), "bandwidth");
1258        assert_eq!(ChmHealthMetric::Jitter(0.0).kind(), "jitter");
1259        assert_eq!(ChmHealthMetric::ErrorRate(0.0).kind(), "error_rate");
1260    }
1261
1262    // -----------------------------------------------------------------------
1263    // 17. AlertThresholds defaults and warning levels
1264    // -----------------------------------------------------------------------
1265
1266    #[test]
1267    fn test_alert_thresholds_defaults() {
1268        let t = AlertThresholds::default();
1269        assert_eq!(t.max_latency_ms, 500.0);
1270        assert_eq!(t.max_packet_loss, 0.05);
1271        assert_eq!(t.min_bandwidth_bps, 100_000.0);
1272        assert_eq!(t.max_jitter_ms, 100.0);
1273        assert_eq!(t.max_error_rate, 0.1);
1274    }
1275
1276    #[test]
1277    fn test_alert_thresholds_warning_levels() {
1278        let t = AlertThresholds::default();
1279        assert!((t.warning_latency_ms() - 400.0).abs() < 1e-9);
1280        assert!((t.warning_packet_loss() - 0.04).abs() < 1e-9);
1281        assert!((t.warning_jitter_ms() - 80.0).abs() < 1e-9);
1282        assert!((t.warning_error_rate() - 0.08).abs() < 1e-9);
1283    }
1284
1285    // -----------------------------------------------------------------------
1286    // 18. check_thresholds directly
1287    // -----------------------------------------------------------------------
1288
1289    #[test]
1290    fn test_check_thresholds_exact_boundary() {
1291        let m = default_monitor();
1292        // Exactly at warning boundary (400 ms).
1293        let alerts = m.check_thresholds("p1", &ChmHealthMetric::Latency(400.0), 400.0, 1000);
1294        assert_eq!(alerts.len(), 1);
1295        assert_eq!(alerts[0].severity, ChmAlertSeverity::Warning);
1296    }
1297
1298    #[test]
1299    fn test_check_thresholds_returns_threshold_value() {
1300        let m = default_monitor();
1301        let alerts = m.check_thresholds("p1", &ChmHealthMetric::Latency(600.0), 600.0, 1000);
1302        assert_eq!(alerts[0].threshold, 500.0);
1303        assert_eq!(alerts[0].value, 600.0);
1304    }
1305
1306    // -----------------------------------------------------------------------
1307    // 19. Multi-peer isolation
1308    // -----------------------------------------------------------------------
1309
1310    #[test]
1311    fn test_peers_do_not_interfere() {
1312        let mut m = default_monitor();
1313        m.record(latency_sample("p1", 10.0, 1000));
1314        m.record(latency_sample("p2", 900.0, 1000));
1315        let s1 = m.health_score("p1").unwrap_or(0.0);
1316        let s2 = m.health_score("p2").unwrap_or(0.0);
1317        assert!(s1 > s2);
1318    }
1319
1320    // -----------------------------------------------------------------------
1321    // 20. last_updated is set correctly
1322    // -----------------------------------------------------------------------
1323
1324    #[test]
1325    fn test_last_updated_reflects_sample_timestamp() {
1326        let mut m = default_monitor();
1327        m.record(latency_sample("p1", 50.0, 42_000));
1328        let conn = m.connections.get("p1").expect("exists");
1329        assert_eq!(conn.last_updated, 42_000);
1330    }
1331
1332    // -----------------------------------------------------------------------
1333    // 21. Custom thresholds
1334    // -----------------------------------------------------------------------
1335
1336    #[test]
1337    fn test_custom_thresholds_stricter_latency() {
1338        let thresholds = AlertThresholds {
1339            max_latency_ms: 100.0,
1340            ..Default::default()
1341        };
1342        let mut m = ConnectionHealthMonitor::new(thresholds, 100);
1343        // 80 ms >= 80 % of 100 ms → warning.
1344        let alerts = m.record(latency_sample("p1", 80.0, 1000));
1345        assert_eq!(alerts.len(), 1);
1346        assert_eq!(alerts[0].severity, ChmAlertSeverity::Warning);
1347    }
1348
1349    #[test]
1350    fn test_custom_thresholds_relaxed_packet_loss() {
1351        let thresholds = AlertThresholds {
1352            max_packet_loss: 0.5,
1353            ..Default::default()
1354        };
1355        let mut m = ConnectionHealthMonitor::new(thresholds, 100);
1356        // 0.05 < 80 % of 0.5 = 0.4 → no alert.
1357        let alerts = m.record(packet_loss_sample("p1", 0.05, 1000));
1358        assert!(alerts.is_empty());
1359    }
1360
1361    // -----------------------------------------------------------------------
1362    // 22. Bandwidth score capped at 1.0
1363    // -----------------------------------------------------------------------
1364
1365    #[test]
1366    fn test_bandwidth_score_capped_at_one() {
1367        let mut conn = ConnectionHealth::new("p".to_string());
1368        // 100 Mbps >> 1 Mbps threshold → capped.
1369        conn.samples.push_back(ChmHealthSample {
1370            peer_id: "p".to_string(),
1371            metric: ChmHealthMetric::Bandwidth(100_000_000.0),
1372            timestamp: 1,
1373        });
1374        let score = ConnectionHealthMonitor::compute_health_score(&conn);
1375        assert!(score <= 1.0);
1376    }
1377
1378    // -----------------------------------------------------------------------
1379    // 23. Mixed metric recording
1380    // -----------------------------------------------------------------------
1381
1382    #[test]
1383    fn test_mixed_metrics_all_recorded() {
1384        let mut m = default_monitor();
1385        m.record(latency_sample("p1", 10.0, 1));
1386        m.record(bandwidth_sample("p1", 1_000_000.0, 2));
1387        m.record(packet_loss_sample("p1", 0.0, 3));
1388        m.record(jitter_sample("p1", 5.0, 4));
1389        m.record(error_rate_sample("p1", 0.0, 5));
1390        let conn = m.connections.get("p1").expect("exists");
1391        assert_eq!(conn.samples.len(), 5);
1392    }
1393
1394    #[test]
1395    fn test_mixed_metrics_health_score_near_one() {
1396        let mut m = default_monitor();
1397        m.record(latency_sample("p1", 0.0, 1));
1398        m.record(bandwidth_sample("p1", 1_000_000.0, 2));
1399        m.record(packet_loss_sample("p1", 0.0, 3));
1400        m.record(jitter_sample("p1", 0.0, 4));
1401        m.record(error_rate_sample("p1", 0.0, 5));
1402        let score = m.health_score("p1").unwrap_or(0.0);
1403        assert!(score > 0.95, "score={score}");
1404    }
1405
1406    // -----------------------------------------------------------------------
1407    // 24. ChmMonitorStats fields
1408    // -----------------------------------------------------------------------
1409
1410    #[test]
1411    fn test_monitor_stats_fields_accessible() {
1412        let s = ChmMonitorStats::default();
1413        assert_eq!(s.total_peers, 0);
1414        assert_eq!(s.healthy_peers, 0);
1415        assert_eq!(s.degraded_peers, 0);
1416        assert_eq!(s.critical_peers, 0);
1417        assert_eq!(s.total_alerts, 0);
1418        assert_eq!(s.avg_health_score, 0.0);
1419    }
1420
1421    // -----------------------------------------------------------------------
1422    // 25. ChmHealthAlert fields
1423    // -----------------------------------------------------------------------
1424
1425    #[test]
1426    fn test_health_alert_fields() {
1427        let alert = ChmHealthAlert {
1428            peer_id: "test".to_string(),
1429            metric: ChmHealthMetric::Latency(600.0),
1430            severity: ChmAlertSeverity::Critical,
1431            value: 600.0,
1432            threshold: 500.0,
1433            timestamp: 99_000,
1434        };
1435        assert_eq!(alert.peer_id, "test");
1436        assert_eq!(alert.value, 600.0);
1437        assert_eq!(alert.threshold, 500.0);
1438        assert_eq!(alert.timestamp, 99_000);
1439    }
1440}