1use chrono::{DateTime, Duration, Utc};
8use offline_protocol_core::Message;
9use offline_protocol_transport::{TransportMetrics, TransportType};
10use std::collections::{HashMap, VecDeque};
11use tracing::debug;
12
13#[derive(Debug, Clone)]
15pub struct DorsConfig {
16 pub switch_hysteresis: f32,
18
19 pub switch_cooldown_secs: u64,
21
22 pub ble_to_wifi_retry_threshold: u32,
24
25 pub min_success_rate_before_escalation: f32,
27
28 pub min_ble_samples_before_success_rate_escalation: usize,
33
34 pub rssi_switch_threshold: i16,
36
37 pub congestion_queue_threshold: usize,
39
40 pub stability_window_secs: u64,
42
43 pub poor_signal_duration_secs: u64,
45
46 pub ttl_escalation_threshold: u8,
48
49 pub prefer_online: bool,
51
52 pub congestion_duration_secs: u64,
54
55 pub ttl_escalation_hold_secs: u64,
57
58 pub history_window_size: usize,
60
61 pub queue_recovery_ratio: f32,
63
64 pub low_battery_threshold: u8,
66
67 pub relay_min_battery_level: u8,
69
70 pub relay_optimal_connection_count: u8,
72}
73
74impl Default for DorsConfig {
75 fn default() -> Self {
76 Self {
77 switch_hysteresis: 15.0,
78 switch_cooldown_secs: 20,
79 ble_to_wifi_retry_threshold: 2,
80 min_success_rate_before_escalation: 0.30,
81 min_ble_samples_before_success_rate_escalation: 5,
82 rssi_switch_threshold: -85,
83 congestion_queue_threshold: 50,
84 stability_window_secs: 8,
85 poor_signal_duration_secs: 10,
86 ttl_escalation_threshold: 2,
87 prefer_online: false,
88 congestion_duration_secs: 10,
89 ttl_escalation_hold_secs: 20,
90 history_window_size: 10,
91 queue_recovery_ratio: 0.5,
92 low_battery_threshold: 20,
93 relay_min_battery_level: 30,
94 relay_optimal_connection_count: 4,
95 }
96 }
97}
98
99const TIE_EPSILON: f32 = 0.01;
103
104const SIGNAL_SCORE_NO_DATA: f32 = 50.0;
107
108const INTERNET_FALLBACK_DEMOTION: f32 = 10_000.0;
115
116pub fn display_routing_score(raw_total: f32) -> f32 {
132 if raw_total < 0.0 {
133 (raw_total + INTERNET_FALLBACK_DEMOTION).max(0.0)
134 } else {
135 raw_total
136 }
137}
138
139fn tie_break_priority(transport_type: TransportType) -> u8 {
147 match transport_type {
148 TransportType::Internet => 0,
149 TransportType::WiFiDirect => 1,
150 TransportType::BLE => 2,
151 TransportType::Reticulum => 3,
152 TransportType::Nostr => 4,
153 }
154}
155
156#[derive(Debug, Clone)]
158struct ScoringWeights {
159 signal: f32,
160 proximity: f32,
161 bandwidth: f32,
162 congestion: f32,
163 energy: f32,
164 reliability: f32,
165 load: f32,
166}
167
168impl ScoringWeights {
169 fn sum(&self) -> f32 {
171 self.signal
172 + self.proximity
173 + self.bandwidth
174 + self.congestion
175 + self.energy
176 + self.reliability
177 + self.load
178 }
179}
180
181#[derive(Debug, Clone)]
187struct TransportScoringProfile {
188 weights: ScoringWeights,
190 base_score: f32,
192 media_bonus: f32,
194 media_penalty: f32,
196 bandwidth_max_bps: u64,
198 bandwidth_default: f32,
200 energy_baseline: f32,
202 is_high_power: bool,
204 active_relay_energy_adjustment: f32,
206 has_signal: bool,
208 default_signal_score: f32,
210 tie_break_priority: u8,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum EscalationTriggerReason {
217 RetryThreshold,
219 PoorSignal,
221 Congestion,
223 LowTtl,
225 LowSuccessRate,
227}
228
229struct EscalationEvaluation {
231 trigger: Option<EscalationTriggerReason>,
233 battery_too_low: bool,
235}
236
237#[non_exhaustive]
246#[derive(Debug, Clone)]
247pub struct TransportScore {
248 pub signal: f32,
250 pub proximity: f32,
252 pub bandwidth: f32,
254 pub congestion: f32,
256 pub energy: f32,
258 pub reliability: f32,
260 pub load: f32,
262 pub total: f32,
264}
265
266#[derive(Debug, Clone, Default)]
271pub struct TransportScoreFactors {
272 pub signal: f32,
274 pub proximity: f32,
276 pub bandwidth: f32,
278 pub congestion: f32,
280 pub energy: f32,
282 pub reliability: f32,
284 pub load: f32,
286 pub total: f32,
288}
289
290impl TransportScore {
291 pub fn from_factors(factors: TransportScoreFactors) -> Self {
297 Self {
298 signal: factors.signal,
299 proximity: factors.proximity,
300 bandwidth: factors.bandwidth,
301 congestion: factors.congestion,
302 energy: factors.energy,
303 reliability: factors.reliability,
304 load: factors.load,
305 total: factors.total,
306 }
307 }
308}
309
310#[derive(Default, Debug)]
311struct TransportHistory {
312 queue_depths: VecDeque<usize>,
313 congestion_levels: VecDeque<f32>,
314 rssi_samples: VecDeque<i16>,
315 success_ratios: VecDeque<f32>,
316 latency_samples: VecDeque<u32>,
317 last_outcome_counts: Option<(u32, u32)>,
324}
325
326impl TransportHistory {
327 fn push_queue_depth(&mut self, depth: usize, limit: usize) {
328 self.queue_depths.push_back(depth);
329 truncate_deque(&mut self.queue_depths, limit);
330 }
331
332 fn push_congestion(&mut self, level: f32, limit: usize) {
333 self.congestion_levels.push_back(level);
334 truncate_deque(&mut self.congestion_levels, limit);
335 }
336
337 fn push_rssi(&mut self, rssi: i16, limit: usize) {
338 self.rssi_samples.push_back(rssi);
339 truncate_deque(&mut self.rssi_samples, limit);
340 }
341
342 fn push_success_ratio(&mut self, ratio: f32, limit: usize) {
343 self.success_ratios.push_back(ratio.clamp(0.0, 1.0));
344 truncate_deque(&mut self.success_ratios, limit);
345 }
346
347 fn observe_outcome_counts(&mut self, success: u32, failure: u32, limit: usize) {
352 let total = success.saturating_add(failure);
353 if total == 0 || self.last_outcome_counts == Some((success, failure)) {
354 return;
355 }
356 self.last_outcome_counts = Some((success, failure));
357 self.push_success_ratio(success as f32 / total as f32, limit);
358 }
359
360 fn push_latency(&mut self, latency: u32, limit: usize) {
361 self.latency_samples.push_back(latency);
362 truncate_deque(&mut self.latency_samples, limit);
363 }
364
365 fn average_queue_depth(&self) -> Option<f32> {
366 average_usize(&self.queue_depths)
367 }
368
369 fn average_congestion(&self) -> Option<f32> {
370 average_f32(&self.congestion_levels)
371 }
372
373 fn average_rssi(&self) -> Option<f32> {
374 if self.rssi_samples.is_empty() {
375 None
376 } else {
377 Some(
378 self.rssi_samples.iter().map(|v| *v as f32).sum::<f32>()
379 / self.rssi_samples.len() as f32,
380 )
381 }
382 }
383
384 fn average_success_ratio(&self) -> Option<f32> {
385 average_f32(&self.success_ratios)
386 }
387
388 fn success_ratio_sample_count(&self) -> usize {
390 self.success_ratios.len()
391 }
392}
393
394fn truncate_deque<T>(deque: &mut VecDeque<T>, limit: usize) {
395 let limit = limit.max(1);
396 while deque.len() > limit {
397 deque.pop_front();
398 }
399}
400
401fn average_usize(samples: &VecDeque<usize>) -> Option<f32> {
402 if samples.is_empty() {
403 return None;
404 }
405 Some(samples.iter().sum::<usize>() as f32 / samples.len() as f32)
406}
407
408fn average_f32(samples: &VecDeque<f32>) -> Option<f32> {
409 if samples.is_empty() {
410 return None;
411 }
412 Some(samples.iter().sum::<f32>() / samples.len() as f32)
413}
414
415pub struct TransportSelector {
420 config: DorsConfig,
421 current_transport: Option<TransportType>,
422 last_switch_time: Option<DateTime<Utc>>,
423 transport_scores_history: HashMap<TransportType, VecDeque<(DateTime<Utc>, f32)>>,
424 transport_history: HashMap<TransportType, TransportHistory>,
425 retry_counts: HashMap<TransportType, u32>,
426 last_metrics: HashMap<TransportType, (TransportMetrics, DateTime<Utc>)>,
427 ble_poor_signal_since: Option<DateTime<Utc>>,
428 ble_high_congestion_since: Option<DateTime<Utc>>,
429 low_ttl_detected_at: Option<DateTime<Utc>>,
430}
431
432impl TransportSelector {
433 pub fn new() -> Self {
435 Self::with_config(DorsConfig::default())
436 }
437
438 pub fn with_config(config: DorsConfig) -> Self {
440 Self {
441 config,
442 current_transport: None,
443 last_switch_time: None,
444 transport_scores_history: HashMap::new(),
445 transport_history: HashMap::new(),
446 retry_counts: HashMap::new(),
447 last_metrics: HashMap::new(),
448 ble_poor_signal_since: None,
449 ble_high_congestion_since: None,
450 low_ttl_detected_at: None,
451 }
452 }
453
454 fn scoring_profile(&self, transport_type: TransportType) -> TransportScoringProfile {
461 let profile = match transport_type {
462 TransportType::Internet => TransportScoringProfile {
463 weights: ScoringWeights {
464 signal: 0.0,
465 proximity: 0.0,
466 bandwidth: 0.35,
467 congestion: 0.15,
468 energy: 0.10,
469 reliability: 0.30,
470 load: 0.10,
471 },
472 base_score: if self.config.prefer_online {
473 25.0
474 } else {
475 10.0
476 },
477 media_bonus: 50.0,
478 media_penalty: 0.0,
479 bandwidth_max_bps: 10_000_000,
480 bandwidth_default: if self.config.prefer_online {
481 70.0
482 } else {
483 50.0
484 },
485 energy_baseline: 60.0,
486 is_high_power: true,
487 active_relay_energy_adjustment: 0.0,
488 has_signal: false,
489 default_signal_score: 60.0,
490 tie_break_priority: 0,
491 },
492 TransportType::BLE => TransportScoringProfile {
493 weights: ScoringWeights {
494 signal: 0.30,
495 proximity: 0.15,
496 bandwidth: 0.0,
497 congestion: 0.15,
498 energy: 0.30,
499 reliability: 0.05,
500 load: 0.05,
501 },
502 base_score: 0.0,
503 media_bonus: 0.0,
504 media_penalty: 40.0,
505 bandwidth_max_bps: 150_000,
506 bandwidth_default: 40.0,
507 energy_baseline: 90.0,
508 is_high_power: false,
509 active_relay_energy_adjustment: 5.0,
510 has_signal: true,
511 default_signal_score: 60.0,
512 tie_break_priority: 2,
513 },
514 TransportType::WiFiDirect => TransportScoringProfile {
515 weights: ScoringWeights {
516 signal: 0.0,
517 proximity: 0.20,
518 bandwidth: 0.35,
519 congestion: 0.20,
520 energy: 0.0,
521 reliability: 0.15,
522 load: 0.10,
523 },
524 base_score: 0.0,
525 media_bonus: 0.0,
526 media_penalty: 0.0,
527 bandwidth_max_bps: 2_000_000,
528 bandwidth_default: 90.0,
529 energy_baseline: 40.0,
530 is_high_power: true,
531 active_relay_energy_adjustment: -10.0,
532 has_signal: true,
533 default_signal_score: 60.0,
534 tie_break_priority: 1,
535 },
536 TransportType::Reticulum => TransportScoringProfile {
537 weights: ScoringWeights {
538 signal: 0.05,
539 proximity: 0.20,
540 bandwidth: 0.05,
541 congestion: 0.15,
542 energy: 0.25,
543 reliability: 0.30,
544 load: 0.0,
545 },
546 base_score: 0.0,
547 media_bonus: 0.0,
548 media_penalty: 40.0,
549 bandwidth_max_bps: 2_700, bandwidth_default: 20.0, energy_baseline: 75.0, is_high_power: false,
553 active_relay_energy_adjustment: 0.0,
554 has_signal: true, default_signal_score: 60.0,
556 tie_break_priority: 3, },
558 TransportType::Nostr => TransportScoringProfile {
559 weights: ScoringWeights {
560 signal: 0.0,
561 proximity: 0.0,
562 bandwidth: 0.20,
563 congestion: 0.20,
564 energy: 0.15,
565 reliability: 0.35,
566 load: 0.10,
567 },
568 base_score: 5.0, media_bonus: 30.0, media_penalty: 0.0,
571 bandwidth_max_bps: 1_000_000, bandwidth_default: 40.0, energy_baseline: 55.0, is_high_power: true, active_relay_energy_adjustment: 0.0,
576 has_signal: false, default_signal_score: 50.0,
578 tie_break_priority: 4, },
580 };
581
582 debug_assert!(
583 (profile.weights.sum() - 1.0).abs() < 1e-6,
584 "Scoring weights for {:?} must sum to 1.0, got {}",
585 transport_type,
586 profile.weights.sum(),
587 );
588 debug_assert_eq!(
589 profile.tie_break_priority,
590 tie_break_priority(transport_type),
591 "tie_break_priority in profile for {:?} must match standalone tie_break_priority()",
592 transport_type,
593 );
594
595 profile
596 }
597
598 pub fn select_transport(
609 &mut self,
610 message: &Message,
611 available_transports: &HashMap<TransportType, TransportMetrics>,
612 ) -> Option<TransportType> {
613 if available_transports.is_empty() {
615 return None;
616 }
617
618 let mut scored_transports: Vec<(TransportType, TransportScore)> = Vec::new();
620
621 for (transport_type, metrics) in available_transports.iter() {
622 self.record_metrics(*transport_type, metrics);
623 self.update_history(*transport_type, metrics);
624
625 if *transport_type == TransportType::BLE {
626 self.update_ble_conditions(message, metrics);
627 }
628
629 let score = self.calculate_transport_score(message, *transport_type, metrics);
630 self.record_score(*transport_type, score.total);
640 scored_transports.push((*transport_type, score));
641 }
642
643 scored_transports.sort_by(|a, b| {
647 b.1.total
648 .partial_cmp(&a.1.total)
649 .unwrap_or(std::cmp::Ordering::Equal)
650 });
651
652 let (best_transport, best_score) = if let Some((best_t, best_s)) = scored_transports
656 .iter()
657 .filter(|(_, s)| s.total.is_finite())
658 .max_by(|a, b| {
659 a.1.total
660 .partial_cmp(&b.1.total)
661 .unwrap_or(std::cmp::Ordering::Equal)
662 }) {
663 let best_total = best_s.total;
664 scored_transports
665 .iter()
666 .filter(|(_, s)| s.total.is_finite() && best_total - s.total <= TIE_EPSILON)
667 .min_by_key(|(t, _)| tie_break_priority(*t))
668 .map(|(t, s)| (*t, s.total))
669 .unwrap_or((*best_t, best_total))
672 } else {
673 scored_transports
675 .iter()
676 .min_by_key(|(t, _)| tie_break_priority(*t))
677 .map(|(t, s)| (*t, s.total))?
678 };
679
680 if tracing::enabled!(tracing::Level::DEBUG) {
683 let scores_summary: Vec<_> = scored_transports
689 .iter()
690 .map(|(t, s)| format!("{:?}={:.1}", t, s.total))
691 .collect();
692 debug!(
693 scores = %scores_summary.join(", "),
694 best = ?best_transport,
695 previous = ?self.current_transport,
696 prefer_online = self.config.prefer_online,
697 "DORS scored transports"
698 );
699 }
700
701 if let Some(current) = self.current_transport {
702 let current_still_available = available_transports.contains_key(¤t);
705
706 if current_still_available {
707 if current == best_transport {
708 return Some(current);
709 }
710
711 if let Some(current_score) = scored_transports
712 .iter()
713 .find(|(t, _)| *t == current)
714 .map(|(_, s)| s.total)
715 {
716 let current_is_demoted_fallback = current_score < 0.0 && best_score >= 0.0;
728 if current_is_demoted_fallback {
729 debug!(
730 current = ?current,
731 current_score = current_score,
732 candidate = ?best_transport,
733 candidate_score = best_score,
734 "DORS: leaving demoted Internet fallback for an available mesh transport (bypassing cooldown)"
735 );
736 } else if !self.should_switch(
737 current,
738 current_score,
739 best_transport,
740 best_score,
741 ) {
742 debug!(
743 current = ?current,
744 current_score = current_score,
745 candidate = ?best_transport,
746 candidate_score = best_score,
747 "DORS: switch blocked by hysteresis/cooldown/stability"
748 );
749 return Some(current);
750 }
751 }
752 } else {
753 debug!(
754 lost = ?current,
755 fallback = ?best_transport,
756 "DORS: current transport unavailable, switching immediately"
757 );
758 }
759 }
760
761 debug!(
763 from = ?self.current_transport,
764 to = ?best_transport,
765 score = best_score,
766 "DORS: transport switched"
767 );
768 self.last_switch_time = Some(Utc::now());
769 self.current_transport = Some(best_transport);
770
771 Some(best_transport)
772 }
773
774 pub fn score_and_rank(
788 &self,
789 message: &Message,
790 available_transports: &HashMap<TransportType, TransportMetrics>,
791 ) -> Vec<(TransportType, f32)> {
792 let mut scored: Vec<(TransportType, f32)> = available_transports
798 .iter()
799 .map(|(transport_type, metrics)| {
800 let score = self.calculate_transport_score(message, *transport_type, metrics);
801 (*transport_type, score.total)
802 })
803 .collect();
804
805 scored.sort_by(|a, b| {
814 let ord = b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal);
815 if ord == std::cmp::Ordering::Equal {
816 tie_break_priority(a.0).cmp(&tie_break_priority(b.0))
817 } else {
818 ord
819 }
820 });
821
822 scored
823 }
824
825 pub fn score_and_rank_detailed(
833 &self,
834 message: &Message,
835 available_transports: &HashMap<TransportType, TransportMetrics>,
836 ) -> Vec<(TransportType, TransportScore)> {
837 let mut scored: Vec<(TransportType, TransportScore)> = available_transports
838 .iter()
839 .map(|(transport_type, metrics)| {
840 let score = self.calculate_transport_score(message, *transport_type, metrics);
841 (*transport_type, score)
842 })
843 .collect();
844 scored.sort_by(|a, b| {
845 let ord =
846 b.1.total
847 .partial_cmp(&a.1.total)
848 .unwrap_or(std::cmp::Ordering::Equal);
849 if ord == std::cmp::Ordering::Equal {
850 tie_break_priority(a.0).cmp(&tie_break_priority(b.0))
851 } else {
852 ord
853 }
854 });
855 scored
856 }
857
858 fn calculate_transport_score(
860 &self,
861 message: &Message,
862 transport_type: TransportType,
863 metrics: &TransportMetrics,
864 ) -> TransportScore {
865 let profile = self.scoring_profile(transport_type);
866
867 let signal_score = self.calculate_signal_score(&profile, transport_type, metrics);
868 let proximity_score = self.calculate_proximity_score(message);
869 let bandwidth_score = self.calculate_bandwidth_score(&profile, metrics);
870 let congestion_score = self.calculate_congestion_score(transport_type, metrics);
871 let energy_score = self.calculate_energy_score(&profile, metrics);
872 let reliability_score = self.calculate_reliability_score(transport_type, metrics);
873 let load_score = self.calculate_load_score(transport_type, metrics);
874
875 let prefers_internet = message
879 .metadata
880 .get("transport_preference")
881 .map(|v| v == "internet")
882 .unwrap_or(false);
883
884 let media_adjust = if prefers_internet {
903 profile.media_bonus - profile.media_penalty
904 } else {
905 0.0
906 };
907
908 let w = &profile.weights;
909 let mut total = (profile.base_score
910 + media_adjust
911 + (signal_score * w.signal)
912 + (proximity_score * w.proximity)
913 + (bandwidth_score * w.bandwidth)
914 + (congestion_score * w.congestion)
915 + (energy_score * w.energy)
916 + (reliability_score * w.reliability)
917 + (load_score * w.load))
918 .max(0.0);
919
920 if transport_type == TransportType::Internet
933 && !self.config.prefer_online
934 && !prefers_internet
935 {
936 total -= INTERNET_FALLBACK_DEMOTION;
937 }
938
939 TransportScore {
940 signal: signal_score,
941 proximity: proximity_score,
942 bandwidth: bandwidth_score,
943 congestion: congestion_score,
944 energy: energy_score,
945 reliability: reliability_score,
946 load: load_score,
947 total,
948 }
949 }
950
951 fn should_switch(
953 &self,
954 current_transport: TransportType,
955 current_score: f32,
956 candidate_transport: TransportType,
957 candidate_score: f32,
958 ) -> bool {
959 if candidate_transport == current_transport {
960 return false;
961 }
962
963 if self.is_emergency_switch_needed(current_transport) {
982 return candidate_score > 10.0; }
984
985 if candidate_score <= current_score {
986 return false;
987 }
988
989 let improvement = candidate_score - current_score;
990 if improvement < self.config.switch_hysteresis {
991 return false;
992 }
993
994 if !self.is_past_cooldown() {
995 return false;
996 }
997
998 self.is_stable_better(
999 candidate_transport,
1000 current_transport,
1001 self.config.stability_window_secs,
1002 self.config.switch_hysteresis / 2.0,
1003 )
1004 }
1005
1006 fn is_emergency_switch_needed(&self, transport: TransportType) -> bool {
1012 let retry_failures = self.retry_counts.get(&transport).copied().unwrap_or(0);
1014 if retry_failures >= self.config.ble_to_wifi_retry_threshold {
1015 return true;
1016 }
1017
1018 let min_rate = self
1020 .config
1021 .min_success_rate_before_escalation
1022 .clamp(0.0, 1.0);
1023 let min_samples = self.config.min_ble_samples_before_success_rate_escalation;
1024 if let Some(history) = self.transport_history.get(&transport) {
1025 if history.success_ratio_sample_count() >= min_samples {
1026 if let Some(success_rate) = history.average_success_ratio() {
1027 if success_rate < min_rate {
1028 return true;
1029 }
1030 }
1031 }
1032 }
1033
1034 if transport == TransportType::BLE {
1037 if let Some((metrics, _)) = self.last_metrics.get(&transport) {
1038 if let Some(rssi) = metrics.rssi {
1039 if rssi < -90 {
1040 let poor_duration = self
1041 .ble_poor_signal_since
1042 .map(|since| Utc::now().signed_duration_since(since).num_seconds());
1043 if poor_duration.unwrap_or(0)
1044 >= self.config.poor_signal_duration_secs as i64 * 2
1045 {
1046 return true;
1047 }
1048 }
1049 }
1050 }
1051 }
1052
1053 false
1054 }
1055
1056 fn calculate_signal_score(
1058 &self,
1059 profile: &TransportScoringProfile,
1060 transport_type: TransportType,
1061 metrics: &TransportMetrics,
1062 ) -> f32 {
1063 if !profile.has_signal {
1064 return profile.default_signal_score;
1065 }
1066
1067 let rssi_value = metrics.rssi.map(|r| r as f32).or_else(|| {
1068 self.transport_history
1069 .get(&transport_type)
1070 .and_then(|history| history.average_rssi())
1071 });
1072
1073 let rssi_value = match rssi_value {
1074 Some(value) => value,
1075 None => return SIGNAL_SCORE_NO_DATA,
1076 };
1077
1078 if rssi_value >= -50.0 {
1079 100.0
1080 } else if rssi_value >= -70.0 {
1081 70.0 + ((rssi_value + 70.0) * 30.0 / 20.0)
1082 } else if rssi_value >= -85.0 {
1083 40.0 + ((rssi_value + 85.0) * 30.0 / 15.0)
1084 } else {
1085 ((rssi_value + 100.0).max(0.0) * 40.0 / 15.0).max(0.0)
1086 }
1087 }
1088
1089 fn calculate_proximity_score(&self, message: &Message) -> f32 {
1091 let hop_count = message.hop_count.value();
1092 let ttl = message.ttl.value();
1093
1094 if hop_count == 0 || ttl == 0 {
1097 100.0
1098 } else {
1099 let ratio = (ttl as f32 - hop_count as f32) / ttl as f32;
1101 (ratio * 100.0).max(0.0)
1102 }
1103 }
1104
1105 fn calculate_bandwidth_score(
1112 &self,
1113 profile: &TransportScoringProfile,
1114 metrics: &TransportMetrics,
1115 ) -> f32 {
1116 if let Some(bandwidth) = metrics.bandwidth_bps {
1117 (bandwidth as f32 / profile.bandwidth_max_bps as f32 * 100.0).min(100.0)
1118 } else {
1119 profile.bandwidth_default
1120 }
1121 }
1122
1123 fn calculate_congestion_score(
1125 &self,
1126 transport_type: TransportType,
1127 metrics: &TransportMetrics,
1128 ) -> f32 {
1129 let avg_congestion = self
1130 .transport_history
1131 .get(&transport_type)
1132 .and_then(|history| history.average_congestion())
1133 .unwrap_or(metrics.congestion);
1134 let congestion_level = avg_congestion.clamp(0.0, 1.0);
1135
1136 let base_score = (1.0 - congestion_level) * 100.0;
1138
1139 let threshold = self.config.congestion_queue_threshold.max(1) as f32;
1141 let avg_queue = self
1142 .transport_history
1143 .get(&transport_type)
1144 .and_then(|history| history.average_queue_depth())
1145 .unwrap_or(metrics.queue_depth as f32);
1146 let queue_pressure = (avg_queue / threshold).clamp(0.0, 1.0);
1147 let queue_penalty = (queue_pressure.min(1.0) * 30.0).max(0.0);
1148
1149 (base_score - queue_penalty).max(0.0)
1150 }
1151
1152 fn calculate_energy_score(
1154 &self,
1155 profile: &TransportScoringProfile,
1156 metrics: &TransportMetrics,
1157 ) -> f32 {
1158 let mut base = profile.energy_baseline;
1159
1160 if let Some(cost) = metrics.energy_cost {
1161 let penalty = (cost * 100.0).clamp(0.0, 40.0);
1163 base = (base - penalty).max(0.0);
1164 }
1165
1166 if let Some(battery) = metrics.battery_level {
1167 let battery = battery.min(100);
1168 let battery_ratio = battery as f32 / 100.0;
1169 let low_threshold = self.config.low_battery_threshold.max(1);
1170
1171 if !metrics.is_charging {
1172 if battery <= low_threshold {
1173 if profile.is_high_power {
1174 let deficit = (low_threshold - battery) as f32 / low_threshold as f32;
1176 base = (base * (1.0 - deficit)).max(0.0);
1177 } else {
1178 base = (base + (20.0 * (1.0 - battery_ratio))).clamp(0.0, 100.0);
1180 }
1181 } else if profile.is_high_power {
1182 let penalty = (1.0 - battery_ratio).max(0.0) * 15.0;
1184 base = (base - penalty).max(0.0);
1185 }
1186 } else {
1187 base = (base + 10.0).min(100.0);
1189 }
1190 } else if metrics.is_charging {
1191 base = (base + 5.0).min(100.0);
1192 }
1193
1194 if metrics.is_active_relay && profile.active_relay_energy_adjustment != 0.0 {
1195 base = (base + profile.active_relay_energy_adjustment).clamp(0.0, 100.0);
1197 }
1198
1199 base.clamp(0.0, 100.0)
1200 }
1201
1202 fn calculate_reliability_score(
1204 &self,
1205 transport_type: TransportType,
1206 metrics: &TransportMetrics,
1207 ) -> f32 {
1208 let ratio = metrics
1209 .effective_delivery_ratio()
1210 .or_else(|| {
1211 self.transport_history
1212 .get(&transport_type)
1213 .and_then(|history| history.average_success_ratio())
1214 })
1215 .unwrap_or(0.85)
1216 .clamp(0.0, 1.0);
1217
1218 (ratio * 100.0).clamp(0.0, 100.0)
1219 }
1220
1221 fn calculate_load_score(
1223 &self,
1224 transport_type: TransportType,
1225 metrics: &TransportMetrics,
1226 ) -> f32 {
1227 let threshold = self.config.congestion_queue_threshold.max(1) as f32;
1228 let avg_queue = self
1229 .transport_history
1230 .get(&transport_type)
1231 .and_then(|history| history.average_queue_depth())
1232 .unwrap_or(metrics.queue_depth as f32);
1233
1234 let utilisation = (avg_queue / threshold).clamp(0.0, 1.5);
1235 let mut score = ((1.0 - utilisation).clamp(0.0, 1.0)) * 100.0;
1236
1237 if let Some(drop) = metrics.effective_drop_ratio() {
1238 let drop_penalty = (drop * 100.0).clamp(0.0, 40.0);
1239 score = (score - drop_penalty).max(0.0);
1240 }
1241
1242 if metrics.is_active_relay {
1243 let optimal = self.config.relay_optimal_connection_count.max(1) as f32;
1244 let connections = metrics.relay_connection_count as f32;
1245 if connections > optimal {
1246 let overload = ((connections - optimal) / optimal).clamp(0.0, 1.5);
1247 score = (score * (1.0 - (0.4 * overload))).max(0.0);
1248 } else {
1249 score = (score + 5.0).min(100.0);
1250 }
1251 }
1252
1253 score.clamp(0.0, 100.0)
1254 }
1255
1256 fn record_metrics(&mut self, transport_type: TransportType, metrics: &TransportMetrics) {
1258 self.last_metrics
1259 .insert(transport_type, (metrics.clone(), Utc::now()));
1260 }
1261
1262 fn history_window(&self) -> usize {
1263 self.config.history_window_size.max(1)
1264 }
1265
1266 fn update_history(&mut self, transport_type: TransportType, metrics: &TransportMetrics) {
1267 let window = self.history_window();
1268 let history = self.transport_history.entry(transport_type).or_default();
1269
1270 history.push_queue_depth(metrics.queue_depth, window);
1271 history.push_congestion(metrics.congestion.clamp(0.0, 1.0), window);
1272
1273 if let Some(rssi) = metrics.rssi {
1274 history.push_rssi(rssi, window);
1275 }
1276
1277 if let Some(latency) = metrics.latency_ms {
1278 history.push_latency(latency, window);
1279 }
1280
1281 history.observe_outcome_counts(metrics.success_count, metrics.failure_count, window);
1282 }
1283
1284 fn update_ble_conditions(&mut self, message: &Message, metrics: &TransportMetrics) {
1286 let now = Utc::now();
1287
1288 if let Some(rssi) = metrics.rssi {
1289 if rssi <= self.config.rssi_switch_threshold {
1290 if self.ble_poor_signal_since.is_none() {
1291 self.ble_poor_signal_since = Some(now);
1292 }
1293 } else {
1294 self.ble_poor_signal_since = None;
1295 }
1296 }
1297
1298 let congestion_threshold = self.config.congestion_queue_threshold;
1299 let average_queue = self
1300 .transport_history
1301 .get(&TransportType::BLE)
1302 .and_then(|history| history.average_queue_depth());
1303
1304 let queue_high = if congestion_threshold == 0 {
1305 metrics.queue_depth > 0
1306 } else {
1307 metrics.queue_depth >= congestion_threshold
1308 || average_queue
1309 .map(|avg| avg >= congestion_threshold as f32)
1310 .unwrap_or(false)
1311 };
1312
1313 if queue_high {
1314 if self.ble_high_congestion_since.is_none() {
1315 self.ble_high_congestion_since = Some(now);
1316 }
1317 } else {
1318 let recovery_ratio = self.config.queue_recovery_ratio.clamp(0.0, 1.0);
1319 let recovery_threshold = if congestion_threshold == 0 {
1320 0
1321 } else {
1322 (congestion_threshold as f32 * recovery_ratio)
1323 .ceil()
1324 .max(1.0) as usize
1325 };
1326
1327 let recovered = if congestion_threshold == 0 {
1328 metrics.queue_depth == 0
1329 } else {
1330 metrics.queue_depth <= recovery_threshold
1331 && average_queue
1332 .map(|avg| avg <= recovery_threshold as f32)
1333 .unwrap_or(true)
1334 };
1335
1336 if recovered {
1337 self.ble_high_congestion_since = None;
1338 }
1339 }
1340
1341 if message.ttl.value() <= self.config.ttl_escalation_threshold {
1342 self.low_ttl_detected_at = Some(now);
1343 } else if message.ttl.value() > self.config.ttl_escalation_threshold.saturating_add(1) {
1344 self.low_ttl_detected_at = None;
1346 }
1347 }
1348
1349 fn is_past_cooldown(&self) -> bool {
1351 if let Some(last_switch) = self.last_switch_time {
1352 let elapsed = Utc::now().signed_duration_since(last_switch);
1353 elapsed.num_seconds() >= self.config.switch_cooldown_secs as i64
1354 } else {
1355 true }
1357 }
1358
1359 fn is_stable_better(
1361 &self,
1362 new_transport: TransportType,
1363 current_transport: TransportType,
1364 window_secs: u64,
1365 required_improvement: f32,
1366 ) -> bool {
1367 let now = Utc::now();
1368 let window_start = now - chrono::Duration::seconds(window_secs as i64);
1369
1370 let new_scores = self.get_scores_in_window(new_transport, window_start);
1372 let current_scores = self.get_scores_in_window(current_transport, window_start);
1373
1374 if new_scores.is_empty() || current_scores.is_empty() {
1376 return true;
1377 }
1378
1379 let new_avg: f32 = new_scores.iter().sum::<f32>() / new_scores.len() as f32;
1381 let current_avg: f32 = current_scores.iter().sum::<f32>() / current_scores.len() as f32;
1382
1383 (new_avg - current_avg) >= required_improvement
1385 }
1386
1387 fn get_scores_in_window(
1389 &self,
1390 transport_type: TransportType,
1391 window_start: DateTime<Utc>,
1392 ) -> Vec<f32> {
1393 self.transport_scores_history
1394 .get(&transport_type)
1395 .map(|history| {
1396 history
1397 .iter()
1398 .filter(|(time, _)| *time >= window_start)
1399 .map(|(_, score)| *score)
1400 .collect()
1401 })
1402 .unwrap_or_default()
1403 }
1404
1405 fn record_score(&mut self, transport_type: TransportType, score: f32) {
1407 let now = Utc::now();
1408 let limit = self.history_window().max(10);
1409 let history = self
1410 .transport_scores_history
1411 .entry(transport_type)
1412 .or_default();
1413
1414 history.push_back((now, score));
1415
1416 while history.len() > limit {
1417 history.pop_front();
1418 }
1419 }
1420
1421 pub fn record_retry_failure(&mut self, transport_type: TransportType) {
1424 let count = self.retry_counts.entry(transport_type).or_insert(0);
1425 *count = count.saturating_add(1);
1426 }
1427
1428 pub fn reset_retry_count(&mut self, transport_type: TransportType) {
1430 self.retry_counts.insert(transport_type, 0);
1431 }
1432
1433 fn evaluate_escalation_conditions(&self) -> EscalationEvaluation {
1447 let now = Utc::now();
1448
1449 let retry_failure = self
1450 .retry_counts
1451 .get(&TransportType::BLE)
1452 .map(|count| *count >= self.config.ble_to_wifi_retry_threshold)
1453 .unwrap_or(false);
1454
1455 let poor_signal = self
1456 .ble_poor_signal_since
1457 .map(|since| {
1458 now.signed_duration_since(since)
1459 >= Duration::seconds(self.config.poor_signal_duration_secs as i64)
1460 })
1461 .unwrap_or(false);
1462
1463 let high_congestion_active = self
1464 .ble_high_congestion_since
1465 .map(|since| {
1466 now.signed_duration_since(since)
1467 >= Duration::seconds(self.config.congestion_duration_secs as i64)
1468 })
1469 .unwrap_or(false);
1470
1471 let load_exceeded = self
1472 .transport_history
1473 .get(&TransportType::BLE)
1474 .and_then(|history| history.average_queue_depth())
1475 .map(|avg| {
1476 let threshold = self.config.congestion_queue_threshold.max(1) as f32;
1477 avg >= threshold
1478 })
1479 .unwrap_or(false);
1480
1481 let high_congestion = high_congestion_active && load_exceeded;
1482
1483 let low_ttl_recent = self
1484 .low_ttl_detected_at
1485 .map(|since| {
1486 now.signed_duration_since(since)
1487 <= Duration::seconds(self.config.ttl_escalation_hold_secs as i64)
1488 })
1489 .unwrap_or(false);
1490
1491 let min_success_rate = self
1492 .config
1493 .min_success_rate_before_escalation
1494 .clamp(0.0, 1.0);
1495 let min_samples = self.config.min_ble_samples_before_success_rate_escalation;
1496 let low_success_rate = self
1497 .transport_history
1498 .get(&TransportType::BLE)
1499 .filter(|h| h.success_ratio_sample_count() >= min_samples)
1500 .and_then(|h| h.average_success_ratio())
1501 .map(|rate| rate < min_success_rate)
1502 .unwrap_or(false);
1503
1504 let battery_too_low = self
1505 .last_metrics
1506 .get(&TransportType::BLE)
1507 .and_then(|(metrics, _)| {
1508 metrics
1509 .battery_level
1510 .map(|level| (level, metrics.is_charging))
1511 })
1512 .map(|(level, charging)| level < self.config.relay_min_battery_level && !charging)
1513 .unwrap_or(false);
1514
1515 let trigger = if retry_failure {
1516 Some(EscalationTriggerReason::RetryThreshold)
1517 } else if poor_signal {
1518 Some(EscalationTriggerReason::PoorSignal)
1519 } else if high_congestion {
1520 Some(EscalationTriggerReason::Congestion)
1521 } else if low_ttl_recent {
1522 Some(EscalationTriggerReason::LowTtl)
1523 } else if low_success_rate {
1524 Some(EscalationTriggerReason::LowSuccessRate)
1525 } else {
1526 None
1527 };
1528
1529 EscalationEvaluation {
1530 trigger,
1531 battery_too_low,
1532 }
1533 }
1534
1535 pub fn escalation_trigger_reason(&self) -> Option<EscalationTriggerReason> {
1540 let eval = self.evaluate_escalation_conditions();
1541 if eval.battery_too_low {
1542 return None;
1543 }
1544 eval.trigger
1545 }
1546
1547 pub fn should_escalate_to_wifi(&self) -> bool {
1549 let eval = self.evaluate_escalation_conditions();
1550 if eval.battery_too_low {
1551 return false;
1552 }
1553 eval.trigger.is_some()
1554 }
1555
1556 pub fn should_escalate_to_wifi_for_priority(
1561 &self,
1562 priority: offline_protocol_core::MessagePriority,
1563 ) -> bool {
1564 use offline_protocol_core::MessagePriority;
1565
1566 let eval = self.evaluate_escalation_conditions();
1567
1568 match priority {
1569 MessagePriority::Critical => eval.trigger.is_some(),
1570 MessagePriority::High => !eval.battery_too_low && eval.trigger.is_some(),
1571 MessagePriority::Medium | MessagePriority::Low => {
1572 if eval.battery_too_low {
1573 return false;
1574 }
1575 let severe_retry = self
1576 .retry_counts
1577 .get(&TransportType::BLE)
1578 .map(|count| *count >= self.config.ble_to_wifi_retry_threshold * 2)
1579 .unwrap_or(false);
1580 severe_retry && eval.trigger.is_some()
1581 }
1582 }
1583 }
1584
1585 pub fn update_config(&mut self, config: DorsConfig) {
1588 self.config = config;
1589 }
1590
1591 pub fn config(&self) -> &DorsConfig {
1597 &self.config
1598 }
1599
1600 pub fn current_transport(&self) -> Option<TransportType> {
1602 self.current_transport
1603 }
1604
1605 pub fn set_current_transport(&mut self, transport: TransportType) {
1619 debug!(
1620 previous = ?self.current_transport,
1621 new = ?transport,
1622 "DORS: current transport overridden by fallback"
1623 );
1624 self.current_transport = Some(transport);
1625 self.last_switch_time = Some(Utc::now());
1626 }
1627}
1628
1629impl Default for TransportSelector {
1630 fn default() -> Self {
1631 Self::new()
1632 }
1633}
1634
1635#[cfg(test)]
1636impl TransportSelector {
1637 pub fn select_from_scores_for_test(
1640 &self,
1641 scores: &[(TransportType, f32)],
1642 ) -> Option<TransportType> {
1643 if scores.is_empty() {
1644 return None;
1645 }
1646
1647 if let Some(best) = scores
1649 .iter()
1650 .map(|(_, s)| *s)
1651 .filter(|s| s.is_finite())
1652 .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1653 {
1654 scores
1657 .iter()
1658 .filter(|(_, s)| s.is_finite() && best - *s <= TIE_EPSILON)
1659 .min_by_key(|(t, _)| tie_break_priority(*t))
1660 .map(|(t, _)| *t)
1661 } else {
1662 scores
1664 .iter()
1665 .min_by_key(|(t, _)| tie_break_priority(*t))
1666 .map(|(t, _)| *t)
1667 }
1668 }
1669
1670 pub fn set_last_switch_time_for_test(&mut self, t: Option<DateTime<Utc>>) {
1673 self.last_switch_time = t;
1674 }
1675}
1676
1677#[cfg(test)]
1678mod tests {
1679 use super::*;
1680 use offline_protocol_core::{AppId, UserId, TTL};
1681
1682 fn create_test_message() -> Message {
1683 Message::new(
1684 UserId::new("alice").unwrap(),
1685 UserId::new("bob").unwrap(),
1686 AppId::new("test").unwrap(),
1687 "Test message",
1688 )
1689 }
1690
1691 fn create_test_message_with_hops(hop_count: u8, ttl: u8) -> Message {
1693 let ttl_val = TTL::new(ttl).unwrap();
1694 let mut msg = Message::builder(
1695 UserId::new("alice").unwrap(),
1696 UserId::new("bob").unwrap(),
1697 AppId::new("test").unwrap(),
1698 )
1699 .ttl(ttl_val)
1700 .build();
1701 for _ in 0..hop_count {
1702 msg.increment_hop().unwrap();
1703 }
1704 msg
1705 }
1706
1707 fn create_test_metrics(
1708 rssi: Option<i16>,
1709 congestion: f32,
1710 queue_depth: usize,
1711 ) -> TransportMetrics {
1712 TransportMetrics {
1713 rssi,
1714 latency_ms: Some(10),
1715 bandwidth_bps: Some(150_000),
1716 congestion,
1717 queue_depth,
1718 success_count: 10,
1719 failure_count: 0,
1720 battery_level: Some(80),
1721 is_charging: false,
1722 relay_connection_count: 3,
1723 is_active_relay: true,
1724 delivery_ratio: Some(0.9),
1725 drop_rate: Some(0.1),
1726 average_hop_count: Some(2.0),
1727 energy_cost: Some(0.2),
1728 }
1729 }
1730
1731 #[test]
1732 fn test_transport_selection_prefers_best_score() {
1733 let mut selector = TransportSelector::new();
1734 let message = create_test_message();
1735
1736 let mut transports = HashMap::new();
1737 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
1738 transports.insert(
1739 TransportType::WiFiDirect,
1740 create_test_metrics(Some(-55), 0.1, 5),
1741 );
1742
1743 let selected = selector.select_transport(&message, &transports);
1744 assert!(selected.is_some());
1745 }
1746
1747 #[test]
1749 fn test_selection_selects_internet_when_prefer_online_and_good_metrics() {
1750 let config = DorsConfig {
1751 prefer_online: true,
1752 ..Default::default()
1753 };
1754 let mut selector = TransportSelector::with_config(config);
1755 let message = create_test_message();
1756
1757 let mut transports = HashMap::new();
1758 let mut internet_metrics = create_test_metrics(None, 0.0, 0);
1759 internet_metrics.bandwidth_bps = None; transports.insert(TransportType::Internet, internet_metrics);
1761 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
1762 transports.insert(
1763 TransportType::WiFiDirect,
1764 create_test_metrics(Some(-55), 0.1, 5),
1765 );
1766
1767 let selected = selector.select_transport(&message, &transports).unwrap();
1768 assert_eq!(
1769 selected,
1770 TransportType::Internet,
1771 "With prefer_online=true and Internet available, selection must be Internet"
1772 );
1773 }
1774
1775 #[test]
1777 fn test_selection_selects_ble_when_good_signal_and_wifi_worse() {
1778 let mut selector = TransportSelector::new();
1779 let message = create_test_message();
1780
1781 let mut transports = HashMap::new();
1782 transports.insert(TransportType::BLE, create_test_metrics(Some(-55), 0.1, 5));
1783 let mut wifi_metrics = create_test_metrics(Some(-88), 0.9, 60);
1784 wifi_metrics.congestion = 0.9;
1785 wifi_metrics.queue_depth = 60;
1786 transports.insert(TransportType::WiFiDirect, wifi_metrics);
1787
1788 let selected = selector.select_transport(&message, &transports).unwrap();
1789 assert_eq!(
1790 selected,
1791 TransportType::BLE,
1792 "With BLE good (rssi=-55, low congestion) and WiFi poor (rssi=-88, high congestion), selection must be BLE"
1793 );
1794 }
1795
1796 #[test]
1798 fn test_selection_selects_wifi_direct_when_good_metrics_and_ble_poor() {
1799 let mut selector = TransportSelector::new();
1800 let message = create_test_message();
1801
1802 let mut transports = HashMap::new();
1803 let mut ble_metrics = create_test_metrics(Some(-90), 0.85, 50);
1804 ble_metrics.congestion = 0.85;
1805 ble_metrics.queue_depth = 50;
1806 transports.insert(TransportType::BLE, ble_metrics);
1807 transports.insert(
1808 TransportType::WiFiDirect,
1809 create_test_metrics(Some(-50), 0.1, 5),
1810 );
1811
1812 let selected = selector.select_transport(&message, &transports).unwrap();
1813 assert_eq!(
1814 selected,
1815 TransportType::WiFiDirect,
1816 "With WiFiDirect good (rssi=-50, low congestion) and BLE poor (rssi=-90, high congestion), selection must be WiFiDirect"
1817 );
1818 }
1819
1820 #[test]
1821 fn test_online_first_mode() {
1822 let config = DorsConfig {
1823 prefer_online: true,
1824 ..Default::default()
1825 };
1826 let mut selector = TransportSelector::with_config(config);
1827 let message = create_test_message();
1828
1829 let mut transports = HashMap::new();
1830 let mut internet_metrics = create_test_metrics(None, 0.0, 0);
1831 internet_metrics.bandwidth_bps = None;
1832 transports.insert(TransportType::Internet, internet_metrics);
1833 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
1834
1835 let selected = selector.select_transport(&message, &transports).unwrap();
1836 assert_eq!(selected, TransportType::Internet);
1837 }
1838
1839 #[test]
1844 fn test_internet_is_fallback_when_not_prefer_online() {
1845 let message = create_test_message();
1847
1848 let mut both = HashMap::new();
1849 both.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
1850 both.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.1, 5));
1851
1852 let mut selector = TransportSelector::new();
1855 let ranked = selector.score_and_rank(&message, &both);
1856 assert_eq!(
1857 ranked[0].0,
1858 TransportType::BLE,
1859 "mesh must outrank Internet when prefer_online = false (ranked: {ranked:?})",
1860 );
1861 assert!(
1862 ranked.iter().any(|(t, _)| *t == TransportType::Internet),
1863 "Internet must remain a candidate for fallback, just ranked last",
1864 );
1865 assert_eq!(
1866 selector.select_transport(&message, &both),
1867 Some(TransportType::BLE),
1868 "primary selection must be mesh, not Internet",
1869 );
1870
1871 let mut internet_only = HashMap::new();
1873 internet_only.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
1874 let mut remote_selector = TransportSelector::new();
1875 assert_eq!(
1876 remote_selector.select_transport(&message, &internet_only),
1877 Some(TransportType::Internet),
1878 "Internet must be selected when it is the only available transport",
1879 );
1880
1881 let mut media = create_test_message();
1883 media
1884 .metadata
1885 .insert("transport_preference".to_string(), "internet".to_string());
1886 let mut media_selector = TransportSelector::new();
1887 assert_eq!(
1888 media_selector.select_transport(&media, &both),
1889 Some(TransportType::Internet),
1890 "an internet-preferred (media) message must keep Internet on top",
1891 );
1892 }
1893
1894 #[test]
1898 fn test_display_routing_score_reverses_fallback_demotion() {
1899 let demoted = 42.0 - INTERNET_FALLBACK_DEMOTION;
1901 assert!(
1902 demoted < 0.0,
1903 "precondition: demotion makes the total negative"
1904 );
1905 assert!((display_routing_score(demoted) - 42.0).abs() < 1e-3);
1906
1907 assert_eq!(display_routing_score(0.0), 0.0);
1909 assert_eq!(display_routing_score(73.5), 73.5);
1910 assert_eq!(display_routing_score(125.0), 125.0);
1911
1912 let once = display_routing_score(demoted);
1914 assert_eq!(display_routing_score(once), once);
1915
1916 let selector = TransportSelector::new();
1919 let message = create_test_message();
1920 let metrics = create_test_metrics(None, 0.0, 0);
1921 let raw = selector
1922 .calculate_transport_score(&message, TransportType::Internet, &metrics)
1923 .total;
1924 assert!(
1925 raw < 0.0,
1926 "Internet is demoted negative under default config"
1927 );
1928 assert!(display_routing_score(raw) >= 0.0);
1929 }
1930
1931 #[test]
1932 fn test_hysteresis_prevents_switching() {
1933 let mut selector = TransportSelector::new();
1934 let message = create_test_message();
1935
1936 let mut transports = HashMap::new();
1937 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
1938 transports.insert(
1939 TransportType::WiFiDirect,
1940 create_test_metrics(Some(-55), 0.1, 5),
1941 );
1942
1943 let first = selector.select_transport(&message, &transports).unwrap();
1945
1946 transports.insert(TransportType::BLE, create_test_metrics(Some(-61), 0.2, 10));
1948
1949 let second = selector.select_transport(&message, &transports).unwrap();
1951 assert_eq!(first, second);
1952 }
1953
1954 #[test]
1957 fn test_anti_flap_hysteresis_score_delta_below_threshold() {
1958 let config = DorsConfig {
1959 switch_hysteresis: 20.0,
1960 switch_cooldown_secs: 0,
1961 stability_window_secs: 0,
1962 ..Default::default()
1963 };
1964 let mut selector = TransportSelector::with_config(config);
1965 let message = create_test_message();
1966
1967 let mut transports = HashMap::new();
1968 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
1969 transports.insert(
1970 TransportType::WiFiDirect,
1971 create_test_metrics(Some(-55), 0.1, 5),
1972 );
1973
1974 let first = selector.select_transport(&message, &transports).unwrap();
1975 transports.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.25, 12));
1977 transports.insert(
1978 TransportType::WiFiDirect,
1979 create_test_metrics(Some(-58), 0.12, 6),
1980 );
1981
1982 let second = selector.select_transport(&message, &transports).unwrap();
1983 assert_eq!(
1984 first, second,
1985 "switch must be blocked when score delta is below hysteresis threshold"
1986 );
1987 }
1988
1989 #[test]
1991 fn test_anti_flap_cooldown_blocks_immediate_reswitch() {
1992 let config = DorsConfig {
1993 switch_hysteresis: 5.0,
1994 switch_cooldown_secs: 1,
1995 stability_window_secs: 0,
1996 ..Default::default()
1997 };
1998 let mut selector = TransportSelector::with_config(config);
1999 let message = create_test_message();
2000
2001 let mut transports = HashMap::new();
2002 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2003 transports.insert(
2004 TransportType::WiFiDirect,
2005 create_test_metrics(Some(-55), 0.1, 5),
2006 );
2007
2008 let first = selector.select_transport(&message, &transports).unwrap();
2009 selector.set_last_switch_time_for_test(Some(Utc::now()));
2011 transports.insert(TransportType::BLE, create_test_metrics(Some(-88), 0.5, 40));
2013 transports.insert(
2014 TransportType::WiFiDirect,
2015 create_test_metrics(Some(-50), 0.05, 3),
2016 );
2017
2018 let second = selector.select_transport(&message, &transports).unwrap();
2019 assert_eq!(
2020 first, second,
2021 "cooldown must block immediate re-switch; should stay on {:?}",
2022 first
2023 );
2024 }
2025
2026 #[test]
2035 fn test_demoted_internet_fallback_yields_to_mesh_despite_cooldown() {
2036 let message = create_test_message();
2039 let mut selector = TransportSelector::new();
2040 assert!(
2041 selector.config.switch_cooldown_secs > 0,
2042 "test depends on a non-zero default cooldown to prove the bypass",
2043 );
2044
2045 let mut internet_only = HashMap::new();
2048 internet_only.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
2049 assert_eq!(
2050 selector.select_transport(&message, &internet_only),
2051 Some(TransportType::Internet),
2052 "cold start with only Internet available must select Internet",
2053 );
2054
2055 let mut both = HashMap::new();
2057 both.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
2058 both.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.1, 5));
2059 assert_eq!(
2060 selector.select_transport(&message, &both),
2061 Some(TransportType::BLE),
2062 "a freshly-available mesh transport must win immediately over the \
2063 demoted Internet fallback, bypassing the switch cooldown",
2064 );
2065 }
2066
2067 #[test]
2070 fn test_anti_flap_stability_window_blocks_switch_without_sustained_superiority() {
2071 let config = DorsConfig {
2072 switch_hysteresis: 10.0,
2073 switch_cooldown_secs: 0,
2074 stability_window_secs: 8,
2075 history_window_size: 20,
2076 ..Default::default()
2077 };
2078 let mut selector = TransportSelector::with_config(config);
2079 let message = create_test_message();
2080
2081 let mut transports = HashMap::new();
2082 transports.insert(TransportType::BLE, create_test_metrics(Some(-55), 0.1, 5));
2083 transports.insert(
2084 TransportType::WiFiDirect,
2085 create_test_metrics(Some(-75), 0.3, 25),
2086 );
2087
2088 for _ in 0..15 {
2090 selector.select_transport(&message, &transports).unwrap();
2091 }
2092 assert_eq!(selector.current_transport(), Some(TransportType::BLE));
2093
2094 transports.insert(TransportType::BLE, create_test_metrics(Some(-90), 0.6, 50));
2096 transports.insert(
2097 TransportType::WiFiDirect,
2098 create_test_metrics(Some(-52), 0.08, 4),
2099 );
2100
2101 let after = selector.select_transport(&message, &transports).unwrap();
2102 assert_eq!(
2103 after,
2104 TransportType::BLE,
2105 "stability window must block switch when candidate was not consistently better in window"
2106 );
2107 }
2108
2109 #[test]
2111 fn test_anti_flap_noisy_metrics_switch_suppression() {
2112 let mut selector = TransportSelector::new();
2113 let message = create_test_message();
2114
2115 let mut transports = HashMap::new();
2116 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2117 transports.insert(
2118 TransportType::WiFiDirect,
2119 create_test_metrics(Some(-62), 0.15, 8),
2120 );
2121
2122 let mut switch_count = 0u32;
2123 let mut prev = None::<TransportType>;
2124
2125 for step in 0..20 {
2127 match step % 3 {
2128 0 => {
2129 transports.insert(TransportType::BLE, create_test_metrics(Some(-58), 0.18, 8));
2130 transports.insert(
2131 TransportType::WiFiDirect,
2132 create_test_metrics(Some(-64), 0.2, 12),
2133 );
2134 }
2135 1 => {
2136 transports.insert(TransportType::BLE, create_test_metrics(Some(-64), 0.22, 14));
2137 transports.insert(
2138 TransportType::WiFiDirect,
2139 create_test_metrics(Some(-59), 0.14, 6),
2140 );
2141 }
2142 _ => {
2143 transports.insert(TransportType::BLE, create_test_metrics(Some(-61), 0.2, 10));
2144 transports.insert(
2145 TransportType::WiFiDirect,
2146 create_test_metrics(Some(-62), 0.16, 9),
2147 );
2148 }
2149 }
2150 let selected = selector.select_transport(&message, &transports).unwrap();
2151 if prev.map(|p| p != selected).unwrap_or(false) {
2152 switch_count += 1;
2153 }
2154 prev = Some(selected);
2155 }
2156
2157 assert!(
2158 switch_count <= 1,
2159 "noisy metrics must not cause flapping; switch_count should be at most 1, got {}",
2160 switch_count
2161 );
2162 }
2163
2164 #[test]
2165 fn test_retry_escalation() {
2166 let mut selector = TransportSelector::new();
2167
2168 assert!(!selector.should_escalate_to_wifi());
2169
2170 selector.record_retry_failure(TransportType::BLE);
2171 assert!(!selector.should_escalate_to_wifi());
2172
2173 selector.record_retry_failure(TransportType::BLE);
2174 assert!(selector.should_escalate_to_wifi());
2175
2176 selector.reset_retry_count(TransportType::BLE);
2177 assert!(!selector.should_escalate_to_wifi());
2178 }
2179
2180 #[test]
2181 fn test_poor_signal_escalation() {
2182 let config = DorsConfig {
2183 rssi_switch_threshold: -80,
2184 poor_signal_duration_secs: 0, ..Default::default()
2186 };
2187
2188 let mut selector = TransportSelector::with_config(config);
2189 let message = create_test_message();
2190
2191 let mut transports = HashMap::new();
2192 transports.insert(TransportType::BLE, create_test_metrics(Some(-90), 0.1, 5));
2193
2194 selector.select_transport(&message, &transports);
2195 assert!(selector.should_escalate_to_wifi());
2196 }
2197
2198 #[test]
2199 fn test_congestion_escalation() {
2200 let config = DorsConfig {
2201 congestion_queue_threshold: 10,
2202 congestion_duration_secs: 0,
2203 ..Default::default()
2204 };
2205
2206 let mut selector = TransportSelector::with_config(config);
2207 let message = create_test_message();
2208
2209 let mut transports = HashMap::new();
2210 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.9, 20));
2211
2212 selector.select_transport(&message, &transports);
2213 assert!(selector.should_escalate_to_wifi());
2214 }
2215
2216 #[test]
2219 fn test_low_success_rate_escalation() {
2220 let config = DorsConfig {
2221 min_success_rate_before_escalation: 0.30,
2222 min_ble_samples_before_success_rate_escalation: 1, ..Default::default()
2224 };
2225 let mut selector = TransportSelector::with_config(config);
2226 let message = create_test_message();
2227
2228 let mut ble_metrics = create_test_metrics(Some(-60), 0.1, 5);
2229 ble_metrics.success_count = 2;
2230 ble_metrics.failure_count = 8;
2231
2232 let mut transports = HashMap::new();
2233 transports.insert(TransportType::BLE, ble_metrics);
2234
2235 selector.select_transport(&message, &transports);
2236 assert!(
2237 selector.should_escalate_to_wifi(),
2238 "BLE success rate 0.2 < 0.30 with sample floor met should trigger escalation"
2239 );
2240 assert_eq!(
2241 selector.escalation_trigger_reason(),
2242 Some(EscalationTriggerReason::LowSuccessRate)
2243 );
2244 }
2245
2246 #[test]
2250 fn test_low_success_rate_escalation_gated_by_sample_floor() {
2251 let config = DorsConfig {
2252 min_success_rate_before_escalation: 0.30,
2253 min_ble_samples_before_success_rate_escalation: 5, ..Default::default()
2255 };
2256 let mut selector = TransportSelector::with_config(config);
2257 let message = create_test_message();
2258
2259 let mut ble_metrics = create_test_metrics(Some(-60), 0.1, 5);
2261 ble_metrics.success_count = 2;
2262 ble_metrics.failure_count = 8;
2263
2264 let mut transports = HashMap::new();
2265 transports.insert(TransportType::BLE, ble_metrics);
2266
2267 selector.select_transport(&message, &transports);
2268 assert!(
2269 !selector.should_escalate_to_wifi(),
2270 "single sample below rate threshold must not trigger escalation when floor is 5"
2271 );
2272 assert_ne!(
2273 selector.escalation_trigger_reason(),
2274 Some(EscalationTriggerReason::LowSuccessRate)
2275 );
2276
2277 for _ in 0..4 {
2280 selector.select_transport(&message, &transports);
2281 }
2282 assert!(
2283 !selector.should_escalate_to_wifi(),
2284 "re-reading unchanged counts must not advance the sample floor"
2285 );
2286
2287 for _ in 0..4 {
2290 let metrics = transports.get_mut(&TransportType::BLE).unwrap();
2291 metrics.failure_count += 1;
2292 selector.select_transport(&message, &transports);
2293 }
2294 assert!(
2295 selector.should_escalate_to_wifi(),
2296 "after 5 observations, low success rate should trigger escalation"
2297 );
2298 assert_eq!(
2299 selector.escalation_trigger_reason(),
2300 Some(EscalationTriggerReason::LowSuccessRate)
2301 );
2302 }
2303
2304 #[test]
2308 fn test_success_ratio_sample_taken_once_per_count_change() {
2309 let mut history = TransportHistory::default();
2310 history.observe_outcome_counts(0, 0, 10);
2311 assert_eq!(history.success_ratio_sample_count(), 0);
2312 history.observe_outcome_counts(0, 1, 10);
2313 history.observe_outcome_counts(0, 1, 10);
2314 assert_eq!(history.success_ratio_sample_count(), 1);
2315 history.observe_outcome_counts(150, 52, 10);
2316 history.observe_outcome_counts(75, 26, 10);
2317 history.observe_outcome_counts(75, 26, 10);
2318 assert_eq!(history.success_ratio_sample_count(), 3);
2319 }
2320
2321 #[test]
2322 fn test_low_battery_blocks_escalation() {
2323 let mut selector = TransportSelector::new();
2324 let message = create_test_message();
2325
2326 let mut ble_metrics = create_test_metrics(Some(-90), 0.9, 25);
2327 ble_metrics.battery_level = Some(10);
2328 ble_metrics.is_charging = false;
2329
2330 let mut transports = HashMap::new();
2331 transports.insert(TransportType::BLE, ble_metrics);
2332 transports.insert(
2333 TransportType::WiFiDirect,
2334 create_test_metrics(Some(-60), 0.2, 5),
2335 );
2336
2337 selector.select_transport(&message, &transports);
2338 selector.record_retry_failure(TransportType::BLE);
2339 selector.record_retry_failure(TransportType::BLE);
2340
2341 assert!(!selector.should_escalate_to_wifi());
2342 }
2343
2344 #[test]
2345 fn test_signal_score_calculation() {
2346 let selector = TransportSelector::new();
2347 let profile = selector.scoring_profile(TransportType::BLE);
2348
2349 let excellent = create_test_metrics(Some(-40), 0.0, 0);
2350 let score = selector.calculate_signal_score(&profile, TransportType::BLE, &excellent);
2351 assert_eq!(score, 100.0);
2352
2353 let poor = create_test_metrics(Some(-90), 0.0, 0);
2354 let score = selector.calculate_signal_score(&profile, TransportType::BLE, &poor);
2355 assert!(score < 40.0);
2356 }
2357
2358 #[test]
2359 fn test_congestion_score() {
2360 let selector = TransportSelector::new();
2361
2362 let low_congestion = create_test_metrics(Some(-60), 0.1, 5);
2363 let score = selector.calculate_congestion_score(TransportType::BLE, &low_congestion);
2364 assert!(score > 80.0);
2365
2366 let high_congestion = create_test_metrics(Some(-60), 0.9, 60);
2367 let score = selector.calculate_congestion_score(TransportType::BLE, &high_congestion);
2368 assert!(score < 20.0);
2369 }
2370
2371 #[test]
2372 fn test_load_score_discourages_overloaded_transport() {
2373 let mut selector = TransportSelector::new();
2374 let message = create_test_message();
2375
2376 let mut transports = HashMap::new();
2377 transports.insert(TransportType::BLE, create_test_metrics(Some(-55), 0.2, 5));
2378
2379 let mut congested = create_test_metrics(Some(-55), 0.9, 90);
2380 congested.queue_depth = 90;
2381 congested.congestion = 0.9;
2382 transports.insert(TransportType::WiFiDirect, congested);
2383
2384 let selected = selector.select_transport(&message, &transports).unwrap();
2385 assert_eq!(selected, TransportType::BLE);
2386 }
2387
2388 #[test]
2389 fn test_each_transport_gets_numeric_score_from_same_scoring_function() {
2390 let selector = TransportSelector::new();
2391 let message = create_test_message();
2392 let mut transports = HashMap::new();
2393 transports.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
2394 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2395 transports.insert(
2396 TransportType::WiFiDirect,
2397 create_test_metrics(Some(-55), 0.1, 5),
2398 );
2399
2400 let ranked = selector.score_and_rank(&message, &transports);
2401 assert_eq!(
2402 ranked.len(),
2403 3,
2404 "Each transport gets one score from the same scoring path"
2405 );
2406 for (tt, score) in &ranked {
2407 assert!(score.is_finite(), "Score for {:?} must be finite", tt);
2408 if *tt != TransportType::Internet {
2411 assert!(*score >= 0.0, "Score for {:?} must be non-negative", tt);
2412 }
2413 }
2414 let scores: Vec<f32> = ranked.iter().map(|(_, s)| *s).collect();
2415 let sorted: Vec<f32> = {
2416 let mut s = scores.clone();
2417 s.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
2418 s
2419 };
2420 assert_eq!(scores, sorted, "score_and_rank returns descending by score");
2421 }
2422
2423 #[test]
2424 fn test_score_and_rank_detailed_matches_score_and_rank_order_and_totals() {
2425 let selector = TransportSelector::new();
2429 let message = create_test_message();
2430 let mut transports = HashMap::new();
2431 transports.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
2432 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2433 transports.insert(
2434 TransportType::WiFiDirect,
2435 create_test_metrics(Some(-55), 0.1, 5),
2436 );
2437
2438 let totals = selector.score_and_rank(&message, &transports);
2439 let detailed = selector.score_and_rank_detailed(&message, &transports);
2440
2441 assert_eq!(totals.len(), detailed.len());
2442 for ((t_total, score_total), (t_det, score_det)) in totals.iter().zip(detailed.iter()) {
2443 assert_eq!(t_total, t_det, "ordering must match between variants");
2444 assert!(
2445 (*score_total - score_det.total).abs() < f32::EPSILON,
2446 "totals must match: {} vs {}",
2447 score_total,
2448 score_det.total,
2449 );
2450 for (label, v) in [
2451 ("signal", score_det.signal),
2452 ("proximity", score_det.proximity),
2453 ("bandwidth", score_det.bandwidth),
2454 ("congestion", score_det.congestion),
2455 ("energy", score_det.energy),
2456 ("reliability", score_det.reliability),
2457 ("load", score_det.load),
2458 ] {
2459 assert!(
2460 v.is_finite(),
2461 "{label} sub-score for {t_det:?} must be finite",
2462 );
2463 }
2464 }
2465 }
2466
2467 #[test]
2468 fn test_missing_metrics_handled_safely_with_defaults() {
2469 let selector = TransportSelector::new();
2470 let message = create_test_message();
2471 let mut transports = HashMap::new();
2472 transports.insert(TransportType::Internet, TransportMetrics::default());
2473 transports.insert(TransportType::BLE, TransportMetrics::default());
2474 transports.insert(TransportType::WiFiDirect, TransportMetrics::default());
2475
2476 let ranked = selector.score_and_rank(&message, &transports);
2477 assert_eq!(ranked.len(), 3);
2478 for (tt, score) in &ranked {
2479 assert!(
2480 score.is_finite(),
2481 "Missing metrics must not produce NaN for {:?}",
2482 tt
2483 );
2484 if *tt != TransportType::Internet {
2487 assert!(
2488 !score.is_nan() && *score >= 0.0,
2489 "Missing metrics must yield safe default score for {:?}",
2490 tt
2491 );
2492 }
2493 }
2494 }
2495
2496 #[test]
2497 fn test_score_calculation_normal_inputs_ble_better_signal_higher_score() {
2498 let selector = TransportSelector::new();
2499 let profile = selector.scoring_profile(TransportType::BLE);
2500 let excellent = create_test_metrics(Some(-45), 0.1, 5);
2501 let poor = create_test_metrics(Some(-88), 0.1, 5);
2502
2503 let excellent_signal =
2504 selector.calculate_signal_score(&profile, TransportType::BLE, &excellent);
2505 let poor_signal = selector.calculate_signal_score(&profile, TransportType::BLE, &poor);
2506 assert!(excellent_signal > poor_signal);
2507 assert!(excellent_signal >= 90.0);
2508 assert!(poor_signal < 50.0);
2509 }
2510
2511 #[test]
2512 fn test_score_calculation_normal_inputs_low_congestion_higher_score() {
2513 let selector = TransportSelector::new();
2514 let low = create_test_metrics(Some(-60), 0.1, 5);
2515 let high = create_test_metrics(Some(-60), 0.95, 80);
2516
2517 let low_cong = selector.calculate_congestion_score(TransportType::BLE, &low);
2518 let high_cong = selector.calculate_congestion_score(TransportType::BLE, &high);
2519 assert!(low_cong > high_cong);
2520 }
2521
2522 #[test]
2523 fn test_score_calculation_edge_no_rssi_uses_default() {
2524 let selector = TransportSelector::new();
2525 let profile = selector.scoring_profile(TransportType::BLE);
2526 let mut metrics = TransportMetrics::default();
2527 metrics.rssi = None;
2528
2529 let score = selector.calculate_signal_score(&profile, TransportType::BLE, &metrics);
2530 assert!(score.is_finite());
2531 assert!(score >= 0.0 && score <= 100.0);
2532 }
2533
2534 #[test]
2535 fn test_score_calculation_edge_no_delivery_ratio_uses_default() {
2536 let selector = TransportSelector::new();
2537 let metrics = TransportMetrics::default();
2538 assert_eq!(metrics.delivery_ratio, None);
2539 assert_eq!(metrics.success_count, 0);
2540 assert_eq!(metrics.failure_count, 0);
2541
2542 let score = selector.calculate_reliability_score(TransportType::BLE, &metrics);
2543 assert!(score.is_finite());
2544 assert!(score >= 0.0 && score <= 100.0);
2545 }
2546
2547 #[test]
2548 fn test_score_calculation_edge_zero_bandwidth_uses_type_default() {
2549 let selector = TransportSelector::new();
2550 let metrics = TransportMetrics::default();
2551 assert_eq!(metrics.bandwidth_bps, None);
2552
2553 let ble_bw = selector
2554 .calculate_bandwidth_score(&selector.scoring_profile(TransportType::BLE), &metrics);
2555 let wifi_bw = selector.calculate_bandwidth_score(
2556 &selector.scoring_profile(TransportType::WiFiDirect),
2557 &metrics,
2558 );
2559 let internet_bw = selector.calculate_bandwidth_score(
2560 &selector.scoring_profile(TransportType::Internet),
2561 &metrics,
2562 );
2563 assert!(ble_bw.is_finite() && ble_bw >= 0.0);
2564 assert!(wifi_bw.is_finite() && wifi_bw >= 0.0);
2565 assert!(internet_bw.is_finite() && internet_bw >= 0.0);
2566 }
2567
2568 #[test]
2569 fn test_internet_bandwidth_uses_measured_when_available() {
2570 let selector = TransportSelector::new();
2571 let profile = selector.scoring_profile(TransportType::Internet);
2572 let mut m10 = TransportMetrics::default();
2574 m10.bandwidth_bps = Some(10_000_000);
2575 assert!((selector.calculate_bandwidth_score(&profile, &m10) - 100.0).abs() < 0.01);
2576 m10.bandwidth_bps = Some(1_000_000);
2578 assert!((selector.calculate_bandwidth_score(&profile, &m10) - 10.0).abs() < 0.01);
2579 m10.bandwidth_bps = Some(100_000_000);
2581 assert!((selector.calculate_bandwidth_score(&profile, &m10) - 100.0).abs() < 0.01);
2582 }
2583
2584 #[test]
2585 fn test_internet_bandwidth_default_respects_prefer_online() {
2586 let metrics = TransportMetrics::default();
2587 assert_eq!(metrics.bandwidth_bps, None);
2588 let prefer = TransportSelector::with_config(DorsConfig {
2589 prefer_online: true,
2590 ..Default::default()
2591 });
2592 let no_prefer = TransportSelector::with_config(DorsConfig {
2593 prefer_online: false,
2594 ..Default::default()
2595 });
2596 assert_eq!(
2597 prefer.calculate_bandwidth_score(
2598 &prefer.scoring_profile(TransportType::Internet),
2599 &metrics
2600 ),
2601 70.0
2602 );
2603 assert_eq!(
2604 no_prefer.calculate_bandwidth_score(
2605 &no_prefer.scoring_profile(TransportType::Internet),
2606 &metrics
2607 ),
2608 50.0
2609 );
2610 }
2611
2612 #[test]
2613 fn test_score_calculation_edge_extreme_congestion_clamped() {
2614 let selector = TransportSelector::new();
2615 let mut metrics = TransportMetrics::default();
2616 metrics.congestion = 2.0;
2617 metrics.queue_depth = 1000;
2618
2619 let score = selector.calculate_congestion_score(TransportType::BLE, &metrics);
2620 assert!(score.is_finite());
2621 assert!(score >= 0.0);
2622 }
2623
2624 #[test]
2625 fn test_score_calculation_edge_negative_rssi_handled() {
2626 let selector = TransportSelector::new();
2627 let profile = selector.scoring_profile(TransportType::BLE);
2628 let metrics = create_test_metrics(Some(-100), 0.0, 0);
2629 let score = selector.calculate_signal_score(&profile, TransportType::BLE, &metrics);
2630 assert!(score.is_finite());
2631 assert!(score >= 0.0 && score <= 100.0);
2632 }
2633
2634 #[test]
2635 fn test_total_score_aggregates_subscores_consistently() {
2636 let selector = TransportSelector::new();
2637 let message = create_test_message();
2638 let metrics = create_test_metrics(Some(-60), 0.2, 10);
2639
2640 let score_ble = selector.calculate_transport_score(&message, TransportType::BLE, &metrics);
2641 let score_wifi =
2642 selector.calculate_transport_score(&message, TransportType::WiFiDirect, &metrics);
2643
2644 assert!(score_ble.total.is_finite() && score_ble.total >= 0.0);
2645 assert!(score_wifi.total.is_finite() && score_wifi.total >= 0.0);
2646 assert!(score_ble.signal >= 0.0 && score_ble.signal <= 100.0);
2647 assert!(score_ble.congestion >= 0.0 && score_ble.congestion <= 100.0);
2648 }
2649
2650 #[test]
2653 fn test_score_calculation_normal_proximity_hop_zero_gives_max() {
2654 let selector = TransportSelector::new();
2655 let message = create_test_message(); let score = selector.calculate_proximity_score(&message);
2657 assert!((score - 100.0).abs() < 0.01);
2658 }
2659
2660 #[test]
2661 fn test_score_calculation_normal_proximity_mid_hops() {
2662 let selector = TransportSelector::new();
2663 let message = create_test_message_with_hops(2, 8); let score = selector.calculate_proximity_score(&message);
2665 assert!((score - 75.0).abs() < 0.01);
2666 }
2667
2668 #[test]
2669 fn test_score_calculation_normal_energy_battery_mid() {
2670 let selector = TransportSelector::new();
2671 let metrics = create_test_metrics(Some(-60), 0.2, 10); let ble = selector
2673 .calculate_energy_score(&selector.scoring_profile(TransportType::BLE), &metrics);
2674 let wifi = selector.calculate_energy_score(
2675 &selector.scoring_profile(TransportType::WiFiDirect),
2676 &metrics,
2677 );
2678 assert!(ble.is_finite() && ble >= 0.0 && ble <= 100.0);
2679 assert!(wifi.is_finite() && wifi >= 0.0 && wifi <= 100.0);
2680 assert!(
2681 ble > wifi,
2682 "BLE should score higher on energy than WiFi at same battery"
2683 );
2684 }
2685
2686 #[test]
2687 fn test_score_calculation_normal_reliability_from_delivery_ratio() {
2688 let selector = TransportSelector::new();
2689 let mut metrics = TransportMetrics::default();
2690 metrics.delivery_ratio = Some(0.9);
2691 let score = selector.calculate_reliability_score(TransportType::BLE, &metrics);
2692 assert!((score - 90.0).abs() < 0.01);
2693 }
2694
2695 #[test]
2696 fn test_score_calculation_normal_reliability_from_success_failure() {
2697 let selector = TransportSelector::new();
2698 let mut metrics = TransportMetrics::default();
2699 metrics.success_count = 9;
2700 metrics.failure_count = 1;
2701 let score = selector.calculate_reliability_score(TransportType::BLE, &metrics);
2702 assert!((score - 90.0).abs() < 0.01);
2703 }
2704
2705 #[test]
2706 fn test_score_calculation_normal_load_low_queue() {
2707 let selector = TransportSelector::new();
2708 let mut metrics = TransportMetrics::default();
2709 metrics.queue_depth = 5;
2710 let score = selector.calculate_load_score(TransportType::BLE, &metrics);
2711 assert!(score.is_finite() && score >= 0.0 && score <= 100.0);
2712 assert!(
2713 score > 50.0,
2714 "low queue should yield relatively high load score"
2715 );
2716 }
2717
2718 #[test]
2719 fn test_score_calculation_normal_internet_total_finite() {
2720 let selector = TransportSelector::new();
2721 let message = create_test_message();
2722 let mut metrics = create_test_metrics(None, 0.0, 0);
2723 metrics.bandwidth_bps = Some(5_000_000); let score = selector.calculate_transport_score(&message, TransportType::Internet, &metrics);
2725 assert!(score.total.is_finite());
2729 assert!(score.bandwidth >= 0.0 && score.bandwidth <= 100.0);
2730 assert!(score.reliability >= 0.0 && score.reliability <= 100.0);
2731 }
2732
2733 #[test]
2734 fn test_score_calculation_normal_signal_rssi_boundaries() {
2735 let selector = TransportSelector::new();
2736 let profile = selector.scoring_profile(TransportType::BLE);
2737 let mut m50 = TransportMetrics::default();
2738 m50.rssi = Some(-50);
2739 assert!(
2740 (selector.calculate_signal_score(&profile, TransportType::BLE, &m50) - 100.0).abs()
2741 < 0.01
2742 );
2743 let mut m70 = TransportMetrics::default();
2744 m70.rssi = Some(-70);
2745 let s70 = selector.calculate_signal_score(&profile, TransportType::BLE, &m70);
2746 assert!(s70 >= 70.0 && s70 <= 100.0);
2747 }
2748
2749 #[test]
2752 fn test_score_calculation_edge_proximity_hop_near_ttl() {
2753 let selector = TransportSelector::new();
2754 let message = create_test_message_with_hops(7, 8); let score = selector.calculate_proximity_score(&message);
2756 assert!(score >= 10.0 && score <= 20.0);
2757 }
2758
2759 #[test]
2760 fn test_score_calculation_edge_energy_no_battery() {
2761 let selector = TransportSelector::new();
2762 let mut metrics = TransportMetrics::default();
2763 metrics.battery_level = None;
2764 metrics.is_charging = false;
2765 let score = selector
2766 .calculate_energy_score(&selector.scoring_profile(TransportType::BLE), &metrics);
2767 assert!(score.is_finite() && score >= 0.0 && score <= 100.0);
2768 }
2769
2770 #[test]
2771 fn test_score_calculation_edge_reliability_zero_and_one() {
2772 let selector = TransportSelector::new();
2773 let mut m0 = TransportMetrics::default();
2774 m0.delivery_ratio = Some(0.0);
2775 assert!((selector.calculate_reliability_score(TransportType::BLE, &m0)).abs() < 0.01);
2776 let mut m1 = TransportMetrics::default();
2777 m1.delivery_ratio = Some(1.0);
2778 assert!(
2779 (selector.calculate_reliability_score(TransportType::BLE, &m1) - 100.0).abs() < 0.01
2780 );
2781 }
2782
2783 #[test]
2784 fn test_score_calculation_edge_load_zero_queue() {
2785 let selector = TransportSelector::new();
2786 let mut metrics = TransportMetrics::default();
2787 metrics.queue_depth = 0;
2788 let score = selector.calculate_load_score(TransportType::BLE, &metrics);
2789 assert!(score.is_finite() && score >= 0.0 && score <= 100.0);
2790 assert!(score >= 90.0, "zero queue should give very high load score");
2791 }
2792
2793 #[test]
2794 fn test_score_calculation_edge_load_queue_above_threshold() {
2795 let selector = TransportSelector::new();
2796 let mut metrics = TransportMetrics::default();
2797 metrics.queue_depth = 200; let score = selector.calculate_load_score(TransportType::BLE, &metrics);
2799 assert!(score.is_finite() && score >= 0.0);
2800 assert!(
2801 score < 50.0,
2802 "queue above threshold should reduce load score"
2803 );
2804 }
2805
2806 #[test]
2807 fn test_score_calculation_edge_total_all_metrics_default() {
2808 let selector = TransportSelector::new();
2809 let message = create_test_message();
2810 let metrics = TransportMetrics::default();
2811 for transport_type in [
2812 TransportType::BLE,
2813 TransportType::WiFiDirect,
2814 TransportType::Internet,
2815 TransportType::Reticulum,
2816 ] {
2817 let score = selector.calculate_transport_score(&message, transport_type, &metrics);
2818 assert!(
2819 score.total.is_finite(),
2820 "total must be finite for {:?}",
2821 transport_type
2822 );
2823 if transport_type != TransportType::Internet {
2826 assert!(
2827 score.total >= 0.0,
2828 "total must be non-negative for {:?}",
2829 transport_type
2830 );
2831 }
2832 assert!(score.bandwidth >= 0.0 && score.bandwidth <= 100.0);
2833 assert!(score.congestion >= 0.0 && score.congestion <= 100.0);
2834 }
2835 }
2836
2837 #[test]
2838 fn test_score_calculation_edge_congestion_zero_and_one() {
2839 let selector = TransportSelector::new();
2840 let mut m0 = TransportMetrics::default();
2841 m0.congestion = 0.0;
2842 m0.queue_depth = 0;
2843 let low = selector.calculate_congestion_score(TransportType::BLE, &m0);
2844 let mut m1 = TransportMetrics::default();
2845 m1.congestion = 1.0;
2846 m1.queue_depth = 100;
2847 let high_cong = selector.calculate_congestion_score(TransportType::BLE, &m1);
2848 assert!(low > high_cong);
2849 assert!(low >= 70.0);
2850 assert!(high_cong <= 50.0);
2851 }
2852
2853 #[test]
2854 fn test_unavailable_transport_bypasses_cooldown() {
2855 let config = DorsConfig {
2856 prefer_online: true,
2857 switch_cooldown_secs: 60, ..Default::default()
2859 };
2860 let mut selector = TransportSelector::with_config(config);
2861 let message = create_test_message();
2862
2863 let mut transports_with_internet = HashMap::new();
2865 let mut internet_metrics = create_test_metrics(None, 0.0, 0);
2866 internet_metrics.bandwidth_bps = None;
2867 transports_with_internet.insert(TransportType::Internet, internet_metrics);
2868 transports_with_internet
2869 .insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2870
2871 let selected = selector
2872 .select_transport(&message, &transports_with_internet)
2873 .unwrap();
2874 assert_eq!(selected, TransportType::Internet);
2875
2876 let mut transports_without_internet = HashMap::new();
2879 transports_without_internet
2880 .insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
2881
2882 let selected = selector
2883 .select_transport(&message, &transports_without_internet)
2884 .unwrap();
2885 assert_eq!(
2886 selected,
2887 TransportType::BLE,
2888 "DORS must fall back to BLE when Internet is no longer available"
2889 );
2890 }
2891
2892 #[test]
2893 fn test_selection_chooses_highest_scored_transport_when_no_current() {
2894 let mut selector = TransportSelector::new();
2895 let message = create_test_message();
2896
2897 let mut transports = HashMap::new();
2898 transports.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.3, 15));
2899 transports.insert(
2900 TransportType::WiFiDirect,
2901 create_test_metrics(Some(-55), 0.05, 2),
2902 );
2903 transports.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
2904
2905 let ranked = selector.score_and_rank(&message, &transports);
2906 assert_eq!(ranked.len(), 3);
2907 let (best_type, best_score) = ranked[0];
2908 let (_second_type, second_score) = ranked[1];
2909 assert!(
2910 best_score >= second_score,
2911 "score_and_rank returns descending order"
2912 );
2913
2914 let selected = selector.select_transport(&message, &transports).unwrap();
2915 assert_eq!(
2916 selected, best_type,
2917 "Selection must choose the transport with the highest policy-compliant score when there is no current transport"
2918 );
2919 }
2920
2921 #[test]
2922 fn test_no_transport_viable_returns_none_typed_fallback() {
2923 let mut selector = TransportSelector::new();
2924 let message = create_test_message();
2925 let transports: HashMap<TransportType, TransportMetrics> = HashMap::new();
2926
2927 let result = selector.select_transport(&message, &transports);
2928 assert!(
2929 result.is_none(),
2930 "When no transport is viable (empty available_transports), must return None as typed fallback"
2931 );
2932 }
2933
2934 #[test]
2935 fn test_single_transport_returns_that_transport() {
2936 let mut selector = TransportSelector::new();
2937 let message = create_test_message();
2938 let mut transports = HashMap::new();
2939 transports.insert(
2940 TransportType::Internet,
2941 create_test_metrics(Some(-60), 0.2, 10),
2942 );
2943
2944 let selected = selector.select_transport(&message, &transports);
2945 assert_eq!(selected, Some(TransportType::Internet));
2946 }
2947
2948 #[test]
2952 fn test_tie_case_returns_some_transport_with_finite_scores() {
2953 let mut selector = TransportSelector::new();
2954 let message = create_test_message();
2955 let mut transports = HashMap::new();
2956 let mut ble = create_test_metrics(Some(-68), 0.15, 8);
2957 ble.bandwidth_bps = Some(120_000);
2958 let mut wifi = create_test_metrics(Some(-65), 0.15, 8);
2959 wifi.bandwidth_bps = Some(1_900_000);
2960 transports.insert(TransportType::BLE, ble);
2961 transports.insert(TransportType::WiFiDirect, wifi);
2962
2963 let ranked = selector.score_and_rank(&message, &transports);
2964 assert_eq!(ranked.len(), 2);
2965 let selected = selector.select_transport(&message, &transports);
2966 assert!(selected.is_some());
2967 let sel = selected.unwrap();
2968 assert!(
2969 sel == TransportType::BLE || sel == TransportType::WiFiDirect,
2970 "On tie or near-tie, selection must return one of the available transports"
2971 );
2972 assert!(
2973 ranked.iter().all(|(_, s)| s.is_finite() && *s >= 0.0),
2974 "All scores must be finite and non-negative"
2975 );
2976 }
2977
2978 #[test]
2981 fn test_tie_break_order_when_scores_forced_equal() {
2982 let selector = TransportSelector::new();
2983
2984 let scores_two = [
2986 (TransportType::WiFiDirect, 50.0),
2987 (TransportType::BLE, 50.0),
2988 ];
2989 assert_eq!(
2990 selector.select_from_scores_for_test(&scores_two),
2991 Some(TransportType::WiFiDirect),
2992 "Tie-break: WiFiDirect > BLE"
2993 );
2994
2995 let scores_three = [
2997 (TransportType::BLE, 50.0),
2998 (TransportType::WiFiDirect, 50.0),
2999 (TransportType::Internet, 50.0),
3000 ];
3001 assert_eq!(
3002 selector.select_from_scores_for_test(&scores_three),
3003 Some(TransportType::Internet),
3004 "Tie-break: Internet > WiFiDirect > BLE"
3005 );
3006
3007 let scores_reversed = [
3009 (TransportType::Internet, 50.0),
3010 (TransportType::WiFiDirect, 50.0),
3011 (TransportType::BLE, 50.0),
3012 ];
3013 assert_eq!(
3014 selector.select_from_scores_for_test(&scores_reversed),
3015 Some(TransportType::Internet)
3016 );
3017 }
3018
3019 #[test]
3020 fn test_policy_compliant_when_hysteresis_blocks_returns_current() {
3021 let mut selector = TransportSelector::new();
3022 let message = create_test_message();
3023
3024 let mut transports = HashMap::new();
3025 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
3026 transports.insert(
3027 TransportType::WiFiDirect,
3028 create_test_metrics(Some(-55), 0.1, 5),
3029 );
3030
3031 let first = selector.select_transport(&message, &transports).unwrap();
3032 let current = first;
3033
3034 transports.insert(TransportType::BLE, create_test_metrics(Some(-62), 0.2, 10));
3035 transports.insert(
3036 TransportType::WiFiDirect,
3037 create_test_metrics(Some(-56), 0.1, 5),
3038 );
3039
3040 let second = selector.select_transport(&message, &transports).unwrap();
3041 assert_eq!(
3042 second, current,
3043 "When hysteresis blocks a switch, selection must return current transport (policy-compliant)"
3044 );
3045 }
3046
3047 #[test]
3048 fn test_dors_when_wifi_direct_removed_from_available_selects_ble() {
3049 let mut selector = TransportSelector::new();
3050 let message = create_test_message();
3051
3052 let mut with_wifi = HashMap::new();
3053 with_wifi.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
3054 with_wifi.insert(
3055 TransportType::WiFiDirect,
3056 create_test_metrics(Some(-50), 0.1, 5),
3057 );
3058
3059 let selected_with_wifi = selector.select_transport(&message, &with_wifi).unwrap();
3060 assert!(
3061 matches!(
3062 selected_with_wifi,
3063 TransportType::BLE | TransportType::WiFiDirect
3064 ),
3065 "DORS may pick BLE or WiFi when both available"
3066 );
3067
3068 let mut without_wifi = HashMap::new();
3069 without_wifi.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
3070 without_wifi.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
3071
3072 let selected_without_wifi = selector.select_transport(&message, &without_wifi).unwrap();
3073 assert!(
3074 selected_without_wifi != TransportType::WiFiDirect,
3075 "WiFi Direct must not be selected when it is not in available_transports"
3076 );
3077 }
3078
3079 #[test]
3080 fn test_dors_ios_background_wifi_suspended_fallback_to_ble_or_internet() {
3081 let mut selector = TransportSelector::new();
3082 let message = create_test_message();
3083
3084 let mut transports_foreground = HashMap::new();
3085 transports_foreground.insert(TransportType::BLE, create_test_metrics(Some(-65), 0.2, 8));
3086 transports_foreground.insert(
3087 TransportType::WiFiDirect,
3088 create_test_metrics(Some(-55), 0.1, 3),
3089 );
3090
3091 let _ = selector.select_transport(&message, &transports_foreground);
3092
3093 let mut transports_background = HashMap::new();
3094 transports_background.insert(TransportType::BLE, create_test_metrics(Some(-65), 0.2, 8));
3095 transports_background.insert(TransportType::Internet, create_test_metrics(None, 0.0, 0));
3096
3097 let selected = selector
3098 .select_transport(&message, &transports_background)
3099 .unwrap();
3100 assert_ne!(
3101 selected,
3102 TransportType::WiFiDirect,
3103 "WiFi Direct must not be selected when removed from available_transports (iOS background)"
3104 );
3105 assert!(
3106 selected == TransportType::BLE || selected == TransportType::Internet,
3107 "DORS must reroute through BLE or Internet when WiFi Direct is suspended by OS"
3108 );
3109 }
3110
3111 #[test]
3112 fn test_dors_wifi_direct_removed_then_only_ble_available_selects_ble() {
3113 let mut selector = TransportSelector::new();
3114 let message = create_test_message();
3115
3116 let mut with_both = HashMap::new();
3117 with_both.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
3118 with_both.insert(
3119 TransportType::WiFiDirect,
3120 create_test_metrics(Some(-50), 0.05, 2),
3121 );
3122 let _ = selector.select_transport(&message, &with_both);
3123
3124 let mut only_ble = HashMap::new();
3125 only_ble.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.2, 10));
3126
3127 let selected = selector.select_transport(&message, &only_ble).unwrap();
3128 assert_eq!(
3129 selected,
3130 TransportType::BLE,
3131 "When WiFi Direct is removed from available_transports (e.g. iOS background), DORS must select BLE so traffic is rerouted through BLE"
3132 );
3133 }
3134
3135 #[test]
3136 fn test_dors_current_transport_wifi_then_removed_switches_immediately() {
3137 let mut selector = TransportSelector::new();
3138 let message = create_test_message();
3139
3140 let mut with_wifi = HashMap::new();
3141 with_wifi.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.3, 15));
3142 with_wifi.insert(
3143 TransportType::WiFiDirect,
3144 create_test_metrics(Some(-55), 0.1, 2),
3145 );
3146 let _first = selector.select_transport(&message, &with_wifi).unwrap();
3147
3148 let mut without_wifi = HashMap::new();
3149 without_wifi.insert(TransportType::BLE, create_test_metrics(Some(-70), 0.3, 15));
3150
3151 let second = selector.select_transport(&message, &without_wifi).unwrap();
3152 assert_eq!(
3153 second,
3154 TransportType::BLE,
3155 "DORS must switch immediately when current transport (e.g. WiFi Direct) is no longer available, without waiting for cooldown"
3156 );
3157 assert_ne!(
3158 second,
3159 TransportType::WiFiDirect,
3160 "WiFi Direct must not be returned when it was removed from available_transports"
3161 );
3162 }
3163
3164 #[test]
3168 fn test_scoring_profile_regression_existing_transports() {
3169 let selector = TransportSelector::new();
3170 let message = create_test_message();
3171
3172 let metrics = create_test_metrics(Some(-60), 0.15, 5);
3174
3175 let ble = selector.calculate_transport_score(&message, TransportType::BLE, &metrics);
3176 let wifi =
3177 selector.calculate_transport_score(&message, TransportType::WiFiDirect, &metrics);
3178 let inet = selector.calculate_transport_score(&message, TransportType::Internet, &metrics);
3179
3180 for (label, score) in [("BLE", &ble), ("WiFiDirect", &wifi), ("Internet", &inet)] {
3182 assert!(
3183 score.total.is_finite(),
3184 "{label} score must be finite, got {}",
3185 score.total,
3186 );
3187 if label != "Internet" {
3190 assert!(
3191 score.total >= 0.0,
3192 "{label} score must be non-negative, got {}",
3193 score.total,
3194 );
3195 }
3196 }
3197
3198 assert!(
3202 ble.total > inet.total,
3203 "BLE ({:.1}) should score higher than Internet ({:.1}) with good signal and 75% battery",
3204 ble.total,
3205 inet.total,
3206 );
3207
3208 for (label, score) in [("BLE", &ble), ("WiFiDirect", &wifi), ("Internet", &inet)] {
3210 assert!(
3211 score.signal >= 0.0 && score.signal <= 100.0,
3212 "{label} signal"
3213 );
3214 assert!(
3215 score.bandwidth >= 0.0 && score.bandwidth <= 100.0,
3216 "{label} bandwidth"
3217 );
3218 assert!(
3219 score.energy >= 0.0 && score.energy <= 100.0,
3220 "{label} energy"
3221 );
3222 assert!(
3223 score.reliability >= 0.0 && score.reliability <= 100.0,
3224 "{label} reliability"
3225 );
3226 }
3227 }
3228
3229 #[test]
3230 fn test_reticulum_scores_as_resilience_fallback() {
3231 let mut selector = TransportSelector::new();
3232 let message = create_test_message();
3233
3234 let mut transports = HashMap::new();
3236 transports.insert(TransportType::Internet, create_test_metrics(None, 0.1, 2));
3237 transports.insert(
3238 TransportType::WiFiDirect,
3239 create_test_metrics(Some(-55), 0.1, 3),
3240 );
3241 transports.insert(TransportType::BLE, create_test_metrics(Some(-60), 0.1, 2));
3242 transports.insert(
3243 TransportType::Reticulum,
3244 create_test_metrics(Some(-65), 0.1, 2),
3245 );
3246
3247 let selected = selector.select_transport(&message, &transports).unwrap();
3248 assert_ne!(
3249 selected,
3250 TransportType::Reticulum,
3251 "Reticulum should not win when higher-bandwidth transports are available"
3252 );
3253 }
3254
3255 #[test]
3256 fn test_reticulum_wins_when_only_available() {
3257 let mut selector = TransportSelector::new();
3258 let message = create_test_message();
3259
3260 let mut transports = HashMap::new();
3261 transports.insert(
3262 TransportType::Reticulum,
3263 create_test_metrics(Some(-60), 0.1, 2),
3264 );
3265
3266 let selected = selector.select_transport(&message, &transports).unwrap();
3267 assert_eq!(
3268 selected,
3269 TransportType::Reticulum,
3270 "Reticulum must be selected when it is the only available transport"
3271 );
3272 }
3273
3274 #[test]
3275 fn test_reticulum_tie_break_is_lowest_priority() {
3276 let selector = TransportSelector::new();
3277
3278 let scores = [(TransportType::Reticulum, 50.0), (TransportType::BLE, 50.0)];
3279 assert_eq!(
3280 selector.select_from_scores_for_test(&scores),
3281 Some(TransportType::BLE),
3282 "Tie-break: BLE > Reticulum"
3283 );
3284
3285 let all = [
3286 (TransportType::Internet, 50.0),
3287 (TransportType::WiFiDirect, 50.0),
3288 (TransportType::BLE, 50.0),
3289 (TransportType::Reticulum, 50.0),
3290 ];
3291 assert_eq!(
3292 selector.select_from_scores_for_test(&all),
3293 Some(TransportType::Internet),
3294 "Tie-break: Internet > WiFiDirect > BLE > Reticulum"
3295 );
3296 }
3297
3298 #[test]
3299 fn test_reticulum_scoring_profile_produces_valid_scores() {
3300 let selector = TransportSelector::new();
3301 let message = create_test_message();
3302 let metrics = create_test_metrics(Some(-70), 0.2, 5);
3303
3304 let score =
3305 selector.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
3306 assert!(
3307 score.total.is_finite() && score.total >= 0.0,
3308 "Reticulum score must be finite and non-negative"
3309 );
3310 assert!(score.signal.is_finite() && score.signal >= 0.0);
3311 assert!(score.bandwidth.is_finite() && score.bandwidth >= 0.0);
3312 assert!(score.energy.is_finite() && score.energy >= 0.0);
3313 assert!(score.reliability.is_finite() && score.reliability >= 0.0);
3314 }
3315
3316 #[test]
3317 fn test_reticulum_penalised_for_media_preference() {
3318 let selector = TransportSelector::new();
3319 let mut message = create_test_message();
3320 let metrics = create_test_metrics(Some(-65), 0.1, 2);
3321
3322 let score_normal =
3323 selector.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
3324
3325 message
3326 .metadata
3327 .insert("transport_preference".to_string(), "internet".to_string());
3328 let score_media =
3329 selector.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
3330
3331 assert!(
3332 score_media.total < score_normal.total,
3333 "Reticulum should be penalised when message prefers internet (media). normal={:.1}, media={:.1}",
3334 score_normal.total,
3335 score_media.total
3336 );
3337 }
3338
3339 #[test]
3341 fn test_reticulum_not_boosted_by_prefer_online() {
3342 let selector_prefer = TransportSelector::with_config(DorsConfig {
3343 prefer_online: true,
3344 ..Default::default()
3345 });
3346 let selector_default = TransportSelector::new();
3347 let message = create_test_message();
3348 let metrics = create_test_metrics(Some(-65), 0.1, 2);
3349
3350 let score_prefer =
3351 selector_prefer.calculate_transport_score(&message, TransportType::Reticulum, &metrics);
3352 let score_default = selector_default.calculate_transport_score(
3353 &message,
3354 TransportType::Reticulum,
3355 &metrics,
3356 );
3357
3358 assert!(
3359 (score_prefer.total - score_default.total).abs() < 0.01,
3360 "prefer_online must not change Reticulum score. prefer={:.1}, default={:.1}",
3361 score_prefer.total,
3362 score_default.total,
3363 );
3364 }
3365}