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