Skip to main content

midi_controller/
controller.rs

1//! Unified controller: single entry point for all input processing.
2//!
3//! Owns all timing-sensitive state (long-press detection, encoder acceleration,
4//! tap tempo, preset state) and delegates pure logic to the engine.
5//!
6//! The Controller handles system actions (preset switching) internally,
7//! including on_enter/on_exit actions and recall MIDI. Callers only need to
8//! send the resulting MIDI output — no orchestration required.
9//!
10//! Both firmware and simulator use the same `Controller` — the only difference
11//! is where `now_ms` comes from (hardware monotonic vs std::Instant).
12
13use crate::action::{action_to_midi, EncoderDirection};
14use crate::config::{Action, ButtonMode, Config, Preset, MAX_BUTTONS, MAX_ENCODERS};
15use crate::encoder_accel::EncoderAccel;
16use crate::engine::{
17    self, process_triggers, ActionStep, ButtonEvent, DisplayEvent, EngineResult, SystemAction,
18};
19use crate::long_press::{Edge, Gesture, LongPressDetector};
20use crate::state::{PresetState, PresetStateStore};
21use crate::tap_tempo::TapTempo;
22
23/// Abstract input event. Hardware-agnostic — firmware maps GPIO edges to these,
24/// the simulator maps keyboard/WebSocket events to these.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Event {
27    /// Button edge (index 0..5).
28    ButtonEdge { index: u8, edge: Edge },
29    /// Encoder detent (index 0..1).
30    EncoderTurn { index: u8, clockwise: bool },
31    /// Analog input (expression pedal). Raw ADC value.
32    /// Calibration (min/max) is read from Config.global.
33    Analog { index: u8, raw: u16 },
34    /// Incoming MIDI (for trigger processing). Up to 3 bytes.
35    IncomingMidi { data: [u8; 3], len: u8 },
36    /// Periodic tick — drives long-press detection. Send every 1-10ms.
37    /// No-op if no buttons are held.
38    Tick,
39}
40
41/// Result of processing an input event through the Controller.
42/// Contains everything the caller needs to emit — no further logic required.
43pub struct Output {
44    /// MIDI actions to emit (includes on_enter/on_exit/recall on preset switch).
45    pub midi: heapless::Vec<ActionStep, 32>,
46    /// Display events (overlays, hints).
47    pub display: heapless::Vec<DisplayEvent, 2>,
48    /// Whether LED state changed and needs re-rendering.
49    pub leds_changed: bool,
50    /// Whether a preset switch occurred (caller may need to update display).
51    pub preset_changed: bool,
52    /// BPM computed from tap tempo (if any).
53    pub bpm: Option<u16>,
54    /// Internal: pending system actions to be handled by the controller.
55    pending_system: heapless::Vec<SystemAction, 2>,
56}
57
58impl Output {
59    fn new() -> Self {
60        Self {
61            midi: heapless::Vec::new(),
62            display: heapless::Vec::new(),
63            leds_changed: false,
64            preset_changed: false,
65            bpm: None,
66            pending_system: heapless::Vec::new(),
67        }
68    }
69}
70
71/// Unified timing controller. Owns all stateful input processing.
72///
73/// # Usage
74///
75/// ```ignore
76/// let mut ctrl = Controller::new();
77/// // On each input poll:
78/// let result = ctrl.process(event, now_ms, &config);
79/// // Send result.midi via MIDI output
80/// // Update display from result.display
81/// // Re-render LEDs if result.leds_changed
82/// ```
83pub struct Controller<const B: usize = MAX_BUTTONS, const E: usize = MAX_ENCODERS> {
84    state_store: PresetStateStore<B, E>,
85    long_press: [LongPressDetector; B],
86    encoder_accel: [EncoderAccel; E],
87    tap_tempo: TapTempo,
88    button_active: [bool; B],
89    active_preset: u8,
90}
91
92/// Type alias for the default controller configuration (6 buttons, 2 encoders).
93pub type DefaultController = Controller<MAX_BUTTONS, MAX_ENCODERS>;
94
95impl<const B: usize, const E: usize> Default for Controller<B, E> {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl<const B: usize, const E: usize> Controller<B, E> {
102    pub fn new() -> Self {
103        Self {
104            state_store: PresetStateStore::new(),
105            long_press: core::array::from_fn(|_| LongPressDetector::new_fired()),
106            encoder_accel: core::array::from_fn(|_| EncoderAccel::new()),
107            tap_tempo: TapTempo::new(),
108            button_active: [false; B],
109            active_preset: 0,
110        }
111    }
112
113    /// Create with a restored state store (from EEPROM/persistence).
114    pub fn with_state(store: PresetStateStore<B, E>) -> Self {
115        let state = store.current().clone();
116        let active = store.active_index();
117        Self {
118            state_store: store,
119            long_press: core::array::from_fn(|_| LongPressDetector::new_fired()),
120            encoder_accel: core::array::from_fn(|_| EncoderAccel::new()),
121            tap_tempo: TapTempo::new(),
122            button_active: state.button_active,
123            active_preset: active,
124        }
125    }
126
127    /// Process a single input event. Returns all MIDI output, display events,
128    /// and flags. System actions (preset switch, tap tempo) are handled internally.
129    pub fn process<const A: usize>(
130        &mut self,
131        event: Event,
132        now_ms: u32,
133        config: &Config<B, E, A>,
134    ) -> Output {
135        let mut result = match event {
136            Event::ButtonEdge { index, edge } => {
137                self.process_button(index as usize, edge, now_ms, config)
138            }
139            Event::EncoderTurn { index, clockwise } => {
140                self.process_encoder(index as usize, clockwise, now_ms, config)
141            }
142            Event::Analog { index, raw } => self.process_analog(index as usize, raw, config),
143            Event::IncomingMidi { data, len } => {
144                self.do_process_incoming_midi(&data[..len as usize], config)
145            }
146            Event::Tick => self.do_tick(now_ms, config),
147        };
148
149        // Handle system actions produced by event processing
150        self.handle_system_actions(&mut result, now_ms, config);
151
152        result
153    }
154
155    fn do_tick<const A: usize>(&mut self, now_ms: u32, config: &Config<B, E, A>) -> Output {
156        let mut result = Output::new();
157
158        if !self.button_held() {
159            return result;
160        }
161
162        let preset = match config.presets.get(self.active_preset as usize) {
163            Some(p) => p,
164            None => return result,
165        };
166
167        for i in 0..B {
168            if self.long_press[i].is_active() && !self.long_press[i].has_fired() {
169                let has_long_press = preset
170                    .buttons
171                    .get(i)
172                    .map(|b| !b.on_long_press.is_empty())
173                    .unwrap_or(false);
174                if has_long_press {
175                    if let Some(gesture) = self.long_press[i].update(None, now_ms) {
176                        self.handle_gesture(i, gesture, preset, &mut result);
177                    }
178                }
179            }
180        }
181
182        result
183    }
184
185    fn do_process_incoming_midi<const A: usize>(
186        &mut self,
187        raw: &[u8],
188        config: &Config<B, E, A>,
189    ) -> Output {
190        let mut result = Output::new();
191
192        let preset = match config.presets.get(self.active_preset as usize) {
193            Some(p) => p,
194            None => return result,
195        };
196
197        if raw.len() >= 2 && !preset.triggers.is_empty() {
198            let mut state = self.working_state();
199            let data2 = if raw.len() >= 3 { raw[2] } else { 0 };
200            let trigger_result = process_triggers(&mut state, preset, raw[0], raw[1], data2);
201            self.apply_state(&state);
202
203            for step in &trigger_result.midi {
204                result.midi.push(step.clone()).ok();
205            }
206            if trigger_result.led_dirty {
207                result.leds_changed = true;
208            }
209
210            // Handle system actions from triggers (preset switch)
211            for s in &trigger_result.system {
212                self.execute_system_action(*s, &mut result, 0, config);
213            }
214        }
215
216        result
217    }
218
219    /// Returns true if any button is currently held.
220    pub fn button_held(&self) -> bool {
221        self.long_press.iter().any(|lp| lp.is_active())
222    }
223
224    /// Returns the current button active state (toggle ON / momentary held).
225    pub fn button_states(&self) -> &[bool; B] {
226        &self.button_active
227    }
228
229    /// Get the active preset index.
230    pub fn active_preset(&self) -> u8 {
231        self.active_preset
232    }
233
234    /// Get the encoder values.
235    pub fn encoder_values(&self) -> [u8; E] {
236        self.state_store.current().encoder_values
237    }
238
239    /// Get a snapshot of the state store (with current working state saved).
240    /// Use this for persistence — the caller decides the serialization format.
241    pub fn snapshot_store(&self) -> PresetStateStore<B, E> {
242        let mut store = self.state_store.clone();
243        let working = self.working_state();
244        store.save_working(&working);
245        store
246    }
247
248    /// Manually switch to a preset (e.g., on boot or from external command).
249    /// Returns on_enter + recall MIDI in the result.
250    pub fn select_preset<const A: usize>(
251        &mut self,
252        preset_idx: u8,
253        config: &Config<B, E, A>,
254    ) -> Output {
255        let mut result = Output::new();
256        self.do_switch_preset(preset_idx, &mut result, config);
257        result
258    }
259
260    /// Set encoder value (for initial state setup from config defaults).
261    pub fn set_encoder_value(&mut self, index: usize, value: u8) {
262        let mut state = self.state_store.current().clone();
263        if index < E {
264            state.encoder_values[index] = value;
265        }
266        self.state_store.save_working(&state);
267    }
268
269    // --- Private ---
270
271    fn current_preset<'a, const A: usize>(
272        &self,
273        config: &'a Config<B, E, A>,
274    ) -> Option<&'a Preset<B, E, A>> {
275        config.presets.get(self.active_preset as usize)
276    }
277
278    fn handle_system_actions<const A: usize>(
279        &mut self,
280        result: &mut Output,
281        now_ms: u32,
282        config: &Config<B, E, A>,
283    ) {
284        let actions: heapless::Vec<SystemAction, 2> = result.pending_system.clone();
285        result.pending_system.clear();
286        for action in &actions {
287            self.execute_system_action(*action, result, now_ms, config);
288        }
289    }
290
291    fn process_button<const A: usize>(
292        &mut self,
293        index: usize,
294        edge: Edge,
295        now_ms: u32,
296        config: &Config<B, E, A>,
297    ) -> Output {
298        let mut result = Output::new();
299
300        let preset = match self.current_preset(config) {
301            Some(p) => p,
302            None => return result,
303        };
304
305        if index >= B {
306            return result;
307        }
308
309        let has_long_press = preset
310            .buttons
311            .get(index)
312            .map(|b| !b.on_long_press.is_empty())
313            .unwrap_or(false);
314
315        let mode = preset
316            .buttons
317            .get(index)
318            .map(|b| &b.mode)
319            .unwrap_or(&ButtonMode::Momentary);
320
321        if has_long_press {
322            if matches!(mode, ButtonMode::Momentary) {
323                match edge {
324                    Edge::Activate => {
325                        self.button_active[index] = true;
326                        result.leds_changed = true;
327                    }
328                    Edge::Deactivate => {
329                        self.button_active[index] = false;
330                        result.leds_changed = true;
331                    }
332                }
333            }
334
335            if edge == Edge::Activate {
336                if let Some(btn) = preset.buttons.get(index) {
337                    let hint_action = btn.on_long_press.iter().find_map(|a| match a {
338                        Action::PresetNext => Some(SystemAction::PresetNext),
339                        Action::PresetPrev => Some(SystemAction::PresetPrev),
340                        _ => None,
341                    });
342                    if let Some(action) = hint_action {
343                        result
344                            .display
345                            .push(DisplayEvent::LongPressHint { action })
346                            .ok();
347                    }
348                }
349            }
350
351            if let Some(gesture) = self.long_press[index].update(Some(edge), now_ms) {
352                self.handle_gesture(index, gesture, preset, &mut result);
353            }
354        } else {
355            match edge {
356                Edge::Activate => {
357                    let mut state = self.working_state();
358                    let r = engine::process_button(&mut state, preset, index, ButtonEvent::Press);
359                    self.apply_state(&state);
360                    self.merge_engine_result(&r, &mut result);
361                }
362                Edge::Deactivate => {
363                    let mut state = self.working_state();
364                    let r = engine::process_button(&mut state, preset, index, ButtonEvent::Release);
365                    self.apply_state(&state);
366                    self.merge_engine_result(&r, &mut result);
367                }
368            }
369        }
370
371        result
372    }
373
374    fn handle_gesture<const A: usize>(
375        &mut self,
376        index: usize,
377        gesture: Gesture,
378        preset: &Preset<B, E, A>,
379        result: &mut Output,
380    ) {
381        let mode = preset
382            .buttons
383            .get(index)
384            .map(|b| &b.mode)
385            .unwrap_or(&ButtonMode::Momentary);
386
387        match gesture {
388            Gesture::ShortPress => {
389                result.display.push(DisplayEvent::LongPressCancel).ok();
390                let mut state = self.working_state();
391                let r = engine::process_button(&mut state, preset, index, ButtonEvent::Press);
392                self.apply_state(&state);
393                self.merge_engine_result(&r, result);
394
395                if let Some(btn) = preset.buttons.get(index) {
396                    if mode == &ButtonMode::Momentary || !btn.on_release.is_empty() {
397                        let mut state2 = self.working_state();
398                        let r2 = engine::process_button(
399                            &mut state2,
400                            preset,
401                            index,
402                            ButtonEvent::Release,
403                        );
404                        self.apply_state(&state2);
405                        self.merge_engine_result(&r2, result);
406                    }
407                }
408            }
409            Gesture::LongPress => {
410                let mut state = self.working_state();
411                let r = engine::process_button(&mut state, preset, index, ButtonEvent::LongPress);
412                self.apply_state(&state);
413                self.merge_engine_result(&r, result);
414            }
415        }
416    }
417
418    fn process_encoder<const A: usize>(
419        &mut self,
420        index: usize,
421        clockwise: bool,
422        now_ms: u32,
423        config: &Config<B, E, A>,
424    ) -> Output {
425        let mut result = Output::new();
426        let preset = match self.current_preset(config) {
427            Some(p) => p,
428            None => return result,
429        };
430
431        if index >= E {
432            return result;
433        }
434
435        let steps = self.encoder_accel[index].steps(now_ms);
436        let direction = if clockwise {
437            EncoderDirection::Clockwise
438        } else {
439            EncoderDirection::CounterClockwise
440        };
441
442        let mut state = self.working_state();
443        let r = engine::process_encoder(&mut state, preset, index, direction, steps);
444        self.apply_state(&state);
445        self.merge_engine_result(&r, &mut result);
446        result
447    }
448
449    fn process_analog<const A: usize>(
450        &mut self,
451        index: usize,
452        raw: u16,
453        config: &Config<B, E, A>,
454    ) -> Output {
455        let mut result = Output::new();
456        let preset = match self.current_preset(config) {
457            Some(p) => p,
458            None => return result,
459        };
460        let (min, max) = match index {
461            0 => (config.global.exp1_min, config.global.exp1_max),
462            1 => (config.global.exp2_min, config.global.exp2_max),
463            _ => return result,
464        };
465        let r = engine::process_analog(preset, index, raw, min, max);
466        self.merge_engine_result(&r, &mut result);
467        result
468    }
469
470    /// Merge engine result into controller result, intercepting system actions.
471    fn merge_engine_result(&mut self, r: &EngineResult, result: &mut Output) {
472        for step in &r.midi {
473            result.midi.push(step.clone()).ok();
474        }
475        for d in &r.display {
476            result.display.push(d.clone()).ok();
477        }
478        if r.led_dirty {
479            result.leds_changed = true;
480        }
481        for s in &r.system {
482            result.pending_system.push(*s).ok();
483        }
484    }
485
486    fn execute_system_action<const A: usize>(
487        &mut self,
488        action: SystemAction,
489        result: &mut Output,
490        now_ms: u32,
491        config: &Config<B, E, A>,
492    ) {
493        let num_presets = config.presets.iter().filter(|p| !p.name.is_empty()).count() as u8;
494
495        match action {
496            SystemAction::PresetNext => {
497                if num_presets > 0 {
498                    let next = (self.active_preset + 1) % num_presets;
499                    self.do_switch_preset(next, result, config);
500                }
501            }
502            SystemAction::PresetPrev => {
503                if num_presets > 0 {
504                    let prev = if self.active_preset == 0 {
505                        num_presets - 1
506                    } else {
507                        self.active_preset - 1
508                    };
509                    self.do_switch_preset(prev, result, config);
510                }
511            }
512            SystemAction::PresetSelect(idx) => {
513                if (idx as usize) < config.presets.len() {
514                    self.do_switch_preset(idx, result, config);
515                }
516            }
517            SystemAction::SetBpm(bpm) => {
518                result.bpm = Some(bpm);
519                result.display.push(DisplayEvent::BpmOverlay { bpm }).ok();
520            }
521            SystemAction::TapTempo => {
522                result.bpm = self.tap_tempo.tap(now_ms);
523                if let Some(bpm) = result.bpm {
524                    result.display.push(DisplayEvent::BpmOverlay { bpm }).ok();
525                }
526            }
527        }
528    }
529
530    fn do_switch_preset<const A: usize>(
531        &mut self,
532        new_idx: u8,
533        result: &mut Output,
534        config: &Config<B, E, A>,
535    ) {
536        let old_preset = config.presets.get(self.active_preset as usize);
537        let new_preset = config.presets.get(new_idx as usize);
538
539        // Fire on_exit for old preset
540        if let Some(old) = old_preset {
541            for action in &old.on_exit {
542                match action {
543                    Action::Delay(ms) => {
544                        result.midi.push(ActionStep::Delay(*ms)).ok();
545                    }
546                    _ => {
547                        if let Some(msg) = action_to_midi(action) {
548                            result.midi.push(ActionStep::Send(msg)).ok();
549                        }
550                    }
551                }
552            }
553        }
554
555        // Switch state
556        if let Some(new_p) = new_preset {
557            let mut working = self.working_state();
558            let recall = self.state_store.switch(new_idx, &mut working, new_p);
559            self.apply_state(&working);
560            self.long_press = core::array::from_fn(|_| LongPressDetector::new_fired());
561            self.encoder_accel = core::array::from_fn(|_| EncoderAccel::new());
562            self.active_preset = new_idx;
563
564            // Fire on_enter for new preset
565            for action in &new_p.on_enter {
566                match action {
567                    Action::Delay(ms) => {
568                        result.midi.push(ActionStep::Delay(*ms)).ok();
569                    }
570                    _ => {
571                        if let Some(msg) = action_to_midi(action) {
572                            result.midi.push(ActionStep::Send(msg)).ok();
573                        }
574                    }
575                }
576            }
577
578            // Recall MIDI (state sync)
579            for msg in &recall {
580                result.midi.push(ActionStep::Send(msg.clone())).ok();
581            }
582        }
583
584        result.preset_changed = true;
585        result.leds_changed = true;
586    }
587
588    fn working_state(&self) -> PresetState<B, E> {
589        let current = self.state_store.current();
590        PresetState {
591            button_active: self.button_active,
592            cycle_index: current.cycle_index,
593            encoder_values: current.encoder_values,
594        }
595    }
596
597    fn apply_state(&mut self, state: &PresetState<B, E>) {
598        self.button_active = state.button_active;
599        self.state_store.save_working(state);
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use crate::config::*;
607    use heapless::Vec as HVec;
608
609    // --- Helpers ---
610
611    /// Build a minimal config with one preset containing the given buttons and encoders.
612    fn make_config(
613        buttons: HVec<ButtonConfig, MAX_BUTTONS>,
614        encoders: HVec<EncoderConfig, MAX_ENCODERS>,
615    ) -> Config {
616        make_config_with_presets(buttons, encoders, HVec::new(), HVec::new(), HVec::new())
617    }
618
619    fn make_config_with_presets(
620        buttons: HVec<ButtonConfig, MAX_BUTTONS>,
621        encoders: HVec<EncoderConfig, MAX_ENCODERS>,
622        on_enter: HVec<Action, MAX_ACTIONS>,
623        on_exit: HVec<Action, MAX_ACTIONS>,
624        triggers: HVec<Trigger, MAX_TRIGGERS>,
625    ) -> Config {
626        let mut presets: HVec<Preset, MAX_PRESETS> = HVec::new();
627        presets
628            .push(Preset {
629                name: Label::try_from("P1").unwrap(),
630                buttons,
631                encoders,
632                analog: HVec::new(),
633                defaults: Default::default(),
634                on_enter,
635                on_exit,
636                triggers,
637            })
638            .ok();
639        Config {
640            global: GlobalConfig::default(),
641            presets,
642        }
643    }
644
645    fn make_two_preset_config() -> Config {
646        let mut presets: HVec<Preset, MAX_PRESETS> = HVec::new();
647
648        // Preset 0: one toggle button with CC#80
649        let mut buttons0: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
650        let mut on_press0: HVec<Action, MAX_ACTIONS> = HVec::new();
651        on_press0.push(Action::cc(80, 127, 1).unwrap()).ok();
652        let mut on_release0: HVec<Action, MAX_ACTIONS> = HVec::new();
653        on_release0.push(Action::cc(80, 0, 1).unwrap()).ok();
654        buttons0
655            .push(ButtonConfig {
656                label: Label::try_from("FX1").unwrap(),
657                color: LedConfig::default(),
658                mode: ButtonMode::Toggle,
659                on_press: on_press0,
660                on_release: on_release0,
661                on_long_press: HVec::new(),
662                cycle_values: HVec::new(),
663                listen_cc: None,
664            })
665            .ok();
666
667        let mut on_enter0: HVec<Action, MAX_ACTIONS> = HVec::new();
668        on_enter0.push(Action::program_change(0, 1).unwrap()).ok();
669
670        presets
671            .push(Preset {
672                name: Label::try_from("Preset0").unwrap(),
673                buttons: buttons0,
674                encoders: HVec::new(),
675                analog: HVec::new(),
676                defaults: Default::default(),
677                on_enter: on_enter0,
678                on_exit: HVec::new(),
679                triggers: HVec::new(),
680            })
681            .ok();
682
683        // Preset 1: one momentary button with CC#81
684        let mut buttons1: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
685        let mut on_press1: HVec<Action, MAX_ACTIONS> = HVec::new();
686        on_press1.push(Action::cc(81, 127, 1).unwrap()).ok();
687        buttons1
688            .push(ButtonConfig {
689                label: Label::try_from("FX2").unwrap(),
690                color: LedConfig::default(),
691                mode: ButtonMode::Momentary,
692                on_press: on_press1,
693                on_release: HVec::new(),
694                on_long_press: HVec::new(),
695                cycle_values: HVec::new(),
696                listen_cc: None,
697            })
698            .ok();
699
700        let mut on_enter1: HVec<Action, MAX_ACTIONS> = HVec::new();
701        on_enter1.push(Action::program_change(1, 1).unwrap()).ok();
702
703        presets
704            .push(Preset {
705                name: Label::try_from("Preset1").unwrap(),
706                buttons: buttons1,
707                encoders: HVec::new(),
708                analog: HVec::new(),
709                defaults: Default::default(),
710                on_enter: on_enter1,
711                on_exit: HVec::new(),
712                triggers: HVec::new(),
713            })
714            .ok();
715
716        Config {
717            global: GlobalConfig::default(),
718            presets,
719        }
720    }
721
722    fn make_three_preset_config() -> Config {
723        let mut presets: HVec<Preset, MAX_PRESETS> = HVec::new();
724        for i in 0..3u8 {
725            let mut on_enter: HVec<Action, MAX_ACTIONS> = HVec::new();
726            on_enter.push(Action::program_change(i, 1).unwrap()).ok();
727            presets
728                .push(Preset {
729                    name: Label::try_from("P").unwrap(),
730                    buttons: HVec::new(),
731                    encoders: HVec::new(),
732                    analog: HVec::new(),
733                    defaults: Default::default(),
734                    on_enter,
735                    on_exit: HVec::new(),
736                    triggers: HVec::new(),
737                })
738                .ok();
739        }
740        Config {
741            global: GlobalConfig::default(),
742            presets,
743        }
744    }
745
746    fn momentary_button(
747        on_press: HVec<Action, MAX_ACTIONS>,
748        on_release: HVec<Action, MAX_ACTIONS>,
749    ) -> ButtonConfig {
750        ButtonConfig {
751            label: Label::new(),
752            color: LedConfig::default(),
753            mode: ButtonMode::Momentary,
754            on_press,
755            on_release,
756            on_long_press: HVec::new(),
757            cycle_values: HVec::new(),
758            listen_cc: None,
759        }
760    }
761
762    fn momentary_button_with_long_press(
763        on_press: HVec<Action, MAX_ACTIONS>,
764        on_long_press: HVec<Action, MAX_ACTIONS>,
765    ) -> ButtonConfig {
766        ButtonConfig {
767            label: Label::new(),
768            color: LedConfig::default(),
769            mode: ButtonMode::Momentary,
770            on_press,
771            on_release: HVec::new(),
772            on_long_press,
773            cycle_values: HVec::new(),
774            listen_cc: None,
775        }
776    }
777
778    fn has_midi_msg(output: &Output, expected: [u8; 3]) -> bool {
779        output
780            .midi
781            .iter()
782            .any(|step| matches!(step, ActionStep::Send(m) if m.data == expected))
783    }
784
785    // --- Test 1: Simple button press generates MIDI (no long-press configured) ---
786
787    #[test]
788    fn simple_button_press_generates_midi() {
789        let mut on_press: HVec<Action, MAX_ACTIONS> = HVec::new();
790        on_press.push(Action::cc(80, 127, 1).unwrap()).ok();
791
792        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
793        buttons.push(momentary_button(on_press, HVec::new())).ok();
794
795        let config = make_config(buttons, HVec::new());
796        let mut ctrl = Controller::new();
797
798        let result = ctrl.process(
799            Event::ButtonEdge {
800                index: 0,
801                edge: Edge::Activate,
802            },
803            0,
804            &config,
805        );
806
807        assert!(has_midi_msg(&result, [0xB0, 80, 127]));
808        assert!(result.leds_changed);
809    }
810
811    // --- Test 2: Button release generates MIDI ---
812
813    #[test]
814    fn button_release_generates_midi() {
815        let mut on_press: HVec<Action, MAX_ACTIONS> = HVec::new();
816        on_press.push(Action::cc(80, 127, 1).unwrap()).ok();
817        let mut on_release: HVec<Action, MAX_ACTIONS> = HVec::new();
818        on_release.push(Action::cc(80, 0, 1).unwrap()).ok();
819
820        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
821        buttons.push(momentary_button(on_press, on_release)).ok();
822
823        let config = make_config(buttons, HVec::new());
824        let mut ctrl = Controller::new();
825
826        // Press
827        ctrl.process(
828            Event::ButtonEdge {
829                index: 0,
830                edge: Edge::Activate,
831            },
832            0,
833            &config,
834        );
835
836        // Release
837        let result = ctrl.process(
838            Event::ButtonEdge {
839                index: 0,
840                edge: Edge::Deactivate,
841            },
842            100,
843            &config,
844        );
845
846        assert!(has_midi_msg(&result, [0xB0, 80, 0]));
847    }
848
849    // --- Test 3: Short press with long-press configured ---
850
851    #[test]
852    fn short_press_with_long_press_configured_fires_on_press_on_release() {
853        let mut on_press: HVec<Action, MAX_ACTIONS> = HVec::new();
854        on_press.push(Action::cc(80, 127, 1).unwrap()).ok();
855        let mut on_long_press: HVec<Action, MAX_ACTIONS> = HVec::new();
856        on_long_press.push(Action::PresetNext).ok();
857
858        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
859        buttons
860            .push(momentary_button_with_long_press(on_press, on_long_press))
861            .ok();
862
863        let config = make_config(buttons, HVec::new());
864        let mut ctrl = Controller::new();
865
866        // Press at t=0
867        let result = ctrl.process(
868            Event::ButtonEdge {
869                index: 0,
870                edge: Edge::Activate,
871            },
872            0,
873            &config,
874        );
875        // With long-press configured, press is deferred — no MIDI yet
876        assert!(!has_midi_msg(&result, [0xB0, 80, 127]));
877
878        // Tick at t=200 (under 500ms threshold)
879        let result = ctrl.process(Event::Tick, 200, &config);
880        assert!(result.midi.is_empty());
881
882        // Release at t=300 (<500ms = short press)
883        let result = ctrl.process(
884            Event::ButtonEdge {
885                index: 0,
886                edge: Edge::Deactivate,
887            },
888            300,
889            &config,
890        );
891
892        // Short press fires on_press actions
893        assert!(has_midi_msg(&result, [0xB0, 80, 127]));
894    }
895
896    // --- Test 4: Long press fires on_long_press actions ---
897
898    #[test]
899    fn long_press_fires_on_long_press_actions() {
900        let mut on_press: HVec<Action, MAX_ACTIONS> = HVec::new();
901        on_press.push(Action::cc(80, 127, 1).unwrap()).ok();
902        let mut on_long_press: HVec<Action, MAX_ACTIONS> = HVec::new();
903        on_long_press.push(Action::cc(99, 127, 2).unwrap()).ok();
904
905        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
906        buttons
907            .push(momentary_button_with_long_press(on_press, on_long_press))
908            .ok();
909
910        let config = make_config(buttons, HVec::new());
911        let mut ctrl = Controller::new();
912
913        // Press at t=0
914        ctrl.process(
915            Event::ButtonEdge {
916                index: 0,
917                edge: Edge::Activate,
918            },
919            0,
920            &config,
921        );
922
923        // Tick at t=500 (threshold reached)
924        let result = ctrl.process(Event::Tick, 500, &config);
925
926        // Long press fires on_long_press: CC#99 on ch2
927        assert!(has_midi_msg(&result, [0xB1, 99, 127]));
928    }
929
930    // --- Test 5: Long press suppresses short press ---
931
932    #[test]
933    fn long_press_suppresses_short_press_on_release() {
934        let mut on_press: HVec<Action, MAX_ACTIONS> = HVec::new();
935        on_press.push(Action::cc(80, 127, 1).unwrap()).ok();
936        let mut on_long_press: HVec<Action, MAX_ACTIONS> = HVec::new();
937        on_long_press.push(Action::cc(99, 127, 2).unwrap()).ok();
938
939        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
940        buttons
941            .push(momentary_button_with_long_press(on_press, on_long_press))
942            .ok();
943
944        let config = make_config(buttons, HVec::new());
945        let mut ctrl = Controller::new();
946
947        // Press at t=0
948        ctrl.process(
949            Event::ButtonEdge {
950                index: 0,
951                edge: Edge::Activate,
952            },
953            0,
954            &config,
955        );
956
957        // Long press fires at t=500
958        ctrl.process(Event::Tick, 500, &config);
959
960        // Release after long press
961        let result = ctrl.process(
962            Event::ButtonEdge {
963                index: 0,
964                edge: Edge::Deactivate,
965            },
966            600,
967            &config,
968        );
969
970        // Release should NOT fire on_press (suppressed)
971        assert!(!has_midi_msg(&result, [0xB0, 80, 127]));
972        assert!(result.midi.is_empty());
973    }
974
975    // --- Test 6: Long press triggers preset switch ---
976
977    #[test]
978    fn long_press_triggers_preset_switch() {
979        let mut on_long_press: HVec<Action, MAX_ACTIONS> = HVec::new();
980        on_long_press.push(Action::PresetNext).ok();
981
982        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
983        buttons
984            .push(momentary_button_with_long_press(HVec::new(), on_long_press))
985            .ok();
986
987        let mut presets: HVec<Preset, MAX_PRESETS> = HVec::new();
988        presets
989            .push(Preset {
990                name: Label::try_from("P1").unwrap(),
991                buttons,
992                encoders: HVec::new(),
993                analog: HVec::new(),
994                defaults: Default::default(),
995                on_enter: HVec::new(),
996                on_exit: HVec::new(),
997                triggers: HVec::new(),
998            })
999            .ok();
1000        presets
1001            .push(Preset {
1002                name: Label::try_from("P2").unwrap(),
1003                buttons: HVec::new(),
1004                encoders: HVec::new(),
1005                analog: HVec::new(),
1006                defaults: Default::default(),
1007                on_enter: HVec::new(),
1008                on_exit: HVec::new(),
1009                triggers: HVec::new(),
1010            })
1011            .ok();
1012        let config: Config = Config {
1013            global: GlobalConfig::default(),
1014            presets,
1015        };
1016        let mut ctrl = Controller::new();
1017
1018        assert_eq!(ctrl.active_preset(), 0);
1019
1020        // Press at t=0
1021        ctrl.process(
1022            Event::ButtonEdge {
1023                index: 0,
1024                edge: Edge::Activate,
1025            },
1026            0,
1027            &config,
1028        );
1029
1030        // Long press fires at t=500 → PresetNext
1031        let result = ctrl.process(Event::Tick, 500, &config);
1032
1033        assert!(result.preset_changed);
1034        assert_eq!(ctrl.active_preset(), 1);
1035    }
1036
1037    // --- Test 7: Toggle state preserved across preset switch round-trip ---
1038
1039    #[test]
1040    fn toggle_state_preserved_across_preset_switch() {
1041        let config = make_two_preset_config();
1042        let mut ctrl = Controller::new();
1043
1044        // Toggle button 0 ON in preset 0 (no long-press → immediate fire)
1045        let result = ctrl.process(
1046            Event::ButtonEdge {
1047                index: 0,
1048                edge: Edge::Activate,
1049            },
1050            0,
1051            &config,
1052        );
1053        assert!(has_midi_msg(&result, [0xB0, 80, 127]));
1054        assert!(ctrl.button_states()[0]);
1055
1056        // Switch to preset 1
1057        ctrl.select_preset(1, &config);
1058        assert_eq!(ctrl.active_preset(), 1);
1059        // Button state is for preset 1 now
1060        assert!(!ctrl.button_states()[0]);
1061
1062        // Switch back to preset 0
1063        ctrl.select_preset(0, &config);
1064        assert_eq!(ctrl.active_preset(), 0);
1065        // Toggle state should be restored
1066        assert!(ctrl.button_states()[0]);
1067    }
1068
1069    // --- Test 8: Encoder turn generates CC ---
1070
1071    #[test]
1072    fn encoder_turn_generates_cc() {
1073        let mut encoders: HVec<EncoderConfig, MAX_ENCODERS> = HVec::new();
1074        encoders
1075            .push(EncoderConfig {
1076                label: Label::try_from("Vol").unwrap(),
1077                action: EncoderAction::Cc {
1078                    cc: 7,
1079                    channel: 1,
1080                    min: 0,
1081                    max: 127,
1082                },
1083            })
1084            .ok();
1085
1086        let config = make_config(HVec::new(), encoders);
1087        let mut ctrl = Controller::new();
1088        ctrl.set_encoder_value(0, 64);
1089
1090        let result = ctrl.process(
1091            Event::EncoderTurn {
1092                index: 0,
1093                clockwise: true,
1094            },
1095            1000,
1096            &config,
1097        );
1098
1099        // First turn always gives 1 step (no prior timestamp for acceleration)
1100        assert!(has_midi_msg(&result, [0xB0, 7, 65]));
1101        assert_eq!(ctrl.encoder_values()[0], 65);
1102    }
1103
1104    // --- Test 9: Encoder acceleration (fast turns → bigger steps) ---
1105
1106    #[test]
1107    fn encoder_acceleration_fast_turns_bigger_steps() {
1108        let mut encoders: HVec<EncoderConfig, MAX_ENCODERS> = HVec::new();
1109        encoders
1110            .push(EncoderConfig {
1111                label: Label::try_from("Vol").unwrap(),
1112                action: EncoderAction::Cc {
1113                    cc: 7,
1114                    channel: 1,
1115                    min: 0,
1116                    max: 127,
1117                },
1118            })
1119            .ok();
1120
1121        let config = make_config(HVec::new(), encoders);
1122        let mut ctrl = Controller::new();
1123        ctrl.set_encoder_value(0, 50);
1124
1125        // First turn at t=0 (initializes, returns 1 step)
1126        ctrl.process(
1127            Event::EncoderTurn {
1128                index: 0,
1129                clockwise: true,
1130            },
1131            0,
1132            &config,
1133        );
1134        assert_eq!(ctrl.encoder_values()[0], 51);
1135
1136        // Fast turn at t=30 (30ms interval → 4 steps from accel_curve)
1137        let result = ctrl.process(
1138            Event::EncoderTurn {
1139                index: 0,
1140                clockwise: true,
1141            },
1142            30,
1143            &config,
1144        );
1145        // 51 + 4 = 55
1146        assert_eq!(ctrl.encoder_values()[0], 55);
1147        assert!(has_midi_msg(&result, [0xB0, 7, 55]));
1148    }
1149
1150    // --- Test 10: Tap tempo via Action::TapTempo ---
1151
1152    #[test]
1153    fn tap_tempo_fires_bpm() {
1154        let mut on_press: HVec<Action, MAX_ACTIONS> = HVec::new();
1155        on_press.push(Action::TapTempo).ok();
1156
1157        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
1158        buttons.push(momentary_button(on_press, HVec::new())).ok();
1159
1160        let config = make_config(buttons, HVec::new());
1161        let mut ctrl = Controller::new();
1162
1163        // First tap at t=0 — no BPM yet (need at least 2)
1164        let result = ctrl.process(
1165            Event::ButtonEdge {
1166                index: 0,
1167                edge: Edge::Activate,
1168            },
1169            0,
1170            &config,
1171        );
1172        assert_eq!(result.bpm, None);
1173
1174        // Release (not relevant for tap tempo, but needed for state)
1175        ctrl.process(
1176            Event::ButtonEdge {
1177                index: 0,
1178                edge: Edge::Deactivate,
1179            },
1180            50,
1181            &config,
1182        );
1183
1184        // Second tap at t=500 → 120 BPM
1185        let result = ctrl.process(
1186            Event::ButtonEdge {
1187                index: 0,
1188                edge: Edge::Activate,
1189            },
1190            500,
1191            &config,
1192        );
1193        assert_eq!(result.bpm, Some(120));
1194    }
1195
1196    // --- Test 11: IncomingMidi triggers (CC trigger activates button) ---
1197
1198    #[test]
1199    fn incoming_midi_trigger_activates_button() {
1200        let mut triggers: HVec<Trigger, MAX_TRIGGERS> = HVec::new();
1201        triggers
1202            .push(Trigger {
1203                match_msg: TriggerMatch::Cc {
1204                    cc: 100,
1205                    channel: 1,
1206                    value_min: 64,
1207                    value_max: 127,
1208                },
1209                action: TriggerAction::Activate(0),
1210            })
1211            .ok();
1212
1213        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
1214        buttons
1215            .push(ButtonConfig {
1216                label: Label::new(),
1217                color: LedConfig::default(),
1218                mode: ButtonMode::Toggle,
1219                on_press: HVec::new(),
1220                on_release: HVec::new(),
1221                on_long_press: HVec::new(),
1222                cycle_values: HVec::new(),
1223                listen_cc: None,
1224            })
1225            .ok();
1226
1227        let config =
1228            make_config_with_presets(buttons, HVec::new(), HVec::new(), HVec::new(), triggers);
1229        let mut ctrl = Controller::new();
1230
1231        assert!(!ctrl.button_states()[0]);
1232
1233        // Send CC#100 = 127 on channel 1
1234        let result = ctrl.process(
1235            Event::IncomingMidi {
1236                data: [0xB0, 100, 127],
1237                len: 3,
1238            },
1239            0,
1240            &config,
1241        );
1242
1243        assert!(ctrl.button_states()[0]);
1244        assert!(result.leds_changed);
1245    }
1246
1247    // --- Test 12: Tick is no-op when no button held ---
1248
1249    #[test]
1250    fn tick_is_noop_when_no_button_held() {
1251        let mut on_long_press: HVec<Action, MAX_ACTIONS> = HVec::new();
1252        on_long_press.push(Action::cc(99, 127, 1).unwrap()).ok();
1253
1254        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
1255        buttons
1256            .push(momentary_button_with_long_press(HVec::new(), on_long_press))
1257            .ok();
1258
1259        let config = make_config(buttons, HVec::new());
1260        let mut ctrl = Controller::new();
1261
1262        // Tick without any button held
1263        let result = ctrl.process(Event::Tick, 1000, &config);
1264
1265        assert!(result.midi.is_empty());
1266        assert!(!result.leds_changed);
1267        assert!(!result.preset_changed);
1268        assert_eq!(result.bpm, None);
1269    }
1270
1271    // --- Test 13: select_preset fires on_enter MIDI ---
1272
1273    #[test]
1274    fn select_preset_fires_on_enter_midi() {
1275        let config = make_two_preset_config();
1276        let mut ctrl = Controller::new();
1277
1278        // Select preset 1 (has on_enter: PC#1 on ch1)
1279        let result = ctrl.select_preset(1, &config);
1280
1281        assert!(result.preset_changed);
1282        assert_eq!(ctrl.active_preset(), 1);
1283        // on_enter for preset 1: Program Change 1 on channel 1
1284        assert!(has_midi_msg(&result, [0xC0, 1, 0]));
1285    }
1286
1287    // --- Test 14: button_held() tracks press/release ---
1288
1289    #[test]
1290    fn button_held_tracks_press_release() {
1291        // Must use a button with long-press configured (long-press detector tracks active state)
1292        let mut on_long_press: HVec<Action, MAX_ACTIONS> = HVec::new();
1293        on_long_press.push(Action::cc(99, 127, 1).unwrap()).ok();
1294
1295        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
1296        buttons
1297            .push(momentary_button_with_long_press(HVec::new(), on_long_press))
1298            .ok();
1299
1300        let config = make_config(buttons, HVec::new());
1301        let mut ctrl = Controller::new();
1302
1303        assert!(!ctrl.button_held());
1304
1305        // Press
1306        ctrl.process(
1307            Event::ButtonEdge {
1308                index: 0,
1309                edge: Edge::Activate,
1310            },
1311            0,
1312            &config,
1313        );
1314        assert!(ctrl.button_held());
1315
1316        // Release
1317        ctrl.process(
1318            Event::ButtonEdge {
1319                index: 0,
1320                edge: Edge::Deactivate,
1321            },
1322            100,
1323            &config,
1324        );
1325        assert!(!ctrl.button_held());
1326    }
1327
1328    // --- Test 15: Preset prev wraps around ---
1329
1330    #[test]
1331    fn preset_prev_wraps_around() {
1332        // Build config with PresetPrev button in all 3 presets
1333        let mut presets: HVec<Preset, MAX_PRESETS> = HVec::new();
1334        for i in 0..3u8 {
1335            let mut btn_on_press: HVec<Action, MAX_ACTIONS> = HVec::new();
1336            btn_on_press.push(Action::PresetPrev).ok();
1337            let mut btns: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
1338            btns.push(momentary_button(btn_on_press, HVec::new())).ok();
1339
1340            let mut on_enter: HVec<Action, MAX_ACTIONS> = HVec::new();
1341            on_enter.push(Action::program_change(i, 1).unwrap()).ok();
1342
1343            presets
1344                .push(Preset {
1345                    name: Label::try_from("P").unwrap(),
1346                    buttons: btns,
1347                    encoders: HVec::new(),
1348                    analog: HVec::new(),
1349                    defaults: Default::default(),
1350                    on_enter,
1351                    on_exit: HVec::new(),
1352                    triggers: HVec::new(),
1353                })
1354                .ok();
1355        }
1356        let config: Config = Config {
1357            global: GlobalConfig::default(),
1358            presets,
1359        };
1360        let mut ctrl = Controller::new();
1361
1362        assert_eq!(ctrl.active_preset(), 0);
1363
1364        // Press button → PresetPrev wraps from 0 → 2
1365        let result = ctrl.process(
1366            Event::ButtonEdge {
1367                index: 0,
1368                edge: Edge::Activate,
1369            },
1370            0,
1371            &config,
1372        );
1373
1374        assert!(result.preset_changed);
1375        assert_eq!(ctrl.active_preset(), 2);
1376    }
1377
1378    // --- Additional edge-case tests ---
1379
1380    #[test]
1381    fn button_held_false_for_button_without_long_press() {
1382        // A button without on_long_press doesn't use the long-press detector
1383        // in "active" mode, so button_held() should remain false.
1384        let mut on_press: HVec<Action, MAX_ACTIONS> = HVec::new();
1385        on_press.push(Action::cc(80, 127, 1).unwrap()).ok();
1386
1387        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
1388        buttons.push(momentary_button(on_press, HVec::new())).ok();
1389
1390        let config = make_config(buttons, HVec::new());
1391        let mut ctrl = Controller::new();
1392
1393        ctrl.process(
1394            Event::ButtonEdge {
1395                index: 0,
1396                edge: Edge::Activate,
1397            },
1398            0,
1399            &config,
1400        );
1401
1402        // Without long-press, the detector doesn't track "active" — button_held returns false
1403        assert!(!ctrl.button_held());
1404    }
1405
1406    #[test]
1407    fn select_preset_fires_on_exit_of_old_preset() {
1408        let mut presets: HVec<Preset, MAX_PRESETS> = HVec::new();
1409
1410        let mut on_exit: HVec<Action, MAX_ACTIONS> = HVec::new();
1411        on_exit.push(Action::cc(123, 0, 1).unwrap()).ok(); // All Notes Off
1412
1413        presets
1414            .push(Preset {
1415                name: Label::try_from("P1").unwrap(),
1416                buttons: HVec::new(),
1417                encoders: HVec::new(),
1418                analog: HVec::new(),
1419                defaults: Default::default(),
1420                on_enter: HVec::new(),
1421                on_exit,
1422                triggers: HVec::new(),
1423            })
1424            .ok();
1425
1426        let mut on_enter: HVec<Action, MAX_ACTIONS> = HVec::new();
1427        on_enter.push(Action::program_change(5, 1).unwrap()).ok();
1428
1429        presets
1430            .push(Preset {
1431                name: Label::try_from("P2").unwrap(),
1432                buttons: HVec::new(),
1433                encoders: HVec::new(),
1434                analog: HVec::new(),
1435                defaults: Default::default(),
1436                on_enter,
1437                on_exit: HVec::new(),
1438                triggers: HVec::new(),
1439            })
1440            .ok();
1441
1442        let config: Config = Config {
1443            global: GlobalConfig::default(),
1444            presets,
1445        };
1446        let mut ctrl = Controller::new();
1447
1448        let result = ctrl.select_preset(1, &config);
1449
1450        // on_exit of preset 0 should fire: CC#123=0 (All Notes Off)
1451        assert!(has_midi_msg(&result, [0xB0, 123, 0]));
1452        // on_enter of preset 1 should fire: PC#5
1453        assert!(has_midi_msg(&result, [0xC0, 5, 0]));
1454    }
1455
1456    #[test]
1457    fn encoder_values_persist_across_preset_switch() {
1458        let mut encoders: HVec<EncoderConfig, MAX_ENCODERS> = HVec::new();
1459        encoders
1460            .push(EncoderConfig {
1461                label: Label::try_from("Vol").unwrap(),
1462                action: EncoderAction::Cc {
1463                    cc: 7,
1464                    channel: 1,
1465                    min: 0,
1466                    max: 127,
1467                },
1468            })
1469            .ok();
1470
1471        let mut presets: HVec<Preset, MAX_PRESETS> = HVec::new();
1472        presets
1473            .push(Preset {
1474                name: Label::try_from("P1").unwrap(),
1475                buttons: HVec::new(),
1476                encoders: encoders.clone(),
1477                analog: HVec::new(),
1478                defaults: Default::default(),
1479                on_enter: HVec::new(),
1480                on_exit: HVec::new(),
1481                triggers: HVec::new(),
1482            })
1483            .ok();
1484        presets
1485            .push(Preset {
1486                name: Label::try_from("P2").unwrap(),
1487                buttons: HVec::new(),
1488                encoders,
1489                analog: HVec::new(),
1490                defaults: Default::default(),
1491                on_enter: HVec::new(),
1492                on_exit: HVec::new(),
1493                triggers: HVec::new(),
1494            })
1495            .ok();
1496
1497        let config: Config = Config {
1498            global: GlobalConfig::default(),
1499            presets,
1500        };
1501        let mut ctrl = Controller::new();
1502        ctrl.set_encoder_value(0, 80);
1503
1504        // Switch to preset 1
1505        ctrl.select_preset(1, &config);
1506        assert_eq!(ctrl.encoder_values()[0], 0); // fresh preset 1 state
1507
1508        // Switch back to preset 0
1509        ctrl.select_preset(0, &config);
1510        assert_eq!(ctrl.encoder_values()[0], 80); // restored
1511    }
1512
1513    #[test]
1514    fn snapshot_store_captures_current_state() {
1515        let mut on_press: HVec<Action, MAX_ACTIONS> = HVec::new();
1516        on_press.push(Action::cc(80, 127, 1).unwrap()).ok();
1517
1518        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
1519        buttons
1520            .push(ButtonConfig {
1521                label: Label::new(),
1522                color: LedConfig::default(),
1523                mode: ButtonMode::Toggle,
1524                on_press,
1525                on_release: HVec::new(),
1526                on_long_press: HVec::new(),
1527                cycle_values: HVec::new(),
1528                listen_cc: None,
1529            })
1530            .ok();
1531
1532        let config = make_config(buttons, HVec::new());
1533        let mut ctrl = Controller::new();
1534        ctrl.set_encoder_value(0, 42);
1535
1536        // Toggle button on
1537        ctrl.process(
1538            Event::ButtonEdge {
1539                index: 0,
1540                edge: Edge::Activate,
1541            },
1542            0,
1543            &config,
1544        );
1545
1546        let store = ctrl.snapshot_store();
1547        let state = store.current();
1548        assert!(state.button_active[0]);
1549        assert_eq!(state.encoder_values[0], 42);
1550    }
1551
1552    #[test]
1553    fn incoming_midi_trigger_below_value_min_no_match() {
1554        let mut triggers: HVec<Trigger, MAX_TRIGGERS> = HVec::new();
1555        triggers
1556            .push(Trigger {
1557                match_msg: TriggerMatch::Cc {
1558                    cc: 100,
1559                    channel: 1,
1560                    value_min: 64,
1561                    value_max: 127,
1562                },
1563                action: TriggerAction::Activate(0),
1564            })
1565            .ok();
1566
1567        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
1568        buttons
1569            .push(ButtonConfig {
1570                label: Label::new(),
1571                color: LedConfig::default(),
1572                mode: ButtonMode::Toggle,
1573                on_press: HVec::new(),
1574                on_release: HVec::new(),
1575                on_long_press: HVec::new(),
1576                cycle_values: HVec::new(),
1577                listen_cc: None,
1578            })
1579            .ok();
1580
1581        let config =
1582            make_config_with_presets(buttons, HVec::new(), HVec::new(), HVec::new(), triggers);
1583        let mut ctrl = Controller::new();
1584
1585        // Send CC#100 = 63 (below value_min=64)
1586        let result = ctrl.process(
1587            Event::IncomingMidi {
1588                data: [0xB0, 100, 63],
1589                len: 3,
1590            },
1591            0,
1592            &config,
1593        );
1594
1595        assert!(!ctrl.button_states()[0]);
1596        assert!(!result.leds_changed);
1597    }
1598
1599    #[test]
1600    fn preset_next_wraps_around() {
1601        let config = make_three_preset_config();
1602        let mut ctrl = Controller::new();
1603
1604        // Go to preset 2 first
1605        ctrl.select_preset(2, &config);
1606        assert_eq!(ctrl.active_preset(), 2);
1607
1608        // Now process PresetNext via a button
1609        let mut on_press: HVec<Action, MAX_ACTIONS> = HVec::new();
1610        on_press.push(Action::PresetNext).ok();
1611
1612        let mut buttons: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
1613        buttons.push(momentary_button(on_press, HVec::new())).ok();
1614
1615        let mut presets: HVec<Preset, MAX_PRESETS> = HVec::new();
1616        for _i in 0..3u8 {
1617            let mut btns: HVec<ButtonConfig, MAX_BUTTONS> = HVec::new();
1618            let mut bp: HVec<Action, MAX_ACTIONS> = HVec::new();
1619            bp.push(Action::PresetNext).ok();
1620            btns.push(momentary_button(bp, HVec::new())).ok();
1621            presets
1622                .push(Preset {
1623                    name: Label::try_from("P").unwrap(),
1624                    buttons: btns,
1625                    encoders: HVec::new(),
1626                    analog: HVec::new(),
1627                    defaults: Default::default(),
1628                    on_enter: HVec::new(),
1629                    on_exit: HVec::new(),
1630                    triggers: HVec::new(),
1631                })
1632                .ok();
1633        }
1634        let config2 = Config {
1635            global: GlobalConfig::default(),
1636            presets,
1637        };
1638        let mut ctrl2 = Controller::new();
1639        ctrl2.select_preset(2, &config2);
1640
1641        let result = ctrl2.process(
1642            Event::ButtonEdge {
1643                index: 0,
1644                edge: Edge::Activate,
1645            },
1646            0,
1647            &config2,
1648        );
1649
1650        assert!(result.preset_changed);
1651        assert_eq!(ctrl2.active_preset(), 0); // wraps from 2 → 0
1652    }
1653}