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::{DeviceCycle, 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    /// Cycle gesture matched on a device with a DeviceCycle. Caller updates
126    /// active locally (the engine has already done so) and sends
127    /// `EdgeToServer::SwitchActiveConnection` so the server can persist
128    /// and broadcast to peer edges + the web UI.
129    CycleSwitch {
130        device_type: String,
131        device_id: String,
132        active_mapping_id: Uuid,
133    },
134}
135
136/// Thread-safe registry of currently-active mappings, keyed by
137/// `(device_type, device_id)` for O(1) lookup per input event.
138#[derive(Default)]
139pub struct RoutingEngine {
140    by_device: RwLock<HashMap<(String, String), Vec<Mapping>>>,
141    selection: RwLock<HashMap<(String, String), SelectionMode>>,
142    /// Device-level cycles. When present for `(device_type, device_id)`,
143    /// `route()` filters mappings to the cycle's `active_mapping_id`
144    /// (other cycle members + non-cycle mappings on that device sit
145    /// dormant), and inputs matching `cycle_gesture` short-circuit to a
146    /// `CycleSwitch` outcome that advances active locally and asks the
147    /// caller to inform the server.
148    cycles: RwLock<HashMap<(String, String), DeviceCycle>>,
149}
150
151impl RoutingEngine {
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    /// Replace all mappings with the provided set. Used on `config_full` push.
157    pub async fn replace_all(&self, mappings: Vec<Mapping>) {
158        let mut by_device: HashMap<(String, String), Vec<Mapping>> = HashMap::new();
159        for m in mappings {
160            by_device
161                .entry((m.device_type.clone(), m.device_id.clone()))
162                .or_default()
163                .push(m);
164        }
165        *self.by_device.write().await = by_device;
166    }
167
168    /// Insert or replace one mapping, keyed by `mapping_id`. Used on
169    /// `config_patch` upsert.
170    pub async fn upsert_mapping(&self, mapping: Mapping) {
171        let mut guard = self.by_device.write().await;
172        // Remove any prior entry with the same mapping_id, regardless of
173        // device key (handles device reassignment).
174        for list in guard.values_mut() {
175            list.retain(|m| m.mapping_id != mapping.mapping_id);
176        }
177        guard
178            .entry((mapping.device_type.clone(), mapping.device_id.clone()))
179            .or_default()
180            .push(mapping);
181    }
182
183    /// Remove any mapping with the given `mapping_id`. Used on
184    /// `config_patch` delete.
185    pub async fn remove_mapping(&self, id: &uuid::Uuid) {
186        let mut guard = self.by_device.write().await;
187        for list in guard.values_mut() {
188            list.retain(|m| &m.mapping_id != id);
189        }
190        // Prune empty device buckets so route() stays tight.
191        guard.retain(|_, list| !list.is_empty());
192    }
193
194    /// Snapshot every mapping currently held, grouped or not. Used to
195    /// persist the local cache after an incremental patch.
196    pub async fn snapshot(&self) -> Vec<Mapping> {
197        self.by_device
198            .read()
199            .await
200            .values()
201            .flatten()
202            .cloned()
203            .collect()
204    }
205
206    /// Replace all device cycles. Used on `config_full` push.
207    pub async fn replace_cycles(&self, cycles: Vec<DeviceCycle>) {
208        let mut map: HashMap<(String, String), DeviceCycle> = HashMap::new();
209        for c in cycles {
210            map.insert((c.device_type.clone(), c.device_id.clone()), c);
211        }
212        *self.cycles.write().await = map;
213    }
214
215    /// Insert or replace a device cycle. Used on `device_cycle_patch` upsert.
216    pub async fn upsert_cycle(&self, cycle: DeviceCycle) {
217        let key = (cycle.device_type.clone(), cycle.device_id.clone());
218        self.cycles.write().await.insert(key, cycle);
219    }
220
221    /// Remove a device's cycle. Used on `device_cycle_patch` delete.
222    pub async fn remove_cycle(&self, device_type: &str, device_id: &str) -> bool {
223        let key = (device_type.to_string(), device_id.to_string());
224        self.cycles.write().await.remove(&key).is_some()
225    }
226
227    /// Update only the active mapping ID for an existing cycle. Returns
228    /// false if no cycle exists or `active_mapping_id` is not in the
229    /// cycle's `mapping_ids` list.
230    pub async fn set_cycle_active(
231        &self,
232        device_type: &str,
233        device_id: &str,
234        active_mapping_id: Uuid,
235    ) -> bool {
236        let key = (device_type.to_string(), device_id.to_string());
237        let mut cycles = self.cycles.write().await;
238        let Some(cycle) = cycles.get_mut(&key) else {
239            return false;
240        };
241        if !cycle.mapping_ids.contains(&active_mapping_id) {
242            return false;
243        }
244        cycle.active_mapping_id = Some(active_mapping_id);
245        true
246    }
247
248    /// Snapshot every cycle. Used to persist the local cache.
249    pub async fn cycles_snapshot(&self) -> Vec<DeviceCycle> {
250        self.cycles.read().await.values().cloned().collect()
251    }
252
253    /// If `input` matches the cycle gesture for the device's `DeviceCycle`,
254    /// advance `active_mapping_id` to the next entry locally and return
255    /// the new active id. Otherwise return `None` and leave state untouched.
256    ///
257    /// Callers (Nuimo / Tap Dial event loops on every platform) should
258    /// invoke this BEFORE `route()`. When it returns `Some(id)`, skip the
259    /// normal routing dispatch and instead emit
260    /// `EdgeToServer::SwitchActiveConnection { ..., active_mapping_id: id }`
261    /// so the server can persist + broadcast. This keeps cycle-gesture
262    /// detection at the engine layer (already correct) while leaving
263    /// callers free to use either `route()` or `route_with_mode()` for
264    /// non-gesture inputs.
265    pub async fn try_cycle_switch(
266        &self,
267        device_type: &str,
268        device_id: &str,
269        input: &InputPrimitive,
270    ) -> Option<Uuid> {
271        let key = (device_type.to_string(), device_id.to_string());
272        let cycle = self.cycles.read().await.get(&key).cloned()?;
273        let gesture = cycle.cycle_gesture.as_deref()?;
274        if !input.matches_route(gesture) {
275            return None;
276        }
277        let next = next_active(&cycle)?;
278        self.cycles
279            .write()
280            .await
281            .entry(key)
282            .and_modify(|c| c.active_mapping_id = Some(next));
283        Some(next)
284    }
285
286    /// Fetch a cycle by device key, if any.
287    pub async fn cycle_for(&self, device_type: &str, device_id: &str) -> Option<DeviceCycle> {
288        let key = (device_type.to_string(), device_id.to_string());
289        self.cycles.read().await.get(&key).cloned()
290    }
291
292    /// Feedback rules attached to whichever mapping covers the given
293    /// `(service_type, target)` — either as its primary target or as a
294    /// listed candidate (including candidates that override the mapping's
295    /// `service_type`). Returns an empty vec when no mapping covers the
296    /// target, letting the caller fall back to hardcoded defaults.
297    ///
298    /// Feedback is stored at the mapping level (all candidates share one
299    /// rule set), so a candidate match still returns the mapping's
300    /// `feedback`.
301    pub async fn feedback_rules_for_target(
302        &self,
303        service_type: &str,
304        target: &str,
305    ) -> Vec<FeedbackRule> {
306        let guard = self.by_device.read().await;
307        for list in guard.values() {
308            if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
309                return rules;
310            }
311        }
312        Vec::new()
313    }
314
315    /// Same as `feedback_rules_for_target` but scoped to one `(device_type,
316    /// device_id)` bucket. Use this from per-device feedback pumps so a
317    /// Nuimo only reacts when the state change belongs to a mapping it
318    /// owns — otherwise a second Nuimo mapped elsewhere would flash glyphs
319    /// for unrelated service activity.
320    ///
321    /// Returns `None` when no mapping on this device covers
322    /// `(service_type, target)` — the caller should skip. Returns
323    /// `Some(vec)` when a mapping exists; the vec may be empty, which
324    /// signals "mapping exists but user configured no rules — fall back
325    /// to hardcoded defaults" (preserves single-device behavior).
326    pub async fn feedback_rules_for_device_target(
327        &self,
328        device_type: &str,
329        device_id: &str,
330        service_type: &str,
331        target: &str,
332    ) -> Option<Vec<FeedbackRule>> {
333        let guard = self.by_device.read().await;
334        let list = guard.get(&(device_type.to_string(), device_id.to_string()))?;
335        mapping_rules_for_target(list, service_type, target)
336    }
337
338    /// Find every device whose mapping owns `(service_type, target)` and
339    /// return its `(device_type, device_id, feedback_rules)`. Used by the
340    /// iOS feedback pump, which fans a single state update out to every
341    /// LED that should display it — typically 1 device, but two Nuimos
342    /// paired to the same iPad both deserve feedback.
343    pub async fn feedback_targets_for(
344        &self,
345        service_type: &str,
346        target: &str,
347    ) -> Vec<(String, String, Vec<FeedbackRule>)> {
348        let guard = self.by_device.read().await;
349        let mut out = Vec::new();
350        for ((device_type, device_id), list) in guard.iter() {
351            if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
352                out.push((device_type.clone(), device_id.clone(), rules));
353            }
354        }
355        out
356    }
357
358    /// Apply the given input primitive from a specific device, returning every
359    /// intent it produces across all matching mappings.
360    ///
361    /// When the device has a `DeviceCycle`, only the mapping identified
362    /// by `active_mapping_id` is eligible — non-active members and
363    /// non-member mappings on the same device sit dormant. Cycle-gesture
364    /// inputs are NOT short-circuited here (they would silently dispatch
365    /// nothing); use `route_with_mode` to surface a `CycleSwitch` outcome.
366    pub async fn route(
367        &self,
368        device_type: &str,
369        device_id: &str,
370        input: &InputPrimitive,
371    ) -> Vec<RoutedIntent> {
372        let key = (device_type.to_string(), device_id.to_string());
373        let cycle = self.cycles.read().await.get(&key).cloned();
374        let guard = self.by_device.read().await;
375        let Some(mappings) = guard.get(&key) else {
376            return Vec::new();
377        };
378        let active_filter = active_filter_from_cycle(cycle.as_ref());
379        route_mappings(mappings, input, active_filter)
380    }
381
382    /// Same dispatch as `route`, but also implements:
383    /// - the device-level Cycle: cycle-gesture inputs short-circuit to a
384    ///   `CycleSwitch` outcome (active is advanced locally; caller relays
385    ///   to server). When a cycle exists, only the active mapping fires.
386    /// - the legacy `target_switch_on` + `target_candidates` selection
387    ///   mode for backward compat — only consulted when no cycle exists
388    ///   for the device.
389    pub async fn route_with_mode(
390        &self,
391        device_type: &str,
392        device_id: &str,
393        input: &InputPrimitive,
394    ) -> RouteOutcome {
395        let key = (device_type.to_string(), device_id.to_string());
396
397        // Cycle path: takes precedence over legacy selection mode.
398        // If the input matches `cycle_gesture`, advance active locally
399        // and return a CycleSwitch outcome. Otherwise apply the active
400        // filter to mapping routing.
401        {
402            let cycles = self.cycles.read().await;
403            if let Some(cycle) = cycles.get(&key).cloned() {
404                drop(cycles);
405                if let Some(gesture) = cycle.cycle_gesture.as_deref() {
406                    if input.matches_route(gesture) {
407                        // Compute next active without holding the read
408                        // lock through the write.
409                        let next = next_active(&cycle);
410                        if let Some(next_id) = next {
411                            // Advance local state; engine is the source
412                            // of truth until server confirms.
413                            self.cycles
414                                .write()
415                                .await
416                                .entry(key.clone())
417                                .and_modify(|c| {
418                                    c.active_mapping_id = Some(next_id);
419                                });
420                            return RouteOutcome::CycleSwitch {
421                                device_type: device_type.to_string(),
422                                device_id: device_id.to_string(),
423                                active_mapping_id: next_id,
424                            };
425                        }
426                    }
427                }
428                // Non-cycle-gesture input on a cycled device: filter
429                // to the active mapping and route normally. Skip the
430                // legacy selection-mode path entirely — devices on
431                // the cycle model don't enter selection mode.
432                let active_filter = active_filter_from_cycle(Some(&cycle));
433                let guard = self.by_device.read().await;
434                let Some(mappings) = guard.get(&key) else {
435                    return RouteOutcome::Normal(Vec::new());
436                };
437                return RouteOutcome::Normal(route_mappings(mappings, input, active_filter));
438            }
439        }
440
441        // Already in selection mode for this device: intercept rotate /
442        // press / cancel before dispatching to normal routing.
443        {
444            let mut sel = self.selection.write().await;
445            if let Some(mode) = sel.get_mut(&key) {
446                match input {
447                    InputPrimitive::Rotate { delta } => {
448                        // Sub-threshold rotations accumulate without
449                        // emitting UpdateSelection — the LED keeps the
450                        // current candidate's glyph and no log line
451                        // fires until the cursor actually moves.
452                        if !mode.advance(*delta) {
453                            return RouteOutcome::Normal(Vec::new());
454                        }
455                        let glyph = mode.current().glyph.clone();
456                        let mapping_id = mode.mapping_id;
457                        return RouteOutcome::UpdateSelection { mapping_id, glyph };
458                    }
459                    InputPrimitive::Press => {
460                        let mapping_id = mode.mapping_id;
461                        let service_target = mode.current().target.clone();
462                        let edge_id = mode.edge_id.clone();
463                        sel.remove(&key);
464                        return RouteOutcome::CommitSelection {
465                            edge_id,
466                            mapping_id,
467                            service_target,
468                        };
469                    }
470                    _ => {
471                        let mapping_id = mode.mapping_id;
472                        sel.remove(&key);
473                        return RouteOutcome::CancelSelection { mapping_id };
474                    }
475                }
476            }
477        }
478
479        // Not in mode yet: check whether this input should enter mode on
480        // any mapping for this device, else fall through to normal routing.
481        let guard = self.by_device.read().await;
482        let Some(mappings) = guard.get(&key) else {
483            return RouteOutcome::Normal(Vec::new());
484        };
485
486        for m in mappings {
487            if !m.active {
488                continue;
489            }
490            let Some(switch_on) = m.target_switch_on.as_deref() else {
491                continue;
492            };
493            if m.target_candidates.is_empty() {
494                continue;
495            }
496            if !input.matches_route(switch_on) {
497                continue;
498            }
499            // Enter mode with cursor one step AFTER the current
500            // service_target — so swipe_up→press (no rotate) cycles to
501            // the next candidate. Rotate still browses from there.
502            let current_idx = m
503                .target_candidates
504                .iter()
505                .position(|c| c.target == m.service_target);
506            let cursor = match current_idx {
507                Some(i) => (i + 1) % m.target_candidates.len(),
508                None => 0,
509            };
510            let mode = SelectionMode::new(
511                m.mapping_id,
512                m.edge_id.clone(),
513                m.target_candidates.clone(),
514                cursor,
515            );
516            let glyph = mode.current().glyph.clone();
517            let mapping_id = mode.mapping_id;
518            let edge_id = mode.edge_id.clone();
519            drop(guard);
520            self.selection.write().await.insert(key, mode);
521            return RouteOutcome::EnterSelection {
522                edge_id,
523                mapping_id,
524                glyph,
525            };
526        }
527
528        RouteOutcome::Normal(route_mappings(mappings, input, None))
529    }
530}
531
532/// Compute the next active mapping ID in the cycle order. Returns `None`
533/// when the cycle is empty or its current `active_mapping_id` does not
534/// match any entry (caller may want to reset to head, but this helper
535/// stays conservative).
536fn next_active(cycle: &DeviceCycle) -> Option<Uuid> {
537    if cycle.mapping_ids.is_empty() {
538        return None;
539    }
540    let active = cycle.active_mapping_id?;
541    let pos = cycle.mapping_ids.iter().position(|id| *id == active)?;
542    let next = (pos + 1) % cycle.mapping_ids.len();
543    Some(cycle.mapping_ids[next])
544}
545
546/// Extract the active filter UUID from a cycle if one exists. `None`
547/// means "no cycle — all matching mappings fire" (preserves backward
548/// compatibility for devices without a cycle).
549fn active_filter_from_cycle(cycle: Option<&DeviceCycle>) -> Option<Uuid> {
550    cycle.and_then(|c| c.active_mapping_id)
551}
552
553/// Look for a mapping in `list` whose primary `(service_type, target)` or
554/// any `target_candidates` entry matches the given pair. Returns the
555/// mapping's `feedback` clone on hit. `None` means no mapping covers the
556/// target, letting the caller fall back to defaults.
557fn mapping_rules_for_target(
558    list: &[Mapping],
559    service_type: &str,
560    target: &str,
561) -> Option<Vec<FeedbackRule>> {
562    for m in list {
563        if m.service_type == service_type && m.service_target == target {
564            return Some(m.feedback.clone());
565        }
566        for c in &m.target_candidates {
567            let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
568            if c_service_type == service_type && c.target == target {
569                return Some(m.feedback.clone());
570            }
571        }
572    }
573    None
574}
575
576fn route_mappings(
577    mappings: &[Mapping],
578    input: &InputPrimitive,
579    active_filter: Option<Uuid>,
580) -> Vec<RoutedIntent> {
581    let mut out = Vec::new();
582    for m in mappings {
583        if !m.active {
584            continue;
585        }
586        // When a cycle is in effect, only the active mapping fires.
587        // Other cycle members + non-cycle mappings on this device are
588        // skipped. (The cycle's mapping_ids list is enforced on the
589        // active_filter side — only the active id will pass through.)
590        if let Some(active_id) = active_filter {
591            if m.mapping_id != active_id {
592                continue;
593            }
594        }
595        // Resolve the effective service_type + routes for the currently
596        // active target. When the active target is a cross-service
597        // candidate (e.g. Roon-flavoured mapping pointing at a Hue light),
598        // `effective_for` returns the candidate's overrides so the
599        // dispatcher sees the right adapter + intent set.
600        let (service_type, routes) = m.effective_for(&m.service_target);
601        for route in routes {
602            if !input.matches_route(&route.input) {
603                continue;
604            }
605            if let Some(intent) = build_intent(route, input) {
606                out.push(RoutedIntent {
607                    service_type: service_type.to_string(),
608                    service_target: m.service_target.clone(),
609                    intent,
610                });
611                // Only first matching route per mapping fires.
612                break;
613            }
614        }
615    }
616    out
617}
618
619fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
620    let damping = route
621        .params
622        .get("damping")
623        .and_then(|v| v.as_f64())
624        .unwrap_or(1.0);
625
626    match route.intent.as_str() {
627        "play" => Some(Intent::Play),
628        "pause" => Some(Intent::Pause),
629        "play_pause" | "playpause" => Some(Intent::PlayPause),
630        "stop" => Some(Intent::Stop),
631        "next" => Some(Intent::Next),
632        "previous" => Some(Intent::Previous),
633        "mute" => Some(Intent::Mute),
634        "unmute" => Some(Intent::Unmute),
635        "power_toggle" => Some(Intent::PowerToggle),
636        "power_on" => Some(Intent::PowerOn),
637        "power_off" => Some(Intent::PowerOff),
638        "volume_change" => input
639            .continuous_value()
640            .map(|v| Intent::VolumeChange { delta: v * damping }),
641        "volume_set" => route
642            .params
643            .get("value")
644            .and_then(|v| v.as_f64())
645            .map(|value| Intent::VolumeSet { value }),
646        "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
647            seconds: v * damping,
648        }),
649        "brightness_change" => input
650            .continuous_value()
651            .map(|v| Intent::BrightnessChange { delta: v * damping }),
652        "color_temperature_change" => input
653            .continuous_value()
654            .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
655        _ => None,
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use crate::Direction;
663    use std::collections::BTreeMap;
664    use uuid::Uuid;
665    use weave_contracts::Route;
666
667    fn rotate_mapping() -> Mapping {
668        Mapping {
669            mapping_id: Uuid::new_v4(),
670            edge_id: "living-room".into(),
671            device_type: "nuimo".into(),
672            device_id: "C3:81:DF:4E".into(),
673            service_type: "roon".into(),
674            service_target: "zone-1".into(),
675            routes: vec![Route {
676                input: "rotate".into(),
677                intent: "volume_change".into(),
678                params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
679            }],
680            feedback: vec![],
681            active: true,
682            target_candidates: vec![],
683            target_switch_on: None,
684        }
685    }
686
687    #[tokio::test]
688    async fn rotate_produces_volume_change_with_damping() {
689        let engine = RoutingEngine::new();
690        engine.replace_all(vec![rotate_mapping()]).await;
691
692        let out = engine
693            .route(
694                "nuimo",
695                "C3:81:DF:4E",
696                &InputPrimitive::Rotate { delta: 0.03 },
697            )
698            .await;
699        assert_eq!(out.len(), 1);
700        match &out[0].intent {
701            Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
702            other => panic!("expected VolumeChange, got {:?}", other),
703        }
704        assert_eq!(out[0].service_type, "roon");
705        assert_eq!(out[0].service_target, "zone-1");
706    }
707
708    #[tokio::test]
709    async fn inactive_mappings_are_skipped() {
710        let mut m = rotate_mapping();
711        m.active = false;
712        let engine = RoutingEngine::new();
713        engine.replace_all(vec![m]).await;
714
715        let out = engine
716            .route(
717                "nuimo",
718                "C3:81:DF:4E",
719                &InputPrimitive::Rotate { delta: 0.03 },
720            )
721            .await;
722        assert!(out.is_empty());
723    }
724
725    #[tokio::test]
726    async fn unknown_device_returns_empty() {
727        let engine = RoutingEngine::new();
728        engine.replace_all(vec![rotate_mapping()]).await;
729
730        let out = engine
731            .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
732            .await;
733        assert!(out.is_empty());
734    }
735
736    fn selection_mapping() -> Mapping {
737        let mut m = rotate_mapping();
738        m.service_target = "target-A".into();
739        m.target_switch_on = Some("swipe_up".into());
740        m.target_candidates = vec![
741            TargetCandidate {
742                target: "target-A".into(),
743                label: "A".into(),
744                glyph: "glyph-a".into(),
745                service_type: None,
746                routes: None,
747            },
748            TargetCandidate {
749                target: "target-B".into(),
750                label: "B".into(),
751                glyph: "glyph-b".into(),
752                service_type: None,
753                routes: None,
754            },
755        ];
756        m
757    }
758
759    /// A mapping whose primary target is a Roon zone and whose secondary
760    /// candidate is a Hue light with its own route table. Used to exercise
761    /// the `effective_for` cross-service override path.
762    fn cross_service_mapping() -> Mapping {
763        let mut m = rotate_mapping();
764        m.service_target = "roon-zone".into();
765        m.target_switch_on = Some("long_press".into());
766        m.target_candidates = vec![
767            TargetCandidate {
768                target: "roon-zone".into(),
769                label: "Roon".into(),
770                glyph: "roon".into(),
771                service_type: None,
772                routes: None,
773            },
774            TargetCandidate {
775                target: "hue-light".into(),
776                label: "Hue".into(),
777                glyph: "hue".into(),
778                service_type: Some("hue".into()),
779                routes: Some(vec![Route {
780                    input: "rotate".into(),
781                    intent: "brightness_change".into(),
782                    params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
783                }]),
784            },
785        ];
786        m
787    }
788
789    #[test]
790    fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
791        let m = cross_service_mapping();
792        let (svc, routes) = m.effective_for("roon-zone");
793        assert_eq!(svc, "roon");
794        assert_eq!(routes.len(), 1);
795        assert_eq!(routes[0].intent, "volume_change");
796    }
797
798    #[test]
799    fn effective_for_returns_candidate_overrides_when_present() {
800        let m = cross_service_mapping();
801        let (svc, routes) = m.effective_for("hue-light");
802        assert_eq!(svc, "hue");
803        assert_eq!(routes.len(), 1);
804        assert_eq!(routes[0].intent, "brightness_change");
805    }
806
807    #[tokio::test]
808    async fn rotate_on_cross_service_candidate_produces_hue_intent() {
809        let mut m = cross_service_mapping();
810        // Simulate the user having switched the active target to the Hue
811        // candidate (as if selection mode had committed on it).
812        m.service_target = "hue-light".into();
813
814        let engine = RoutingEngine::new();
815        engine.replace_all(vec![m]).await;
816
817        let out = engine
818            .route(
819                "nuimo",
820                "C3:81:DF:4E",
821                &InputPrimitive::Rotate { delta: 0.1 },
822            )
823            .await;
824        assert_eq!(out.len(), 1);
825        assert_eq!(out[0].service_type, "hue");
826        assert_eq!(out[0].service_target, "hue-light");
827        match &out[0].intent {
828            Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
829            other => panic!("expected BrightnessChange, got {:?}", other),
830        }
831    }
832
833    #[tokio::test]
834    async fn swipe_up_press_cycles_to_next_target_without_rotate() {
835        let engine = RoutingEngine::new();
836        engine.replace_all(vec![selection_mapping()]).await;
837
838        match engine
839            .route_with_mode(
840                "nuimo",
841                "C3:81:DF:4E",
842                &InputPrimitive::Swipe {
843                    direction: Direction::Up,
844                },
845            )
846            .await
847        {
848            RouteOutcome::EnterSelection { glyph, .. } => {
849                // Entering mode from target-A should point at target-B.
850                assert_eq!(glyph, "glyph-b");
851            }
852            other => panic!("expected EnterSelection, got {:?}", other),
853        }
854
855        match engine
856            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
857            .await
858        {
859            RouteOutcome::CommitSelection { service_target, .. } => {
860                assert_eq!(service_target, "target-B")
861            }
862            other => panic!("expected CommitSelection, got {:?}", other),
863        }
864    }
865
866    #[tokio::test]
867    async fn rotate_then_press_commits_advanced_candidate() {
868        let engine = RoutingEngine::new();
869        engine.replace_all(vec![selection_mapping()]).await;
870
871        // Enter: cursor jumps to target-B (position 1).
872        let _ = engine
873            .route_with_mode(
874                "nuimo",
875                "C3:81:DF:4E",
876                &InputPrimitive::Swipe {
877                    direction: Direction::Up,
878                },
879            )
880            .await;
881        // Rotate forward past the damping threshold — wraps from B back to A.
882        match engine
883            .route_with_mode(
884                "nuimo",
885                "C3:81:DF:4E",
886                &InputPrimitive::Rotate {
887                    delta: SELECTION_ROTATION_STEP,
888                },
889            )
890            .await
891        {
892            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
893            other => panic!("expected UpdateSelection, got {:?}", other),
894        }
895        match engine
896            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
897            .await
898        {
899            RouteOutcome::CommitSelection { service_target, .. } => {
900                assert_eq!(service_target, "target-A")
901            }
902            other => panic!("expected CommitSelection, got {:?}", other),
903        }
904    }
905
906    #[tokio::test]
907    async fn sub_threshold_rotate_keeps_cursor_still() {
908        let engine = RoutingEngine::new();
909        engine.replace_all(vec![selection_mapping()]).await;
910
911        // Enter selection at target-B.
912        let _ = engine
913            .route_with_mode(
914                "nuimo",
915                "C3:81:DF:4E",
916                &InputPrimitive::Swipe {
917                    direction: Direction::Up,
918                },
919            )
920            .await;
921
922        // A single small rotation accumulates but does not cross the
923        // threshold. Caller sees an empty Normal outcome — no LED push,
924        // no state change.
925        match engine
926            .route_with_mode(
927                "nuimo",
928                "C3:81:DF:4E",
929                &InputPrimitive::Rotate {
930                    delta: SELECTION_ROTATION_STEP / 2.0,
931                },
932            )
933            .await
934        {
935            RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
936            other => panic!("expected Normal(empty), got {:?}", other),
937        }
938
939        // A second small rotation pushes the accumulator over the
940        // threshold — cursor moves.
941        match engine
942            .route_with_mode(
943                "nuimo",
944                "C3:81:DF:4E",
945                &InputPrimitive::Rotate {
946                    delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
947                },
948            )
949            .await
950        {
951            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
952            other => panic!("expected UpdateSelection, got {:?}", other),
953        }
954    }
955
956    #[tokio::test]
957    async fn large_rotate_produces_single_step_per_outcome() {
958        // advance() advances until the accumulator is below the threshold,
959        // but route_with_mode yields a single RouteOutcome per input event.
960        // Verify a 2.4-step delta lands on a cursor position consistent
961        // with two advances (not one, not four).
962        let engine = RoutingEngine::new();
963        engine.replace_all(vec![selection_mapping()]).await;
964
965        let _ = engine
966            .route_with_mode(
967                "nuimo",
968                "C3:81:DF:4E",
969                &InputPrimitive::Swipe {
970                    direction: Direction::Up,
971                },
972            )
973            .await;
974        // Starting cursor is at B (index 1). 2 steps forward in a 2-entry
975        // list wraps back to B. Use a delta that advances exactly twice
976        // to land deterministically.
977        match engine
978            .route_with_mode(
979                "nuimo",
980                "C3:81:DF:4E",
981                &InputPrimitive::Rotate {
982                    delta: SELECTION_ROTATION_STEP * 2.0,
983                },
984            )
985            .await
986        {
987            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
988            other => panic!("expected UpdateSelection, got {:?}", other),
989        }
990    }
991
992    fn playback_glyph_rule() -> FeedbackRule {
993        FeedbackRule {
994            state: "playback".into(),
995            feedback_type: "glyph".into(),
996            mapping: serde_json::json!({
997                "playing": "play",
998                "paused": "pause",
999                "stopped": "pause",
1000            }),
1001        }
1002    }
1003
1004    fn mapping_with_feedback() -> Mapping {
1005        let mut m = rotate_mapping();
1006        m.feedback = vec![playback_glyph_rule()];
1007        m
1008    }
1009
1010    #[tokio::test]
1011    async fn feedback_rules_match_primary_target() {
1012        let engine = RoutingEngine::new();
1013        engine.replace_all(vec![mapping_with_feedback()]).await;
1014
1015        let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
1016        assert_eq!(rules.len(), 1);
1017        assert_eq!(rules[0].state, "playback");
1018    }
1019
1020    #[tokio::test]
1021    async fn feedback_rules_match_candidate_override_service_type() {
1022        let mut m = mapping_with_feedback();
1023        m.target_candidates = vec![TargetCandidate {
1024            target: "hue-light-1".into(),
1025            label: "Living".into(),
1026            glyph: "link".into(),
1027            service_type: Some("hue".into()),
1028            routes: None,
1029        }];
1030        let engine = RoutingEngine::new();
1031        engine.replace_all(vec![m]).await;
1032
1033        let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
1034        assert_eq!(
1035            rules.len(),
1036            1,
1037            "candidate with overridden service_type inherits mapping feedback",
1038        );
1039    }
1040
1041    #[tokio::test]
1042    async fn feedback_rules_empty_for_unknown_target() {
1043        let engine = RoutingEngine::new();
1044        engine.replace_all(vec![mapping_with_feedback()]).await;
1045
1046        let rules = engine
1047            .feedback_rules_for_target("roon", "some-other-zone")
1048            .await;
1049        assert!(rules.is_empty());
1050    }
1051
1052    #[tokio::test]
1053    async fn feedback_rules_for_device_target_scopes_by_device() {
1054        // Two mappings for the same Roon zone, but on different Nuimos.
1055        // Only the device that owns the mapping should receive feedback.
1056        let mut m_a = mapping_with_feedback();
1057        m_a.device_id = "nuimo-a".into();
1058        let mut m_b = mapping_with_feedback();
1059        m_b.mapping_id = Uuid::new_v4();
1060        m_b.device_id = "nuimo-b".into();
1061        m_b.service_target = "zone-b".into();
1062
1063        let engine = RoutingEngine::new();
1064        engine.replace_all(vec![m_a, m_b]).await;
1065
1066        let rules_a = engine
1067            .feedback_rules_for_device_target("nuimo", "nuimo-a", "roon", "zone-1")
1068            .await;
1069        let rules_a = rules_a.expect("nuimo-a owns zone-1");
1070        assert_eq!(rules_a.len(), 1);
1071
1072        let rules_b_wrong = engine
1073            .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-1")
1074            .await;
1075        assert!(
1076            rules_b_wrong.is_none(),
1077            "nuimo-b does not own zone-1 — must skip entirely",
1078        );
1079
1080        let rules_b_right = engine
1081            .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-b")
1082            .await;
1083        let rules_b_right = rules_b_right.expect("nuimo-b owns zone-b");
1084        assert_eq!(rules_b_right.len(), 1);
1085    }
1086
1087    #[tokio::test]
1088    async fn feedback_rules_for_device_target_none_for_unknown_device() {
1089        let engine = RoutingEngine::new();
1090        engine.replace_all(vec![mapping_with_feedback()]).await;
1091
1092        let rules = engine
1093            .feedback_rules_for_device_target("nuimo", "unknown-device", "roon", "zone-1")
1094            .await;
1095        assert!(
1096            rules.is_none(),
1097            "no mapping for unknown device — caller should skip",
1098        );
1099    }
1100
1101    #[tokio::test]
1102    async fn feedback_rules_for_device_target_some_empty_for_mapping_without_rules() {
1103        // A mapping exists for the device + target, but `feedback` is empty.
1104        // Result must be `Some(vec![])` so the caller falls back to
1105        // hardcoded defaults (preserves single-device behavior).
1106        let m = rotate_mapping(); // no feedback rules
1107        let engine = RoutingEngine::new();
1108        engine.replace_all(vec![m]).await;
1109
1110        let rules = engine
1111            .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
1112            .await;
1113        let rules = rules.expect("mapping exists — caller should fall back to defaults");
1114        assert!(
1115            rules.is_empty(),
1116            "no explicit rules configured — pump will use FeedbackPlan defaults",
1117        );
1118    }
1119}