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