1use std::collections::HashMap;
5
6use tokio::sync::RwLock;
7use uuid::Uuid;
8use weave_contracts::{DeviceCycle, FeedbackRule, Mapping, Route, TargetCandidate};
9
10use super::intent::{InputPrimitive, Intent};
11
12#[derive(Debug, Clone)]
14pub struct RoutedIntent {
15 pub service_type: String,
16 pub service_target: String,
17 pub intent: Intent,
18}
19
20#[derive(Debug, Clone)]
26pub struct SelectionMode {
27 pub mapping_id: Uuid,
28 pub edge_id: String,
29 pub candidates: Vec<TargetCandidate>,
30 pub cursor: usize,
31 rotation_accumulator: f64,
39}
40
41pub const SELECTION_ROTATION_STEP: f64 = 0.25;
48
49impl SelectionMode {
50 pub fn new(
51 mapping_id: Uuid,
52 edge_id: String,
53 candidates: Vec<TargetCandidate>,
54 cursor: usize,
55 ) -> Self {
56 Self {
57 mapping_id,
58 edge_id,
59 candidates,
60 cursor,
61 rotation_accumulator: 0.0,
62 }
63 }
64
65 fn current(&self) -> &TargetCandidate {
66 &self.candidates[self.cursor]
67 }
68
69 fn advance(&mut self, delta: f64) -> bool {
75 let n = self.candidates.len();
76 if n == 0 {
77 return false;
78 }
79 self.rotation_accumulator += delta;
80 let mut moved = false;
81 while self.rotation_accumulator >= SELECTION_ROTATION_STEP {
82 self.cursor = (self.cursor + 1) % n;
83 self.rotation_accumulator -= SELECTION_ROTATION_STEP;
84 moved = true;
85 }
86 while self.rotation_accumulator <= -SELECTION_ROTATION_STEP {
87 self.cursor = (self.cursor + n - 1) % n;
88 self.rotation_accumulator += SELECTION_ROTATION_STEP;
89 moved = true;
90 }
91 moved
92 }
93}
94
95#[derive(Debug, Clone)]
100pub enum RouteOutcome {
101 Normal(Vec<RoutedIntent>),
102 EnterSelection {
104 edge_id: String,
105 mapping_id: Uuid,
106 glyph: String,
107 },
108 UpdateSelection {
110 mapping_id: Uuid,
111 glyph: String,
112 },
113 CommitSelection {
116 edge_id: String,
117 mapping_id: Uuid,
118 service_target: String,
119 },
120 CancelSelection {
123 mapping_id: Uuid,
124 },
125 CycleSwitch {
130 device_type: String,
131 device_id: String,
132 active_mapping_id: Uuid,
133 },
134}
135
136#[derive(Default)]
139pub struct RoutingEngine {
140 by_device: RwLock<HashMap<(String, String), Vec<Mapping>>>,
141 selection: RwLock<HashMap<(String, String), SelectionMode>>,
142 cycles: RwLock<HashMap<(String, String), DeviceCycle>>,
149}
150
151impl RoutingEngine {
152 pub fn new() -> Self {
153 Self::default()
154 }
155
156 pub async fn replace_all(&self, mappings: Vec<Mapping>) {
158 let mut by_device: HashMap<(String, String), Vec<Mapping>> = HashMap::new();
159 for m in mappings {
160 by_device
161 .entry((m.device_type.clone(), m.device_id.clone()))
162 .or_default()
163 .push(m);
164 }
165 *self.by_device.write().await = by_device;
166 }
167
168 pub async fn upsert_mapping(&self, mapping: Mapping) {
171 let mut guard = self.by_device.write().await;
172 for list in guard.values_mut() {
175 list.retain(|m| m.mapping_id != mapping.mapping_id);
176 }
177 guard
178 .entry((mapping.device_type.clone(), mapping.device_id.clone()))
179 .or_default()
180 .push(mapping);
181 }
182
183 pub async fn remove_mapping(&self, id: &uuid::Uuid) {
186 let mut guard = self.by_device.write().await;
187 for list in guard.values_mut() {
188 list.retain(|m| &m.mapping_id != id);
189 }
190 guard.retain(|_, list| !list.is_empty());
192 }
193
194 pub async fn snapshot(&self) -> Vec<Mapping> {
197 self.by_device
198 .read()
199 .await
200 .values()
201 .flatten()
202 .cloned()
203 .collect()
204 }
205
206 pub async fn replace_cycles(&self, cycles: Vec<DeviceCycle>) {
208 let mut map: HashMap<(String, String), DeviceCycle> = HashMap::new();
209 for c in cycles {
210 map.insert((c.device_type.clone(), c.device_id.clone()), c);
211 }
212 *self.cycles.write().await = map;
213 }
214
215 pub async fn upsert_cycle(&self, cycle: DeviceCycle) {
217 let key = (cycle.device_type.clone(), cycle.device_id.clone());
218 self.cycles.write().await.insert(key, cycle);
219 }
220
221 pub async fn remove_cycle(&self, device_type: &str, device_id: &str) -> bool {
223 let key = (device_type.to_string(), device_id.to_string());
224 self.cycles.write().await.remove(&key).is_some()
225 }
226
227 pub async fn set_cycle_active(
231 &self,
232 device_type: &str,
233 device_id: &str,
234 active_mapping_id: Uuid,
235 ) -> bool {
236 let key = (device_type.to_string(), device_id.to_string());
237 let mut cycles = self.cycles.write().await;
238 let Some(cycle) = cycles.get_mut(&key) else {
239 return false;
240 };
241 if !cycle.mapping_ids.contains(&active_mapping_id) {
242 return false;
243 }
244 cycle.active_mapping_id = Some(active_mapping_id);
245 true
246 }
247
248 pub async fn cycles_snapshot(&self) -> Vec<DeviceCycle> {
250 self.cycles.read().await.values().cloned().collect()
251 }
252
253 pub async fn try_cycle_switch(
266 &self,
267 device_type: &str,
268 device_id: &str,
269 input: &InputPrimitive,
270 ) -> Option<Uuid> {
271 let key = (device_type.to_string(), device_id.to_string());
272 let cycle = self.cycles.read().await.get(&key).cloned()?;
273 let gesture = cycle.cycle_gesture.as_deref()?;
274 if !input.matches_route(gesture) {
275 return None;
276 }
277 let next = next_active(&cycle)?;
278 self.cycles
279 .write()
280 .await
281 .entry(key)
282 .and_modify(|c| c.active_mapping_id = Some(next));
283 Some(next)
284 }
285
286 pub async fn cycle_for(&self, device_type: &str, device_id: &str) -> Option<DeviceCycle> {
288 let key = (device_type.to_string(), device_id.to_string());
289 self.cycles.read().await.get(&key).cloned()
290 }
291
292 pub async fn feedback_rules_for_target(
302 &self,
303 service_type: &str,
304 target: &str,
305 ) -> Vec<FeedbackRule> {
306 let guard = self.by_device.read().await;
307 for list in guard.values() {
308 if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
309 return rules;
310 }
311 }
312 Vec::new()
313 }
314
315 pub async fn feedback_rules_for_device_target(
327 &self,
328 device_type: &str,
329 device_id: &str,
330 service_type: &str,
331 target: &str,
332 ) -> Option<Vec<FeedbackRule>> {
333 let guard = self.by_device.read().await;
334 let list = guard.get(&(device_type.to_string(), device_id.to_string()))?;
335 mapping_rules_for_target(list, service_type, target)
336 }
337
338 pub async fn feedback_targets_for(
344 &self,
345 service_type: &str,
346 target: &str,
347 ) -> Vec<(String, String, Vec<FeedbackRule>)> {
348 let guard = self.by_device.read().await;
349 let mut out = Vec::new();
350 for ((device_type, device_id), list) in guard.iter() {
351 if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
352 out.push((device_type.clone(), device_id.clone(), rules));
353 }
354 }
355 out
356 }
357
358 pub async fn route(
367 &self,
368 device_type: &str,
369 device_id: &str,
370 input: &InputPrimitive,
371 ) -> Vec<RoutedIntent> {
372 let key = (device_type.to_string(), device_id.to_string());
373 let cycle = self.cycles.read().await.get(&key).cloned();
374 let guard = self.by_device.read().await;
375 let Some(mappings) = guard.get(&key) else {
376 return Vec::new();
377 };
378 let active_filter = active_filter_from_cycle(cycle.as_ref());
379 route_mappings(mappings, input, active_filter)
380 }
381
382 pub async fn route_with_mode(
390 &self,
391 device_type: &str,
392 device_id: &str,
393 input: &InputPrimitive,
394 ) -> RouteOutcome {
395 let key = (device_type.to_string(), device_id.to_string());
396
397 {
402 let cycles = self.cycles.read().await;
403 if let Some(cycle) = cycles.get(&key).cloned() {
404 drop(cycles);
405 if let Some(gesture) = cycle.cycle_gesture.as_deref() {
406 if input.matches_route(gesture) {
407 let next = next_active(&cycle);
410 if let Some(next_id) = next {
411 self.cycles
414 .write()
415 .await
416 .entry(key.clone())
417 .and_modify(|c| {
418 c.active_mapping_id = Some(next_id);
419 });
420 return RouteOutcome::CycleSwitch {
421 device_type: device_type.to_string(),
422 device_id: device_id.to_string(),
423 active_mapping_id: next_id,
424 };
425 }
426 }
427 }
428 let active_filter = active_filter_from_cycle(Some(&cycle));
433 let guard = self.by_device.read().await;
434 let Some(mappings) = guard.get(&key) else {
435 return RouteOutcome::Normal(Vec::new());
436 };
437 return RouteOutcome::Normal(route_mappings(mappings, input, active_filter));
438 }
439 }
440
441 {
444 let mut sel = self.selection.write().await;
445 if let Some(mode) = sel.get_mut(&key) {
446 match input {
447 InputPrimitive::Rotate { delta } => {
448 if !mode.advance(*delta) {
453 return RouteOutcome::Normal(Vec::new());
454 }
455 let glyph = mode.current().glyph.clone();
456 let mapping_id = mode.mapping_id;
457 return RouteOutcome::UpdateSelection { mapping_id, glyph };
458 }
459 InputPrimitive::Press => {
460 let mapping_id = mode.mapping_id;
461 let service_target = mode.current().target.clone();
462 let edge_id = mode.edge_id.clone();
463 sel.remove(&key);
464 return RouteOutcome::CommitSelection {
465 edge_id,
466 mapping_id,
467 service_target,
468 };
469 }
470 _ => {
471 let mapping_id = mode.mapping_id;
472 sel.remove(&key);
473 return RouteOutcome::CancelSelection { mapping_id };
474 }
475 }
476 }
477 }
478
479 let guard = self.by_device.read().await;
482 let Some(mappings) = guard.get(&key) else {
483 return RouteOutcome::Normal(Vec::new());
484 };
485
486 for m in mappings {
487 if !m.active {
488 continue;
489 }
490 let Some(switch_on) = m.target_switch_on.as_deref() else {
491 continue;
492 };
493 if m.target_candidates.is_empty() {
494 continue;
495 }
496 if !input.matches_route(switch_on) {
497 continue;
498 }
499 let current_idx = m
503 .target_candidates
504 .iter()
505 .position(|c| c.target == m.service_target);
506 let cursor = match current_idx {
507 Some(i) => (i + 1) % m.target_candidates.len(),
508 None => 0,
509 };
510 let mode = SelectionMode::new(
511 m.mapping_id,
512 m.edge_id.clone(),
513 m.target_candidates.clone(),
514 cursor,
515 );
516 let glyph = mode.current().glyph.clone();
517 let mapping_id = mode.mapping_id;
518 let edge_id = mode.edge_id.clone();
519 drop(guard);
520 self.selection.write().await.insert(key, mode);
521 return RouteOutcome::EnterSelection {
522 edge_id,
523 mapping_id,
524 glyph,
525 };
526 }
527
528 RouteOutcome::Normal(route_mappings(mappings, input, None))
529 }
530}
531
532fn next_active(cycle: &DeviceCycle) -> Option<Uuid> {
537 if cycle.mapping_ids.is_empty() {
538 return None;
539 }
540 let active = cycle.active_mapping_id?;
541 let pos = cycle.mapping_ids.iter().position(|id| *id == active)?;
542 let next = (pos + 1) % cycle.mapping_ids.len();
543 Some(cycle.mapping_ids[next])
544}
545
546fn active_filter_from_cycle(cycle: Option<&DeviceCycle>) -> Option<Uuid> {
550 cycle.and_then(|c| c.active_mapping_id)
551}
552
553fn mapping_rules_for_target(
565 list: &[Mapping],
566 service_type: &str,
567 target: &str,
568) -> Option<Vec<FeedbackRule>> {
569 for m in list {
570 if !m.active {
571 continue;
572 }
573 if m.service_type == service_type && m.service_target == target {
574 return Some(m.feedback.clone());
575 }
576 for c in &m.target_candidates {
577 let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
578 if c_service_type == service_type && c.target == target {
579 return Some(m.feedback.clone());
580 }
581 }
582 }
583 None
584}
585
586fn route_mappings(
587 mappings: &[Mapping],
588 input: &InputPrimitive,
589 active_filter: Option<Uuid>,
590) -> Vec<RoutedIntent> {
591 let mut out = Vec::new();
592 for m in mappings {
593 if !m.active {
594 continue;
595 }
596 if let Some(active_id) = active_filter {
601 if m.mapping_id != active_id {
602 continue;
603 }
604 }
605 let (service_type, routes) = m.effective_for(&m.service_target);
611 for route in routes {
612 if !input.matches_route(&route.input) {
613 continue;
614 }
615 if let Some(intent) = build_intent(route, input) {
616 out.push(RoutedIntent {
617 service_type: service_type.to_string(),
618 service_target: m.service_target.clone(),
619 intent,
620 });
621 break;
623 }
624 }
625 }
626 out
627}
628
629fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
630 let damping = route
631 .params
632 .get("damping")
633 .and_then(|v| v.as_f64())
634 .unwrap_or(1.0);
635
636 match route.intent.as_str() {
637 "play" => Some(Intent::Play),
638 "pause" => Some(Intent::Pause),
639 "play_pause" | "playpause" => Some(Intent::PlayPause),
640 "stop" => Some(Intent::Stop),
641 "next" => Some(Intent::Next),
642 "previous" => Some(Intent::Previous),
643 "mute" => Some(Intent::Mute),
644 "unmute" => Some(Intent::Unmute),
645 "power_toggle" => Some(Intent::PowerToggle),
646 "power_on" => Some(Intent::PowerOn),
647 "power_off" => Some(Intent::PowerOff),
648 "volume_change" => input
649 .continuous_value()
650 .map(|v| Intent::VolumeChange { delta: v * damping }),
651 "volume_set" => route
652 .params
653 .get("value")
654 .and_then(|v| v.as_f64())
655 .map(|value| Intent::VolumeSet { value }),
656 "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
657 seconds: v * damping,
658 }),
659 "brightness_change" => input
660 .continuous_value()
661 .map(|v| Intent::BrightnessChange { delta: v * damping }),
662 "color_temperature_change" => input
663 .continuous_value()
664 .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
665 _ => None,
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use crate::Direction;
673 use std::collections::BTreeMap;
674 use uuid::Uuid;
675 use weave_contracts::Route;
676
677 fn rotate_mapping() -> Mapping {
678 Mapping {
679 mapping_id: Uuid::new_v4(),
680 edge_id: "living-room".into(),
681 device_type: "nuimo".into(),
682 device_id: "C3:81:DF:4E".into(),
683 service_type: "roon".into(),
684 service_target: "zone-1".into(),
685 routes: vec![Route {
686 input: "rotate".into(),
687 intent: "volume_change".into(),
688 params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
689 }],
690 feedback: vec![],
691 active: true,
692 target_candidates: vec![],
693 target_switch_on: None,
694 }
695 }
696
697 #[tokio::test]
698 async fn rotate_produces_volume_change_with_damping() {
699 let engine = RoutingEngine::new();
700 engine.replace_all(vec![rotate_mapping()]).await;
701
702 let out = engine
703 .route(
704 "nuimo",
705 "C3:81:DF:4E",
706 &InputPrimitive::Rotate { delta: 0.03 },
707 )
708 .await;
709 assert_eq!(out.len(), 1);
710 match &out[0].intent {
711 Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
712 other => panic!("expected VolumeChange, got {:?}", other),
713 }
714 assert_eq!(out[0].service_type, "roon");
715 assert_eq!(out[0].service_target, "zone-1");
716 }
717
718 #[tokio::test]
719 async fn inactive_mappings_are_skipped() {
720 let mut m = rotate_mapping();
721 m.active = false;
722 let engine = RoutingEngine::new();
723 engine.replace_all(vec![m]).await;
724
725 let out = engine
726 .route(
727 "nuimo",
728 "C3:81:DF:4E",
729 &InputPrimitive::Rotate { delta: 0.03 },
730 )
731 .await;
732 assert!(out.is_empty());
733 }
734
735 #[tokio::test]
736 async fn unknown_device_returns_empty() {
737 let engine = RoutingEngine::new();
738 engine.replace_all(vec![rotate_mapping()]).await;
739
740 let out = engine
741 .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
742 .await;
743 assert!(out.is_empty());
744 }
745
746 fn selection_mapping() -> Mapping {
747 let mut m = rotate_mapping();
748 m.service_target = "target-A".into();
749 m.target_switch_on = Some("swipe_up".into());
750 m.target_candidates = vec![
751 TargetCandidate {
752 target: "target-A".into(),
753 label: "A".into(),
754 glyph: "glyph-a".into(),
755 service_type: None,
756 routes: None,
757 },
758 TargetCandidate {
759 target: "target-B".into(),
760 label: "B".into(),
761 glyph: "glyph-b".into(),
762 service_type: None,
763 routes: None,
764 },
765 ];
766 m
767 }
768
769 fn cross_service_mapping() -> Mapping {
773 let mut m = rotate_mapping();
774 m.service_target = "roon-zone".into();
775 m.target_switch_on = Some("long_press".into());
776 m.target_candidates = vec![
777 TargetCandidate {
778 target: "roon-zone".into(),
779 label: "Roon".into(),
780 glyph: "roon".into(),
781 service_type: None,
782 routes: None,
783 },
784 TargetCandidate {
785 target: "hue-light".into(),
786 label: "Hue".into(),
787 glyph: "hue".into(),
788 service_type: Some("hue".into()),
789 routes: Some(vec![Route {
790 input: "rotate".into(),
791 intent: "brightness_change".into(),
792 params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
793 }]),
794 },
795 ];
796 m
797 }
798
799 #[test]
800 fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
801 let m = cross_service_mapping();
802 let (svc, routes) = m.effective_for("roon-zone");
803 assert_eq!(svc, "roon");
804 assert_eq!(routes.len(), 1);
805 assert_eq!(routes[0].intent, "volume_change");
806 }
807
808 #[test]
809 fn effective_for_returns_candidate_overrides_when_present() {
810 let m = cross_service_mapping();
811 let (svc, routes) = m.effective_for("hue-light");
812 assert_eq!(svc, "hue");
813 assert_eq!(routes.len(), 1);
814 assert_eq!(routes[0].intent, "brightness_change");
815 }
816
817 #[tokio::test]
818 async fn rotate_on_cross_service_candidate_produces_hue_intent() {
819 let mut m = cross_service_mapping();
820 m.service_target = "hue-light".into();
823
824 let engine = RoutingEngine::new();
825 engine.replace_all(vec![m]).await;
826
827 let out = engine
828 .route(
829 "nuimo",
830 "C3:81:DF:4E",
831 &InputPrimitive::Rotate { delta: 0.1 },
832 )
833 .await;
834 assert_eq!(out.len(), 1);
835 assert_eq!(out[0].service_type, "hue");
836 assert_eq!(out[0].service_target, "hue-light");
837 match &out[0].intent {
838 Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
839 other => panic!("expected BrightnessChange, got {:?}", other),
840 }
841 }
842
843 #[tokio::test]
844 async fn swipe_up_press_cycles_to_next_target_without_rotate() {
845 let engine = RoutingEngine::new();
846 engine.replace_all(vec![selection_mapping()]).await;
847
848 match engine
849 .route_with_mode(
850 "nuimo",
851 "C3:81:DF:4E",
852 &InputPrimitive::Swipe {
853 direction: Direction::Up,
854 },
855 )
856 .await
857 {
858 RouteOutcome::EnterSelection { glyph, .. } => {
859 assert_eq!(glyph, "glyph-b");
861 }
862 other => panic!("expected EnterSelection, got {:?}", other),
863 }
864
865 match engine
866 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
867 .await
868 {
869 RouteOutcome::CommitSelection { service_target, .. } => {
870 assert_eq!(service_target, "target-B")
871 }
872 other => panic!("expected CommitSelection, got {:?}", other),
873 }
874 }
875
876 #[tokio::test]
877 async fn rotate_then_press_commits_advanced_candidate() {
878 let engine = RoutingEngine::new();
879 engine.replace_all(vec![selection_mapping()]).await;
880
881 let _ = engine
883 .route_with_mode(
884 "nuimo",
885 "C3:81:DF:4E",
886 &InputPrimitive::Swipe {
887 direction: Direction::Up,
888 },
889 )
890 .await;
891 match engine
893 .route_with_mode(
894 "nuimo",
895 "C3:81:DF:4E",
896 &InputPrimitive::Rotate {
897 delta: SELECTION_ROTATION_STEP,
898 },
899 )
900 .await
901 {
902 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
903 other => panic!("expected UpdateSelection, got {:?}", other),
904 }
905 match engine
906 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
907 .await
908 {
909 RouteOutcome::CommitSelection { service_target, .. } => {
910 assert_eq!(service_target, "target-A")
911 }
912 other => panic!("expected CommitSelection, got {:?}", other),
913 }
914 }
915
916 #[tokio::test]
917 async fn sub_threshold_rotate_keeps_cursor_still() {
918 let engine = RoutingEngine::new();
919 engine.replace_all(vec![selection_mapping()]).await;
920
921 let _ = engine
923 .route_with_mode(
924 "nuimo",
925 "C3:81:DF:4E",
926 &InputPrimitive::Swipe {
927 direction: Direction::Up,
928 },
929 )
930 .await;
931
932 match engine
936 .route_with_mode(
937 "nuimo",
938 "C3:81:DF:4E",
939 &InputPrimitive::Rotate {
940 delta: SELECTION_ROTATION_STEP / 2.0,
941 },
942 )
943 .await
944 {
945 RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
946 other => panic!("expected Normal(empty), got {:?}", other),
947 }
948
949 match engine
952 .route_with_mode(
953 "nuimo",
954 "C3:81:DF:4E",
955 &InputPrimitive::Rotate {
956 delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
957 },
958 )
959 .await
960 {
961 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
962 other => panic!("expected UpdateSelection, got {:?}", other),
963 }
964 }
965
966 #[tokio::test]
967 async fn large_rotate_produces_single_step_per_outcome() {
968 let engine = RoutingEngine::new();
973 engine.replace_all(vec![selection_mapping()]).await;
974
975 let _ = engine
976 .route_with_mode(
977 "nuimo",
978 "C3:81:DF:4E",
979 &InputPrimitive::Swipe {
980 direction: Direction::Up,
981 },
982 )
983 .await;
984 match engine
988 .route_with_mode(
989 "nuimo",
990 "C3:81:DF:4E",
991 &InputPrimitive::Rotate {
992 delta: SELECTION_ROTATION_STEP * 2.0,
993 },
994 )
995 .await
996 {
997 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
998 other => panic!("expected UpdateSelection, got {:?}", other),
999 }
1000 }
1001
1002 fn playback_glyph_rule() -> FeedbackRule {
1003 FeedbackRule {
1004 state: "playback".into(),
1005 feedback_type: "glyph".into(),
1006 mapping: serde_json::json!({
1007 "playing": "play",
1008 "paused": "pause",
1009 "stopped": "pause",
1010 }),
1011 }
1012 }
1013
1014 fn mapping_with_feedback() -> Mapping {
1015 let mut m = rotate_mapping();
1016 m.feedback = vec![playback_glyph_rule()];
1017 m
1018 }
1019
1020 #[tokio::test]
1021 async fn feedback_rules_match_primary_target() {
1022 let engine = RoutingEngine::new();
1023 engine.replace_all(vec![mapping_with_feedback()]).await;
1024
1025 let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
1026 assert_eq!(rules.len(), 1);
1027 assert_eq!(rules[0].state, "playback");
1028 }
1029
1030 #[tokio::test]
1031 async fn feedback_rules_match_candidate_override_service_type() {
1032 let mut m = mapping_with_feedback();
1033 m.target_candidates = vec![TargetCandidate {
1034 target: "hue-light-1".into(),
1035 label: "Living".into(),
1036 glyph: "link".into(),
1037 service_type: Some("hue".into()),
1038 routes: None,
1039 }];
1040 let engine = RoutingEngine::new();
1041 engine.replace_all(vec![m]).await;
1042
1043 let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
1044 assert_eq!(
1045 rules.len(),
1046 1,
1047 "candidate with overridden service_type inherits mapping feedback",
1048 );
1049 }
1050
1051 #[tokio::test]
1052 async fn feedback_rules_empty_for_unknown_target() {
1053 let engine = RoutingEngine::new();
1054 engine.replace_all(vec![mapping_with_feedback()]).await;
1055
1056 let rules = engine
1057 .feedback_rules_for_target("roon", "some-other-zone")
1058 .await;
1059 assert!(rules.is_empty());
1060 }
1061
1062 #[tokio::test]
1063 async fn feedback_rules_for_device_target_scopes_by_device() {
1064 let mut m_a = mapping_with_feedback();
1067 m_a.device_id = "nuimo-a".into();
1068 let mut m_b = mapping_with_feedback();
1069 m_b.mapping_id = Uuid::new_v4();
1070 m_b.device_id = "nuimo-b".into();
1071 m_b.service_target = "zone-b".into();
1072
1073 let engine = RoutingEngine::new();
1074 engine.replace_all(vec![m_a, m_b]).await;
1075
1076 let rules_a = engine
1077 .feedback_rules_for_device_target("nuimo", "nuimo-a", "roon", "zone-1")
1078 .await;
1079 let rules_a = rules_a.expect("nuimo-a owns zone-1");
1080 assert_eq!(rules_a.len(), 1);
1081
1082 let rules_b_wrong = engine
1083 .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-1")
1084 .await;
1085 assert!(
1086 rules_b_wrong.is_none(),
1087 "nuimo-b does not own zone-1 — must skip entirely",
1088 );
1089
1090 let rules_b_right = engine
1091 .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-b")
1092 .await;
1093 let rules_b_right = rules_b_right.expect("nuimo-b owns zone-b");
1094 assert_eq!(rules_b_right.len(), 1);
1095 }
1096
1097 #[tokio::test]
1098 async fn feedback_rules_for_device_target_none_for_unknown_device() {
1099 let engine = RoutingEngine::new();
1100 engine.replace_all(vec![mapping_with_feedback()]).await;
1101
1102 let rules = engine
1103 .feedback_rules_for_device_target("nuimo", "unknown-device", "roon", "zone-1")
1104 .await;
1105 assert!(
1106 rules.is_none(),
1107 "no mapping for unknown device — caller should skip",
1108 );
1109 }
1110
1111 #[tokio::test]
1112 async fn feedback_rules_for_device_target_some_empty_for_mapping_without_rules() {
1113 let m = rotate_mapping(); let engine = RoutingEngine::new();
1118 engine.replace_all(vec![m]).await;
1119
1120 let rules = engine
1121 .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
1122 .await;
1123 let rules = rules.expect("mapping exists — caller should fall back to defaults");
1124 assert!(
1125 rules.is_empty(),
1126 "no explicit rules configured — pump will use FeedbackPlan defaults",
1127 );
1128 }
1129
1130 #[tokio::test]
1131 async fn feedback_rules_for_device_target_skips_inactive_mapping() {
1132 let mut active_zone1 = mapping_with_feedback();
1139 active_zone1.service_target = "zone-1".into();
1140 active_zone1.active = true;
1141
1142 let mut inactive_zone2 = mapping_with_feedback();
1143 inactive_zone2.mapping_id = uuid::Uuid::new_v4();
1144 inactive_zone2.service_target = "zone-2".into();
1145 inactive_zone2.active = false;
1146
1147 let engine = RoutingEngine::new();
1148 engine.replace_all(vec![active_zone1, inactive_zone2]).await;
1149
1150 let rules_active = engine
1151 .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
1152 .await;
1153 assert!(
1154 rules_active.is_some(),
1155 "active mapping for zone-1 must still produce rules"
1156 );
1157
1158 let rules_inactive = engine
1159 .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-2")
1160 .await;
1161 assert!(
1162 rules_inactive.is_none(),
1163 "inactive mapping for zone-2 must NOT produce feedback rules — \
1164 dormant by user choice, no LED render",
1165 );
1166 }
1167
1168 #[tokio::test]
1169 async fn feedback_targets_for_skips_inactive_mappings() {
1170 let mut active_zone1 = mapping_with_feedback();
1175 active_zone1.service_target = "zone-1".into();
1176 active_zone1.active = true;
1177
1178 let mut inactive_zone2 = mapping_with_feedback();
1179 inactive_zone2.mapping_id = uuid::Uuid::new_v4();
1180 inactive_zone2.service_target = "zone-2".into();
1181 inactive_zone2.active = false;
1182
1183 let engine = RoutingEngine::new();
1184 engine.replace_all(vec![active_zone1, inactive_zone2]).await;
1185
1186 let targets_zone1 = engine.feedback_targets_for("roon", "zone-1").await;
1187 assert_eq!(
1188 targets_zone1.len(),
1189 1,
1190 "active zone-1 mapping yields one feedback target"
1191 );
1192
1193 let targets_zone2 = engine.feedback_targets_for("roon", "zone-2").await;
1194 assert!(
1195 targets_zone2.is_empty(),
1196 "inactive zone-2 mapping must NOT yield feedback targets"
1197 );
1198 }
1199}