Skip to main content

midi_controller/
engine.rs

1//! Preset engine: processes abstract input events against preset config.
2//! Pure business logic — no hardware dependencies.
3
4use crate::action::{action_to_midi, analog_cc, encoder_cc, EncoderDirection, MidiMessage};
5use crate::config::{
6    Action, ButtonMode, EncoderAction, Label, Preset, MAX_ACTIONS, MAX_CYCLE_VALUES,
7};
8use crate::state::PresetState;
9
10/// Abstract button event after long-press detection is resolved.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ButtonEvent {
13    /// Short press (or immediate press if no long-press configured)
14    Press,
15    /// Release (for momentary mode)
16    Release,
17    /// Long press (held past threshold)
18    LongPress,
19}
20
21/// System-level actions that transcend MIDI output.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SystemAction {
24    PresetNext,
25    PresetPrev,
26    PresetSelect(u8),
27    SetBpm(u16),
28    TapTempo,
29}
30
31/// Which display to show an overlay on.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DisplaySide {
34    L,
35    R,
36}
37
38/// Display events emitted directly from actions.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum DisplayEvent {
41    EncoderOverlay {
42        side: DisplaySide,
43        label: Label,
44        value: u8,
45    },
46    AnalogOverlay {
47        side: DisplaySide,
48        label: Label,
49        value: u8,
50    },
51    /// Shown while holding a button with on_long_press (before threshold fires)
52    LongPressHint { action: SystemAction },
53    /// Clear the hint (button released before threshold)
54    LongPressCancel,
55    /// Tap tempo BPM overlay (shown briefly after BPM is computed)
56    BpmOverlay { bpm: u16 },
57}
58
59/// A single step in an action sequence: either a MIDI message, a delay, or an LED change.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum ActionStep {
62    Send(MidiMessage),
63    Delay(u16),
64    SetLed {
65        color: crate::config::Color,
66        animation: crate::config::LedAnimation,
67    },
68}
69
70/// Result of processing an input event.
71pub struct EngineResult {
72    pub midi: heapless::Vec<ActionStep, 8>,
73    pub system: heapless::Vec<SystemAction, 2>,
74    pub display: heapless::Vec<DisplayEvent, 2>,
75    pub led_dirty: bool,
76}
77
78impl EngineResult {
79    fn new() -> Self {
80        Self {
81            midi: heapless::Vec::new(),
82            system: heapless::Vec::new(),
83            display: heapless::Vec::new(),
84            led_dirty: false,
85        }
86    }
87}
88
89/// Process a button event. Updates state and returns MIDI/system/display actions.
90pub fn process_button<const B: usize, const E: usize, const A: usize>(
91    state: &mut PresetState<B, E>,
92    preset: &Preset<B, E, A>,
93    btn_idx: usize,
94    event: ButtonEvent,
95) -> EngineResult {
96    let mut result = EngineResult::new();
97    let Some(btn) = preset.buttons.get(btn_idx) else {
98        return result;
99    };
100    let mode = &btn.mode;
101
102    match event {
103        ButtonEvent::Press => match mode {
104            ButtonMode::Toggle => {
105                state.button_active[btn_idx] = !state.button_active[btn_idx];
106                result.led_dirty = true;
107                if state.button_active[btn_idx] {
108                    execute_actions(
109                        &btn.on_press,
110                        &btn.cycle_values,
111                        &mut result.midi,
112                        &mut result.system,
113                        &mut state.cycle_index[btn_idx],
114                    );
115                } else {
116                    execute_actions(
117                        &btn.on_release,
118                        &btn.cycle_values,
119                        &mut result.midi,
120                        &mut result.system,
121                        &mut state.cycle_index[btn_idx],
122                    );
123                }
124            }
125            ButtonMode::Momentary => {
126                state.button_active[btn_idx] = true;
127                result.led_dirty = true;
128                execute_actions(
129                    &btn.on_press,
130                    &btn.cycle_values,
131                    &mut result.midi,
132                    &mut result.system,
133                    &mut state.cycle_index[btn_idx],
134                );
135            }
136            ButtonMode::RadioGroup(group) => {
137                for j in 0..B {
138                    if j != btn_idx {
139                        if let Some(other) = preset.buttons.get(j) {
140                            if other.mode == ButtonMode::RadioGroup(*group) {
141                                state.button_active[j] = false;
142                            }
143                        }
144                    }
145                }
146                state.button_active[btn_idx] = true;
147                result.led_dirty = true;
148                execute_actions(
149                    &btn.on_press,
150                    &btn.cycle_values,
151                    &mut result.midi,
152                    &mut result.system,
153                    &mut state.cycle_index[btn_idx],
154                );
155            }
156        },
157        ButtonEvent::Release => {
158            if matches!(mode, ButtonMode::Momentary) {
159                state.button_active[btn_idx] = false;
160                result.led_dirty = true;
161                execute_actions(
162                    &btn.on_release,
163                    &btn.cycle_values,
164                    &mut result.midi,
165                    &mut result.system,
166                    &mut state.cycle_index[btn_idx],
167                );
168            }
169        }
170        ButtonEvent::LongPress => {
171            execute_actions(
172                &btn.on_long_press,
173                &btn.cycle_values,
174                &mut result.midi,
175                &mut result.system,
176                &mut state.cycle_index[btn_idx],
177            );
178        }
179    }
180
181    result
182}
183
184/// Process an encoder change. Applies steps, returns MIDI + display event.
185pub fn process_encoder<const B: usize, const E: usize, const A: usize>(
186    state: &mut PresetState<B, E>,
187    preset: &Preset<B, E, A>,
188    enc_idx: usize,
189    direction: EncoderDirection,
190    steps: u8,
191) -> EngineResult {
192    let mut result = EngineResult::new();
193    let Some(enc) = preset.encoders.get(enc_idx) else {
194        return result;
195    };
196
197    for _ in 0..steps {
198        encoder_cc(
199            preset,
200            enc_idx,
201            direction,
202            &mut state.encoder_values[enc_idx],
203        );
204    }
205
206    match &enc.action {
207        EncoderAction::Cc { cc, channel, .. } => {
208            result
209                .midi
210                .push(ActionStep::Send(MidiMessage {
211                    data: [
212                        0xB0 | (channel - 1),
213                        *cc as u8,
214                        state.encoder_values[enc_idx],
215                    ],
216                    len: 3,
217                }))
218                .ok();
219        }
220        EncoderAction::CcRelative {
221            cc,
222            channel,
223            increment,
224            decrement,
225        } => {
226            let val = match direction {
227                EncoderDirection::Clockwise => *increment,
228                EncoderDirection::CounterClockwise => *decrement,
229            };
230            result
231                .midi
232                .push(ActionStep::Send(MidiMessage {
233                    data: [0xB0 | (channel - 1), *cc, val],
234                    len: 3,
235                }))
236                .ok();
237        }
238        EncoderAction::PresetScroll => match direction {
239            EncoderDirection::Clockwise => {
240                result.system.push(SystemAction::PresetNext).ok();
241            }
242            EncoderDirection::CounterClockwise => {
243                result.system.push(SystemAction::PresetPrev).ok();
244            }
245        },
246    }
247
248    let side = if enc_idx == 0 {
249        DisplaySide::L
250    } else {
251        DisplaySide::R
252    };
253    result
254        .display
255        .push(DisplayEvent::EncoderOverlay {
256            side,
257            label: enc.label.clone(),
258            value: state.encoder_values[enc_idx],
259        })
260        .ok();
261    result.led_dirty = true;
262
263    result
264}
265
266/// Process an analog input change. Returns MIDI + display event.
267pub fn process_analog<const B: usize, const E: usize, const A: usize>(
268    preset: &Preset<B, E, A>,
269    analog_idx: usize,
270    raw: u16,
271    adc_min: u16,
272    adc_max: u16,
273) -> EngineResult {
274    let mut result = EngineResult::new();
275    if let Some(msg) = analog_cc(preset, analog_idx, raw, adc_min, adc_max) {
276        let side = if analog_idx == 0 {
277            DisplaySide::L
278        } else {
279            DisplaySide::R
280        };
281        let label = preset
282            .analog
283            .get(analog_idx)
284            .map(|a| a.label.clone())
285            .unwrap_or_default();
286        result
287            .display
288            .push(DisplayEvent::AnalogOverlay {
289                side,
290                label,
291                value: msg.data[2],
292            })
293            .ok();
294        result.midi.push(ActionStep::Send(msg)).ok();
295    }
296    result
297}
298
299fn execute_actions(
300    actions: &heapless::Vec<Action, MAX_ACTIONS>,
301    cycle_values: &heapless::Vec<u8, MAX_CYCLE_VALUES>,
302    midi: &mut heapless::Vec<ActionStep, 8>,
303    system: &mut heapless::Vec<SystemAction, 2>,
304    cycle_index: &mut u8,
305) {
306    for action in actions {
307        match action {
308            Action::PresetNext => {
309                system.push(SystemAction::PresetNext).ok();
310            }
311            Action::PresetPrev => {
312                system.push(SystemAction::PresetPrev).ok();
313            }
314            Action::PresetSelect(idx) => {
315                system.push(SystemAction::PresetSelect(*idx)).ok();
316            }
317            Action::BankUp | Action::BankDown => {
318                // Bank actions are PresetSelect with page arithmetic.
319                // Requires runtime context (active preset, page size) not available here.
320                // Handled as PresetNext/PresetPrev until bank concept is defined.
321                let sa = if matches!(action, Action::BankUp) {
322                    SystemAction::PresetNext
323                } else {
324                    SystemAction::PresetPrev
325                };
326                system.push(sa).ok();
327            }
328            Action::TapTempo => {
329                // Handled by firmware — needs timestamp context not available here.
330                // Emit as a system action so the caller can process it.
331                system.push(SystemAction::TapTempo).ok();
332            }
333            Action::Delay(ms) => {
334                midi.push(ActionStep::Delay(*ms)).ok();
335            }
336            Action::CcCycle {
337                cc,
338                channel,
339                reverse,
340            } => {
341                if !cycle_values.is_empty() {
342                    let idx = (*cycle_index as usize) % cycle_values.len();
343                    let value = cycle_values[idx];
344                    midi.push(ActionStep::Send(MidiMessage {
345                        data: [0xB0 | (channel - 1), *cc, value],
346                        len: 3,
347                    }))
348                    .ok();
349                    if *reverse {
350                        *cycle_index = if *cycle_index == 0 {
351                            (cycle_values.len() - 1) as u8
352                        } else {
353                            *cycle_index - 1
354                        };
355                    } else {
356                        *cycle_index = ((*cycle_index as usize + 1) % cycle_values.len()) as u8;
357                    }
358                }
359            }
360            Action::SetLed { color, animation } => {
361                midi.push(ActionStep::SetLed {
362                    color: *color,
363                    animation: *animation,
364                })
365                .ok();
366            }
367            _ => {
368                if let Some(msg) = action_to_midi(action) {
369                    midi.push(ActionStep::Send(msg)).ok();
370                }
371            }
372        }
373    }
374}
375
376/// Result of processing incoming MIDI against triggers.
377pub struct TriggerResult {
378    /// MIDI messages to send (from Execute action).
379    pub midi: heapless::Vec<ActionStep, 8>,
380    /// System actions (preset switch).
381    pub system: heapless::Vec<SystemAction, 2>,
382    /// Whether LED state changed (activate/deactivate).
383    pub led_dirty: bool,
384}
385
386/// Process incoming MIDI against preset triggers. Updates button state directly.
387pub fn process_triggers<const B: usize, const E: usize, const A: usize>(
388    state: &mut PresetState<B, E>,
389    preset: &Preset<B, E, A>,
390    status: u8,
391    data1: u8,
392    data2: u8,
393) -> TriggerResult {
394    use crate::config::{TriggerAction, TriggerMatch};
395
396    let mut result = TriggerResult {
397        midi: heapless::Vec::new(),
398        system: heapless::Vec::new(),
399        led_dirty: false,
400    };
401
402    let msg_type = status & 0xF0;
403    let channel = (status & 0x0F) + 1;
404
405    for trigger in &preset.triggers {
406        let matched = match &trigger.match_msg {
407            TriggerMatch::Cc {
408                cc,
409                channel: ch,
410                value_min,
411                value_max,
412            } => {
413                msg_type == 0xB0
414                    && channel == *ch
415                    && data1 == *cc
416                    && data2 >= *value_min
417                    && data2 <= *value_max
418            }
419            TriggerMatch::ProgramChange {
420                program,
421                channel: ch,
422            } => msg_type == 0xC0 && channel == *ch && data1 == *program,
423            TriggerMatch::NoteOn { note, channel: ch } => {
424                msg_type == 0x90 && channel == *ch && data1 == *note && data2 > 0
425            }
426        };
427
428        if !matched {
429            continue;
430        }
431
432        match &trigger.action {
433            TriggerAction::Activate(btn_idx) => {
434                let idx = *btn_idx as usize;
435                if idx < state.button_active.len() {
436                    state.button_active[idx] = true;
437                    result.led_dirty = true;
438                }
439            }
440            TriggerAction::Deactivate(btn_idx) => {
441                let idx = *btn_idx as usize;
442                if idx < state.button_active.len() {
443                    state.button_active[idx] = false;
444                    result.led_dirty = true;
445                }
446            }
447            TriggerAction::PresetSelect(preset_idx) => {
448                result
449                    .system
450                    .push(SystemAction::PresetSelect(*preset_idx))
451                    .ok();
452            }
453            TriggerAction::Execute(btn_idx) => {
454                let idx = *btn_idx as usize;
455                if let Some(btn) = preset.buttons.get(idx) {
456                    execute_actions(
457                        &btn.on_press,
458                        &btn.cycle_values,
459                        &mut result.midi,
460                        &mut result.system,
461                        &mut state.cycle_index[idx],
462                    );
463                }
464            }
465        }
466    }
467
468    result
469}
470
471/// Result of a reactive CC match.
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473pub enum ReactiveResult {
474    /// Heatmap fill level (0-12 LEDs).
475    Heatmap(usize, u8),
476    /// Trigger on/off (button index, active).
477    Trigger(usize, bool),
478}
479
480/// Check incoming CC against preset's reactive LED bindings.
481pub fn process_incoming_cc<const B: usize, const E: usize, const A: usize>(
482    preset: &Preset<B, E, A>,
483    channel: u8,
484    cc: u8,
485    value: u8,
486) -> Option<ReactiveResult> {
487    use crate::config::ListenMode;
488    for (i, btn) in preset.buttons.iter().enumerate() {
489        if let Some(listen) = &btn.listen_cc {
490            if listen.cc == cc && listen.channel == channel {
491                return Some(match listen.mode {
492                    ListenMode::Heatmap => {
493                        let fill = ((value as u16 * 12) / 127).min(12) as u8;
494                        ReactiveResult::Heatmap(i, fill)
495                    }
496                    ListenMode::Trigger => ReactiveResult::Trigger(i, value >= listen.threshold),
497                });
498            }
499        }
500    }
501    None
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::config::*;
508
509    fn make_toggle_preset() -> Preset {
510        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
511        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
512        on_press.push(Action::cc(80, 127, 1).unwrap()).ok();
513        let mut on_release: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
514        on_release.push(Action::cc(80, 0, 1).unwrap()).ok();
515        buttons
516            .push(ButtonConfig {
517                label: Label::new(),
518                color: LedConfig::default(),
519                mode: ButtonMode::Toggle,
520                on_press,
521                on_release,
522                on_long_press: heapless::Vec::new(),
523                cycle_values: heapless::Vec::new(),
524                listen_cc: None,
525            })
526            .ok();
527        Preset {
528            name: Label::try_from("Test").unwrap(),
529            buttons,
530            encoders: heapless::Vec::new(),
531            analog: heapless::Vec::new(),
532            defaults: Default::default(),
533            on_enter: heapless::Vec::new(),
534            on_exit: heapless::Vec::new(),
535            triggers: heapless::Vec::new(),
536        }
537    }
538
539    #[test]
540    fn toggle_press_flips_state_and_fires_on_press() {
541        let preset = make_toggle_preset();
542        let mut state = PresetState::default();
543
544        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
545        assert!(state.button_active[0]);
546        assert!(r.led_dirty);
547        assert_eq!(r.midi.len(), 1);
548        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 80, 127]));
549    }
550
551    #[test]
552    fn toggle_second_press_deactivates_and_fires_on_release() {
553        let preset = make_toggle_preset();
554        let mut state = PresetState::default();
555
556        process_button(&mut state, &preset, 0, ButtonEvent::Press);
557        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
558        assert!(!state.button_active[0]);
559        // Second press deactivates → fires on_release (CC#80 = 0)
560        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 80, 0]));
561    }
562
563    #[test]
564    fn momentary_press_and_release() {
565        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
566        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
567        on_press.push(Action::note_on(60, 1).unwrap()).ok();
568        let mut on_release: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
569        on_release.push(Action::note_off(60, 1).unwrap()).ok();
570        buttons
571            .push(ButtonConfig {
572                label: Label::new(),
573                color: LedConfig::default(),
574                mode: ButtonMode::Momentary,
575                on_press,
576                on_release,
577                on_long_press: heapless::Vec::new(),
578                cycle_values: heapless::Vec::new(),
579                listen_cc: None,
580            })
581            .ok();
582        let preset: Preset = Preset {
583            name: Label::new(),
584            buttons,
585            encoders: heapless::Vec::new(),
586            analog: heapless::Vec::new(),
587            defaults: Default::default(),
588            on_enter: heapless::Vec::new(),
589            on_exit: heapless::Vec::new(),
590            triggers: heapless::Vec::new(),
591        };
592        let mut state = PresetState::default();
593
594        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
595        assert!(state.button_active[0]);
596        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0x90, 60, 127]));
597
598        let r = process_button(&mut state, &preset, 0, ButtonEvent::Release);
599        assert!(!state.button_active[0]);
600        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0x80, 60, 0]));
601    }
602
603    #[test]
604    fn long_press_fires_on_long_press_actions() {
605        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
606        let mut on_long_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
607        on_long_press.push(Action::PresetNext).ok();
608        buttons
609            .push(ButtonConfig {
610                label: Label::new(),
611                color: LedConfig::default(),
612                mode: ButtonMode::Momentary,
613                on_press: heapless::Vec::new(),
614                on_release: heapless::Vec::new(),
615                on_long_press,
616                cycle_values: heapless::Vec::new(),
617                listen_cc: None,
618            })
619            .ok();
620        let preset: Preset = Preset {
621            name: Label::new(),
622            buttons,
623            encoders: heapless::Vec::new(),
624            analog: heapless::Vec::new(),
625            defaults: Default::default(),
626            on_enter: heapless::Vec::new(),
627            on_exit: heapless::Vec::new(),
628            triggers: heapless::Vec::new(),
629        };
630        let mut state = PresetState::default();
631
632        let r = process_button(&mut state, &preset, 0, ButtonEvent::LongPress);
633        assert_eq!(r.system.len(), 1);
634        assert_eq!(r.system[0], SystemAction::PresetNext);
635    }
636
637    #[test]
638    fn radio_group_deactivates_others() {
639        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
640        for _ in 0..3 {
641            buttons
642                .push(ButtonConfig {
643                    label: Label::new(),
644                    color: LedConfig::default(),
645                    mode: ButtonMode::RadioGroup(1),
646                    on_press: heapless::Vec::new(),
647                    on_release: heapless::Vec::new(),
648                    on_long_press: heapless::Vec::new(),
649                    cycle_values: heapless::Vec::new(),
650                    listen_cc: None,
651                })
652                .ok();
653        }
654        let preset: Preset = Preset {
655            name: Label::new(),
656            buttons,
657            encoders: heapless::Vec::new(),
658            analog: heapless::Vec::new(),
659            defaults: Default::default(),
660            on_enter: heapless::Vec::new(),
661            on_exit: heapless::Vec::new(),
662            triggers: heapless::Vec::new(),
663        };
664        let mut state = PresetState::default();
665
666        process_button(&mut state, &preset, 0, ButtonEvent::Press);
667        assert!(state.button_active[0]);
668
669        process_button(&mut state, &preset, 1, ButtonEvent::Press);
670        assert!(!state.button_active[0]);
671        assert!(state.button_active[1]);
672    }
673
674    #[test]
675    fn encoder_cc_increments_and_emits_display() {
676        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
677        encoders
678            .push(EncoderConfig {
679                label: Label::try_from("Vol").unwrap(),
680                action: EncoderAction::Cc {
681                    cc: 7,
682                    channel: 1,
683                    min: 0,
684                    max: 127,
685                },
686            })
687            .ok();
688        let preset: Preset = Preset {
689            name: Label::new(),
690            buttons: heapless::Vec::new(),
691            encoders,
692            analog: heapless::Vec::new(),
693            defaults: Default::default(),
694            on_enter: heapless::Vec::new(),
695            on_exit: heapless::Vec::new(),
696            triggers: heapless::Vec::new(),
697        };
698        let mut state = PresetState::default();
699        state.encoder_values[0] = 64;
700
701        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 1);
702        assert_eq!(state.encoder_values[0], 65);
703        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 7, 65]));
704        assert_eq!(r.display.len(), 1);
705        match &r.display[0] {
706            DisplayEvent::EncoderOverlay { side, label, value } => {
707                assert_eq!(*side, DisplaySide::L);
708                assert_eq!(label.as_str(), "Vol");
709                assert_eq!(*value, 65);
710            }
711            _ => panic!("expected EncoderOverlay"),
712        }
713    }
714
715    #[test]
716    fn encoder_cc_counter_clockwise_decrements() {
717        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
718        encoders
719            .push(EncoderConfig {
720                label: Label::try_from("Vol").unwrap(),
721                action: EncoderAction::Cc {
722                    cc: 7,
723                    channel: 1,
724                    min: 0,
725                    max: 127,
726                },
727            })
728            .ok();
729        let preset: Preset = Preset {
730            name: Label::new(),
731            buttons: heapless::Vec::new(),
732            encoders,
733            analog: heapless::Vec::new(),
734            defaults: Default::default(),
735            on_enter: heapless::Vec::new(),
736            on_exit: heapless::Vec::new(),
737            triggers: heapless::Vec::new(),
738        };
739        let mut state = PresetState::default();
740        state.encoder_values[0] = 64;
741
742        let r = process_encoder(
743            &mut state,
744            &preset,
745            0,
746            EncoderDirection::CounterClockwise,
747            1,
748        );
749        assert_eq!(state.encoder_values[0], 63);
750        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 7, 63]));
751    }
752
753    #[test]
754    fn encoder_cc_multi_step_acceleration() {
755        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
756        encoders
757            .push(EncoderConfig {
758                label: Label::try_from("Gain").unwrap(),
759                action: EncoderAction::Cc {
760                    cc: 7,
761                    channel: 1,
762                    min: 0,
763                    max: 127,
764                },
765            })
766            .ok();
767        let preset: Preset = Preset {
768            name: Label::new(),
769            buttons: heapless::Vec::new(),
770            encoders,
771            analog: heapless::Vec::new(),
772            defaults: Default::default(),
773            on_enter: heapless::Vec::new(),
774            on_exit: heapless::Vec::new(),
775            triggers: heapless::Vec::new(),
776        };
777        let mut state = PresetState::default();
778        state.encoder_values[0] = 50;
779
780        // 4 steps CW: 50 → 54, single MIDI message with final value
781        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 4);
782        assert_eq!(state.encoder_values[0], 54);
783        assert_eq!(r.midi.len(), 1);
784        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 7, 54]));
785    }
786
787    #[test]
788    fn encoder_cc_clamps_at_max() {
789        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
790        encoders
791            .push(EncoderConfig {
792                label: Label::new(),
793                action: EncoderAction::Cc {
794                    cc: 7,
795                    channel: 1,
796                    min: 0,
797                    max: 100,
798                },
799            })
800            .ok();
801        let preset: Preset = Preset {
802            name: Label::new(),
803            buttons: heapless::Vec::new(),
804            encoders,
805            analog: heapless::Vec::new(),
806            defaults: Default::default(),
807            on_enter: heapless::Vec::new(),
808            on_exit: heapless::Vec::new(),
809            triggers: heapless::Vec::new(),
810        };
811        let mut state = PresetState::default();
812        state.encoder_values[0] = 99;
813
814        // 5 steps CW from 99, max=100 → clamps at 100
815        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 5);
816        assert_eq!(state.encoder_values[0], 100);
817        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data[2] == 100));
818    }
819
820    #[test]
821    fn encoder_cc_clamps_at_min() {
822        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
823        encoders
824            .push(EncoderConfig {
825                label: Label::new(),
826                action: EncoderAction::Cc {
827                    cc: 7,
828                    channel: 1,
829                    min: 20,
830                    max: 127,
831                },
832            })
833            .ok();
834        let preset: Preset = Preset {
835            name: Label::new(),
836            buttons: heapless::Vec::new(),
837            encoders,
838            analog: heapless::Vec::new(),
839            defaults: Default::default(),
840            on_enter: heapless::Vec::new(),
841            on_exit: heapless::Vec::new(),
842            triggers: heapless::Vec::new(),
843        };
844        let mut state = PresetState::default();
845        state.encoder_values[0] = 21;
846
847        // 5 steps CCW from 21, min=20 → clamps at 20
848        let r = process_encoder(
849            &mut state,
850            &preset,
851            0,
852            EncoderDirection::CounterClockwise,
853            5,
854        );
855        assert_eq!(state.encoder_values[0], 20);
856        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data[2] == 20));
857    }
858
859    #[test]
860    fn encoder_relative_sends_increment_decrement() {
861        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
862        encoders
863            .push(EncoderConfig {
864                label: Label::try_from("FX").unwrap(),
865                action: EncoderAction::CcRelative {
866                    cc: 16,
867                    channel: 1,
868                    increment: 65,
869                    decrement: 63,
870                },
871            })
872            .ok();
873        let preset: Preset = Preset {
874            name: Label::new(),
875            buttons: heapless::Vec::new(),
876            encoders,
877            analog: heapless::Vec::new(),
878            defaults: Default::default(),
879            on_enter: heapless::Vec::new(),
880            on_exit: heapless::Vec::new(),
881            triggers: heapless::Vec::new(),
882        };
883        let mut state = PresetState::default();
884
885        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 1);
886        assert_eq!(r.midi.len(), 1);
887        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 16, 65]));
888
889        let r = process_encoder(
890            &mut state,
891            &preset,
892            0,
893            EncoderDirection::CounterClockwise,
894            1,
895        );
896        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 16, 63]));
897    }
898
899    #[test]
900    fn encoder_preset_scroll_emits_system_action() {
901        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
902        encoders
903            .push(EncoderConfig {
904                label: Label::try_from("Bank").unwrap(),
905                action: EncoderAction::PresetScroll,
906            })
907            .ok();
908        let preset: Preset = Preset {
909            name: Label::new(),
910            buttons: heapless::Vec::new(),
911            encoders,
912            analog: heapless::Vec::new(),
913            defaults: Default::default(),
914            on_enter: heapless::Vec::new(),
915            on_exit: heapless::Vec::new(),
916            triggers: heapless::Vec::new(),
917        };
918        let mut state = PresetState::default();
919
920        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 1);
921        assert!(r.midi.is_empty());
922        assert_eq!(r.system.len(), 1);
923        assert_eq!(r.system[0], SystemAction::PresetNext);
924
925        let r = process_encoder(
926            &mut state,
927            &preset,
928            0,
929            EncoderDirection::CounterClockwise,
930            1,
931        );
932        assert_eq!(r.system[0], SystemAction::PresetPrev);
933    }
934
935    #[test]
936    fn encoder_invalid_index_returns_empty() {
937        let preset: Preset = Preset {
938            name: Label::new(),
939            buttons: heapless::Vec::new(),
940            encoders: heapless::Vec::new(),
941            analog: heapless::Vec::new(),
942            defaults: Default::default(),
943            on_enter: heapless::Vec::new(),
944            on_exit: heapless::Vec::new(),
945            triggers: heapless::Vec::new(),
946        };
947        let mut state = PresetState::default();
948
949        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 1);
950        assert!(r.midi.is_empty());
951        assert!(r.system.is_empty());
952        assert!(r.display.is_empty());
953    }
954
955    #[test]
956    fn encoder_second_index_uses_right_display() {
957        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
958        encoders
959            .push(EncoderConfig {
960                label: Label::try_from("Vol").unwrap(),
961                action: EncoderAction::Cc {
962                    cc: 7,
963                    channel: 1,
964                    min: 0,
965                    max: 127,
966                },
967            })
968            .ok();
969        encoders
970            .push(EncoderConfig {
971                label: Label::try_from("Gain").unwrap(),
972                action: EncoderAction::Cc {
973                    cc: 8,
974                    channel: 1,
975                    min: 0,
976                    max: 127,
977                },
978            })
979            .ok();
980        let preset: Preset = Preset {
981            name: Label::new(),
982            buttons: heapless::Vec::new(),
983            encoders,
984            analog: heapless::Vec::new(),
985            defaults: Default::default(),
986            on_enter: heapless::Vec::new(),
987            on_exit: heapless::Vec::new(),
988            triggers: heapless::Vec::new(),
989        };
990        let mut state = PresetState::default();
991        state.encoder_values[1] = 50;
992
993        let r = process_encoder(&mut state, &preset, 1, EncoderDirection::Clockwise, 1);
994        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 8, 51]));
995        match &r.display[0] {
996            DisplayEvent::EncoderOverlay { side, label, value } => {
997                assert_eq!(*side, DisplaySide::R);
998                assert_eq!(label.as_str(), "Gain");
999                assert_eq!(*value, 51);
1000            }
1001            _ => panic!("expected EncoderOverlay"),
1002        }
1003    }
1004
1005    #[test]
1006    fn delay_in_action_sequence() {
1007        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1008        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1009        on_press.push(Action::cc(1, 127, 1).unwrap()).ok();
1010        on_press.push(Action::Delay(50)).ok();
1011        on_press.push(Action::cc(2, 0, 1).unwrap()).ok();
1012        buttons
1013            .push(ButtonConfig {
1014                label: Label::new(),
1015                color: LedConfig::default(),
1016                mode: ButtonMode::Momentary,
1017                on_press,
1018                on_release: heapless::Vec::new(),
1019                on_long_press: heapless::Vec::new(),
1020                cycle_values: heapless::Vec::new(),
1021                listen_cc: None,
1022            })
1023            .ok();
1024        let preset: Preset = Preset {
1025            name: Label::new(),
1026            buttons,
1027            encoders: heapless::Vec::new(),
1028            analog: heapless::Vec::new(),
1029            defaults: Default::default(),
1030            on_enter: heapless::Vec::new(),
1031            on_exit: heapless::Vec::new(),
1032            triggers: heapless::Vec::new(),
1033        };
1034        let mut state = PresetState::default();
1035
1036        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1037        assert_eq!(r.midi.len(), 3);
1038        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 1, 127]));
1039        assert_eq!(r.midi[1], ActionStep::Delay(50));
1040        assert!(matches!(&r.midi[2], ActionStep::Send(m) if m.data == [0xB0, 2, 0]));
1041    }
1042
1043    #[test]
1044    fn set_led_in_action_sequence() {
1045        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1046        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1047        on_press.push(Action::cc(3, 127, 1).unwrap()).ok();
1048        on_press
1049            .push(Action::SetLed {
1050                color: Color::Red,
1051                animation: LedAnimation::Blink,
1052            })
1053            .ok();
1054        on_press.push(Action::Delay(100)).ok();
1055        on_press
1056            .push(Action::SetLed {
1057                color: Color::Green,
1058                animation: LedAnimation::Solid,
1059            })
1060            .ok();
1061        buttons
1062            .push(ButtonConfig {
1063                label: Label::new(),
1064                color: LedConfig::default(),
1065                mode: ButtonMode::Momentary,
1066                on_press,
1067                on_release: heapless::Vec::new(),
1068                on_long_press: heapless::Vec::new(),
1069                cycle_values: heapless::Vec::new(),
1070                listen_cc: None,
1071            })
1072            .ok();
1073        let preset: Preset = Preset {
1074            name: Label::new(),
1075            buttons,
1076            encoders: heapless::Vec::new(),
1077            analog: heapless::Vec::new(),
1078            defaults: Default::default(),
1079            on_enter: heapless::Vec::new(),
1080            on_exit: heapless::Vec::new(),
1081            triggers: heapless::Vec::new(),
1082        };
1083        let mut state = PresetState::default();
1084
1085        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1086        assert_eq!(r.midi.len(), 4);
1087        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 3, 127]));
1088        assert_eq!(
1089            r.midi[1],
1090            ActionStep::SetLed {
1091                color: Color::Red,
1092                animation: LedAnimation::Blink
1093            }
1094        );
1095        assert_eq!(r.midi[2], ActionStep::Delay(100));
1096        assert_eq!(
1097            r.midi[3],
1098            ActionStep::SetLed {
1099                color: Color::Green,
1100                animation: LedAnimation::Solid
1101            }
1102        );
1103    }
1104
1105    #[test]
1106    fn incoming_cc_matches_listen_binding() {
1107        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1108        buttons
1109            .push(ButtonConfig {
1110                label: Label::new(),
1111                color: LedConfig::default(),
1112                mode: ButtonMode::default(),
1113                on_press: heapless::Vec::new(),
1114                on_release: heapless::Vec::new(),
1115                on_long_press: heapless::Vec::new(),
1116                cycle_values: heapless::Vec::new(),
1117                listen_cc: Some(ListenCc {
1118                    cc: 100,
1119                    channel: 3,
1120                    mode: ListenMode::default(),
1121                    threshold: 64,
1122                }),
1123            })
1124            .ok();
1125        let preset: Preset = Preset {
1126            name: Label::new(),
1127            buttons,
1128            encoders: heapless::Vec::new(),
1129            analog: heapless::Vec::new(),
1130            defaults: Default::default(),
1131            on_enter: heapless::Vec::new(),
1132            on_exit: heapless::Vec::new(),
1133            triggers: heapless::Vec::new(),
1134        };
1135
1136        // Matching CC
1137        let result = process_incoming_cc(&preset, 3, 100, 127);
1138        assert_eq!(result, Some(ReactiveResult::Heatmap(0, 12)));
1139
1140        // Half value
1141        let result = process_incoming_cc(&preset, 3, 100, 64);
1142        assert_eq!(result, Some(ReactiveResult::Heatmap(0, 6)));
1143
1144        // Zero
1145        let result = process_incoming_cc(&preset, 3, 100, 0);
1146        assert_eq!(result, Some(ReactiveResult::Heatmap(0, 0)));
1147
1148        // Wrong CC
1149        assert_eq!(process_incoming_cc(&preset, 3, 99, 127), None);
1150
1151        // Wrong channel
1152        assert_eq!(process_incoming_cc(&preset, 1, 100, 127), None);
1153    }
1154
1155    #[test]
1156    fn incoming_cc_trigger_mode() {
1157        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1158        buttons
1159            .push(ButtonConfig {
1160                label: Label::new(),
1161                color: LedConfig::default(),
1162                mode: ButtonMode::default(),
1163                on_press: heapless::Vec::new(),
1164                on_release: heapless::Vec::new(),
1165                on_long_press: heapless::Vec::new(),
1166                cycle_values: heapless::Vec::new(),
1167                listen_cc: Some(ListenCc {
1168                    cc: 80,
1169                    channel: 2,
1170                    mode: ListenMode::Trigger,
1171                    threshold: 64,
1172                }),
1173            })
1174            .ok();
1175        let preset: Preset = Preset {
1176            name: Label::new(),
1177            buttons,
1178            encoders: heapless::Vec::new(),
1179            analog: heapless::Vec::new(),
1180            defaults: Default::default(),
1181            on_enter: heapless::Vec::new(),
1182            on_exit: heapless::Vec::new(),
1183            triggers: heapless::Vec::new(),
1184        };
1185
1186        assert_eq!(
1187            process_incoming_cc(&preset, 2, 80, 127),
1188            Some(ReactiveResult::Trigger(0, true))
1189        );
1190        assert_eq!(
1191            process_incoming_cc(&preset, 2, 80, 64),
1192            Some(ReactiveResult::Trigger(0, true))
1193        );
1194        assert_eq!(
1195            process_incoming_cc(&preset, 2, 80, 63),
1196            Some(ReactiveResult::Trigger(0, false))
1197        );
1198        assert_eq!(
1199            process_incoming_cc(&preset, 2, 80, 0),
1200            Some(ReactiveResult::Trigger(0, false))
1201        );
1202    }
1203
1204    #[test]
1205    fn preset_select_emits_system_action() {
1206        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1207        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1208        on_press.push(Action::PresetSelect(5)).ok();
1209        buttons
1210            .push(ButtonConfig {
1211                label: Label::new(),
1212                color: LedConfig::default(),
1213                mode: ButtonMode::Momentary,
1214                on_press,
1215                on_release: heapless::Vec::new(),
1216                on_long_press: heapless::Vec::new(),
1217                cycle_values: heapless::Vec::new(),
1218                listen_cc: None,
1219            })
1220            .ok();
1221        let preset: Preset = Preset {
1222            name: Label::new(),
1223            buttons,
1224            encoders: heapless::Vec::new(),
1225            analog: heapless::Vec::new(),
1226            defaults: Default::default(),
1227            on_enter: heapless::Vec::new(),
1228            on_exit: heapless::Vec::new(),
1229            triggers: heapless::Vec::new(),
1230        };
1231        let mut state = PresetState::default();
1232
1233        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1234        assert_eq!(r.system.len(), 1);
1235        assert_eq!(r.system[0], SystemAction::PresetSelect(5));
1236        // No MIDI output — it's a system action only
1237        assert!(r.midi.is_empty());
1238    }
1239
1240    #[test]
1241    fn bank_up_down_emit_system_actions() {
1242        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1243        let mut on_press_up: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1244        on_press_up.push(Action::BankUp).ok();
1245        let mut on_press_down: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1246        on_press_down.push(Action::BankDown).ok();
1247        buttons
1248            .push(ButtonConfig {
1249                label: Label::new(),
1250                color: LedConfig::default(),
1251                mode: ButtonMode::Momentary,
1252                on_press: on_press_up,
1253                on_release: heapless::Vec::new(),
1254                on_long_press: heapless::Vec::new(),
1255                cycle_values: heapless::Vec::new(),
1256                listen_cc: None,
1257            })
1258            .ok();
1259        buttons
1260            .push(ButtonConfig {
1261                label: Label::new(),
1262                color: LedConfig::default(),
1263                mode: ButtonMode::Momentary,
1264                on_press: on_press_down,
1265                on_release: heapless::Vec::new(),
1266                on_long_press: heapless::Vec::new(),
1267                cycle_values: heapless::Vec::new(),
1268                listen_cc: None,
1269            })
1270            .ok();
1271        let preset: Preset = Preset {
1272            name: Label::new(),
1273            buttons,
1274            encoders: heapless::Vec::new(),
1275            analog: heapless::Vec::new(),
1276            defaults: Default::default(),
1277            on_enter: heapless::Vec::new(),
1278            on_exit: heapless::Vec::new(),
1279            triggers: heapless::Vec::new(),
1280        };
1281        let mut state = PresetState::default();
1282
1283        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1284        assert_eq!(r.system.len(), 1);
1285        assert_eq!(r.system[0], SystemAction::PresetNext);
1286
1287        let r = process_button(&mut state, &preset, 1, ButtonEvent::Press);
1288        assert_eq!(r.system.len(), 1);
1289        assert_eq!(r.system[0], SystemAction::PresetPrev);
1290    }
1291
1292    // --- CcCycle tests ---
1293
1294    fn make_cc_cycle_preset(reverse: bool) -> Preset {
1295        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1296        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1297        on_press
1298            .push(Action::CcCycle {
1299                cc: 50,
1300                channel: 1,
1301                reverse,
1302            })
1303            .ok();
1304        let mut cycle_values: heapless::Vec<u8, MAX_CYCLE_VALUES> = heapless::Vec::new();
1305        cycle_values.push(0).ok();
1306        cycle_values.push(32).ok();
1307        cycle_values.push(64).ok();
1308        cycle_values.push(127).ok();
1309        buttons
1310            .push(ButtonConfig {
1311                label: Label::new(),
1312                color: LedConfig::default(),
1313                mode: ButtonMode::Momentary,
1314                on_press,
1315                on_release: heapless::Vec::new(),
1316                on_long_press: heapless::Vec::new(),
1317                cycle_values,
1318                listen_cc: None,
1319            })
1320            .ok();
1321        Preset {
1322            name: Label::new(),
1323            buttons,
1324            encoders: heapless::Vec::new(),
1325            analog: heapless::Vec::new(),
1326            defaults: Default::default(),
1327            on_enter: heapless::Vec::new(),
1328            on_exit: heapless::Vec::new(),
1329            triggers: heapless::Vec::new(),
1330        }
1331    }
1332
1333    #[test]
1334    fn cc_cycle_forward_iterates_values() {
1335        let preset = make_cc_cycle_preset(false);
1336        let mut state = PresetState::default();
1337
1338        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1339        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 0]));
1340
1341        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1342        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 32]));
1343
1344        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1345        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 64]));
1346
1347        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1348        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 127]));
1349    }
1350
1351    #[test]
1352    fn cc_cycle_forward_wraps_around() {
1353        let preset = make_cc_cycle_preset(false);
1354        let mut state = PresetState::default();
1355
1356        // Cycle through all 4 values
1357        for _ in 0..4 {
1358            process_button(&mut state, &preset, 0, ButtonEvent::Press);
1359        }
1360        // 5th press should wrap to first value
1361        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1362        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 0]));
1363    }
1364
1365    #[test]
1366    fn cc_cycle_reverse_iterates_backward() {
1367        let preset = make_cc_cycle_preset(true);
1368        let mut state = PresetState::default();
1369
1370        // First press at index 0: value=0, then index wraps to 3
1371        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1372        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 0]));
1373
1374        // Second press at index 3: value=127, then index becomes 2
1375        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1376        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 127]));
1377
1378        // Third press at index 2: value=64
1379        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1380        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 64]));
1381    }
1382
1383    #[test]
1384    fn cc_cycle_empty_values_no_output() {
1385        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1386        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1387        on_press
1388            .push(Action::CcCycle {
1389                cc: 50,
1390                channel: 1,
1391                reverse: false,
1392            })
1393            .ok();
1394        buttons
1395            .push(ButtonConfig {
1396                label: Label::new(),
1397                color: LedConfig::default(),
1398                mode: ButtonMode::Momentary,
1399                on_press,
1400                on_release: heapless::Vec::new(),
1401                on_long_press: heapless::Vec::new(),
1402                cycle_values: heapless::Vec::new(), // empty!
1403                listen_cc: None,
1404            })
1405            .ok();
1406        let preset: Preset = Preset {
1407            name: Label::new(),
1408            buttons,
1409            encoders: heapless::Vec::new(),
1410            analog: heapless::Vec::new(),
1411            defaults: Default::default(),
1412            on_enter: heapless::Vec::new(),
1413            on_exit: heapless::Vec::new(),
1414            triggers: heapless::Vec::new(),
1415        };
1416        let mut state = PresetState::default();
1417
1418        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1419        assert!(r.midi.is_empty());
1420    }
1421
1422    // --- process_analog tests ---
1423
1424    fn make_analog_preset(cc: u8, channel: u8, min: u8, max: u8) -> Preset {
1425        let mut analog: heapless::Vec<AnalogConfig, MAX_ANALOG> = heapless::Vec::new();
1426        analog
1427            .push(AnalogConfig {
1428                label: Label::try_from("Wah").unwrap(),
1429                cc,
1430                channel,
1431                min,
1432                max,
1433            })
1434            .ok();
1435        Preset {
1436            name: Label::new(),
1437            buttons: heapless::Vec::new(),
1438            encoders: heapless::Vec::new(),
1439            analog,
1440            defaults: Default::default(),
1441            on_enter: heapless::Vec::new(),
1442            on_exit: heapless::Vec::new(),
1443            triggers: heapless::Vec::new(),
1444        }
1445    }
1446
1447    #[test]
1448    fn analog_full_range_maps_to_max() {
1449        let preset = make_analog_preset(11, 1, 0, 127);
1450        let r = process_analog(&preset, 0, 3750, 0, 3750);
1451        assert_eq!(r.midi.len(), 1);
1452        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 11, 127]));
1453    }
1454
1455    #[test]
1456    fn analog_min_maps_to_zero() {
1457        let preset = make_analog_preset(11, 1, 0, 127);
1458        let r = process_analog(&preset, 0, 0, 0, 3750);
1459        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 11, 0]));
1460    }
1461
1462    #[test]
1463    fn analog_midpoint() {
1464        let preset = make_analog_preset(11, 1, 0, 127);
1465        let r = process_analog(&preset, 0, 1875, 0, 3750);
1466        // 1875/3750 * 127 = 63.5 → 63
1467        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data[2] == 63));
1468    }
1469
1470    #[test]
1471    fn analog_custom_range() {
1472        let preset = make_analog_preset(11, 1, 20, 100);
1473        let r = process_analog(&preset, 0, 3750, 0, 3750);
1474        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 11, 100]));
1475
1476        let r = process_analog(&preset, 0, 0, 0, 3750);
1477        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 11, 20]));
1478    }
1479
1480    #[test]
1481    fn analog_clamps_below_adc_min() {
1482        let preset = make_analog_preset(11, 1, 0, 127);
1483        // raw=100 but adc_min=200 — clamps to min
1484        let r = process_analog(&preset, 0, 100, 200, 3750);
1485        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data[2] == 0));
1486    }
1487
1488    #[test]
1489    fn analog_clamps_above_adc_max() {
1490        let preset = make_analog_preset(11, 1, 0, 127);
1491        // raw=4000 but adc_max=3750 — clamps to max
1492        let r = process_analog(&preset, 0, 4000, 0, 3750);
1493        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data[2] == 127));
1494    }
1495
1496    #[test]
1497    fn analog_emits_display_event() {
1498        let preset = make_analog_preset(11, 1, 0, 127);
1499        let r = process_analog(&preset, 0, 1875, 0, 3750);
1500        assert_eq!(r.display.len(), 1);
1501        match &r.display[0] {
1502            DisplayEvent::AnalogOverlay { side, label, value } => {
1503                assert_eq!(*side, DisplaySide::L);
1504                assert_eq!(label.as_str(), "Wah");
1505                assert_eq!(*value, 63);
1506            }
1507            _ => panic!("expected AnalogOverlay"),
1508        }
1509    }
1510
1511    #[test]
1512    fn analog_invalid_index_returns_empty() {
1513        let preset = make_analog_preset(11, 1, 0, 127);
1514        let r = process_analog(&preset, 5, 1875, 0, 3750);
1515        assert!(r.midi.is_empty());
1516        assert!(r.display.is_empty());
1517    }
1518
1519    // --- Trigger tests ---
1520
1521    fn make_trigger_preset(triggers: heapless::Vec<Trigger, MAX_TRIGGERS>) -> Preset {
1522        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1523        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1524        on_press.push(Action::cc(80, 127, 1).unwrap()).ok();
1525        buttons
1526            .push(ButtonConfig {
1527                label: Label::new(),
1528                color: LedConfig::default(),
1529                mode: ButtonMode::Toggle,
1530                on_press,
1531                on_release: heapless::Vec::new(),
1532                on_long_press: heapless::Vec::new(),
1533                cycle_values: heapless::Vec::new(),
1534                listen_cc: None,
1535            })
1536            .ok();
1537        Preset {
1538            name: Label::new(),
1539            buttons,
1540            encoders: heapless::Vec::new(),
1541            analog: heapless::Vec::new(),
1542            defaults: Default::default(),
1543            on_enter: heapless::Vec::new(),
1544            on_exit: heapless::Vec::new(),
1545            triggers,
1546        }
1547    }
1548
1549    #[test]
1550    fn trigger_cc_activates_button() {
1551        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1552        triggers
1553            .push(Trigger {
1554                match_msg: TriggerMatch::Cc {
1555                    cc: 100,
1556                    channel: 1,
1557                    value_min: 64,
1558                    value_max: 127,
1559                },
1560                action: TriggerAction::Activate(0),
1561            })
1562            .ok();
1563        let preset = make_trigger_preset(triggers);
1564        let mut state = PresetState::default();
1565
1566        let r = process_triggers(&mut state, &preset, 0xB0, 100, 127);
1567        assert!(state.button_active[0]);
1568        assert!(r.led_dirty);
1569    }
1570
1571    #[test]
1572    fn trigger_cc_below_value_min_no_match() {
1573        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1574        triggers
1575            .push(Trigger {
1576                match_msg: TriggerMatch::Cc {
1577                    cc: 100,
1578                    channel: 1,
1579                    value_min: 64,
1580                    value_max: 127,
1581                },
1582                action: TriggerAction::Activate(0),
1583            })
1584            .ok();
1585        let preset = make_trigger_preset(triggers);
1586        let mut state = PresetState::default();
1587
1588        let r = process_triggers(&mut state, &preset, 0xB0, 100, 63);
1589        assert!(!state.button_active[0]);
1590        assert!(!r.led_dirty);
1591    }
1592
1593    #[test]
1594    fn trigger_deactivate_button() {
1595        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1596        triggers
1597            .push(Trigger {
1598                match_msg: TriggerMatch::Cc {
1599                    cc: 100,
1600                    channel: 1,
1601                    value_min: 0,
1602                    value_max: 63,
1603                },
1604                action: TriggerAction::Deactivate(0),
1605            })
1606            .ok();
1607        let preset = make_trigger_preset(triggers);
1608        let mut state = PresetState::default();
1609        state.button_active[0] = true;
1610
1611        let r = process_triggers(&mut state, &preset, 0xB0, 100, 0);
1612        assert!(!state.button_active[0]);
1613        assert!(r.led_dirty);
1614    }
1615
1616    #[test]
1617    fn trigger_program_change_selects_preset() {
1618        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1619        triggers
1620            .push(Trigger {
1621                match_msg: TriggerMatch::ProgramChange {
1622                    program: 5,
1623                    channel: 1,
1624                },
1625                action: TriggerAction::PresetSelect(3),
1626            })
1627            .ok();
1628        let preset = make_trigger_preset(triggers);
1629        let mut state = PresetState::default();
1630
1631        let r = process_triggers(&mut state, &preset, 0xC0, 5, 0);
1632        assert_eq!(r.system.len(), 1);
1633        assert_eq!(r.system[0], SystemAction::PresetSelect(3));
1634    }
1635
1636    #[test]
1637    fn trigger_note_on_executes_button() {
1638        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1639        triggers
1640            .push(Trigger {
1641                match_msg: TriggerMatch::NoteOn {
1642                    note: 60,
1643                    channel: 1,
1644                },
1645                action: TriggerAction::Execute(0),
1646            })
1647            .ok();
1648        let preset = make_trigger_preset(triggers);
1649        let mut state = PresetState::default();
1650
1651        let r = process_triggers(&mut state, &preset, 0x90, 60, 127);
1652        // Execute fires button 0's on_press: CC#80=127
1653        assert_eq!(r.midi.len(), 1);
1654        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 80, 127]));
1655    }
1656
1657    #[test]
1658    fn trigger_note_on_velocity_zero_no_match() {
1659        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1660        triggers
1661            .push(Trigger {
1662                match_msg: TriggerMatch::NoteOn {
1663                    note: 60,
1664                    channel: 1,
1665                },
1666                action: TriggerAction::Activate(0),
1667            })
1668            .ok();
1669        let preset = make_trigger_preset(triggers);
1670        let mut state = PresetState::default();
1671
1672        // Note On with velocity 0 = Note Off, should not match
1673        let r = process_triggers(&mut state, &preset, 0x90, 60, 0);
1674        assert!(!state.button_active[0]);
1675        assert!(!r.led_dirty);
1676    }
1677
1678    #[test]
1679    fn trigger_wrong_channel_no_match() {
1680        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1681        triggers
1682            .push(Trigger {
1683                match_msg: TriggerMatch::Cc {
1684                    cc: 100,
1685                    channel: 2,
1686                    value_min: 0,
1687                    value_max: 127,
1688                },
1689                action: TriggerAction::Activate(0),
1690            })
1691            .ok();
1692        let preset = make_trigger_preset(triggers);
1693        let mut state = PresetState::default();
1694
1695        // Send on channel 1 (0xB0), trigger expects channel 2
1696        let r = process_triggers(&mut state, &preset, 0xB0, 100, 127);
1697        assert!(!state.button_active[0]);
1698        assert!(!r.led_dirty);
1699    }
1700
1701    #[test]
1702    fn trigger_multiple_triggers_all_fire() {
1703        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1704        triggers
1705            .push(Trigger {
1706                match_msg: TriggerMatch::Cc {
1707                    cc: 100,
1708                    channel: 1,
1709                    value_min: 0,
1710                    value_max: 127,
1711                },
1712                action: TriggerAction::Activate(0),
1713            })
1714            .ok();
1715        triggers
1716            .push(Trigger {
1717                match_msg: TriggerMatch::Cc {
1718                    cc: 100,
1719                    channel: 1,
1720                    value_min: 64,
1721                    value_max: 127,
1722                },
1723                action: TriggerAction::PresetSelect(2),
1724            })
1725            .ok();
1726        let preset = make_trigger_preset(triggers);
1727        let mut state = PresetState::default();
1728
1729        // CC#100 = 100 on ch1: matches both triggers
1730        let r = process_triggers(&mut state, &preset, 0xB0, 100, 100);
1731        assert!(state.button_active[0]);
1732        assert!(r.led_dirty);
1733        assert_eq!(r.system.len(), 1);
1734        assert_eq!(r.system[0], SystemAction::PresetSelect(2));
1735    }
1736}