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 cycle_for(&self, device_type: &str, device_id: &str) -> Option<DeviceCycle> {
255 let key = (device_type.to_string(), device_id.to_string());
256 self.cycles.read().await.get(&key).cloned()
257 }
258
259 pub async fn feedback_rules_for_target(
269 &self,
270 service_type: &str,
271 target: &str,
272 ) -> Vec<FeedbackRule> {
273 let guard = self.by_device.read().await;
274 for list in guard.values() {
275 if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
276 return rules;
277 }
278 }
279 Vec::new()
280 }
281
282 pub async fn feedback_rules_for_device_target(
294 &self,
295 device_type: &str,
296 device_id: &str,
297 service_type: &str,
298 target: &str,
299 ) -> Option<Vec<FeedbackRule>> {
300 let guard = self.by_device.read().await;
301 let list = guard.get(&(device_type.to_string(), device_id.to_string()))?;
302 mapping_rules_for_target(list, service_type, target)
303 }
304
305 pub async fn feedback_targets_for(
311 &self,
312 service_type: &str,
313 target: &str,
314 ) -> Vec<(String, String, Vec<FeedbackRule>)> {
315 let guard = self.by_device.read().await;
316 let mut out = Vec::new();
317 for ((device_type, device_id), list) in guard.iter() {
318 if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
319 out.push((device_type.clone(), device_id.clone(), rules));
320 }
321 }
322 out
323 }
324
325 pub async fn route(
334 &self,
335 device_type: &str,
336 device_id: &str,
337 input: &InputPrimitive,
338 ) -> Vec<RoutedIntent> {
339 let key = (device_type.to_string(), device_id.to_string());
340 let cycle = self.cycles.read().await.get(&key).cloned();
341 let guard = self.by_device.read().await;
342 let Some(mappings) = guard.get(&key) else {
343 return Vec::new();
344 };
345 let active_filter = active_filter_from_cycle(cycle.as_ref());
346 route_mappings(mappings, input, active_filter)
347 }
348
349 pub async fn route_with_mode(
357 &self,
358 device_type: &str,
359 device_id: &str,
360 input: &InputPrimitive,
361 ) -> RouteOutcome {
362 let key = (device_type.to_string(), device_id.to_string());
363
364 {
369 let cycles = self.cycles.read().await;
370 if let Some(cycle) = cycles.get(&key).cloned() {
371 drop(cycles);
372 if let Some(gesture) = cycle.cycle_gesture.as_deref() {
373 if input.matches_route(gesture) {
374 let next = next_active(&cycle);
377 if let Some(next_id) = next {
378 self.cycles
381 .write()
382 .await
383 .entry(key.clone())
384 .and_modify(|c| {
385 c.active_mapping_id = Some(next_id);
386 });
387 return RouteOutcome::CycleSwitch {
388 device_type: device_type.to_string(),
389 device_id: device_id.to_string(),
390 active_mapping_id: next_id,
391 };
392 }
393 }
394 }
395 let active_filter = active_filter_from_cycle(Some(&cycle));
400 let guard = self.by_device.read().await;
401 let Some(mappings) = guard.get(&key) else {
402 return RouteOutcome::Normal(Vec::new());
403 };
404 return RouteOutcome::Normal(route_mappings(mappings, input, active_filter));
405 }
406 }
407
408 {
411 let mut sel = self.selection.write().await;
412 if let Some(mode) = sel.get_mut(&key) {
413 match input {
414 InputPrimitive::Rotate { delta } => {
415 if !mode.advance(*delta) {
420 return RouteOutcome::Normal(Vec::new());
421 }
422 let glyph = mode.current().glyph.clone();
423 let mapping_id = mode.mapping_id;
424 return RouteOutcome::UpdateSelection { mapping_id, glyph };
425 }
426 InputPrimitive::Press => {
427 let mapping_id = mode.mapping_id;
428 let service_target = mode.current().target.clone();
429 let edge_id = mode.edge_id.clone();
430 sel.remove(&key);
431 return RouteOutcome::CommitSelection {
432 edge_id,
433 mapping_id,
434 service_target,
435 };
436 }
437 _ => {
438 let mapping_id = mode.mapping_id;
439 sel.remove(&key);
440 return RouteOutcome::CancelSelection { mapping_id };
441 }
442 }
443 }
444 }
445
446 let guard = self.by_device.read().await;
449 let Some(mappings) = guard.get(&key) else {
450 return RouteOutcome::Normal(Vec::new());
451 };
452
453 for m in mappings {
454 if !m.active {
455 continue;
456 }
457 let Some(switch_on) = m.target_switch_on.as_deref() else {
458 continue;
459 };
460 if m.target_candidates.is_empty() {
461 continue;
462 }
463 if !input.matches_route(switch_on) {
464 continue;
465 }
466 let current_idx = m
470 .target_candidates
471 .iter()
472 .position(|c| c.target == m.service_target);
473 let cursor = match current_idx {
474 Some(i) => (i + 1) % m.target_candidates.len(),
475 None => 0,
476 };
477 let mode = SelectionMode::new(
478 m.mapping_id,
479 m.edge_id.clone(),
480 m.target_candidates.clone(),
481 cursor,
482 );
483 let glyph = mode.current().glyph.clone();
484 let mapping_id = mode.mapping_id;
485 let edge_id = mode.edge_id.clone();
486 drop(guard);
487 self.selection.write().await.insert(key, mode);
488 return RouteOutcome::EnterSelection {
489 edge_id,
490 mapping_id,
491 glyph,
492 };
493 }
494
495 RouteOutcome::Normal(route_mappings(mappings, input, None))
496 }
497}
498
499fn next_active(cycle: &DeviceCycle) -> Option<Uuid> {
504 if cycle.mapping_ids.is_empty() {
505 return None;
506 }
507 let active = cycle.active_mapping_id?;
508 let pos = cycle.mapping_ids.iter().position(|id| *id == active)?;
509 let next = (pos + 1) % cycle.mapping_ids.len();
510 Some(cycle.mapping_ids[next])
511}
512
513fn active_filter_from_cycle(cycle: Option<&DeviceCycle>) -> Option<Uuid> {
517 cycle.and_then(|c| c.active_mapping_id)
518}
519
520fn mapping_rules_for_target(
525 list: &[Mapping],
526 service_type: &str,
527 target: &str,
528) -> Option<Vec<FeedbackRule>> {
529 for m in list {
530 if m.service_type == service_type && m.service_target == target {
531 return Some(m.feedback.clone());
532 }
533 for c in &m.target_candidates {
534 let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
535 if c_service_type == service_type && c.target == target {
536 return Some(m.feedback.clone());
537 }
538 }
539 }
540 None
541}
542
543fn route_mappings(
544 mappings: &[Mapping],
545 input: &InputPrimitive,
546 active_filter: Option<Uuid>,
547) -> Vec<RoutedIntent> {
548 let mut out = Vec::new();
549 for m in mappings {
550 if !m.active {
551 continue;
552 }
553 if let Some(active_id) = active_filter {
558 if m.mapping_id != active_id {
559 continue;
560 }
561 }
562 let (service_type, routes) = m.effective_for(&m.service_target);
568 for route in routes {
569 if !input.matches_route(&route.input) {
570 continue;
571 }
572 if let Some(intent) = build_intent(route, input) {
573 out.push(RoutedIntent {
574 service_type: service_type.to_string(),
575 service_target: m.service_target.clone(),
576 intent,
577 });
578 break;
580 }
581 }
582 }
583 out
584}
585
586fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
587 let damping = route
588 .params
589 .get("damping")
590 .and_then(|v| v.as_f64())
591 .unwrap_or(1.0);
592
593 match route.intent.as_str() {
594 "play" => Some(Intent::Play),
595 "pause" => Some(Intent::Pause),
596 "play_pause" | "playpause" => Some(Intent::PlayPause),
597 "stop" => Some(Intent::Stop),
598 "next" => Some(Intent::Next),
599 "previous" => Some(Intent::Previous),
600 "mute" => Some(Intent::Mute),
601 "unmute" => Some(Intent::Unmute),
602 "power_toggle" => Some(Intent::PowerToggle),
603 "power_on" => Some(Intent::PowerOn),
604 "power_off" => Some(Intent::PowerOff),
605 "volume_change" => input
606 .continuous_value()
607 .map(|v| Intent::VolumeChange { delta: v * damping }),
608 "volume_set" => route
609 .params
610 .get("value")
611 .and_then(|v| v.as_f64())
612 .map(|value| Intent::VolumeSet { value }),
613 "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
614 seconds: v * damping,
615 }),
616 "brightness_change" => input
617 .continuous_value()
618 .map(|v| Intent::BrightnessChange { delta: v * damping }),
619 "color_temperature_change" => input
620 .continuous_value()
621 .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
622 _ => None,
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use crate::Direction;
630 use std::collections::BTreeMap;
631 use uuid::Uuid;
632 use weave_contracts::Route;
633
634 fn rotate_mapping() -> Mapping {
635 Mapping {
636 mapping_id: Uuid::new_v4(),
637 edge_id: "living-room".into(),
638 device_type: "nuimo".into(),
639 device_id: "C3:81:DF:4E".into(),
640 service_type: "roon".into(),
641 service_target: "zone-1".into(),
642 routes: vec![Route {
643 input: "rotate".into(),
644 intent: "volume_change".into(),
645 params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
646 }],
647 feedback: vec![],
648 active: true,
649 target_candidates: vec![],
650 target_switch_on: None,
651 }
652 }
653
654 #[tokio::test]
655 async fn rotate_produces_volume_change_with_damping() {
656 let engine = RoutingEngine::new();
657 engine.replace_all(vec![rotate_mapping()]).await;
658
659 let out = engine
660 .route(
661 "nuimo",
662 "C3:81:DF:4E",
663 &InputPrimitive::Rotate { delta: 0.03 },
664 )
665 .await;
666 assert_eq!(out.len(), 1);
667 match &out[0].intent {
668 Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
669 other => panic!("expected VolumeChange, got {:?}", other),
670 }
671 assert_eq!(out[0].service_type, "roon");
672 assert_eq!(out[0].service_target, "zone-1");
673 }
674
675 #[tokio::test]
676 async fn inactive_mappings_are_skipped() {
677 let mut m = rotate_mapping();
678 m.active = false;
679 let engine = RoutingEngine::new();
680 engine.replace_all(vec![m]).await;
681
682 let out = engine
683 .route(
684 "nuimo",
685 "C3:81:DF:4E",
686 &InputPrimitive::Rotate { delta: 0.03 },
687 )
688 .await;
689 assert!(out.is_empty());
690 }
691
692 #[tokio::test]
693 async fn unknown_device_returns_empty() {
694 let engine = RoutingEngine::new();
695 engine.replace_all(vec![rotate_mapping()]).await;
696
697 let out = engine
698 .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
699 .await;
700 assert!(out.is_empty());
701 }
702
703 fn selection_mapping() -> Mapping {
704 let mut m = rotate_mapping();
705 m.service_target = "target-A".into();
706 m.target_switch_on = Some("swipe_up".into());
707 m.target_candidates = vec![
708 TargetCandidate {
709 target: "target-A".into(),
710 label: "A".into(),
711 glyph: "glyph-a".into(),
712 service_type: None,
713 routes: None,
714 },
715 TargetCandidate {
716 target: "target-B".into(),
717 label: "B".into(),
718 glyph: "glyph-b".into(),
719 service_type: None,
720 routes: None,
721 },
722 ];
723 m
724 }
725
726 fn cross_service_mapping() -> Mapping {
730 let mut m = rotate_mapping();
731 m.service_target = "roon-zone".into();
732 m.target_switch_on = Some("long_press".into());
733 m.target_candidates = vec![
734 TargetCandidate {
735 target: "roon-zone".into(),
736 label: "Roon".into(),
737 glyph: "roon".into(),
738 service_type: None,
739 routes: None,
740 },
741 TargetCandidate {
742 target: "hue-light".into(),
743 label: "Hue".into(),
744 glyph: "hue".into(),
745 service_type: Some("hue".into()),
746 routes: Some(vec![Route {
747 input: "rotate".into(),
748 intent: "brightness_change".into(),
749 params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
750 }]),
751 },
752 ];
753 m
754 }
755
756 #[test]
757 fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
758 let m = cross_service_mapping();
759 let (svc, routes) = m.effective_for("roon-zone");
760 assert_eq!(svc, "roon");
761 assert_eq!(routes.len(), 1);
762 assert_eq!(routes[0].intent, "volume_change");
763 }
764
765 #[test]
766 fn effective_for_returns_candidate_overrides_when_present() {
767 let m = cross_service_mapping();
768 let (svc, routes) = m.effective_for("hue-light");
769 assert_eq!(svc, "hue");
770 assert_eq!(routes.len(), 1);
771 assert_eq!(routes[0].intent, "brightness_change");
772 }
773
774 #[tokio::test]
775 async fn rotate_on_cross_service_candidate_produces_hue_intent() {
776 let mut m = cross_service_mapping();
777 m.service_target = "hue-light".into();
780
781 let engine = RoutingEngine::new();
782 engine.replace_all(vec![m]).await;
783
784 let out = engine
785 .route(
786 "nuimo",
787 "C3:81:DF:4E",
788 &InputPrimitive::Rotate { delta: 0.1 },
789 )
790 .await;
791 assert_eq!(out.len(), 1);
792 assert_eq!(out[0].service_type, "hue");
793 assert_eq!(out[0].service_target, "hue-light");
794 match &out[0].intent {
795 Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
796 other => panic!("expected BrightnessChange, got {:?}", other),
797 }
798 }
799
800 #[tokio::test]
801 async fn swipe_up_press_cycles_to_next_target_without_rotate() {
802 let engine = RoutingEngine::new();
803 engine.replace_all(vec![selection_mapping()]).await;
804
805 match engine
806 .route_with_mode(
807 "nuimo",
808 "C3:81:DF:4E",
809 &InputPrimitive::Swipe {
810 direction: Direction::Up,
811 },
812 )
813 .await
814 {
815 RouteOutcome::EnterSelection { glyph, .. } => {
816 assert_eq!(glyph, "glyph-b");
818 }
819 other => panic!("expected EnterSelection, got {:?}", other),
820 }
821
822 match engine
823 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
824 .await
825 {
826 RouteOutcome::CommitSelection { service_target, .. } => {
827 assert_eq!(service_target, "target-B")
828 }
829 other => panic!("expected CommitSelection, got {:?}", other),
830 }
831 }
832
833 #[tokio::test]
834 async fn rotate_then_press_commits_advanced_candidate() {
835 let engine = RoutingEngine::new();
836 engine.replace_all(vec![selection_mapping()]).await;
837
838 let _ = engine
840 .route_with_mode(
841 "nuimo",
842 "C3:81:DF:4E",
843 &InputPrimitive::Swipe {
844 direction: Direction::Up,
845 },
846 )
847 .await;
848 match engine
850 .route_with_mode(
851 "nuimo",
852 "C3:81:DF:4E",
853 &InputPrimitive::Rotate {
854 delta: SELECTION_ROTATION_STEP,
855 },
856 )
857 .await
858 {
859 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
860 other => panic!("expected UpdateSelection, got {:?}", other),
861 }
862 match engine
863 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
864 .await
865 {
866 RouteOutcome::CommitSelection { service_target, .. } => {
867 assert_eq!(service_target, "target-A")
868 }
869 other => panic!("expected CommitSelection, got {:?}", other),
870 }
871 }
872
873 #[tokio::test]
874 async fn sub_threshold_rotate_keeps_cursor_still() {
875 let engine = RoutingEngine::new();
876 engine.replace_all(vec![selection_mapping()]).await;
877
878 let _ = engine
880 .route_with_mode(
881 "nuimo",
882 "C3:81:DF:4E",
883 &InputPrimitive::Swipe {
884 direction: Direction::Up,
885 },
886 )
887 .await;
888
889 match engine
893 .route_with_mode(
894 "nuimo",
895 "C3:81:DF:4E",
896 &InputPrimitive::Rotate {
897 delta: SELECTION_ROTATION_STEP / 2.0,
898 },
899 )
900 .await
901 {
902 RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
903 other => panic!("expected Normal(empty), got {:?}", other),
904 }
905
906 match engine
909 .route_with_mode(
910 "nuimo",
911 "C3:81:DF:4E",
912 &InputPrimitive::Rotate {
913 delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
914 },
915 )
916 .await
917 {
918 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
919 other => panic!("expected UpdateSelection, got {:?}", other),
920 }
921 }
922
923 #[tokio::test]
924 async fn large_rotate_produces_single_step_per_outcome() {
925 let engine = RoutingEngine::new();
930 engine.replace_all(vec![selection_mapping()]).await;
931
932 let _ = engine
933 .route_with_mode(
934 "nuimo",
935 "C3:81:DF:4E",
936 &InputPrimitive::Swipe {
937 direction: Direction::Up,
938 },
939 )
940 .await;
941 match engine
945 .route_with_mode(
946 "nuimo",
947 "C3:81:DF:4E",
948 &InputPrimitive::Rotate {
949 delta: SELECTION_ROTATION_STEP * 2.0,
950 },
951 )
952 .await
953 {
954 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
955 other => panic!("expected UpdateSelection, got {:?}", other),
956 }
957 }
958
959 fn playback_glyph_rule() -> FeedbackRule {
960 FeedbackRule {
961 state: "playback".into(),
962 feedback_type: "glyph".into(),
963 mapping: serde_json::json!({
964 "playing": "play",
965 "paused": "pause",
966 "stopped": "pause",
967 }),
968 }
969 }
970
971 fn mapping_with_feedback() -> Mapping {
972 let mut m = rotate_mapping();
973 m.feedback = vec![playback_glyph_rule()];
974 m
975 }
976
977 #[tokio::test]
978 async fn feedback_rules_match_primary_target() {
979 let engine = RoutingEngine::new();
980 engine.replace_all(vec![mapping_with_feedback()]).await;
981
982 let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
983 assert_eq!(rules.len(), 1);
984 assert_eq!(rules[0].state, "playback");
985 }
986
987 #[tokio::test]
988 async fn feedback_rules_match_candidate_override_service_type() {
989 let mut m = mapping_with_feedback();
990 m.target_candidates = vec![TargetCandidate {
991 target: "hue-light-1".into(),
992 label: "Living".into(),
993 glyph: "link".into(),
994 service_type: Some("hue".into()),
995 routes: None,
996 }];
997 let engine = RoutingEngine::new();
998 engine.replace_all(vec![m]).await;
999
1000 let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
1001 assert_eq!(
1002 rules.len(),
1003 1,
1004 "candidate with overridden service_type inherits mapping feedback",
1005 );
1006 }
1007
1008 #[tokio::test]
1009 async fn feedback_rules_empty_for_unknown_target() {
1010 let engine = RoutingEngine::new();
1011 engine.replace_all(vec![mapping_with_feedback()]).await;
1012
1013 let rules = engine
1014 .feedback_rules_for_target("roon", "some-other-zone")
1015 .await;
1016 assert!(rules.is_empty());
1017 }
1018
1019 #[tokio::test]
1020 async fn feedback_rules_for_device_target_scopes_by_device() {
1021 let mut m_a = mapping_with_feedback();
1024 m_a.device_id = "nuimo-a".into();
1025 let mut m_b = mapping_with_feedback();
1026 m_b.mapping_id = Uuid::new_v4();
1027 m_b.device_id = "nuimo-b".into();
1028 m_b.service_target = "zone-b".into();
1029
1030 let engine = RoutingEngine::new();
1031 engine.replace_all(vec![m_a, m_b]).await;
1032
1033 let rules_a = engine
1034 .feedback_rules_for_device_target("nuimo", "nuimo-a", "roon", "zone-1")
1035 .await;
1036 let rules_a = rules_a.expect("nuimo-a owns zone-1");
1037 assert_eq!(rules_a.len(), 1);
1038
1039 let rules_b_wrong = engine
1040 .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-1")
1041 .await;
1042 assert!(
1043 rules_b_wrong.is_none(),
1044 "nuimo-b does not own zone-1 — must skip entirely",
1045 );
1046
1047 let rules_b_right = engine
1048 .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-b")
1049 .await;
1050 let rules_b_right = rules_b_right.expect("nuimo-b owns zone-b");
1051 assert_eq!(rules_b_right.len(), 1);
1052 }
1053
1054 #[tokio::test]
1055 async fn feedback_rules_for_device_target_none_for_unknown_device() {
1056 let engine = RoutingEngine::new();
1057 engine.replace_all(vec![mapping_with_feedback()]).await;
1058
1059 let rules = engine
1060 .feedback_rules_for_device_target("nuimo", "unknown-device", "roon", "zone-1")
1061 .await;
1062 assert!(
1063 rules.is_none(),
1064 "no mapping for unknown device — caller should skip",
1065 );
1066 }
1067
1068 #[tokio::test]
1069 async fn feedback_rules_for_device_target_some_empty_for_mapping_without_rules() {
1070 let m = rotate_mapping(); let engine = RoutingEngine::new();
1075 engine.replace_all(vec![m]).await;
1076
1077 let rules = engine
1078 .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
1079 .await;
1080 let rules = rules.expect("mapping exists — caller should fall back to defaults");
1081 assert!(
1082 rules.is_empty(),
1083 "no explicit rules configured — pump will use FeedbackPlan defaults",
1084 );
1085 }
1086}