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