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(
558 list: &[Mapping],
559 service_type: &str,
560 target: &str,
561) -> Option<Vec<FeedbackRule>> {
562 for m in list {
563 if m.service_type == service_type && m.service_target == target {
564 return Some(m.feedback.clone());
565 }
566 for c in &m.target_candidates {
567 let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
568 if c_service_type == service_type && c.target == target {
569 return Some(m.feedback.clone());
570 }
571 }
572 }
573 None
574}
575
576fn route_mappings(
577 mappings: &[Mapping],
578 input: &InputPrimitive,
579 active_filter: Option<Uuid>,
580) -> Vec<RoutedIntent> {
581 let mut out = Vec::new();
582 for m in mappings {
583 if !m.active {
584 continue;
585 }
586 if let Some(active_id) = active_filter {
591 if m.mapping_id != active_id {
592 continue;
593 }
594 }
595 let (service_type, routes) = m.effective_for(&m.service_target);
601 for route in routes {
602 if !input.matches_route(&route.input) {
603 continue;
604 }
605 if let Some(intent) = build_intent(route, input) {
606 out.push(RoutedIntent {
607 service_type: service_type.to_string(),
608 service_target: m.service_target.clone(),
609 intent,
610 });
611 break;
613 }
614 }
615 }
616 out
617}
618
619fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
620 let damping = route
621 .params
622 .get("damping")
623 .and_then(|v| v.as_f64())
624 .unwrap_or(1.0);
625
626 match route.intent.as_str() {
627 "play" => Some(Intent::Play),
628 "pause" => Some(Intent::Pause),
629 "play_pause" | "playpause" => Some(Intent::PlayPause),
630 "stop" => Some(Intent::Stop),
631 "next" => Some(Intent::Next),
632 "previous" => Some(Intent::Previous),
633 "mute" => Some(Intent::Mute),
634 "unmute" => Some(Intent::Unmute),
635 "power_toggle" => Some(Intent::PowerToggle),
636 "power_on" => Some(Intent::PowerOn),
637 "power_off" => Some(Intent::PowerOff),
638 "volume_change" => input
639 .continuous_value()
640 .map(|v| Intent::VolumeChange { delta: v * damping }),
641 "volume_set" => route
642 .params
643 .get("value")
644 .and_then(|v| v.as_f64())
645 .map(|value| Intent::VolumeSet { value }),
646 "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
647 seconds: v * damping,
648 }),
649 "brightness_change" => input
650 .continuous_value()
651 .map(|v| Intent::BrightnessChange { delta: v * damping }),
652 "color_temperature_change" => input
653 .continuous_value()
654 .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
655 _ => None,
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use crate::Direction;
663 use std::collections::BTreeMap;
664 use uuid::Uuid;
665 use weave_contracts::Route;
666
667 fn rotate_mapping() -> Mapping {
668 Mapping {
669 mapping_id: Uuid::new_v4(),
670 edge_id: "living-room".into(),
671 device_type: "nuimo".into(),
672 device_id: "C3:81:DF:4E".into(),
673 service_type: "roon".into(),
674 service_target: "zone-1".into(),
675 routes: vec![Route {
676 input: "rotate".into(),
677 intent: "volume_change".into(),
678 params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
679 }],
680 feedback: vec![],
681 active: true,
682 target_candidates: vec![],
683 target_switch_on: None,
684 }
685 }
686
687 #[tokio::test]
688 async fn rotate_produces_volume_change_with_damping() {
689 let engine = RoutingEngine::new();
690 engine.replace_all(vec![rotate_mapping()]).await;
691
692 let out = engine
693 .route(
694 "nuimo",
695 "C3:81:DF:4E",
696 &InputPrimitive::Rotate { delta: 0.03 },
697 )
698 .await;
699 assert_eq!(out.len(), 1);
700 match &out[0].intent {
701 Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
702 other => panic!("expected VolumeChange, got {:?}", other),
703 }
704 assert_eq!(out[0].service_type, "roon");
705 assert_eq!(out[0].service_target, "zone-1");
706 }
707
708 #[tokio::test]
709 async fn inactive_mappings_are_skipped() {
710 let mut m = rotate_mapping();
711 m.active = false;
712 let engine = RoutingEngine::new();
713 engine.replace_all(vec![m]).await;
714
715 let out = engine
716 .route(
717 "nuimo",
718 "C3:81:DF:4E",
719 &InputPrimitive::Rotate { delta: 0.03 },
720 )
721 .await;
722 assert!(out.is_empty());
723 }
724
725 #[tokio::test]
726 async fn unknown_device_returns_empty() {
727 let engine = RoutingEngine::new();
728 engine.replace_all(vec![rotate_mapping()]).await;
729
730 let out = engine
731 .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
732 .await;
733 assert!(out.is_empty());
734 }
735
736 fn selection_mapping() -> Mapping {
737 let mut m = rotate_mapping();
738 m.service_target = "target-A".into();
739 m.target_switch_on = Some("swipe_up".into());
740 m.target_candidates = vec![
741 TargetCandidate {
742 target: "target-A".into(),
743 label: "A".into(),
744 glyph: "glyph-a".into(),
745 service_type: None,
746 routes: None,
747 },
748 TargetCandidate {
749 target: "target-B".into(),
750 label: "B".into(),
751 glyph: "glyph-b".into(),
752 service_type: None,
753 routes: None,
754 },
755 ];
756 m
757 }
758
759 fn cross_service_mapping() -> Mapping {
763 let mut m = rotate_mapping();
764 m.service_target = "roon-zone".into();
765 m.target_switch_on = Some("long_press".into());
766 m.target_candidates = vec![
767 TargetCandidate {
768 target: "roon-zone".into(),
769 label: "Roon".into(),
770 glyph: "roon".into(),
771 service_type: None,
772 routes: None,
773 },
774 TargetCandidate {
775 target: "hue-light".into(),
776 label: "Hue".into(),
777 glyph: "hue".into(),
778 service_type: Some("hue".into()),
779 routes: Some(vec![Route {
780 input: "rotate".into(),
781 intent: "brightness_change".into(),
782 params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
783 }]),
784 },
785 ];
786 m
787 }
788
789 #[test]
790 fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
791 let m = cross_service_mapping();
792 let (svc, routes) = m.effective_for("roon-zone");
793 assert_eq!(svc, "roon");
794 assert_eq!(routes.len(), 1);
795 assert_eq!(routes[0].intent, "volume_change");
796 }
797
798 #[test]
799 fn effective_for_returns_candidate_overrides_when_present() {
800 let m = cross_service_mapping();
801 let (svc, routes) = m.effective_for("hue-light");
802 assert_eq!(svc, "hue");
803 assert_eq!(routes.len(), 1);
804 assert_eq!(routes[0].intent, "brightness_change");
805 }
806
807 #[tokio::test]
808 async fn rotate_on_cross_service_candidate_produces_hue_intent() {
809 let mut m = cross_service_mapping();
810 m.service_target = "hue-light".into();
813
814 let engine = RoutingEngine::new();
815 engine.replace_all(vec![m]).await;
816
817 let out = engine
818 .route(
819 "nuimo",
820 "C3:81:DF:4E",
821 &InputPrimitive::Rotate { delta: 0.1 },
822 )
823 .await;
824 assert_eq!(out.len(), 1);
825 assert_eq!(out[0].service_type, "hue");
826 assert_eq!(out[0].service_target, "hue-light");
827 match &out[0].intent {
828 Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
829 other => panic!("expected BrightnessChange, got {:?}", other),
830 }
831 }
832
833 #[tokio::test]
834 async fn swipe_up_press_cycles_to_next_target_without_rotate() {
835 let engine = RoutingEngine::new();
836 engine.replace_all(vec![selection_mapping()]).await;
837
838 match engine
839 .route_with_mode(
840 "nuimo",
841 "C3:81:DF:4E",
842 &InputPrimitive::Swipe {
843 direction: Direction::Up,
844 },
845 )
846 .await
847 {
848 RouteOutcome::EnterSelection { glyph, .. } => {
849 assert_eq!(glyph, "glyph-b");
851 }
852 other => panic!("expected EnterSelection, got {:?}", other),
853 }
854
855 match engine
856 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
857 .await
858 {
859 RouteOutcome::CommitSelection { service_target, .. } => {
860 assert_eq!(service_target, "target-B")
861 }
862 other => panic!("expected CommitSelection, got {:?}", other),
863 }
864 }
865
866 #[tokio::test]
867 async fn rotate_then_press_commits_advanced_candidate() {
868 let engine = RoutingEngine::new();
869 engine.replace_all(vec![selection_mapping()]).await;
870
871 let _ = engine
873 .route_with_mode(
874 "nuimo",
875 "C3:81:DF:4E",
876 &InputPrimitive::Swipe {
877 direction: Direction::Up,
878 },
879 )
880 .await;
881 match engine
883 .route_with_mode(
884 "nuimo",
885 "C3:81:DF:4E",
886 &InputPrimitive::Rotate {
887 delta: SELECTION_ROTATION_STEP,
888 },
889 )
890 .await
891 {
892 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
893 other => panic!("expected UpdateSelection, got {:?}", other),
894 }
895 match engine
896 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
897 .await
898 {
899 RouteOutcome::CommitSelection { service_target, .. } => {
900 assert_eq!(service_target, "target-A")
901 }
902 other => panic!("expected CommitSelection, got {:?}", other),
903 }
904 }
905
906 #[tokio::test]
907 async fn sub_threshold_rotate_keeps_cursor_still() {
908 let engine = RoutingEngine::new();
909 engine.replace_all(vec![selection_mapping()]).await;
910
911 let _ = engine
913 .route_with_mode(
914 "nuimo",
915 "C3:81:DF:4E",
916 &InputPrimitive::Swipe {
917 direction: Direction::Up,
918 },
919 )
920 .await;
921
922 match engine
926 .route_with_mode(
927 "nuimo",
928 "C3:81:DF:4E",
929 &InputPrimitive::Rotate {
930 delta: SELECTION_ROTATION_STEP / 2.0,
931 },
932 )
933 .await
934 {
935 RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
936 other => panic!("expected Normal(empty), got {:?}", other),
937 }
938
939 match engine
942 .route_with_mode(
943 "nuimo",
944 "C3:81:DF:4E",
945 &InputPrimitive::Rotate {
946 delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
947 },
948 )
949 .await
950 {
951 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
952 other => panic!("expected UpdateSelection, got {:?}", other),
953 }
954 }
955
956 #[tokio::test]
957 async fn large_rotate_produces_single_step_per_outcome() {
958 let engine = RoutingEngine::new();
963 engine.replace_all(vec![selection_mapping()]).await;
964
965 let _ = engine
966 .route_with_mode(
967 "nuimo",
968 "C3:81:DF:4E",
969 &InputPrimitive::Swipe {
970 direction: Direction::Up,
971 },
972 )
973 .await;
974 match engine
978 .route_with_mode(
979 "nuimo",
980 "C3:81:DF:4E",
981 &InputPrimitive::Rotate {
982 delta: SELECTION_ROTATION_STEP * 2.0,
983 },
984 )
985 .await
986 {
987 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
988 other => panic!("expected UpdateSelection, got {:?}", other),
989 }
990 }
991
992 fn playback_glyph_rule() -> FeedbackRule {
993 FeedbackRule {
994 state: "playback".into(),
995 feedback_type: "glyph".into(),
996 mapping: serde_json::json!({
997 "playing": "play",
998 "paused": "pause",
999 "stopped": "pause",
1000 }),
1001 }
1002 }
1003
1004 fn mapping_with_feedback() -> Mapping {
1005 let mut m = rotate_mapping();
1006 m.feedback = vec![playback_glyph_rule()];
1007 m
1008 }
1009
1010 #[tokio::test]
1011 async fn feedback_rules_match_primary_target() {
1012 let engine = RoutingEngine::new();
1013 engine.replace_all(vec![mapping_with_feedback()]).await;
1014
1015 let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
1016 assert_eq!(rules.len(), 1);
1017 assert_eq!(rules[0].state, "playback");
1018 }
1019
1020 #[tokio::test]
1021 async fn feedback_rules_match_candidate_override_service_type() {
1022 let mut m = mapping_with_feedback();
1023 m.target_candidates = vec![TargetCandidate {
1024 target: "hue-light-1".into(),
1025 label: "Living".into(),
1026 glyph: "link".into(),
1027 service_type: Some("hue".into()),
1028 routes: None,
1029 }];
1030 let engine = RoutingEngine::new();
1031 engine.replace_all(vec![m]).await;
1032
1033 let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
1034 assert_eq!(
1035 rules.len(),
1036 1,
1037 "candidate with overridden service_type inherits mapping feedback",
1038 );
1039 }
1040
1041 #[tokio::test]
1042 async fn feedback_rules_empty_for_unknown_target() {
1043 let engine = RoutingEngine::new();
1044 engine.replace_all(vec![mapping_with_feedback()]).await;
1045
1046 let rules = engine
1047 .feedback_rules_for_target("roon", "some-other-zone")
1048 .await;
1049 assert!(rules.is_empty());
1050 }
1051
1052 #[tokio::test]
1053 async fn feedback_rules_for_device_target_scopes_by_device() {
1054 let mut m_a = mapping_with_feedback();
1057 m_a.device_id = "nuimo-a".into();
1058 let mut m_b = mapping_with_feedback();
1059 m_b.mapping_id = Uuid::new_v4();
1060 m_b.device_id = "nuimo-b".into();
1061 m_b.service_target = "zone-b".into();
1062
1063 let engine = RoutingEngine::new();
1064 engine.replace_all(vec![m_a, m_b]).await;
1065
1066 let rules_a = engine
1067 .feedback_rules_for_device_target("nuimo", "nuimo-a", "roon", "zone-1")
1068 .await;
1069 let rules_a = rules_a.expect("nuimo-a owns zone-1");
1070 assert_eq!(rules_a.len(), 1);
1071
1072 let rules_b_wrong = engine
1073 .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-1")
1074 .await;
1075 assert!(
1076 rules_b_wrong.is_none(),
1077 "nuimo-b does not own zone-1 — must skip entirely",
1078 );
1079
1080 let rules_b_right = engine
1081 .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-b")
1082 .await;
1083 let rules_b_right = rules_b_right.expect("nuimo-b owns zone-b");
1084 assert_eq!(rules_b_right.len(), 1);
1085 }
1086
1087 #[tokio::test]
1088 async fn feedback_rules_for_device_target_none_for_unknown_device() {
1089 let engine = RoutingEngine::new();
1090 engine.replace_all(vec![mapping_with_feedback()]).await;
1091
1092 let rules = engine
1093 .feedback_rules_for_device_target("nuimo", "unknown-device", "roon", "zone-1")
1094 .await;
1095 assert!(
1096 rules.is_none(),
1097 "no mapping for unknown device — caller should skip",
1098 );
1099 }
1100
1101 #[tokio::test]
1102 async fn feedback_rules_for_device_target_some_empty_for_mapping_without_rules() {
1103 let m = rotate_mapping(); let engine = RoutingEngine::new();
1108 engine.replace_all(vec![m]).await;
1109
1110 let rules = engine
1111 .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
1112 .await;
1113 let rules = rules.expect("mapping exists — caller should fall back to defaults");
1114 assert!(
1115 rules.is_empty(),
1116 "no explicit rules configured — pump will use FeedbackPlan defaults",
1117 );
1118 }
1119}