Skip to main content

edge_core/
routing.rs

1//! Routing engine: applies a device input primitive against the active
2//! mappings to produce zero or more `(service_type, target, intent)` triples.
3
4use std::collections::HashMap;
5
6use tokio::sync::RwLock;
7use uuid::Uuid;
8use weave_contracts::{FeedbackRule, Mapping, Route, TargetCandidate};
9
10use super::intent::{InputPrimitive, Intent};
11
12/// A concrete intent destined for a specific service target.
13#[derive(Debug, Clone)]
14pub struct RoutedIntent {
15    pub service_type: String,
16    pub service_target: String,
17    pub intent: Intent,
18}
19
20/// Per-(device_type, device_id) transient state used by target-selection
21/// mode. Keyed off the mapping that declared `target_switch_on`. While
22/// this struct is in `RoutingEngine::selection`, the device has entered
23/// mode: `Rotate` browses `cursor`, `Press` commits, any other input
24/// cancels.
25#[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    /// Accumulated rotation delta since the last cursor step. Nuimo emits
32    /// rotation events at the characteristic update rate — each encoder
33    /// tick is ~1/2650 of a full turn — so a "one tick = one candidate"
34    /// policy skips through the whole list on a light nudge. The
35    /// accumulator turns that into "one candidate per `SELECTION_ROTATION_STEP`
36    /// radians-fraction of travel" so the selection ramp is legible at
37    /// hardware rotation speeds.
38    rotation_accumulator: f64,
39}
40
41/// Fraction of a full Nuimo turn required to advance the selection
42/// cursor by one candidate. `1.0` = one full 360° turn; `0.25` = a
43/// quarter turn. Tuned experimentally: for the typical 2-4 candidate
44/// case this gives roughly one candidate per thumb-flick, versus the
45/// pre-damping behaviour where a finger slip cycled through every
46/// candidate in the list.
47pub 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    /// Add `delta` to the rotation accumulator and advance the cursor by
70    /// one candidate for each full `SELECTION_ROTATION_STEP` worth of
71    /// accumulated travel. Returns `true` if the cursor moved — callers
72    /// use this to suppress redundant LED redraws for sub-threshold
73    /// rotations.
74    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/// One outcome of routing a single input primitive. `Normal` is the
96/// existing "route to zero or more intents" path; the other variants are
97/// target-selection side effects that the caller must translate into LED
98/// feedback or a server-bound `SwitchTarget` frame.
99#[derive(Debug, Clone)]
100pub enum RouteOutcome {
101    Normal(Vec<RoutedIntent>),
102    /// Device just entered selection mode; show `glyph` on the LED.
103    EnterSelection {
104        edge_id: String,
105        mapping_id: Uuid,
106        glyph: String,
107    },
108    /// Device still in selection mode, cursor moved; show `glyph`.
109    UpdateSelection {
110        mapping_id: Uuid,
111        glyph: String,
112    },
113    /// Device committed the selection; caller sends
114    /// `EdgeToServer::SwitchTarget` and optionally clears the LED.
115    CommitSelection {
116        edge_id: String,
117        mapping_id: Uuid,
118        service_target: String,
119    },
120    /// Device exited selection mode without committing (non-rotate/press
121    /// input). Caller should clear any lingering LED feedback.
122    CancelSelection {
123        mapping_id: Uuid,
124    },
125}
126
127/// Thread-safe registry of currently-active mappings, keyed by
128/// `(device_type, device_id)` for O(1) lookup per input event.
129#[derive(Default)]
130pub struct RoutingEngine {
131    by_device: RwLock<HashMap<(String, String), Vec<Mapping>>>,
132    selection: RwLock<HashMap<(String, String), SelectionMode>>,
133}
134
135impl RoutingEngine {
136    pub fn new() -> Self {
137        Self::default()
138    }
139
140    /// Replace all mappings with the provided set. Used on `config_full` push.
141    pub async fn replace_all(&self, mappings: Vec<Mapping>) {
142        let mut by_device: HashMap<(String, String), Vec<Mapping>> = HashMap::new();
143        for m in mappings {
144            by_device
145                .entry((m.device_type.clone(), m.device_id.clone()))
146                .or_default()
147                .push(m);
148        }
149        *self.by_device.write().await = by_device;
150    }
151
152    /// Insert or replace one mapping, keyed by `mapping_id`. Used on
153    /// `config_patch` upsert.
154    pub async fn upsert_mapping(&self, mapping: Mapping) {
155        let mut guard = self.by_device.write().await;
156        // Remove any prior entry with the same mapping_id, regardless of
157        // device key (handles device reassignment).
158        for list in guard.values_mut() {
159            list.retain(|m| m.mapping_id != mapping.mapping_id);
160        }
161        guard
162            .entry((mapping.device_type.clone(), mapping.device_id.clone()))
163            .or_default()
164            .push(mapping);
165    }
166
167    /// Remove any mapping with the given `mapping_id`. Used on
168    /// `config_patch` delete.
169    pub async fn remove_mapping(&self, id: &uuid::Uuid) {
170        let mut guard = self.by_device.write().await;
171        for list in guard.values_mut() {
172            list.retain(|m| &m.mapping_id != id);
173        }
174        // Prune empty device buckets so route() stays tight.
175        guard.retain(|_, list| !list.is_empty());
176    }
177
178    /// Snapshot every mapping currently held, grouped or not. Used to
179    /// persist the local cache after an incremental patch.
180    pub async fn snapshot(&self) -> Vec<Mapping> {
181        self.by_device
182            .read()
183            .await
184            .values()
185            .flatten()
186            .cloned()
187            .collect()
188    }
189
190    /// Feedback rules attached to whichever mapping covers the given
191    /// `(service_type, target)` — either as its primary target or as a
192    /// listed candidate (including candidates that override the mapping's
193    /// `service_type`). Returns an empty vec when no mapping covers the
194    /// target, letting the caller fall back to hardcoded defaults.
195    ///
196    /// Feedback is stored at the mapping level (all candidates share one
197    /// rule set), so a candidate match still returns the mapping's
198    /// `feedback`.
199    pub async fn feedback_rules_for_target(
200        &self,
201        service_type: &str,
202        target: &str,
203    ) -> Vec<FeedbackRule> {
204        let guard = self.by_device.read().await;
205        for list in guard.values() {
206            for m in list {
207                if m.service_type == service_type && m.service_target == target {
208                    return m.feedback.clone();
209                }
210                for c in &m.target_candidates {
211                    let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
212                    if c_service_type == service_type && c.target == target {
213                        return m.feedback.clone();
214                    }
215                }
216            }
217        }
218        Vec::new()
219    }
220
221    /// Apply the given input primitive from a specific device, returning every
222    /// intent it produces across all matching mappings.
223    pub async fn route(
224        &self,
225        device_type: &str,
226        device_id: &str,
227        input: &InputPrimitive,
228    ) -> Vec<RoutedIntent> {
229        let guard = self.by_device.read().await;
230        let Some(mappings) = guard.get(&(device_type.to_string(), device_id.to_string())) else {
231            return Vec::new();
232        };
233        route_mappings(mappings, input)
234    }
235
236    /// Same dispatch as `route`, but also implements the target-selection
237    /// state machine (`Mapping.target_switch_on` + `target_candidates`).
238    /// Callers that want on-device target switching should use this
239    /// instead of `route`.
240    pub async fn route_with_mode(
241        &self,
242        device_type: &str,
243        device_id: &str,
244        input: &InputPrimitive,
245    ) -> RouteOutcome {
246        let key = (device_type.to_string(), device_id.to_string());
247
248        // Already in selection mode for this device: intercept rotate /
249        // press / cancel before dispatching to normal routing.
250        {
251            let mut sel = self.selection.write().await;
252            if let Some(mode) = sel.get_mut(&key) {
253                match input {
254                    InputPrimitive::Rotate { delta } => {
255                        // Sub-threshold rotations accumulate without
256                        // emitting UpdateSelection — the LED keeps the
257                        // current candidate's glyph and no log line
258                        // fires until the cursor actually moves.
259                        if !mode.advance(*delta) {
260                            return RouteOutcome::Normal(Vec::new());
261                        }
262                        let glyph = mode.current().glyph.clone();
263                        let mapping_id = mode.mapping_id;
264                        return RouteOutcome::UpdateSelection { mapping_id, glyph };
265                    }
266                    InputPrimitive::Press => {
267                        let mapping_id = mode.mapping_id;
268                        let service_target = mode.current().target.clone();
269                        let edge_id = mode.edge_id.clone();
270                        sel.remove(&key);
271                        return RouteOutcome::CommitSelection {
272                            edge_id,
273                            mapping_id,
274                            service_target,
275                        };
276                    }
277                    _ => {
278                        let mapping_id = mode.mapping_id;
279                        sel.remove(&key);
280                        return RouteOutcome::CancelSelection { mapping_id };
281                    }
282                }
283            }
284        }
285
286        // Not in mode yet: check whether this input should enter mode on
287        // any mapping for this device, else fall through to normal routing.
288        let guard = self.by_device.read().await;
289        let Some(mappings) = guard.get(&key) else {
290            return RouteOutcome::Normal(Vec::new());
291        };
292
293        for m in mappings {
294            if !m.active {
295                continue;
296            }
297            let Some(switch_on) = m.target_switch_on.as_deref() else {
298                continue;
299            };
300            if m.target_candidates.is_empty() {
301                continue;
302            }
303            if !input.matches_route(switch_on) {
304                continue;
305            }
306            // Enter mode with cursor one step AFTER the current
307            // service_target — so swipe_up→press (no rotate) cycles to
308            // the next candidate. Rotate still browses from there.
309            let current_idx = m
310                .target_candidates
311                .iter()
312                .position(|c| c.target == m.service_target);
313            let cursor = match current_idx {
314                Some(i) => (i + 1) % m.target_candidates.len(),
315                None => 0,
316            };
317            let mode = SelectionMode::new(
318                m.mapping_id,
319                m.edge_id.clone(),
320                m.target_candidates.clone(),
321                cursor,
322            );
323            let glyph = mode.current().glyph.clone();
324            let mapping_id = mode.mapping_id;
325            let edge_id = mode.edge_id.clone();
326            drop(guard);
327            self.selection.write().await.insert(key, mode);
328            return RouteOutcome::EnterSelection {
329                edge_id,
330                mapping_id,
331                glyph,
332            };
333        }
334
335        RouteOutcome::Normal(route_mappings(mappings, input))
336    }
337}
338
339fn route_mappings(mappings: &[Mapping], input: &InputPrimitive) -> Vec<RoutedIntent> {
340    let mut out = Vec::new();
341    for m in mappings {
342        if !m.active {
343            continue;
344        }
345        // Resolve the effective service_type + routes for the currently
346        // active target. When the active target is a cross-service
347        // candidate (e.g. Roon-flavoured mapping pointing at a Hue light),
348        // `effective_for` returns the candidate's overrides so the
349        // dispatcher sees the right adapter + intent set.
350        let (service_type, routes) = m.effective_for(&m.service_target);
351        for route in routes {
352            if !input.matches_route(&route.input) {
353                continue;
354            }
355            if let Some(intent) = build_intent(route, input) {
356                out.push(RoutedIntent {
357                    service_type: service_type.to_string(),
358                    service_target: m.service_target.clone(),
359                    intent,
360                });
361                // Only first matching route per mapping fires.
362                break;
363            }
364        }
365    }
366    out
367}
368
369fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
370    let damping = route
371        .params
372        .get("damping")
373        .and_then(|v| v.as_f64())
374        .unwrap_or(1.0);
375
376    match route.intent.as_str() {
377        "play" => Some(Intent::Play),
378        "pause" => Some(Intent::Pause),
379        "play_pause" | "playpause" => Some(Intent::PlayPause),
380        "stop" => Some(Intent::Stop),
381        "next" => Some(Intent::Next),
382        "previous" => Some(Intent::Previous),
383        "mute" => Some(Intent::Mute),
384        "unmute" => Some(Intent::Unmute),
385        "power_toggle" => Some(Intent::PowerToggle),
386        "power_on" => Some(Intent::PowerOn),
387        "power_off" => Some(Intent::PowerOff),
388        "volume_change" => input
389            .continuous_value()
390            .map(|v| Intent::VolumeChange { delta: v * damping }),
391        "volume_set" => route
392            .params
393            .get("value")
394            .and_then(|v| v.as_f64())
395            .map(|value| Intent::VolumeSet { value }),
396        "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
397            seconds: v * damping,
398        }),
399        "brightness_change" => input
400            .continuous_value()
401            .map(|v| Intent::BrightnessChange { delta: v * damping }),
402        "color_temperature_change" => input
403            .continuous_value()
404            .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
405        _ => None,
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::Direction;
413    use std::collections::BTreeMap;
414    use uuid::Uuid;
415    use weave_contracts::Route;
416
417    fn rotate_mapping() -> Mapping {
418        Mapping {
419            mapping_id: Uuid::new_v4(),
420            edge_id: "living-room".into(),
421            device_type: "nuimo".into(),
422            device_id: "C3:81:DF:4E".into(),
423            service_type: "roon".into(),
424            service_target: "zone-1".into(),
425            routes: vec![Route {
426                input: "rotate".into(),
427                intent: "volume_change".into(),
428                params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
429            }],
430            feedback: vec![],
431            active: true,
432            target_candidates: vec![],
433            target_switch_on: None,
434        }
435    }
436
437    #[tokio::test]
438    async fn rotate_produces_volume_change_with_damping() {
439        let engine = RoutingEngine::new();
440        engine.replace_all(vec![rotate_mapping()]).await;
441
442        let out = engine
443            .route(
444                "nuimo",
445                "C3:81:DF:4E",
446                &InputPrimitive::Rotate { delta: 0.03 },
447            )
448            .await;
449        assert_eq!(out.len(), 1);
450        match &out[0].intent {
451            Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
452            other => panic!("expected VolumeChange, got {:?}", other),
453        }
454        assert_eq!(out[0].service_type, "roon");
455        assert_eq!(out[0].service_target, "zone-1");
456    }
457
458    #[tokio::test]
459    async fn inactive_mappings_are_skipped() {
460        let mut m = rotate_mapping();
461        m.active = false;
462        let engine = RoutingEngine::new();
463        engine.replace_all(vec![m]).await;
464
465        let out = engine
466            .route(
467                "nuimo",
468                "C3:81:DF:4E",
469                &InputPrimitive::Rotate { delta: 0.03 },
470            )
471            .await;
472        assert!(out.is_empty());
473    }
474
475    #[tokio::test]
476    async fn unknown_device_returns_empty() {
477        let engine = RoutingEngine::new();
478        engine.replace_all(vec![rotate_mapping()]).await;
479
480        let out = engine
481            .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
482            .await;
483        assert!(out.is_empty());
484    }
485
486    fn selection_mapping() -> Mapping {
487        let mut m = rotate_mapping();
488        m.service_target = "target-A".into();
489        m.target_switch_on = Some("swipe_up".into());
490        m.target_candidates = vec![
491            TargetCandidate {
492                target: "target-A".into(),
493                label: "A".into(),
494                glyph: "glyph-a".into(),
495                service_type: None,
496                routes: None,
497            },
498            TargetCandidate {
499                target: "target-B".into(),
500                label: "B".into(),
501                glyph: "glyph-b".into(),
502                service_type: None,
503                routes: None,
504            },
505        ];
506        m
507    }
508
509    /// A mapping whose primary target is a Roon zone and whose secondary
510    /// candidate is a Hue light with its own route table. Used to exercise
511    /// the `effective_for` cross-service override path.
512    fn cross_service_mapping() -> Mapping {
513        let mut m = rotate_mapping();
514        m.service_target = "roon-zone".into();
515        m.target_switch_on = Some("long_press".into());
516        m.target_candidates = vec![
517            TargetCandidate {
518                target: "roon-zone".into(),
519                label: "Roon".into(),
520                glyph: "roon".into(),
521                service_type: None,
522                routes: None,
523            },
524            TargetCandidate {
525                target: "hue-light".into(),
526                label: "Hue".into(),
527                glyph: "hue".into(),
528                service_type: Some("hue".into()),
529                routes: Some(vec![Route {
530                    input: "rotate".into(),
531                    intent: "brightness_change".into(),
532                    params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
533                }]),
534            },
535        ];
536        m
537    }
538
539    #[test]
540    fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
541        let m = cross_service_mapping();
542        let (svc, routes) = m.effective_for("roon-zone");
543        assert_eq!(svc, "roon");
544        assert_eq!(routes.len(), 1);
545        assert_eq!(routes[0].intent, "volume_change");
546    }
547
548    #[test]
549    fn effective_for_returns_candidate_overrides_when_present() {
550        let m = cross_service_mapping();
551        let (svc, routes) = m.effective_for("hue-light");
552        assert_eq!(svc, "hue");
553        assert_eq!(routes.len(), 1);
554        assert_eq!(routes[0].intent, "brightness_change");
555    }
556
557    #[tokio::test]
558    async fn rotate_on_cross_service_candidate_produces_hue_intent() {
559        let mut m = cross_service_mapping();
560        // Simulate the user having switched the active target to the Hue
561        // candidate (as if selection mode had committed on it).
562        m.service_target = "hue-light".into();
563
564        let engine = RoutingEngine::new();
565        engine.replace_all(vec![m]).await;
566
567        let out = engine
568            .route(
569                "nuimo",
570                "C3:81:DF:4E",
571                &InputPrimitive::Rotate { delta: 0.1 },
572            )
573            .await;
574        assert_eq!(out.len(), 1);
575        assert_eq!(out[0].service_type, "hue");
576        assert_eq!(out[0].service_target, "hue-light");
577        match &out[0].intent {
578            Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
579            other => panic!("expected BrightnessChange, got {:?}", other),
580        }
581    }
582
583    #[tokio::test]
584    async fn swipe_up_press_cycles_to_next_target_without_rotate() {
585        let engine = RoutingEngine::new();
586        engine.replace_all(vec![selection_mapping()]).await;
587
588        match engine
589            .route_with_mode(
590                "nuimo",
591                "C3:81:DF:4E",
592                &InputPrimitive::Swipe {
593                    direction: Direction::Up,
594                },
595            )
596            .await
597        {
598            RouteOutcome::EnterSelection { glyph, .. } => {
599                // Entering mode from target-A should point at target-B.
600                assert_eq!(glyph, "glyph-b");
601            }
602            other => panic!("expected EnterSelection, got {:?}", other),
603        }
604
605        match engine
606            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
607            .await
608        {
609            RouteOutcome::CommitSelection { service_target, .. } => {
610                assert_eq!(service_target, "target-B")
611            }
612            other => panic!("expected CommitSelection, got {:?}", other),
613        }
614    }
615
616    #[tokio::test]
617    async fn rotate_then_press_commits_advanced_candidate() {
618        let engine = RoutingEngine::new();
619        engine.replace_all(vec![selection_mapping()]).await;
620
621        // Enter: cursor jumps to target-B (position 1).
622        let _ = engine
623            .route_with_mode(
624                "nuimo",
625                "C3:81:DF:4E",
626                &InputPrimitive::Swipe {
627                    direction: Direction::Up,
628                },
629            )
630            .await;
631        // Rotate forward past the damping threshold — wraps from B back to A.
632        match engine
633            .route_with_mode(
634                "nuimo",
635                "C3:81:DF:4E",
636                &InputPrimitive::Rotate {
637                    delta: SELECTION_ROTATION_STEP,
638                },
639            )
640            .await
641        {
642            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
643            other => panic!("expected UpdateSelection, got {:?}", other),
644        }
645        match engine
646            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
647            .await
648        {
649            RouteOutcome::CommitSelection { service_target, .. } => {
650                assert_eq!(service_target, "target-A")
651            }
652            other => panic!("expected CommitSelection, got {:?}", other),
653        }
654    }
655
656    #[tokio::test]
657    async fn sub_threshold_rotate_keeps_cursor_still() {
658        let engine = RoutingEngine::new();
659        engine.replace_all(vec![selection_mapping()]).await;
660
661        // Enter selection at target-B.
662        let _ = engine
663            .route_with_mode(
664                "nuimo",
665                "C3:81:DF:4E",
666                &InputPrimitive::Swipe {
667                    direction: Direction::Up,
668                },
669            )
670            .await;
671
672        // A single small rotation accumulates but does not cross the
673        // threshold. Caller sees an empty Normal outcome — no LED push,
674        // no state change.
675        match engine
676            .route_with_mode(
677                "nuimo",
678                "C3:81:DF:4E",
679                &InputPrimitive::Rotate {
680                    delta: SELECTION_ROTATION_STEP / 2.0,
681                },
682            )
683            .await
684        {
685            RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
686            other => panic!("expected Normal(empty), got {:?}", other),
687        }
688
689        // A second small rotation pushes the accumulator over the
690        // threshold — cursor moves.
691        match engine
692            .route_with_mode(
693                "nuimo",
694                "C3:81:DF:4E",
695                &InputPrimitive::Rotate {
696                    delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
697                },
698            )
699            .await
700        {
701            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
702            other => panic!("expected UpdateSelection, got {:?}", other),
703        }
704    }
705
706    #[tokio::test]
707    async fn large_rotate_produces_single_step_per_outcome() {
708        // advance() advances until the accumulator is below the threshold,
709        // but route_with_mode yields a single RouteOutcome per input event.
710        // Verify a 2.4-step delta lands on a cursor position consistent
711        // with two advances (not one, not four).
712        let engine = RoutingEngine::new();
713        engine.replace_all(vec![selection_mapping()]).await;
714
715        let _ = engine
716            .route_with_mode(
717                "nuimo",
718                "C3:81:DF:4E",
719                &InputPrimitive::Swipe {
720                    direction: Direction::Up,
721                },
722            )
723            .await;
724        // Starting cursor is at B (index 1). 2 steps forward in a 2-entry
725        // list wraps back to B. Use a delta that advances exactly twice
726        // to land deterministically.
727        match engine
728            .route_with_mode(
729                "nuimo",
730                "C3:81:DF:4E",
731                &InputPrimitive::Rotate {
732                    delta: SELECTION_ROTATION_STEP * 2.0,
733                },
734            )
735            .await
736        {
737            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
738            other => panic!("expected UpdateSelection, got {:?}", other),
739        }
740    }
741
742    fn playback_glyph_rule() -> FeedbackRule {
743        FeedbackRule {
744            state: "playback".into(),
745            feedback_type: "glyph".into(),
746            mapping: serde_json::json!({
747                "playing": "play",
748                "paused": "pause",
749                "stopped": "pause",
750            }),
751        }
752    }
753
754    fn mapping_with_feedback() -> Mapping {
755        let mut m = rotate_mapping();
756        m.feedback = vec![playback_glyph_rule()];
757        m
758    }
759
760    #[tokio::test]
761    async fn feedback_rules_match_primary_target() {
762        let engine = RoutingEngine::new();
763        engine.replace_all(vec![mapping_with_feedback()]).await;
764
765        let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
766        assert_eq!(rules.len(), 1);
767        assert_eq!(rules[0].state, "playback");
768    }
769
770    #[tokio::test]
771    async fn feedback_rules_match_candidate_override_service_type() {
772        let mut m = mapping_with_feedback();
773        m.target_candidates = vec![TargetCandidate {
774            target: "hue-light-1".into(),
775            label: "Living".into(),
776            glyph: "link".into(),
777            service_type: Some("hue".into()),
778            routes: None,
779        }];
780        let engine = RoutingEngine::new();
781        engine.replace_all(vec![m]).await;
782
783        let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
784        assert_eq!(
785            rules.len(),
786            1,
787            "candidate with overridden service_type inherits mapping feedback",
788        );
789    }
790
791    #[tokio::test]
792    async fn feedback_rules_empty_for_unknown_target() {
793        let engine = RoutingEngine::new();
794        engine.replace_all(vec![mapping_with_feedback()]).await;
795
796        let rules = engine
797            .feedback_rules_for_target("roon", "some-other-zone")
798            .await;
799        assert!(rules.is_empty());
800    }
801}