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            if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
207                return rules;
208            }
209        }
210        Vec::new()
211    }
212
213    /// Same as `feedback_rules_for_target` but scoped to one `(device_type,
214    /// device_id)` bucket. Use this from per-device feedback pumps so a
215    /// Nuimo only reacts when the state change belongs to a mapping it
216    /// owns — otherwise a second Nuimo mapped elsewhere would flash glyphs
217    /// for unrelated service activity.
218    ///
219    /// Returns `None` when no mapping on this device covers
220    /// `(service_type, target)` — the caller should skip. Returns
221    /// `Some(vec)` when a mapping exists; the vec may be empty, which
222    /// signals "mapping exists but user configured no rules — fall back
223    /// to hardcoded defaults" (preserves single-device behavior).
224    pub async fn feedback_rules_for_device_target(
225        &self,
226        device_type: &str,
227        device_id: &str,
228        service_type: &str,
229        target: &str,
230    ) -> Option<Vec<FeedbackRule>> {
231        let guard = self.by_device.read().await;
232        let list = guard.get(&(device_type.to_string(), device_id.to_string()))?;
233        mapping_rules_for_target(list, service_type, target)
234    }
235
236    /// Find every device whose mapping owns `(service_type, target)` and
237    /// return its `(device_type, device_id, feedback_rules)`. Used by the
238    /// iOS feedback pump, which fans a single state update out to every
239    /// LED that should display it — typically 1 device, but two Nuimos
240    /// paired to the same iPad both deserve feedback.
241    pub async fn feedback_targets_for(
242        &self,
243        service_type: &str,
244        target: &str,
245    ) -> Vec<(String, String, Vec<FeedbackRule>)> {
246        let guard = self.by_device.read().await;
247        let mut out = Vec::new();
248        for ((device_type, device_id), list) in guard.iter() {
249            if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
250                out.push((device_type.clone(), device_id.clone(), rules));
251            }
252        }
253        out
254    }
255
256    /// Apply the given input primitive from a specific device, returning every
257    /// intent it produces across all matching mappings.
258    pub async fn route(
259        &self,
260        device_type: &str,
261        device_id: &str,
262        input: &InputPrimitive,
263    ) -> Vec<RoutedIntent> {
264        let guard = self.by_device.read().await;
265        let Some(mappings) = guard.get(&(device_type.to_string(), device_id.to_string())) else {
266            return Vec::new();
267        };
268        route_mappings(mappings, input)
269    }
270
271    /// Same dispatch as `route`, but also implements the target-selection
272    /// state machine (`Mapping.target_switch_on` + `target_candidates`).
273    /// Callers that want on-device target switching should use this
274    /// instead of `route`.
275    pub async fn route_with_mode(
276        &self,
277        device_type: &str,
278        device_id: &str,
279        input: &InputPrimitive,
280    ) -> RouteOutcome {
281        let key = (device_type.to_string(), device_id.to_string());
282
283        // Already in selection mode for this device: intercept rotate /
284        // press / cancel before dispatching to normal routing.
285        {
286            let mut sel = self.selection.write().await;
287            if let Some(mode) = sel.get_mut(&key) {
288                match input {
289                    InputPrimitive::Rotate { delta } => {
290                        // Sub-threshold rotations accumulate without
291                        // emitting UpdateSelection — the LED keeps the
292                        // current candidate's glyph and no log line
293                        // fires until the cursor actually moves.
294                        if !mode.advance(*delta) {
295                            return RouteOutcome::Normal(Vec::new());
296                        }
297                        let glyph = mode.current().glyph.clone();
298                        let mapping_id = mode.mapping_id;
299                        return RouteOutcome::UpdateSelection { mapping_id, glyph };
300                    }
301                    InputPrimitive::Press => {
302                        let mapping_id = mode.mapping_id;
303                        let service_target = mode.current().target.clone();
304                        let edge_id = mode.edge_id.clone();
305                        sel.remove(&key);
306                        return RouteOutcome::CommitSelection {
307                            edge_id,
308                            mapping_id,
309                            service_target,
310                        };
311                    }
312                    _ => {
313                        let mapping_id = mode.mapping_id;
314                        sel.remove(&key);
315                        return RouteOutcome::CancelSelection { mapping_id };
316                    }
317                }
318            }
319        }
320
321        // Not in mode yet: check whether this input should enter mode on
322        // any mapping for this device, else fall through to normal routing.
323        let guard = self.by_device.read().await;
324        let Some(mappings) = guard.get(&key) else {
325            return RouteOutcome::Normal(Vec::new());
326        };
327
328        for m in mappings {
329            if !m.active {
330                continue;
331            }
332            let Some(switch_on) = m.target_switch_on.as_deref() else {
333                continue;
334            };
335            if m.target_candidates.is_empty() {
336                continue;
337            }
338            if !input.matches_route(switch_on) {
339                continue;
340            }
341            // Enter mode with cursor one step AFTER the current
342            // service_target — so swipe_up→press (no rotate) cycles to
343            // the next candidate. Rotate still browses from there.
344            let current_idx = m
345                .target_candidates
346                .iter()
347                .position(|c| c.target == m.service_target);
348            let cursor = match current_idx {
349                Some(i) => (i + 1) % m.target_candidates.len(),
350                None => 0,
351            };
352            let mode = SelectionMode::new(
353                m.mapping_id,
354                m.edge_id.clone(),
355                m.target_candidates.clone(),
356                cursor,
357            );
358            let glyph = mode.current().glyph.clone();
359            let mapping_id = mode.mapping_id;
360            let edge_id = mode.edge_id.clone();
361            drop(guard);
362            self.selection.write().await.insert(key, mode);
363            return RouteOutcome::EnterSelection {
364                edge_id,
365                mapping_id,
366                glyph,
367            };
368        }
369
370        RouteOutcome::Normal(route_mappings(mappings, input))
371    }
372}
373
374/// Look for a mapping in `list` whose primary `(service_type, target)` or
375/// any `target_candidates` entry matches the given pair. Returns the
376/// mapping's `feedback` clone on hit. `None` means no mapping covers the
377/// target, letting the caller fall back to defaults.
378fn mapping_rules_for_target(
379    list: &[Mapping],
380    service_type: &str,
381    target: &str,
382) -> Option<Vec<FeedbackRule>> {
383    for m in list {
384        if m.service_type == service_type && m.service_target == target {
385            return Some(m.feedback.clone());
386        }
387        for c in &m.target_candidates {
388            let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
389            if c_service_type == service_type && c.target == target {
390                return Some(m.feedback.clone());
391            }
392        }
393    }
394    None
395}
396
397fn route_mappings(mappings: &[Mapping], input: &InputPrimitive) -> Vec<RoutedIntent> {
398    let mut out = Vec::new();
399    for m in mappings {
400        if !m.active {
401            continue;
402        }
403        // Resolve the effective service_type + routes for the currently
404        // active target. When the active target is a cross-service
405        // candidate (e.g. Roon-flavoured mapping pointing at a Hue light),
406        // `effective_for` returns the candidate's overrides so the
407        // dispatcher sees the right adapter + intent set.
408        let (service_type, routes) = m.effective_for(&m.service_target);
409        for route in routes {
410            if !input.matches_route(&route.input) {
411                continue;
412            }
413            if let Some(intent) = build_intent(route, input) {
414                out.push(RoutedIntent {
415                    service_type: service_type.to_string(),
416                    service_target: m.service_target.clone(),
417                    intent,
418                });
419                // Only first matching route per mapping fires.
420                break;
421            }
422        }
423    }
424    out
425}
426
427fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
428    let damping = route
429        .params
430        .get("damping")
431        .and_then(|v| v.as_f64())
432        .unwrap_or(1.0);
433
434    match route.intent.as_str() {
435        "play" => Some(Intent::Play),
436        "pause" => Some(Intent::Pause),
437        "play_pause" | "playpause" => Some(Intent::PlayPause),
438        "stop" => Some(Intent::Stop),
439        "next" => Some(Intent::Next),
440        "previous" => Some(Intent::Previous),
441        "mute" => Some(Intent::Mute),
442        "unmute" => Some(Intent::Unmute),
443        "power_toggle" => Some(Intent::PowerToggle),
444        "power_on" => Some(Intent::PowerOn),
445        "power_off" => Some(Intent::PowerOff),
446        "volume_change" => input
447            .continuous_value()
448            .map(|v| Intent::VolumeChange { delta: v * damping }),
449        "volume_set" => route
450            .params
451            .get("value")
452            .and_then(|v| v.as_f64())
453            .map(|value| Intent::VolumeSet { value }),
454        "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
455            seconds: v * damping,
456        }),
457        "brightness_change" => input
458            .continuous_value()
459            .map(|v| Intent::BrightnessChange { delta: v * damping }),
460        "color_temperature_change" => input
461            .continuous_value()
462            .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
463        _ => None,
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::Direction;
471    use std::collections::BTreeMap;
472    use uuid::Uuid;
473    use weave_contracts::Route;
474
475    fn rotate_mapping() -> Mapping {
476        Mapping {
477            mapping_id: Uuid::new_v4(),
478            edge_id: "living-room".into(),
479            device_type: "nuimo".into(),
480            device_id: "C3:81:DF:4E".into(),
481            service_type: "roon".into(),
482            service_target: "zone-1".into(),
483            routes: vec![Route {
484                input: "rotate".into(),
485                intent: "volume_change".into(),
486                params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
487            }],
488            feedback: vec![],
489            active: true,
490            target_candidates: vec![],
491            target_switch_on: None,
492        }
493    }
494
495    #[tokio::test]
496    async fn rotate_produces_volume_change_with_damping() {
497        let engine = RoutingEngine::new();
498        engine.replace_all(vec![rotate_mapping()]).await;
499
500        let out = engine
501            .route(
502                "nuimo",
503                "C3:81:DF:4E",
504                &InputPrimitive::Rotate { delta: 0.03 },
505            )
506            .await;
507        assert_eq!(out.len(), 1);
508        match &out[0].intent {
509            Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
510            other => panic!("expected VolumeChange, got {:?}", other),
511        }
512        assert_eq!(out[0].service_type, "roon");
513        assert_eq!(out[0].service_target, "zone-1");
514    }
515
516    #[tokio::test]
517    async fn inactive_mappings_are_skipped() {
518        let mut m = rotate_mapping();
519        m.active = false;
520        let engine = RoutingEngine::new();
521        engine.replace_all(vec![m]).await;
522
523        let out = engine
524            .route(
525                "nuimo",
526                "C3:81:DF:4E",
527                &InputPrimitive::Rotate { delta: 0.03 },
528            )
529            .await;
530        assert!(out.is_empty());
531    }
532
533    #[tokio::test]
534    async fn unknown_device_returns_empty() {
535        let engine = RoutingEngine::new();
536        engine.replace_all(vec![rotate_mapping()]).await;
537
538        let out = engine
539            .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
540            .await;
541        assert!(out.is_empty());
542    }
543
544    fn selection_mapping() -> Mapping {
545        let mut m = rotate_mapping();
546        m.service_target = "target-A".into();
547        m.target_switch_on = Some("swipe_up".into());
548        m.target_candidates = vec![
549            TargetCandidate {
550                target: "target-A".into(),
551                label: "A".into(),
552                glyph: "glyph-a".into(),
553                service_type: None,
554                routes: None,
555            },
556            TargetCandidate {
557                target: "target-B".into(),
558                label: "B".into(),
559                glyph: "glyph-b".into(),
560                service_type: None,
561                routes: None,
562            },
563        ];
564        m
565    }
566
567    /// A mapping whose primary target is a Roon zone and whose secondary
568    /// candidate is a Hue light with its own route table. Used to exercise
569    /// the `effective_for` cross-service override path.
570    fn cross_service_mapping() -> Mapping {
571        let mut m = rotate_mapping();
572        m.service_target = "roon-zone".into();
573        m.target_switch_on = Some("long_press".into());
574        m.target_candidates = vec![
575            TargetCandidate {
576                target: "roon-zone".into(),
577                label: "Roon".into(),
578                glyph: "roon".into(),
579                service_type: None,
580                routes: None,
581            },
582            TargetCandidate {
583                target: "hue-light".into(),
584                label: "Hue".into(),
585                glyph: "hue".into(),
586                service_type: Some("hue".into()),
587                routes: Some(vec![Route {
588                    input: "rotate".into(),
589                    intent: "brightness_change".into(),
590                    params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
591                }]),
592            },
593        ];
594        m
595    }
596
597    #[test]
598    fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
599        let m = cross_service_mapping();
600        let (svc, routes) = m.effective_for("roon-zone");
601        assert_eq!(svc, "roon");
602        assert_eq!(routes.len(), 1);
603        assert_eq!(routes[0].intent, "volume_change");
604    }
605
606    #[test]
607    fn effective_for_returns_candidate_overrides_when_present() {
608        let m = cross_service_mapping();
609        let (svc, routes) = m.effective_for("hue-light");
610        assert_eq!(svc, "hue");
611        assert_eq!(routes.len(), 1);
612        assert_eq!(routes[0].intent, "brightness_change");
613    }
614
615    #[tokio::test]
616    async fn rotate_on_cross_service_candidate_produces_hue_intent() {
617        let mut m = cross_service_mapping();
618        // Simulate the user having switched the active target to the Hue
619        // candidate (as if selection mode had committed on it).
620        m.service_target = "hue-light".into();
621
622        let engine = RoutingEngine::new();
623        engine.replace_all(vec![m]).await;
624
625        let out = engine
626            .route(
627                "nuimo",
628                "C3:81:DF:4E",
629                &InputPrimitive::Rotate { delta: 0.1 },
630            )
631            .await;
632        assert_eq!(out.len(), 1);
633        assert_eq!(out[0].service_type, "hue");
634        assert_eq!(out[0].service_target, "hue-light");
635        match &out[0].intent {
636            Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
637            other => panic!("expected BrightnessChange, got {:?}", other),
638        }
639    }
640
641    #[tokio::test]
642    async fn swipe_up_press_cycles_to_next_target_without_rotate() {
643        let engine = RoutingEngine::new();
644        engine.replace_all(vec![selection_mapping()]).await;
645
646        match engine
647            .route_with_mode(
648                "nuimo",
649                "C3:81:DF:4E",
650                &InputPrimitive::Swipe {
651                    direction: Direction::Up,
652                },
653            )
654            .await
655        {
656            RouteOutcome::EnterSelection { glyph, .. } => {
657                // Entering mode from target-A should point at target-B.
658                assert_eq!(glyph, "glyph-b");
659            }
660            other => panic!("expected EnterSelection, got {:?}", other),
661        }
662
663        match engine
664            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
665            .await
666        {
667            RouteOutcome::CommitSelection { service_target, .. } => {
668                assert_eq!(service_target, "target-B")
669            }
670            other => panic!("expected CommitSelection, got {:?}", other),
671        }
672    }
673
674    #[tokio::test]
675    async fn rotate_then_press_commits_advanced_candidate() {
676        let engine = RoutingEngine::new();
677        engine.replace_all(vec![selection_mapping()]).await;
678
679        // Enter: cursor jumps to target-B (position 1).
680        let _ = engine
681            .route_with_mode(
682                "nuimo",
683                "C3:81:DF:4E",
684                &InputPrimitive::Swipe {
685                    direction: Direction::Up,
686                },
687            )
688            .await;
689        // Rotate forward past the damping threshold — wraps from B back to A.
690        match engine
691            .route_with_mode(
692                "nuimo",
693                "C3:81:DF:4E",
694                &InputPrimitive::Rotate {
695                    delta: SELECTION_ROTATION_STEP,
696                },
697            )
698            .await
699        {
700            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
701            other => panic!("expected UpdateSelection, got {:?}", other),
702        }
703        match engine
704            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
705            .await
706        {
707            RouteOutcome::CommitSelection { service_target, .. } => {
708                assert_eq!(service_target, "target-A")
709            }
710            other => panic!("expected CommitSelection, got {:?}", other),
711        }
712    }
713
714    #[tokio::test]
715    async fn sub_threshold_rotate_keeps_cursor_still() {
716        let engine = RoutingEngine::new();
717        engine.replace_all(vec![selection_mapping()]).await;
718
719        // Enter selection at target-B.
720        let _ = engine
721            .route_with_mode(
722                "nuimo",
723                "C3:81:DF:4E",
724                &InputPrimitive::Swipe {
725                    direction: Direction::Up,
726                },
727            )
728            .await;
729
730        // A single small rotation accumulates but does not cross the
731        // threshold. Caller sees an empty Normal outcome — no LED push,
732        // no state change.
733        match engine
734            .route_with_mode(
735                "nuimo",
736                "C3:81:DF:4E",
737                &InputPrimitive::Rotate {
738                    delta: SELECTION_ROTATION_STEP / 2.0,
739                },
740            )
741            .await
742        {
743            RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
744            other => panic!("expected Normal(empty), got {:?}", other),
745        }
746
747        // A second small rotation pushes the accumulator over the
748        // threshold — cursor moves.
749        match engine
750            .route_with_mode(
751                "nuimo",
752                "C3:81:DF:4E",
753                &InputPrimitive::Rotate {
754                    delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
755                },
756            )
757            .await
758        {
759            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
760            other => panic!("expected UpdateSelection, got {:?}", other),
761        }
762    }
763
764    #[tokio::test]
765    async fn large_rotate_produces_single_step_per_outcome() {
766        // advance() advances until the accumulator is below the threshold,
767        // but route_with_mode yields a single RouteOutcome per input event.
768        // Verify a 2.4-step delta lands on a cursor position consistent
769        // with two advances (not one, not four).
770        let engine = RoutingEngine::new();
771        engine.replace_all(vec![selection_mapping()]).await;
772
773        let _ = engine
774            .route_with_mode(
775                "nuimo",
776                "C3:81:DF:4E",
777                &InputPrimitive::Swipe {
778                    direction: Direction::Up,
779                },
780            )
781            .await;
782        // Starting cursor is at B (index 1). 2 steps forward in a 2-entry
783        // list wraps back to B. Use a delta that advances exactly twice
784        // to land deterministically.
785        match engine
786            .route_with_mode(
787                "nuimo",
788                "C3:81:DF:4E",
789                &InputPrimitive::Rotate {
790                    delta: SELECTION_ROTATION_STEP * 2.0,
791                },
792            )
793            .await
794        {
795            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
796            other => panic!("expected UpdateSelection, got {:?}", other),
797        }
798    }
799
800    fn playback_glyph_rule() -> FeedbackRule {
801        FeedbackRule {
802            state: "playback".into(),
803            feedback_type: "glyph".into(),
804            mapping: serde_json::json!({
805                "playing": "play",
806                "paused": "pause",
807                "stopped": "pause",
808            }),
809        }
810    }
811
812    fn mapping_with_feedback() -> Mapping {
813        let mut m = rotate_mapping();
814        m.feedback = vec![playback_glyph_rule()];
815        m
816    }
817
818    #[tokio::test]
819    async fn feedback_rules_match_primary_target() {
820        let engine = RoutingEngine::new();
821        engine.replace_all(vec![mapping_with_feedback()]).await;
822
823        let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
824        assert_eq!(rules.len(), 1);
825        assert_eq!(rules[0].state, "playback");
826    }
827
828    #[tokio::test]
829    async fn feedback_rules_match_candidate_override_service_type() {
830        let mut m = mapping_with_feedback();
831        m.target_candidates = vec![TargetCandidate {
832            target: "hue-light-1".into(),
833            label: "Living".into(),
834            glyph: "link".into(),
835            service_type: Some("hue".into()),
836            routes: None,
837        }];
838        let engine = RoutingEngine::new();
839        engine.replace_all(vec![m]).await;
840
841        let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
842        assert_eq!(
843            rules.len(),
844            1,
845            "candidate with overridden service_type inherits mapping feedback",
846        );
847    }
848
849    #[tokio::test]
850    async fn feedback_rules_empty_for_unknown_target() {
851        let engine = RoutingEngine::new();
852        engine.replace_all(vec![mapping_with_feedback()]).await;
853
854        let rules = engine
855            .feedback_rules_for_target("roon", "some-other-zone")
856            .await;
857        assert!(rules.is_empty());
858    }
859
860    #[tokio::test]
861    async fn feedback_rules_for_device_target_scopes_by_device() {
862        // Two mappings for the same Roon zone, but on different Nuimos.
863        // Only the device that owns the mapping should receive feedback.
864        let mut m_a = mapping_with_feedback();
865        m_a.device_id = "nuimo-a".into();
866        let mut m_b = mapping_with_feedback();
867        m_b.mapping_id = Uuid::new_v4();
868        m_b.device_id = "nuimo-b".into();
869        m_b.service_target = "zone-b".into();
870
871        let engine = RoutingEngine::new();
872        engine.replace_all(vec![m_a, m_b]).await;
873
874        let rules_a = engine
875            .feedback_rules_for_device_target("nuimo", "nuimo-a", "roon", "zone-1")
876            .await;
877        let rules_a = rules_a.expect("nuimo-a owns zone-1");
878        assert_eq!(rules_a.len(), 1);
879
880        let rules_b_wrong = engine
881            .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-1")
882            .await;
883        assert!(
884            rules_b_wrong.is_none(),
885            "nuimo-b does not own zone-1 — must skip entirely",
886        );
887
888        let rules_b_right = engine
889            .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-b")
890            .await;
891        let rules_b_right = rules_b_right.expect("nuimo-b owns zone-b");
892        assert_eq!(rules_b_right.len(), 1);
893    }
894
895    #[tokio::test]
896    async fn feedback_rules_for_device_target_none_for_unknown_device() {
897        let engine = RoutingEngine::new();
898        engine.replace_all(vec![mapping_with_feedback()]).await;
899
900        let rules = engine
901            .feedback_rules_for_device_target("nuimo", "unknown-device", "roon", "zone-1")
902            .await;
903        assert!(
904            rules.is_none(),
905            "no mapping for unknown device — caller should skip",
906        );
907    }
908
909    #[tokio::test]
910    async fn feedback_rules_for_device_target_some_empty_for_mapping_without_rules() {
911        // A mapping exists for the device + target, but `feedback` is empty.
912        // Result must be `Some(vec![])` so the caller falls back to
913        // hardcoded defaults (preserves single-device behavior).
914        let m = rotate_mapping(); // no feedback rules
915        let engine = RoutingEngine::new();
916        engine.replace_all(vec![m]).await;
917
918        let rules = engine
919            .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
920            .await;
921        let rules = rules.expect("mapping exists — caller should fall back to defaults");
922        assert!(
923            rules.is_empty(),
924            "no explicit rules configured — pump will use FeedbackPlan defaults",
925        );
926    }
927}