Skip to main content

ipfrs_network/
peer_discovery_manager.rs

1//! Multi-strategy Peer Discovery Manager
2//!
3//! Provides [`PeerDiscoveryManager`] — a production-grade component that tracks
4//! peer candidates sourced from multiple discovery strategies, deduplicates
5//! them, scores connection likelihood, and drives the connection-attempt
6//! lifecycle from first-seen through disconnect.
7//!
8//! # Design overview
9//!
10//! * **Deduplication** – every candidate is keyed by its peer-id string; a
11//!   second `add_candidate` call for the same peer-id merges the new addresses
12//!   into the existing record rather than creating a duplicate entry.
13//!
14//! * **Scoring** – each candidate carries a `connect_score` (∈ [0, 10]) that
15//!   rises on successful connection and falls on failure, timeout, or
16//!   unreachability.  Candidates are ranked by score when the caller asks for
17//!   connection targets.
18//!
19//! * **Backoff** – `candidates_to_try` returns only those peers whose
20//!   `last_attempt` is old enough (≥ `retry_backoff_ms`) or who have never
21//!   been attempted.
22//!
23//! * **Stale eviction** – `evict_stale` removes non-connected candidates that
24//!   were both *discovered* and *last attempted* (or never attempted) beyond
25//!   `stale_threshold_ms`.
26
27use std::collections::{HashMap, HashSet};
28
29// ---------------------------------------------------------------------------
30// Public types
31// ---------------------------------------------------------------------------
32
33/// The mechanism through which a [`PeerCandidate`] was first discovered.
34#[derive(Clone, Debug, PartialEq, Eq, Hash)]
35pub enum DiscoveryMethod {
36    /// Peer came from the static bootstrap list.
37    Bootstrap,
38    /// Local-network multicast discovery via mDNS.
39    Mdns,
40    /// Kademlia DHT iterative peer lookup / random walk.
41    Dht,
42    /// Another connected peer advertised this peer (PEX).
43    PeerExchange,
44    /// Manually added by an operator or the application layer.
45    Manual,
46}
47
48impl std::fmt::Display for DiscoveryMethod {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            DiscoveryMethod::Bootstrap => write!(f, "Bootstrap"),
52            DiscoveryMethod::Mdns => write!(f, "Mdns"),
53            DiscoveryMethod::Dht => write!(f, "Dht"),
54            DiscoveryMethod::PeerExchange => write!(f, "PeerExchange"),
55            DiscoveryMethod::Manual => write!(f, "Manual"),
56        }
57    }
58}
59
60/// A single peer candidate tracked by [`PeerDiscoveryManager`].
61#[derive(Clone, Debug)]
62pub struct PeerCandidate {
63    /// Unique peer identifier (e.g., libp2p PeerId as a string).
64    pub peer_id: String,
65    /// Known multiaddresses for this peer.
66    pub addresses: Vec<String>,
67    /// Which discovery mechanism first surfaced this peer.
68    pub discovered_via: DiscoveryMethod,
69    /// Unix timestamp (milliseconds) when this peer was first discovered.
70    pub discovered_at: u64,
71    /// Connection likelihood score.  Starts at `1.0`; capped to `[0.0, 10.0]`.
72    pub connect_score: f64,
73    /// Whether at least one connection attempt has been made.
74    pub attempted: bool,
75    /// Whether the peer is currently connected.
76    pub connected: bool,
77    /// Unix timestamp (milliseconds) of the most recent connection attempt.
78    pub last_attempt: Option<u64>,
79}
80
81impl PeerCandidate {
82    /// Create a new candidate with default scoring state.
83    pub fn new(
84        peer_id: impl Into<String>,
85        addresses: Vec<String>,
86        discovered_via: DiscoveryMethod,
87        discovered_at: u64,
88    ) -> Self {
89        Self {
90            peer_id: peer_id.into(),
91            addresses,
92            discovered_via,
93            discovered_at,
94            connect_score: 1.0,
95            attempted: false,
96            connected: false,
97            last_attempt: None,
98        }
99    }
100}
101
102/// Outcome of a single connection attempt reported back to the manager.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub enum ConnectOutcome {
105    /// Connection established successfully.
106    Success,
107    /// Remote peer actively refused the connection.
108    Refused,
109    /// Connection attempt timed out.
110    Timeout,
111    /// Peer address is not reachable at all.
112    Unreachable,
113}
114
115/// Configuration knobs for [`PeerDiscoveryManager`].
116#[derive(Clone, Debug)]
117pub struct DiscoveryConfig {
118    /// Maximum number of candidates to track at any time.
119    pub max_candidates: usize,
120    /// Maximum number of simultaneously connected peers.
121    pub max_connected: usize,
122    /// How often (ms) scores are eligible for re-evaluation (informational).
123    pub rescore_interval_ms: u64,
124    /// Minimum gap (ms) between successive connection attempts to the same peer.
125    pub retry_backoff_ms: u64,
126    /// Age threshold (ms) beyond which an unconnected, un-attempted peer is
127    /// considered stale and eligible for eviction.
128    pub stale_threshold_ms: u64,
129}
130
131impl Default for DiscoveryConfig {
132    fn default() -> Self {
133        Self {
134            max_candidates: 500,
135            max_connected: 50,
136            rescore_interval_ms: 60_000,
137            retry_backoff_ms: 5_000,
138            stale_threshold_ms: 300_000,
139        }
140    }
141}
142
143/// Aggregated statistics snapshot returned by [`PeerDiscoveryManager::stats`].
144#[derive(Clone, Debug, Default)]
145pub struct DiscoveryStats {
146    /// Total candidates currently tracked.
147    pub total_candidates: usize,
148    /// Number of currently connected peers.
149    pub connected: usize,
150    /// Number of candidates for which at least one attempt was made.
151    pub attempted: usize,
152    /// Average `connect_score` across all tracked candidates.
153    pub avg_score: f64,
154    /// Cumulative distinct peers ever added (including evicted ones).
155    pub total_discovered: u64,
156    /// Cumulative peers that reached `ConnectOutcome::Success`.
157    pub total_connected: u64,
158    /// Cumulative failed connection attempts (Refused + Timeout + Unreachable).
159    pub total_failed: u64,
160    /// How many candidates are tracked per discovery method (method name → count).
161    pub method_distribution: HashMap<String, usize>,
162}
163
164// ---------------------------------------------------------------------------
165// Score arithmetic helpers
166// ---------------------------------------------------------------------------
167
168const SCORE_MIN: f64 = 0.0;
169const SCORE_MAX: f64 = 10.0;
170const SCORE_SUCCESS_DELTA: f64 = 0.5;
171const SCORE_REFUSED_DELTA: f64 = -0.3;
172const SCORE_TIMEOUT_DELTA: f64 = -0.2;
173const SCORE_UNREACHABLE_DELTA: f64 = -0.5;
174const SCORE_DISCONNECT_DELTA: f64 = -0.1;
175
176#[inline]
177fn clamp_score(score: f64) -> f64 {
178    score.clamp(SCORE_MIN, SCORE_MAX)
179}
180
181// ---------------------------------------------------------------------------
182// PeerDiscoveryManager
183// ---------------------------------------------------------------------------
184
185/// Multi-strategy peer discovery manager.
186///
187/// Maintains a scored, deduplicated pool of [`PeerCandidate`]s, drives the
188/// connection lifecycle, and provides ranked candidate selection with
189/// configurable backoff and stale eviction.
190pub struct PeerDiscoveryManager {
191    /// Configuration.
192    pub config: DiscoveryConfig,
193    /// All tracked candidates, keyed by peer-id.
194    candidates: HashMap<String, PeerCandidate>,
195    /// Set of peer-ids that are currently connected.
196    connected: HashSet<String>,
197    /// Cumulative peers ever added.
198    total_discovered: u64,
199    /// Cumulative `ConnectOutcome::Success` events.
200    total_connected: u64,
201    /// Cumulative failed connection outcomes.
202    total_failed: u64,
203}
204
205impl PeerDiscoveryManager {
206    // -----------------------------------------------------------------------
207    // Construction
208    // -----------------------------------------------------------------------
209
210    /// Create a new manager with the given configuration.
211    pub fn new(config: DiscoveryConfig) -> Self {
212        Self {
213            config,
214            candidates: HashMap::new(),
215            connected: HashSet::new(),
216            total_discovered: 0,
217            total_connected: 0,
218            total_failed: 0,
219        }
220    }
221
222    // -----------------------------------------------------------------------
223    // Candidate lifecycle
224    // -----------------------------------------------------------------------
225
226    /// Add or update a candidate.
227    ///
228    /// * If the peer is already tracked, the new addresses are merged
229    ///   (deduplicated) into the existing record and `true` is returned.
230    /// * If the candidate pool is at `max_candidates`, the lowest-scoring
231    ///   **non-connected** candidate is evicted first.  If no eviction is
232    ///   possible (all slots are connected) `false` is returned.
233    /// * Otherwise the candidate is inserted and `true` is returned.
234    pub fn add_candidate(&mut self, candidate: PeerCandidate) -> bool {
235        // Merge path: peer already known.
236        if let Some(existing) = self.candidates.get_mut(&candidate.peer_id) {
237            for addr in &candidate.addresses {
238                if !existing.addresses.contains(addr) {
239                    existing.addresses.push(addr.clone());
240                }
241            }
242            return true;
243        }
244
245        // Evict if necessary.
246        if self.candidates.len() >= self.config.max_candidates && !self.evict_lowest_non_connected()
247        {
248            return false;
249        }
250
251        self.total_discovered += 1;
252        self.candidates.insert(candidate.peer_id.clone(), candidate);
253        true
254    }
255
256    /// Record the outcome of a connection attempt.
257    ///
258    /// Updates `connect_score`, `attempted`, `connected`, and counters.
259    /// Returns `false` when the peer is not found.
260    pub fn record_outcome(&mut self, peer_id: &str, outcome: ConnectOutcome, now: u64) -> bool {
261        let candidate = match self.candidates.get_mut(peer_id) {
262            Some(c) => c,
263            None => return false,
264        };
265
266        candidate.last_attempt = Some(now);
267
268        match outcome {
269            ConnectOutcome::Success => {
270                candidate.connected = true;
271                candidate.connect_score =
272                    clamp_score(candidate.connect_score + SCORE_SUCCESS_DELTA);
273                let pid = peer_id.to_owned();
274                self.connected.insert(pid);
275                self.total_connected += 1;
276            }
277            ConnectOutcome::Refused => {
278                candidate.attempted = true;
279                candidate.connect_score =
280                    clamp_score(candidate.connect_score + SCORE_REFUSED_DELTA);
281                self.total_failed += 1;
282            }
283            ConnectOutcome::Timeout => {
284                candidate.attempted = true;
285                candidate.connect_score =
286                    clamp_score(candidate.connect_score + SCORE_TIMEOUT_DELTA);
287                self.total_failed += 1;
288            }
289            ConnectOutcome::Unreachable => {
290                candidate.attempted = true;
291                candidate.connect_score =
292                    clamp_score(candidate.connect_score + SCORE_UNREACHABLE_DELTA);
293                self.total_failed += 1;
294            }
295        }
296
297        true
298    }
299
300    /// Mark a peer as disconnected.
301    ///
302    /// The peer remains in the candidate pool with a slightly reduced score so
303    /// it can be re-tried later.  Returns `false` when the peer is not found.
304    pub fn mark_disconnected(&mut self, peer_id: &str) -> bool {
305        let candidate = match self.candidates.get_mut(peer_id) {
306            Some(c) => c,
307            None => return false,
308        };
309
310        candidate.connected = false;
311        candidate.connect_score = clamp_score(candidate.connect_score + SCORE_DISCONNECT_DELTA);
312        self.connected.remove(peer_id);
313        true
314    }
315
316    // -----------------------------------------------------------------------
317    // Candidate selection
318    // -----------------------------------------------------------------------
319
320    /// Return up to `n` candidates that should be tried next.
321    ///
322    /// Eligible candidates are those that:
323    /// * Are **not** currently connected.
324    /// * Have `last_attempt == None` **or** `last_attempt` is at least
325    ///   `retry_backoff_ms` before `now`.
326    ///
327    /// Results are sorted by `connect_score` descending.
328    pub fn candidates_to_try(&self, n: usize, now: u64) -> Vec<&PeerCandidate> {
329        let backoff = self.config.retry_backoff_ms;
330        let mut eligible: Vec<&PeerCandidate> = self
331            .candidates
332            .values()
333            .filter(|c| {
334                if c.connected {
335                    return false;
336                }
337                match c.last_attempt {
338                    None => true,
339                    Some(t) => now.saturating_sub(t) >= backoff,
340                }
341            })
342            .collect();
343
344        eligible.sort_by(|a, b| {
345            b.connect_score
346                .partial_cmp(&a.connect_score)
347                .unwrap_or(std::cmp::Ordering::Equal)
348        });
349        eligible.truncate(n);
350        eligible
351    }
352
353    /// Evict non-connected candidates that are considered stale.
354    ///
355    /// A candidate is stale when **both**:
356    /// * `discovered_at < now - stale_threshold_ms`
357    /// * `last_attempt < now - stale_threshold_ms` *or* `last_attempt == None`
358    ///
359    /// Returns the number of evicted candidates.
360    pub fn evict_stale(&mut self, now: u64) -> usize {
361        let threshold = self.config.stale_threshold_ms;
362        let cutoff = now.saturating_sub(threshold);
363
364        let stale_ids: Vec<String> = self
365            .candidates
366            .iter()
367            .filter(|(_, c)| {
368                if c.connected {
369                    return false;
370                }
371                let old_discovery = c.discovered_at < cutoff;
372                let old_attempt = match c.last_attempt {
373                    None => true,
374                    Some(t) => t < cutoff,
375                };
376                old_discovery && old_attempt
377            })
378            .map(|(k, _)| k.clone())
379            .collect();
380
381        let count = stale_ids.len();
382        for id in stale_ids {
383            self.candidates.remove(&id);
384        }
385        count
386    }
387
388    // -----------------------------------------------------------------------
389    // Queries
390    // -----------------------------------------------------------------------
391
392    /// All currently connected peer candidates.
393    pub fn connected_peers(&self) -> Vec<&PeerCandidate> {
394        self.candidates.values().filter(|c| c.connected).collect()
395    }
396
397    /// Total number of tracked candidates.
398    pub fn peer_count(&self) -> usize {
399        self.candidates.len()
400    }
401
402    /// Number of currently connected peers.
403    pub fn connected_count(&self) -> usize {
404        self.connected.len()
405    }
406
407    /// The highest-scoring candidate that is **not** currently connected.
408    pub fn best_candidate(&self) -> Option<&PeerCandidate> {
409        self.candidates
410            .values()
411            .filter(|c| !c.connected)
412            .max_by(|a, b| {
413                a.connect_score
414                    .partial_cmp(&b.connect_score)
415                    .unwrap_or(std::cmp::Ordering::Equal)
416            })
417    }
418
419    /// All candidates discovered via the given method.
420    pub fn candidates_by_method(&self, method: &DiscoveryMethod) -> Vec<&PeerCandidate> {
421        self.candidates
422            .values()
423            .filter(|c| &c.discovered_via == method)
424            .collect()
425    }
426
427    /// A statistics snapshot covering the current pool state.
428    pub fn stats(&self) -> DiscoveryStats {
429        let total_candidates = self.candidates.len();
430        let connected = self.connected.len();
431        let attempted = self.candidates.values().filter(|c| c.attempted).count();
432
433        let avg_score = if total_candidates == 0 {
434            0.0
435        } else {
436            let sum: f64 = self.candidates.values().map(|c| c.connect_score).sum();
437            sum / total_candidates as f64
438        };
439
440        let mut method_distribution: HashMap<String, usize> = HashMap::new();
441        for c in self.candidates.values() {
442            *method_distribution
443                .entry(c.discovered_via.to_string())
444                .or_insert(0) += 1;
445        }
446
447        DiscoveryStats {
448            total_candidates,
449            connected,
450            attempted,
451            avg_score,
452            total_discovered: self.total_discovered,
453            total_connected: self.total_connected,
454            total_failed: self.total_failed,
455            method_distribution,
456        }
457    }
458
459    // -----------------------------------------------------------------------
460    // Internal helpers
461    // -----------------------------------------------------------------------
462
463    /// Evict the single lowest-scoring non-connected candidate.
464    /// Returns `true` if an eviction occurred.
465    fn evict_lowest_non_connected(&mut self) -> bool {
466        let victim_id: Option<String> = self
467            .candidates
468            .iter()
469            .filter(|(_, c)| !c.connected)
470            .min_by(|(_, a), (_, b)| {
471                a.connect_score
472                    .partial_cmp(&b.connect_score)
473                    .unwrap_or(std::cmp::Ordering::Equal)
474            })
475            .map(|(k, _)| k.clone());
476
477        if let Some(id) = victim_id {
478            self.candidates.remove(&id);
479            true
480        } else {
481            false
482        }
483    }
484}
485
486// ---------------------------------------------------------------------------
487// Tests
488// ---------------------------------------------------------------------------
489
490#[cfg(test)]
491mod tests {
492    use std::collections::HashMap;
493
494    use crate::peer_discovery_manager::{
495        ConnectOutcome, DiscoveryConfig, DiscoveryMethod, PeerCandidate, PeerDiscoveryManager,
496    };
497
498    // -----------------------------------------------------------------------
499    // Helpers
500    // -----------------------------------------------------------------------
501
502    fn default_config() -> DiscoveryConfig {
503        DiscoveryConfig::default()
504    }
505
506    fn make_candidate(id: &str, method: DiscoveryMethod, discovered_at: u64) -> PeerCandidate {
507        PeerCandidate::new(
508            id,
509            vec![format!("/ip4/1.2.3.4/tcp/{id}")],
510            method,
511            discovered_at,
512        )
513    }
514
515    fn make_candidate_with_addrs(
516        id: &str,
517        addrs: Vec<String>,
518        method: DiscoveryMethod,
519        discovered_at: u64,
520    ) -> PeerCandidate {
521        PeerCandidate::new(id, addrs, method, discovered_at)
522    }
523
524    // -----------------------------------------------------------------------
525    // Construction / defaults
526    // -----------------------------------------------------------------------
527
528    #[test]
529    fn test_new_empty_state() {
530        let mgr = PeerDiscoveryManager::new(default_config());
531        assert_eq!(mgr.peer_count(), 0);
532        assert_eq!(mgr.connected_count(), 0);
533    }
534
535    #[test]
536    fn test_default_config_values() {
537        let cfg = DiscoveryConfig::default();
538        assert_eq!(cfg.max_candidates, 500);
539        assert_eq!(cfg.max_connected, 50);
540        assert_eq!(cfg.rescore_interval_ms, 60_000);
541        assert_eq!(cfg.retry_backoff_ms, 5_000);
542        assert_eq!(cfg.stale_threshold_ms, 300_000);
543    }
544
545    #[test]
546    fn test_peer_candidate_new_defaults() {
547        let c = make_candidate("p1", DiscoveryMethod::Bootstrap, 1000);
548        assert_eq!(c.peer_id, "p1");
549        assert!((c.connect_score - 1.0).abs() < f64::EPSILON);
550        assert!(!c.attempted);
551        assert!(!c.connected);
552        assert!(c.last_attempt.is_none());
553    }
554
555    // -----------------------------------------------------------------------
556    // add_candidate
557    // -----------------------------------------------------------------------
558
559    #[test]
560    fn test_add_candidate_returns_true() {
561        let mut mgr = PeerDiscoveryManager::new(default_config());
562        let c = make_candidate("p1", DiscoveryMethod::Bootstrap, 1000);
563        assert!(mgr.add_candidate(c));
564        assert_eq!(mgr.peer_count(), 1);
565    }
566
567    #[test]
568    fn test_add_duplicate_merges_addresses() {
569        let mut mgr = PeerDiscoveryManager::new(default_config());
570        let c1 = make_candidate_with_addrs(
571            "p1",
572            vec!["/ip4/1.0.0.1/tcp/4001".to_string()],
573            DiscoveryMethod::Bootstrap,
574            1000,
575        );
576        let c2 = make_candidate_with_addrs(
577            "p1",
578            vec![
579                "/ip4/1.0.0.1/tcp/4001".to_string(), // duplicate
580                "/ip4/2.0.0.2/tcp/4001".to_string(), // new
581            ],
582            DiscoveryMethod::Mdns,
583            2000,
584        );
585        mgr.add_candidate(c1);
586        let added = mgr.add_candidate(c2);
587        assert!(added);
588        assert_eq!(mgr.peer_count(), 1);
589        let stored = mgr.candidates.get("p1").expect("should exist");
590        assert_eq!(stored.addresses.len(), 2);
591    }
592
593    #[test]
594    fn test_add_duplicate_no_address_duplication() {
595        let mut mgr = PeerDiscoveryManager::new(default_config());
596        let addr = "/ip4/1.0.0.1/tcp/4001".to_string();
597        mgr.add_candidate(make_candidate_with_addrs(
598            "p1",
599            vec![addr.clone()],
600            DiscoveryMethod::Bootstrap,
601            0,
602        ));
603        mgr.add_candidate(make_candidate_with_addrs(
604            "p1",
605            vec![addr.clone()],
606            DiscoveryMethod::Bootstrap,
607            0,
608        ));
609        assert_eq!(
610            mgr.candidates.get("p1").expect("present").addresses.len(),
611            1
612        );
613    }
614
615    #[test]
616    fn test_add_candidate_at_capacity_evicts_lowest() {
617        let cfg = DiscoveryConfig {
618            max_candidates: 3,
619            ..Default::default()
620        };
621        let mut mgr = PeerDiscoveryManager::new(cfg);
622
623        let mut c_low = make_candidate("low", DiscoveryMethod::Bootstrap, 0);
624        c_low.connect_score = 0.1;
625
626        let mut c_mid = make_candidate("mid", DiscoveryMethod::Bootstrap, 0);
627        c_mid.connect_score = 1.0;
628
629        let mut c_high = make_candidate("high", DiscoveryMethod::Bootstrap, 0);
630        c_high.connect_score = 5.0;
631
632        mgr.add_candidate(c_low);
633        mgr.add_candidate(c_mid);
634        mgr.add_candidate(c_high);
635        assert_eq!(mgr.peer_count(), 3);
636
637        // Adding a 4th should evict "low"
638        let c_new = make_candidate("new", DiscoveryMethod::Dht, 0);
639        assert!(mgr.add_candidate(c_new));
640        assert_eq!(mgr.peer_count(), 3);
641        assert!(!mgr.candidates.contains_key("low"));
642        assert!(mgr.candidates.contains_key("new"));
643    }
644
645    #[test]
646    fn test_add_candidate_at_capacity_all_connected_returns_false() {
647        let cfg = DiscoveryConfig {
648            max_candidates: 2,
649            ..Default::default()
650        };
651        let mut mgr = PeerDiscoveryManager::new(cfg);
652
653        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
654        mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
655
656        // Mark both connected so there is nothing to evict.
657        mgr.record_outcome("p1", ConnectOutcome::Success, 100);
658        mgr.record_outcome("p2", ConnectOutcome::Success, 100);
659
660        let c_new = make_candidate("p3", DiscoveryMethod::Dht, 0);
661        assert!(!mgr.add_candidate(c_new));
662        assert_eq!(mgr.peer_count(), 2);
663    }
664
665    #[test]
666    fn test_total_discovered_increments() {
667        let mut mgr = PeerDiscoveryManager::new(default_config());
668        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
669        mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
670        // Duplicate should NOT increment.
671        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
672        assert_eq!(mgr.total_discovered, 2);
673    }
674
675    // -----------------------------------------------------------------------
676    // record_outcome
677    // -----------------------------------------------------------------------
678
679    #[test]
680    fn test_record_outcome_success_marks_connected() {
681        let mut mgr = PeerDiscoveryManager::new(default_config());
682        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
683        assert!(mgr.record_outcome("p1", ConnectOutcome::Success, 1000));
684        let c = mgr.candidates.get("p1").expect("present");
685        assert!(c.connected);
686        assert_eq!(mgr.connected_count(), 1);
687        assert_eq!(mgr.total_connected, 1);
688    }
689
690    #[test]
691    fn test_record_outcome_success_increases_score() {
692        let mut mgr = PeerDiscoveryManager::new(default_config());
693        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
694        mgr.record_outcome("p1", ConnectOutcome::Success, 1000);
695        let score = mgr.candidates.get("p1").expect("present").connect_score;
696        assert!((score - 1.5).abs() < 1e-10, "expected 1.5, got {score}");
697    }
698
699    #[test]
700    fn test_record_outcome_success_score_capped_at_10() {
701        let mut mgr = PeerDiscoveryManager::new(default_config());
702        let mut c = make_candidate("p1", DiscoveryMethod::Bootstrap, 0);
703        c.connect_score = 9.9;
704        mgr.add_candidate(c);
705        mgr.record_outcome("p1", ConnectOutcome::Success, 1000);
706        let score = mgr.candidates.get("p1").expect("present").connect_score;
707        assert!(score <= 10.0, "score must not exceed 10.0");
708        assert!((score - 10.0).abs() < 1e-10, "expected 10.0, got {score}");
709    }
710
711    #[test]
712    fn test_record_outcome_refused_decreases_score() {
713        let mut mgr = PeerDiscoveryManager::new(default_config());
714        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
715        mgr.record_outcome("p1", ConnectOutcome::Refused, 1000);
716        let c = mgr.candidates.get("p1").expect("present");
717        assert!(c.attempted);
718        assert!((c.connect_score - 0.7).abs() < 1e-10);
719        assert_eq!(mgr.total_failed, 1);
720    }
721
722    #[test]
723    fn test_record_outcome_timeout_decreases_score() {
724        let mut mgr = PeerDiscoveryManager::new(default_config());
725        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
726        mgr.record_outcome("p1", ConnectOutcome::Timeout, 1000);
727        let c = mgr.candidates.get("p1").expect("present");
728        assert!((c.connect_score - 0.8).abs() < 1e-10);
729    }
730
731    #[test]
732    fn test_record_outcome_unreachable_decreases_score() {
733        let mut mgr = PeerDiscoveryManager::new(default_config());
734        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
735        mgr.record_outcome("p1", ConnectOutcome::Unreachable, 1000);
736        let c = mgr.candidates.get("p1").expect("present");
737        assert!((c.connect_score - 0.5).abs() < 1e-10);
738    }
739
740    #[test]
741    fn test_record_outcome_score_floor_at_zero() {
742        let mut mgr = PeerDiscoveryManager::new(default_config());
743        let mut c = make_candidate("p1", DiscoveryMethod::Bootstrap, 0);
744        c.connect_score = 0.1;
745        mgr.add_candidate(c);
746        mgr.record_outcome("p1", ConnectOutcome::Unreachable, 1000);
747        let score = mgr.candidates.get("p1").expect("present").connect_score;
748        assert!(score >= 0.0, "score must not go below 0");
749        assert!((score - 0.0).abs() < 1e-10, "expected 0.0, got {score}");
750    }
751
752    #[test]
753    fn test_record_outcome_unknown_peer_returns_false() {
754        let mut mgr = PeerDiscoveryManager::new(default_config());
755        assert!(!mgr.record_outcome("ghost", ConnectOutcome::Success, 0));
756    }
757
758    #[test]
759    fn test_record_outcome_sets_last_attempt() {
760        let mut mgr = PeerDiscoveryManager::new(default_config());
761        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
762        mgr.record_outcome("p1", ConnectOutcome::Refused, 9999);
763        assert_eq!(
764            mgr.candidates.get("p1").expect("present").last_attempt,
765            Some(9999)
766        );
767    }
768
769    // -----------------------------------------------------------------------
770    // mark_disconnected
771    // -----------------------------------------------------------------------
772
773    #[test]
774    fn test_mark_disconnected_clears_connected_flag() {
775        let mut mgr = PeerDiscoveryManager::new(default_config());
776        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
777        mgr.record_outcome("p1", ConnectOutcome::Success, 100);
778        assert!(mgr.mark_disconnected("p1"));
779        assert!(!mgr.candidates.get("p1").expect("present").connected);
780        assert_eq!(mgr.connected_count(), 0);
781    }
782
783    #[test]
784    fn test_mark_disconnected_reduces_score() {
785        let mut mgr = PeerDiscoveryManager::new(default_config());
786        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
787        mgr.record_outcome("p1", ConnectOutcome::Success, 100);
788        let score_before = mgr.candidates.get("p1").expect("present").connect_score;
789        mgr.mark_disconnected("p1");
790        let score_after = mgr.candidates.get("p1").expect("present").connect_score;
791        assert!(score_after < score_before);
792    }
793
794    #[test]
795    fn test_mark_disconnected_score_floor_zero() {
796        let mut mgr = PeerDiscoveryManager::new(default_config());
797        let mut c = make_candidate("p1", DiscoveryMethod::Bootstrap, 0);
798        c.connect_score = 0.05;
799        c.connected = true;
800        mgr.add_candidate(c);
801        mgr.connected.insert("p1".to_owned());
802        mgr.mark_disconnected("p1");
803        assert!(mgr.candidates.get("p1").expect("present").connect_score >= 0.0);
804    }
805
806    #[test]
807    fn test_mark_disconnected_unknown_peer_returns_false() {
808        let mut mgr = PeerDiscoveryManager::new(default_config());
809        assert!(!mgr.mark_disconnected("ghost"));
810    }
811
812    // -----------------------------------------------------------------------
813    // candidates_to_try
814    // -----------------------------------------------------------------------
815
816    #[test]
817    fn test_candidates_to_try_excludes_connected() {
818        let mut mgr = PeerDiscoveryManager::new(default_config());
819        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
820        mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
821        mgr.record_outcome("p1", ConnectOutcome::Success, 500);
822
823        let candidates = mgr.candidates_to_try(10, 1000);
824        assert_eq!(candidates.len(), 1);
825        assert_eq!(candidates[0].peer_id, "p2");
826    }
827
828    #[test]
829    fn test_candidates_to_try_respects_backoff() {
830        let cfg = DiscoveryConfig {
831            retry_backoff_ms: 5_000,
832            ..Default::default()
833        };
834        let mut mgr = PeerDiscoveryManager::new(cfg);
835        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
836        // Attempt at t=1000 → not eligible until t=6000
837        mgr.record_outcome("p1", ConnectOutcome::Refused, 1000);
838
839        let too_early = mgr.candidates_to_try(10, 5_999);
840        assert!(too_early.is_empty(), "should be in backoff");
841
842        let after_backoff = mgr.candidates_to_try(10, 6_001);
843        assert_eq!(after_backoff.len(), 1);
844    }
845
846    #[test]
847    fn test_candidates_to_try_sorts_by_score_descending() {
848        let mut mgr = PeerDiscoveryManager::new(default_config());
849
850        let mut c_low = make_candidate("low", DiscoveryMethod::Bootstrap, 0);
851        c_low.connect_score = 0.5;
852        let mut c_mid = make_candidate("mid", DiscoveryMethod::Bootstrap, 0);
853        c_mid.connect_score = 2.0;
854        let mut c_high = make_candidate("high", DiscoveryMethod::Bootstrap, 0);
855        c_high.connect_score = 7.0;
856
857        mgr.add_candidate(c_low);
858        mgr.add_candidate(c_mid);
859        mgr.add_candidate(c_high);
860
861        let result = mgr.candidates_to_try(3, 100_000);
862        assert_eq!(result.len(), 3);
863        assert_eq!(result[0].peer_id, "high");
864        assert_eq!(result[1].peer_id, "mid");
865        assert_eq!(result[2].peer_id, "low");
866    }
867
868    #[test]
869    fn test_candidates_to_try_truncates_to_n() {
870        let mut mgr = PeerDiscoveryManager::new(default_config());
871        for i in 0..10 {
872            mgr.add_candidate(make_candidate(&format!("p{i}"), DiscoveryMethod::Dht, 0));
873        }
874        assert_eq!(mgr.candidates_to_try(3, 100_000).len(), 3);
875    }
876
877    #[test]
878    fn test_candidates_to_try_never_attempted_always_eligible() {
879        let mut mgr = PeerDiscoveryManager::new(default_config());
880        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
881        // now=0 with backoff=5000: no last_attempt → eligible
882        let result = mgr.candidates_to_try(10, 0);
883        assert_eq!(result.len(), 1);
884    }
885
886    // -----------------------------------------------------------------------
887    // evict_stale
888    // -----------------------------------------------------------------------
889
890    #[test]
891    fn test_evict_stale_removes_old_unattempted() {
892        let cfg = DiscoveryConfig {
893            stale_threshold_ms: 10_000,
894            ..Default::default()
895        };
896        let mut mgr = PeerDiscoveryManager::new(cfg);
897        mgr.add_candidate(make_candidate("old", DiscoveryMethod::Bootstrap, 0));
898        mgr.add_candidate(make_candidate("recent", DiscoveryMethod::Bootstrap, 5_000));
899
900        // now = 20_000 → cutoff = 10_000
901        // "old" discovered_at=0 < 10_000 and no last_attempt → stale
902        // "recent" discovered_at=5_000 < 10_000 and no last_attempt → stale too
903        let evicted = mgr.evict_stale(20_000);
904        assert_eq!(evicted, 2);
905        assert_eq!(mgr.peer_count(), 0);
906    }
907
908    #[test]
909    fn test_evict_stale_keeps_recently_attempted() {
910        let cfg = DiscoveryConfig {
911            stale_threshold_ms: 10_000,
912            ..Default::default()
913        };
914        let mut mgr = PeerDiscoveryManager::new(cfg);
915        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
916        // Attempt at t=15_000 → last_attempt=15_000, cutoff=10_000 → NOT stale
917        mgr.record_outcome("p1", ConnectOutcome::Refused, 15_000);
918
919        let evicted = mgr.evict_stale(20_000);
920        assert_eq!(evicted, 0);
921        assert_eq!(mgr.peer_count(), 1);
922    }
923
924    #[test]
925    fn test_evict_stale_never_evicts_connected() {
926        let cfg = DiscoveryConfig {
927            stale_threshold_ms: 1,
928            ..Default::default()
929        };
930        let mut mgr = PeerDiscoveryManager::new(cfg);
931        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
932        mgr.record_outcome("p1", ConnectOutcome::Success, 0);
933
934        // Even though discovered_at=0 and stale_threshold=1, connected peers
935        // must never be evicted.
936        let evicted = mgr.evict_stale(100_000);
937        assert_eq!(evicted, 0);
938        assert_eq!(mgr.peer_count(), 1);
939    }
940
941    #[test]
942    fn test_evict_stale_returns_count() {
943        let cfg = DiscoveryConfig {
944            stale_threshold_ms: 100,
945            ..Default::default()
946        };
947        let mut mgr = PeerDiscoveryManager::new(cfg);
948        for i in 0..5 {
949            mgr.add_candidate(make_candidate(&format!("p{i}"), DiscoveryMethod::Dht, 0));
950        }
951        let evicted = mgr.evict_stale(200);
952        assert_eq!(evicted, 5);
953    }
954
955    // -----------------------------------------------------------------------
956    // connected_peers
957    // -----------------------------------------------------------------------
958
959    #[test]
960    fn test_connected_peers_only_returns_connected() {
961        let mut mgr = PeerDiscoveryManager::new(default_config());
962        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
963        mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
964        mgr.record_outcome("p1", ConnectOutcome::Success, 100);
965
966        let connected = mgr.connected_peers();
967        assert_eq!(connected.len(), 1);
968        assert_eq!(connected[0].peer_id, "p1");
969    }
970
971    #[test]
972    fn test_connected_peers_empty_initially() {
973        let mgr = PeerDiscoveryManager::new(default_config());
974        assert!(mgr.connected_peers().is_empty());
975    }
976
977    // -----------------------------------------------------------------------
978    // best_candidate
979    // -----------------------------------------------------------------------
980
981    #[test]
982    fn test_best_candidate_returns_highest_score() {
983        let mut mgr = PeerDiscoveryManager::new(default_config());
984        let mut c_low = make_candidate("low", DiscoveryMethod::Bootstrap, 0);
985        c_low.connect_score = 0.5;
986        let mut c_high = make_candidate("high", DiscoveryMethod::Bootstrap, 0);
987        c_high.connect_score = 8.0;
988        mgr.add_candidate(c_low);
989        mgr.add_candidate(c_high);
990
991        let best = mgr.best_candidate().expect("should have a best");
992        assert_eq!(best.peer_id, "high");
993    }
994
995    #[test]
996    fn test_best_candidate_excludes_connected() {
997        let mut mgr = PeerDiscoveryManager::new(default_config());
998        let mut c1 = make_candidate("p1", DiscoveryMethod::Bootstrap, 0);
999        c1.connect_score = 9.0;
1000        let mut c2 = make_candidate("p2", DiscoveryMethod::Bootstrap, 0);
1001        c2.connect_score = 3.0;
1002        mgr.add_candidate(c1);
1003        mgr.add_candidate(c2);
1004        mgr.record_outcome("p1", ConnectOutcome::Success, 100);
1005
1006        let best = mgr.best_candidate().expect("p2 should be best");
1007        assert_eq!(best.peer_id, "p2");
1008    }
1009
1010    #[test]
1011    fn test_best_candidate_returns_none_when_all_connected() {
1012        let mut mgr = PeerDiscoveryManager::new(default_config());
1013        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1014        mgr.record_outcome("p1", ConnectOutcome::Success, 100);
1015        assert!(mgr.best_candidate().is_none());
1016    }
1017
1018    #[test]
1019    fn test_best_candidate_empty_manager() {
1020        let mgr = PeerDiscoveryManager::new(default_config());
1021        assert!(mgr.best_candidate().is_none());
1022    }
1023
1024    // -----------------------------------------------------------------------
1025    // candidates_by_method
1026    // -----------------------------------------------------------------------
1027
1028    #[test]
1029    fn test_candidates_by_method_filters_correctly() {
1030        let mut mgr = PeerDiscoveryManager::new(default_config());
1031        mgr.add_candidate(make_candidate("b1", DiscoveryMethod::Bootstrap, 0));
1032        mgr.add_candidate(make_candidate("b2", DiscoveryMethod::Bootstrap, 0));
1033        mgr.add_candidate(make_candidate("m1", DiscoveryMethod::Mdns, 0));
1034
1035        let bootstrap_peers = mgr.candidates_by_method(&DiscoveryMethod::Bootstrap);
1036        assert_eq!(bootstrap_peers.len(), 2);
1037
1038        let mdns_peers = mgr.candidates_by_method(&DiscoveryMethod::Mdns);
1039        assert_eq!(mdns_peers.len(), 1);
1040
1041        let dht_peers = mgr.candidates_by_method(&DiscoveryMethod::Dht);
1042        assert!(dht_peers.is_empty());
1043    }
1044
1045    #[test]
1046    fn test_candidates_by_method_all_variants() {
1047        let mut mgr = PeerDiscoveryManager::new(default_config());
1048        mgr.add_candidate(make_candidate("b", DiscoveryMethod::Bootstrap, 0));
1049        mgr.add_candidate(make_candidate("m", DiscoveryMethod::Mdns, 0));
1050        mgr.add_candidate(make_candidate("d", DiscoveryMethod::Dht, 0));
1051        mgr.add_candidate(make_candidate("px", DiscoveryMethod::PeerExchange, 0));
1052        mgr.add_candidate(make_candidate("man", DiscoveryMethod::Manual, 0));
1053
1054        for method in [
1055            DiscoveryMethod::Bootstrap,
1056            DiscoveryMethod::Mdns,
1057            DiscoveryMethod::Dht,
1058            DiscoveryMethod::PeerExchange,
1059            DiscoveryMethod::Manual,
1060        ] {
1061            assert_eq!(mgr.candidates_by_method(&method).len(), 1);
1062        }
1063    }
1064
1065    // -----------------------------------------------------------------------
1066    // stats
1067    // -----------------------------------------------------------------------
1068
1069    #[test]
1070    fn test_stats_initial_empty() {
1071        let mgr = PeerDiscoveryManager::new(default_config());
1072        let s = mgr.stats();
1073        assert_eq!(s.total_candidates, 0);
1074        assert_eq!(s.connected, 0);
1075        assert_eq!(s.attempted, 0);
1076        assert!((s.avg_score - 0.0).abs() < f64::EPSILON);
1077        assert_eq!(s.total_discovered, 0);
1078    }
1079
1080    #[test]
1081    fn test_stats_avg_score() {
1082        let mut mgr = PeerDiscoveryManager::new(default_config());
1083        let mut c1 = make_candidate("p1", DiscoveryMethod::Bootstrap, 0);
1084        c1.connect_score = 2.0;
1085        let mut c2 = make_candidate("p2", DiscoveryMethod::Bootstrap, 0);
1086        c2.connect_score = 4.0;
1087        mgr.add_candidate(c1);
1088        mgr.add_candidate(c2);
1089        let s = mgr.stats();
1090        assert!((s.avg_score - 3.0).abs() < 1e-10);
1091    }
1092
1093    #[test]
1094    fn test_stats_method_distribution() {
1095        let mut mgr = PeerDiscoveryManager::new(default_config());
1096        mgr.add_candidate(make_candidate("b1", DiscoveryMethod::Bootstrap, 0));
1097        mgr.add_candidate(make_candidate("b2", DiscoveryMethod::Bootstrap, 0));
1098        mgr.add_candidate(make_candidate("m1", DiscoveryMethod::Mdns, 0));
1099        let s = mgr.stats();
1100        assert_eq!(
1101            s.method_distribution.get("Bootstrap").copied().unwrap_or(0),
1102            2
1103        );
1104        assert_eq!(s.method_distribution.get("Mdns").copied().unwrap_or(0), 1);
1105    }
1106
1107    #[test]
1108    fn test_stats_totals_after_lifecycle() {
1109        let mut mgr = PeerDiscoveryManager::new(default_config());
1110        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1111        mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
1112        mgr.record_outcome("p1", ConnectOutcome::Success, 100);
1113        mgr.record_outcome("p2", ConnectOutcome::Refused, 100);
1114
1115        let s = mgr.stats();
1116        assert_eq!(s.total_discovered, 2);
1117        assert_eq!(s.total_connected, 1);
1118        assert_eq!(s.total_failed, 1);
1119        assert_eq!(s.connected, 1);
1120        assert_eq!(s.attempted, 1); // only p2 has attempted=true
1121    }
1122
1123    #[test]
1124    fn test_stats_attempted_count() {
1125        let mut mgr = PeerDiscoveryManager::new(default_config());
1126        for i in 0..5 {
1127            mgr.add_candidate(make_candidate(
1128                &format!("p{i}"),
1129                DiscoveryMethod::Bootstrap,
1130                0,
1131            ));
1132        }
1133        mgr.record_outcome("p0", ConnectOutcome::Refused, 100);
1134        mgr.record_outcome("p1", ConnectOutcome::Timeout, 100);
1135        let s = mgr.stats();
1136        assert_eq!(s.attempted, 2);
1137    }
1138
1139    // -----------------------------------------------------------------------
1140    // DiscoveryMethod display / hash
1141    // -----------------------------------------------------------------------
1142
1143    #[test]
1144    fn test_discovery_method_display() {
1145        assert_eq!(DiscoveryMethod::Bootstrap.to_string(), "Bootstrap");
1146        assert_eq!(DiscoveryMethod::Mdns.to_string(), "Mdns");
1147        assert_eq!(DiscoveryMethod::Dht.to_string(), "Dht");
1148        assert_eq!(DiscoveryMethod::PeerExchange.to_string(), "PeerExchange");
1149        assert_eq!(DiscoveryMethod::Manual.to_string(), "Manual");
1150    }
1151
1152    #[test]
1153    fn test_discovery_method_in_hashmap() {
1154        let mut map: HashMap<DiscoveryMethod, u32> = HashMap::new();
1155        map.insert(DiscoveryMethod::Bootstrap, 1);
1156        map.insert(DiscoveryMethod::Mdns, 2);
1157        assert_eq!(map[&DiscoveryMethod::Bootstrap], 1);
1158        assert_eq!(map[&DiscoveryMethod::Mdns], 2);
1159    }
1160
1161    // -----------------------------------------------------------------------
1162    // Edge-cases
1163    // -----------------------------------------------------------------------
1164
1165    #[test]
1166    fn test_multiple_successes_cap_score() {
1167        let mut mgr = PeerDiscoveryManager::new(default_config());
1168        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1169        for i in 0..30 {
1170            mgr.record_outcome("p1", ConnectOutcome::Success, i * 100);
1171        }
1172        let score = mgr.candidates.get("p1").expect("present").connect_score;
1173        assert!(score <= 10.0, "score must never exceed 10.0");
1174    }
1175
1176    #[test]
1177    fn test_multiple_failures_floor_score() {
1178        let mut mgr = PeerDiscoveryManager::new(default_config());
1179        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1180        for i in 0..30 {
1181            mgr.record_outcome("p1", ConnectOutcome::Unreachable, i * 100);
1182        }
1183        let score = mgr.candidates.get("p1").expect("present").connect_score;
1184        assert!(score >= 0.0, "score must never go below 0.0");
1185    }
1186
1187    #[test]
1188    fn test_reconnect_after_disconnect_increments_total_connected() {
1189        let mut mgr = PeerDiscoveryManager::new(default_config());
1190        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1191        mgr.record_outcome("p1", ConnectOutcome::Success, 100);
1192        mgr.mark_disconnected("p1");
1193        mgr.record_outcome("p1", ConnectOutcome::Success, 200);
1194        assert_eq!(mgr.total_connected, 2);
1195    }
1196
1197    #[test]
1198    fn test_peer_count_and_connected_count_consistent() {
1199        let mut mgr = PeerDiscoveryManager::new(default_config());
1200        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1201        mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
1202        mgr.add_candidate(make_candidate("p3", DiscoveryMethod::Bootstrap, 0));
1203        mgr.record_outcome("p1", ConnectOutcome::Success, 100);
1204        mgr.record_outcome("p2", ConnectOutcome::Success, 100);
1205
1206        assert_eq!(mgr.peer_count(), 3);
1207        assert_eq!(mgr.connected_count(), 2);
1208    }
1209
1210    #[test]
1211    fn test_evict_stale_does_not_touch_non_stale() {
1212        let cfg = DiscoveryConfig {
1213            stale_threshold_ms: 10_000,
1214            ..Default::default()
1215        };
1216        let mut mgr = PeerDiscoveryManager::new(cfg);
1217        // Discovered at 15_000 → not old enough when now=20_000, cutoff=10_000
1218        mgr.add_candidate(make_candidate("fresh", DiscoveryMethod::Bootstrap, 15_000));
1219        mgr.add_candidate(make_candidate("stale", DiscoveryMethod::Bootstrap, 0));
1220
1221        let evicted = mgr.evict_stale(20_000);
1222        assert_eq!(evicted, 1);
1223        assert!(mgr.candidates.contains_key("fresh"));
1224        assert!(!mgr.candidates.contains_key("stale"));
1225    }
1226
1227    #[test]
1228    fn test_no_candidates_to_try_when_all_in_backoff() {
1229        let cfg = DiscoveryConfig {
1230            retry_backoff_ms: 10_000,
1231            ..Default::default()
1232        };
1233        let mut mgr = PeerDiscoveryManager::new(cfg);
1234        mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1235        mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
1236        mgr.record_outcome("p1", ConnectOutcome::Refused, 5_000);
1237        mgr.record_outcome("p2", ConnectOutcome::Timeout, 5_000);
1238
1239        // at t=6_000 both are still in backoff
1240        assert!(mgr.candidates_to_try(10, 6_000).is_empty());
1241    }
1242}