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                ..Default::default()
687            })
688            .ok();
689        let preset: Preset = Preset {
690            name: Label::new(),
691            buttons: heapless::Vec::new(),
692            encoders,
693            analog: heapless::Vec::new(),
694            defaults: Default::default(),
695            on_enter: heapless::Vec::new(),
696            on_exit: heapless::Vec::new(),
697            triggers: heapless::Vec::new(),
698        };
699        let mut state = PresetState::default();
700        state.encoder_values[0] = 64;
701
702        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 1);
703        assert_eq!(state.encoder_values[0], 65);
704        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 7, 65]));
705        assert_eq!(r.display.len(), 1);
706        match &r.display[0] {
707            DisplayEvent::EncoderOverlay { side, label, value } => {
708                assert_eq!(*side, DisplaySide::L);
709                assert_eq!(label.as_str(), "Vol");
710                assert_eq!(*value, 65);
711            }
712            _ => panic!("expected EncoderOverlay"),
713        }
714    }
715
716    #[test]
717    fn encoder_cc_counter_clockwise_decrements() {
718        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
719        encoders
720            .push(EncoderConfig {
721                label: Label::try_from("Vol").unwrap(),
722                action: EncoderAction::Cc {
723                    cc: 7,
724                    channel: 1,
725                    min: 0,
726                    max: 127,
727                },
728                ..Default::default()
729            })
730            .ok();
731        let preset: Preset = Preset {
732            name: Label::new(),
733            buttons: heapless::Vec::new(),
734            encoders,
735            analog: heapless::Vec::new(),
736            defaults: Default::default(),
737            on_enter: heapless::Vec::new(),
738            on_exit: heapless::Vec::new(),
739            triggers: heapless::Vec::new(),
740        };
741        let mut state = PresetState::default();
742        state.encoder_values[0] = 64;
743
744        let r = process_encoder(
745            &mut state,
746            &preset,
747            0,
748            EncoderDirection::CounterClockwise,
749            1,
750        );
751        assert_eq!(state.encoder_values[0], 63);
752        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 7, 63]));
753    }
754
755    #[test]
756    fn encoder_cc_multi_step_acceleration() {
757        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
758        encoders
759            .push(EncoderConfig {
760                label: Label::try_from("Gain").unwrap(),
761                action: EncoderAction::Cc {
762                    cc: 7,
763                    channel: 1,
764                    min: 0,
765                    max: 127,
766                },
767                ..Default::default()
768            })
769            .ok();
770        let preset: Preset = Preset {
771            name: Label::new(),
772            buttons: heapless::Vec::new(),
773            encoders,
774            analog: heapless::Vec::new(),
775            defaults: Default::default(),
776            on_enter: heapless::Vec::new(),
777            on_exit: heapless::Vec::new(),
778            triggers: heapless::Vec::new(),
779        };
780        let mut state = PresetState::default();
781        state.encoder_values[0] = 50;
782
783        // 4 steps CW: 50 → 54, single MIDI message with final value
784        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 4);
785        assert_eq!(state.encoder_values[0], 54);
786        assert_eq!(r.midi.len(), 1);
787        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 7, 54]));
788    }
789
790    #[test]
791    fn encoder_cc_clamps_at_max() {
792        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
793        encoders
794            .push(EncoderConfig {
795                label: Label::new(),
796                action: EncoderAction::Cc {
797                    cc: 7,
798                    channel: 1,
799                    min: 0,
800                    max: 100,
801                },
802                ..Default::default()
803            })
804            .ok();
805        let preset: Preset = Preset {
806            name: Label::new(),
807            buttons: heapless::Vec::new(),
808            encoders,
809            analog: heapless::Vec::new(),
810            defaults: Default::default(),
811            on_enter: heapless::Vec::new(),
812            on_exit: heapless::Vec::new(),
813            triggers: heapless::Vec::new(),
814        };
815        let mut state = PresetState::default();
816        state.encoder_values[0] = 99;
817
818        // 5 steps CW from 99, max=100 → clamps at 100
819        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 5);
820        assert_eq!(state.encoder_values[0], 100);
821        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data[2] == 100));
822    }
823
824    #[test]
825    fn encoder_cc_clamps_at_min() {
826        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
827        encoders
828            .push(EncoderConfig {
829                label: Label::new(),
830                action: EncoderAction::Cc {
831                    cc: 7,
832                    channel: 1,
833                    min: 20,
834                    max: 127,
835                },
836                ..Default::default()
837            })
838            .ok();
839        let preset: Preset = Preset {
840            name: Label::new(),
841            buttons: heapless::Vec::new(),
842            encoders,
843            analog: heapless::Vec::new(),
844            defaults: Default::default(),
845            on_enter: heapless::Vec::new(),
846            on_exit: heapless::Vec::new(),
847            triggers: heapless::Vec::new(),
848        };
849        let mut state = PresetState::default();
850        state.encoder_values[0] = 21;
851
852        // 5 steps CCW from 21, min=20 → clamps at 20
853        let r = process_encoder(
854            &mut state,
855            &preset,
856            0,
857            EncoderDirection::CounterClockwise,
858            5,
859        );
860        assert_eq!(state.encoder_values[0], 20);
861        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data[2] == 20));
862    }
863
864    #[test]
865    fn encoder_relative_sends_increment_decrement() {
866        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
867        encoders
868            .push(EncoderConfig {
869                label: Label::try_from("FX").unwrap(),
870                action: EncoderAction::CcRelative {
871                    cc: 16,
872                    channel: 1,
873                    increment: 65,
874                    decrement: 63,
875                },
876                ..Default::default()
877            })
878            .ok();
879        let preset: Preset = Preset {
880            name: Label::new(),
881            buttons: heapless::Vec::new(),
882            encoders,
883            analog: heapless::Vec::new(),
884            defaults: Default::default(),
885            on_enter: heapless::Vec::new(),
886            on_exit: heapless::Vec::new(),
887            triggers: heapless::Vec::new(),
888        };
889        let mut state = PresetState::default();
890
891        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 1);
892        assert_eq!(r.midi.len(), 1);
893        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 16, 65]));
894
895        let r = process_encoder(
896            &mut state,
897            &preset,
898            0,
899            EncoderDirection::CounterClockwise,
900            1,
901        );
902        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 16, 63]));
903    }
904
905    #[test]
906    fn encoder_preset_scroll_emits_system_action() {
907        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
908        encoders
909            .push(EncoderConfig {
910                label: Label::try_from("Bank").unwrap(),
911                action: EncoderAction::PresetScroll,
912                ..Default::default()
913            })
914            .ok();
915        let preset: Preset = Preset {
916            name: Label::new(),
917            buttons: heapless::Vec::new(),
918            encoders,
919            analog: heapless::Vec::new(),
920            defaults: Default::default(),
921            on_enter: heapless::Vec::new(),
922            on_exit: heapless::Vec::new(),
923            triggers: heapless::Vec::new(),
924        };
925        let mut state = PresetState::default();
926
927        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 1);
928        assert!(r.midi.is_empty());
929        assert_eq!(r.system.len(), 1);
930        assert_eq!(r.system[0], SystemAction::PresetNext);
931
932        let r = process_encoder(
933            &mut state,
934            &preset,
935            0,
936            EncoderDirection::CounterClockwise,
937            1,
938        );
939        assert_eq!(r.system[0], SystemAction::PresetPrev);
940    }
941
942    #[test]
943    fn encoder_invalid_index_returns_empty() {
944        let preset: Preset = Preset {
945            name: Label::new(),
946            buttons: heapless::Vec::new(),
947            encoders: heapless::Vec::new(),
948            analog: heapless::Vec::new(),
949            defaults: Default::default(),
950            on_enter: heapless::Vec::new(),
951            on_exit: heapless::Vec::new(),
952            triggers: heapless::Vec::new(),
953        };
954        let mut state = PresetState::default();
955
956        let r = process_encoder(&mut state, &preset, 0, EncoderDirection::Clockwise, 1);
957        assert!(r.midi.is_empty());
958        assert!(r.system.is_empty());
959        assert!(r.display.is_empty());
960    }
961
962    #[test]
963    fn encoder_second_index_uses_right_display() {
964        let mut encoders: heapless::Vec<EncoderConfig, MAX_ENCODERS> = heapless::Vec::new();
965        encoders
966            .push(EncoderConfig {
967                label: Label::try_from("Vol").unwrap(),
968                action: EncoderAction::Cc {
969                    cc: 7,
970                    channel: 1,
971                    min: 0,
972                    max: 127,
973                },
974                ..Default::default()
975            })
976            .ok();
977        encoders
978            .push(EncoderConfig {
979                label: Label::try_from("Gain").unwrap(),
980                action: EncoderAction::Cc {
981                    cc: 8,
982                    channel: 1,
983                    min: 0,
984                    max: 127,
985                },
986                ..Default::default()
987            })
988            .ok();
989        let preset: Preset = Preset {
990            name: Label::new(),
991            buttons: heapless::Vec::new(),
992            encoders,
993            analog: heapless::Vec::new(),
994            defaults: Default::default(),
995            on_enter: heapless::Vec::new(),
996            on_exit: heapless::Vec::new(),
997            triggers: heapless::Vec::new(),
998        };
999        let mut state = PresetState::default();
1000        state.encoder_values[1] = 50;
1001
1002        let r = process_encoder(&mut state, &preset, 1, EncoderDirection::Clockwise, 1);
1003        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 8, 51]));
1004        match &r.display[0] {
1005            DisplayEvent::EncoderOverlay { side, label, value } => {
1006                assert_eq!(*side, DisplaySide::R);
1007                assert_eq!(label.as_str(), "Gain");
1008                assert_eq!(*value, 51);
1009            }
1010            _ => panic!("expected EncoderOverlay"),
1011        }
1012    }
1013
1014    #[test]
1015    fn delay_in_action_sequence() {
1016        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1017        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1018        on_press.push(Action::cc(1, 127, 1).unwrap()).ok();
1019        on_press.push(Action::Delay(50)).ok();
1020        on_press.push(Action::cc(2, 0, 1).unwrap()).ok();
1021        buttons
1022            .push(ButtonConfig {
1023                label: Label::new(),
1024                color: LedConfig::default(),
1025                mode: ButtonMode::Momentary,
1026                on_press,
1027                on_release: heapless::Vec::new(),
1028                on_long_press: heapless::Vec::new(),
1029                cycle_values: heapless::Vec::new(),
1030                listen_cc: None,
1031            })
1032            .ok();
1033        let preset: Preset = Preset {
1034            name: Label::new(),
1035            buttons,
1036            encoders: heapless::Vec::new(),
1037            analog: heapless::Vec::new(),
1038            defaults: Default::default(),
1039            on_enter: heapless::Vec::new(),
1040            on_exit: heapless::Vec::new(),
1041            triggers: heapless::Vec::new(),
1042        };
1043        let mut state = PresetState::default();
1044
1045        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1046        assert_eq!(r.midi.len(), 3);
1047        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 1, 127]));
1048        assert_eq!(r.midi[1], ActionStep::Delay(50));
1049        assert!(matches!(&r.midi[2], ActionStep::Send(m) if m.data == [0xB0, 2, 0]));
1050    }
1051
1052    #[test]
1053    fn set_led_in_action_sequence() {
1054        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1055        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1056        on_press.push(Action::cc(3, 127, 1).unwrap()).ok();
1057        on_press
1058            .push(Action::SetLed {
1059                color: Color::Red,
1060                animation: LedAnimation::Blink,
1061            })
1062            .ok();
1063        on_press.push(Action::Delay(100)).ok();
1064        on_press
1065            .push(Action::SetLed {
1066                color: Color::Green,
1067                animation: LedAnimation::Solid,
1068            })
1069            .ok();
1070        buttons
1071            .push(ButtonConfig {
1072                label: Label::new(),
1073                color: LedConfig::default(),
1074                mode: ButtonMode::Momentary,
1075                on_press,
1076                on_release: heapless::Vec::new(),
1077                on_long_press: heapless::Vec::new(),
1078                cycle_values: heapless::Vec::new(),
1079                listen_cc: None,
1080            })
1081            .ok();
1082        let preset: Preset = Preset {
1083            name: Label::new(),
1084            buttons,
1085            encoders: heapless::Vec::new(),
1086            analog: heapless::Vec::new(),
1087            defaults: Default::default(),
1088            on_enter: heapless::Vec::new(),
1089            on_exit: heapless::Vec::new(),
1090            triggers: heapless::Vec::new(),
1091        };
1092        let mut state = PresetState::default();
1093
1094        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1095        assert_eq!(r.midi.len(), 4);
1096        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 3, 127]));
1097        assert_eq!(
1098            r.midi[1],
1099            ActionStep::SetLed {
1100                color: Color::Red,
1101                animation: LedAnimation::Blink
1102            }
1103        );
1104        assert_eq!(r.midi[2], ActionStep::Delay(100));
1105        assert_eq!(
1106            r.midi[3],
1107            ActionStep::SetLed {
1108                color: Color::Green,
1109                animation: LedAnimation::Solid
1110            }
1111        );
1112    }
1113
1114    #[test]
1115    fn incoming_cc_matches_listen_binding() {
1116        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1117        buttons
1118            .push(ButtonConfig {
1119                label: Label::new(),
1120                color: LedConfig::default(),
1121                mode: ButtonMode::default(),
1122                on_press: heapless::Vec::new(),
1123                on_release: heapless::Vec::new(),
1124                on_long_press: heapless::Vec::new(),
1125                cycle_values: heapless::Vec::new(),
1126                listen_cc: Some(ListenCc {
1127                    cc: 100,
1128                    channel: 3,
1129                    mode: ListenMode::default(),
1130                    threshold: 64,
1131                }),
1132            })
1133            .ok();
1134        let preset: Preset = Preset {
1135            name: Label::new(),
1136            buttons,
1137            encoders: heapless::Vec::new(),
1138            analog: heapless::Vec::new(),
1139            defaults: Default::default(),
1140            on_enter: heapless::Vec::new(),
1141            on_exit: heapless::Vec::new(),
1142            triggers: heapless::Vec::new(),
1143        };
1144
1145        // Matching CC
1146        let result = process_incoming_cc(&preset, 3, 100, 127);
1147        assert_eq!(result, Some(ReactiveResult::Heatmap(0, 12)));
1148
1149        // Half value
1150        let result = process_incoming_cc(&preset, 3, 100, 64);
1151        assert_eq!(result, Some(ReactiveResult::Heatmap(0, 6)));
1152
1153        // Zero
1154        let result = process_incoming_cc(&preset, 3, 100, 0);
1155        assert_eq!(result, Some(ReactiveResult::Heatmap(0, 0)));
1156
1157        // Wrong CC
1158        assert_eq!(process_incoming_cc(&preset, 3, 99, 127), None);
1159
1160        // Wrong channel
1161        assert_eq!(process_incoming_cc(&preset, 1, 100, 127), None);
1162    }
1163
1164    #[test]
1165    fn incoming_cc_trigger_mode() {
1166        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1167        buttons
1168            .push(ButtonConfig {
1169                label: Label::new(),
1170                color: LedConfig::default(),
1171                mode: ButtonMode::default(),
1172                on_press: heapless::Vec::new(),
1173                on_release: heapless::Vec::new(),
1174                on_long_press: heapless::Vec::new(),
1175                cycle_values: heapless::Vec::new(),
1176                listen_cc: Some(ListenCc {
1177                    cc: 80,
1178                    channel: 2,
1179                    mode: ListenMode::Trigger,
1180                    threshold: 64,
1181                }),
1182            })
1183            .ok();
1184        let preset: Preset = Preset {
1185            name: Label::new(),
1186            buttons,
1187            encoders: heapless::Vec::new(),
1188            analog: heapless::Vec::new(),
1189            defaults: Default::default(),
1190            on_enter: heapless::Vec::new(),
1191            on_exit: heapless::Vec::new(),
1192            triggers: heapless::Vec::new(),
1193        };
1194
1195        assert_eq!(
1196            process_incoming_cc(&preset, 2, 80, 127),
1197            Some(ReactiveResult::Trigger(0, true))
1198        );
1199        assert_eq!(
1200            process_incoming_cc(&preset, 2, 80, 64),
1201            Some(ReactiveResult::Trigger(0, true))
1202        );
1203        assert_eq!(
1204            process_incoming_cc(&preset, 2, 80, 63),
1205            Some(ReactiveResult::Trigger(0, false))
1206        );
1207        assert_eq!(
1208            process_incoming_cc(&preset, 2, 80, 0),
1209            Some(ReactiveResult::Trigger(0, false))
1210        );
1211    }
1212
1213    #[test]
1214    fn preset_select_emits_system_action() {
1215        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1216        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1217        on_press.push(Action::PresetSelect(5)).ok();
1218        buttons
1219            .push(ButtonConfig {
1220                label: Label::new(),
1221                color: LedConfig::default(),
1222                mode: ButtonMode::Momentary,
1223                on_press,
1224                on_release: heapless::Vec::new(),
1225                on_long_press: heapless::Vec::new(),
1226                cycle_values: heapless::Vec::new(),
1227                listen_cc: None,
1228            })
1229            .ok();
1230        let preset: Preset = Preset {
1231            name: Label::new(),
1232            buttons,
1233            encoders: heapless::Vec::new(),
1234            analog: heapless::Vec::new(),
1235            defaults: Default::default(),
1236            on_enter: heapless::Vec::new(),
1237            on_exit: heapless::Vec::new(),
1238            triggers: heapless::Vec::new(),
1239        };
1240        let mut state = PresetState::default();
1241
1242        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1243        assert_eq!(r.system.len(), 1);
1244        assert_eq!(r.system[0], SystemAction::PresetSelect(5));
1245        // No MIDI output — it's a system action only
1246        assert!(r.midi.is_empty());
1247    }
1248
1249    #[test]
1250    fn bank_up_down_emit_system_actions() {
1251        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1252        let mut on_press_up: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1253        on_press_up.push(Action::BankUp).ok();
1254        let mut on_press_down: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1255        on_press_down.push(Action::BankDown).ok();
1256        buttons
1257            .push(ButtonConfig {
1258                label: Label::new(),
1259                color: LedConfig::default(),
1260                mode: ButtonMode::Momentary,
1261                on_press: on_press_up,
1262                on_release: heapless::Vec::new(),
1263                on_long_press: heapless::Vec::new(),
1264                cycle_values: heapless::Vec::new(),
1265                listen_cc: None,
1266            })
1267            .ok();
1268        buttons
1269            .push(ButtonConfig {
1270                label: Label::new(),
1271                color: LedConfig::default(),
1272                mode: ButtonMode::Momentary,
1273                on_press: on_press_down,
1274                on_release: heapless::Vec::new(),
1275                on_long_press: heapless::Vec::new(),
1276                cycle_values: heapless::Vec::new(),
1277                listen_cc: None,
1278            })
1279            .ok();
1280        let preset: Preset = Preset {
1281            name: Label::new(),
1282            buttons,
1283            encoders: heapless::Vec::new(),
1284            analog: heapless::Vec::new(),
1285            defaults: Default::default(),
1286            on_enter: heapless::Vec::new(),
1287            on_exit: heapless::Vec::new(),
1288            triggers: heapless::Vec::new(),
1289        };
1290        let mut state = PresetState::default();
1291
1292        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1293        assert_eq!(r.system.len(), 1);
1294        assert_eq!(r.system[0], SystemAction::PresetNext);
1295
1296        let r = process_button(&mut state, &preset, 1, ButtonEvent::Press);
1297        assert_eq!(r.system.len(), 1);
1298        assert_eq!(r.system[0], SystemAction::PresetPrev);
1299    }
1300
1301    // --- CcCycle tests ---
1302
1303    fn make_cc_cycle_preset(reverse: bool) -> Preset {
1304        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1305        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1306        on_press
1307            .push(Action::CcCycle {
1308                cc: 50,
1309                channel: 1,
1310                reverse,
1311            })
1312            .ok();
1313        let mut cycle_values: heapless::Vec<u8, MAX_CYCLE_VALUES> = heapless::Vec::new();
1314        cycle_values.push(0).ok();
1315        cycle_values.push(32).ok();
1316        cycle_values.push(64).ok();
1317        cycle_values.push(127).ok();
1318        buttons
1319            .push(ButtonConfig {
1320                label: Label::new(),
1321                color: LedConfig::default(),
1322                mode: ButtonMode::Momentary,
1323                on_press,
1324                on_release: heapless::Vec::new(),
1325                on_long_press: heapless::Vec::new(),
1326                cycle_values,
1327                listen_cc: None,
1328            })
1329            .ok();
1330        Preset {
1331            name: Label::new(),
1332            buttons,
1333            encoders: heapless::Vec::new(),
1334            analog: heapless::Vec::new(),
1335            defaults: Default::default(),
1336            on_enter: heapless::Vec::new(),
1337            on_exit: heapless::Vec::new(),
1338            triggers: heapless::Vec::new(),
1339        }
1340    }
1341
1342    #[test]
1343    fn cc_cycle_forward_iterates_values() {
1344        let preset = make_cc_cycle_preset(false);
1345        let mut state = PresetState::default();
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, 0]));
1349
1350        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1351        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 32]));
1352
1353        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1354        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 64]));
1355
1356        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1357        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 127]));
1358    }
1359
1360    #[test]
1361    fn cc_cycle_forward_wraps_around() {
1362        let preset = make_cc_cycle_preset(false);
1363        let mut state = PresetState::default();
1364
1365        // Cycle through all 4 values
1366        for _ in 0..4 {
1367            process_button(&mut state, &preset, 0, ButtonEvent::Press);
1368        }
1369        // 5th press should wrap to first value
1370        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1371        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 0]));
1372    }
1373
1374    #[test]
1375    fn cc_cycle_reverse_iterates_backward() {
1376        let preset = make_cc_cycle_preset(true);
1377        let mut state = PresetState::default();
1378
1379        // First press at index 0: value=0, then index wraps to 3
1380        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1381        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 0]));
1382
1383        // Second press at index 3: value=127, then index becomes 2
1384        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1385        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 127]));
1386
1387        // Third press at index 2: value=64
1388        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1389        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 50, 64]));
1390    }
1391
1392    #[test]
1393    fn cc_cycle_empty_values_no_output() {
1394        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1395        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1396        on_press
1397            .push(Action::CcCycle {
1398                cc: 50,
1399                channel: 1,
1400                reverse: false,
1401            })
1402            .ok();
1403        buttons
1404            .push(ButtonConfig {
1405                label: Label::new(),
1406                color: LedConfig::default(),
1407                mode: ButtonMode::Momentary,
1408                on_press,
1409                on_release: heapless::Vec::new(),
1410                on_long_press: heapless::Vec::new(),
1411                cycle_values: heapless::Vec::new(), // empty!
1412                listen_cc: None,
1413            })
1414            .ok();
1415        let preset: Preset = Preset {
1416            name: Label::new(),
1417            buttons,
1418            encoders: heapless::Vec::new(),
1419            analog: heapless::Vec::new(),
1420            defaults: Default::default(),
1421            on_enter: heapless::Vec::new(),
1422            on_exit: heapless::Vec::new(),
1423            triggers: heapless::Vec::new(),
1424        };
1425        let mut state = PresetState::default();
1426
1427        let r = process_button(&mut state, &preset, 0, ButtonEvent::Press);
1428        assert!(r.midi.is_empty());
1429    }
1430
1431    // --- process_analog tests ---
1432
1433    fn make_analog_preset(cc: u8, channel: u8, min: u8, max: u8) -> Preset {
1434        let mut analog: heapless::Vec<AnalogConfig, MAX_ANALOG> = heapless::Vec::new();
1435        analog
1436            .push(AnalogConfig {
1437                label: Label::try_from("Wah").unwrap(),
1438                cc,
1439                channel,
1440                min,
1441                max,
1442            })
1443            .ok();
1444        Preset {
1445            name: Label::new(),
1446            buttons: heapless::Vec::new(),
1447            encoders: heapless::Vec::new(),
1448            analog,
1449            defaults: Default::default(),
1450            on_enter: heapless::Vec::new(),
1451            on_exit: heapless::Vec::new(),
1452            triggers: heapless::Vec::new(),
1453        }
1454    }
1455
1456    #[test]
1457    fn analog_full_range_maps_to_max() {
1458        let preset = make_analog_preset(11, 1, 0, 127);
1459        let r = process_analog(&preset, 0, 3750, 0, 3750);
1460        assert_eq!(r.midi.len(), 1);
1461        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 11, 127]));
1462    }
1463
1464    #[test]
1465    fn analog_min_maps_to_zero() {
1466        let preset = make_analog_preset(11, 1, 0, 127);
1467        let r = process_analog(&preset, 0, 0, 0, 3750);
1468        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 11, 0]));
1469    }
1470
1471    #[test]
1472    fn analog_midpoint() {
1473        let preset = make_analog_preset(11, 1, 0, 127);
1474        let r = process_analog(&preset, 0, 1875, 0, 3750);
1475        // 1875/3750 * 127 = 63.5 → 63
1476        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data[2] == 63));
1477    }
1478
1479    #[test]
1480    fn analog_custom_range() {
1481        let preset = make_analog_preset(11, 1, 20, 100);
1482        let r = process_analog(&preset, 0, 3750, 0, 3750);
1483        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 11, 100]));
1484
1485        let r = process_analog(&preset, 0, 0, 0, 3750);
1486        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 11, 20]));
1487    }
1488
1489    #[test]
1490    fn analog_clamps_below_adc_min() {
1491        let preset = make_analog_preset(11, 1, 0, 127);
1492        // raw=100 but adc_min=200 — clamps to min
1493        let r = process_analog(&preset, 0, 100, 200, 3750);
1494        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data[2] == 0));
1495    }
1496
1497    #[test]
1498    fn analog_clamps_above_adc_max() {
1499        let preset = make_analog_preset(11, 1, 0, 127);
1500        // raw=4000 but adc_max=3750 — clamps to max
1501        let r = process_analog(&preset, 0, 4000, 0, 3750);
1502        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data[2] == 127));
1503    }
1504
1505    #[test]
1506    fn analog_emits_display_event() {
1507        let preset = make_analog_preset(11, 1, 0, 127);
1508        let r = process_analog(&preset, 0, 1875, 0, 3750);
1509        assert_eq!(r.display.len(), 1);
1510        match &r.display[0] {
1511            DisplayEvent::AnalogOverlay { side, label, value } => {
1512                assert_eq!(*side, DisplaySide::L);
1513                assert_eq!(label.as_str(), "Wah");
1514                assert_eq!(*value, 63);
1515            }
1516            _ => panic!("expected AnalogOverlay"),
1517        }
1518    }
1519
1520    #[test]
1521    fn analog_invalid_index_returns_empty() {
1522        let preset = make_analog_preset(11, 1, 0, 127);
1523        let r = process_analog(&preset, 5, 1875, 0, 3750);
1524        assert!(r.midi.is_empty());
1525        assert!(r.display.is_empty());
1526    }
1527
1528    // --- Trigger tests ---
1529
1530    fn make_trigger_preset(triggers: heapless::Vec<Trigger, MAX_TRIGGERS>) -> Preset {
1531        let mut buttons: heapless::Vec<ButtonConfig, MAX_BUTTONS> = heapless::Vec::new();
1532        let mut on_press: heapless::Vec<Action, MAX_ACTIONS> = heapless::Vec::new();
1533        on_press.push(Action::cc(80, 127, 1).unwrap()).ok();
1534        buttons
1535            .push(ButtonConfig {
1536                label: Label::new(),
1537                color: LedConfig::default(),
1538                mode: ButtonMode::Toggle,
1539                on_press,
1540                on_release: heapless::Vec::new(),
1541                on_long_press: heapless::Vec::new(),
1542                cycle_values: heapless::Vec::new(),
1543                listen_cc: None,
1544            })
1545            .ok();
1546        Preset {
1547            name: Label::new(),
1548            buttons,
1549            encoders: heapless::Vec::new(),
1550            analog: heapless::Vec::new(),
1551            defaults: Default::default(),
1552            on_enter: heapless::Vec::new(),
1553            on_exit: heapless::Vec::new(),
1554            triggers,
1555        }
1556    }
1557
1558    #[test]
1559    fn trigger_cc_activates_button() {
1560        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1561        triggers
1562            .push(Trigger {
1563                match_msg: TriggerMatch::Cc {
1564                    cc: 100,
1565                    channel: 1,
1566                    value_min: 64,
1567                    value_max: 127,
1568                },
1569                action: TriggerAction::Activate(0),
1570            })
1571            .ok();
1572        let preset = make_trigger_preset(triggers);
1573        let mut state = PresetState::default();
1574
1575        let r = process_triggers(&mut state, &preset, 0xB0, 100, 127);
1576        assert!(state.button_active[0]);
1577        assert!(r.led_dirty);
1578    }
1579
1580    #[test]
1581    fn trigger_cc_below_value_min_no_match() {
1582        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1583        triggers
1584            .push(Trigger {
1585                match_msg: TriggerMatch::Cc {
1586                    cc: 100,
1587                    channel: 1,
1588                    value_min: 64,
1589                    value_max: 127,
1590                },
1591                action: TriggerAction::Activate(0),
1592            })
1593            .ok();
1594        let preset = make_trigger_preset(triggers);
1595        let mut state = PresetState::default();
1596
1597        let r = process_triggers(&mut state, &preset, 0xB0, 100, 63);
1598        assert!(!state.button_active[0]);
1599        assert!(!r.led_dirty);
1600    }
1601
1602    #[test]
1603    fn trigger_deactivate_button() {
1604        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1605        triggers
1606            .push(Trigger {
1607                match_msg: TriggerMatch::Cc {
1608                    cc: 100,
1609                    channel: 1,
1610                    value_min: 0,
1611                    value_max: 63,
1612                },
1613                action: TriggerAction::Deactivate(0),
1614            })
1615            .ok();
1616        let preset = make_trigger_preset(triggers);
1617        let mut state = PresetState::default();
1618        state.button_active[0] = true;
1619
1620        let r = process_triggers(&mut state, &preset, 0xB0, 100, 0);
1621        assert!(!state.button_active[0]);
1622        assert!(r.led_dirty);
1623    }
1624
1625    #[test]
1626    fn trigger_program_change_selects_preset() {
1627        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1628        triggers
1629            .push(Trigger {
1630                match_msg: TriggerMatch::ProgramChange {
1631                    program: 5,
1632                    channel: 1,
1633                },
1634                action: TriggerAction::PresetSelect(3),
1635            })
1636            .ok();
1637        let preset = make_trigger_preset(triggers);
1638        let mut state = PresetState::default();
1639
1640        let r = process_triggers(&mut state, &preset, 0xC0, 5, 0);
1641        assert_eq!(r.system.len(), 1);
1642        assert_eq!(r.system[0], SystemAction::PresetSelect(3));
1643    }
1644
1645    #[test]
1646    fn trigger_note_on_executes_button() {
1647        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1648        triggers
1649            .push(Trigger {
1650                match_msg: TriggerMatch::NoteOn {
1651                    note: 60,
1652                    channel: 1,
1653                },
1654                action: TriggerAction::Execute(0),
1655            })
1656            .ok();
1657        let preset = make_trigger_preset(triggers);
1658        let mut state = PresetState::default();
1659
1660        let r = process_triggers(&mut state, &preset, 0x90, 60, 127);
1661        // Execute fires button 0's on_press: CC#80=127
1662        assert_eq!(r.midi.len(), 1);
1663        assert!(matches!(&r.midi[0], ActionStep::Send(m) if m.data == [0xB0, 80, 127]));
1664    }
1665
1666    #[test]
1667    fn trigger_note_on_velocity_zero_no_match() {
1668        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1669        triggers
1670            .push(Trigger {
1671                match_msg: TriggerMatch::NoteOn {
1672                    note: 60,
1673                    channel: 1,
1674                },
1675                action: TriggerAction::Activate(0),
1676            })
1677            .ok();
1678        let preset = make_trigger_preset(triggers);
1679        let mut state = PresetState::default();
1680
1681        // Note On with velocity 0 = Note Off, should not match
1682        let r = process_triggers(&mut state, &preset, 0x90, 60, 0);
1683        assert!(!state.button_active[0]);
1684        assert!(!r.led_dirty);
1685    }
1686
1687    #[test]
1688    fn trigger_wrong_channel_no_match() {
1689        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1690        triggers
1691            .push(Trigger {
1692                match_msg: TriggerMatch::Cc {
1693                    cc: 100,
1694                    channel: 2,
1695                    value_min: 0,
1696                    value_max: 127,
1697                },
1698                action: TriggerAction::Activate(0),
1699            })
1700            .ok();
1701        let preset = make_trigger_preset(triggers);
1702        let mut state = PresetState::default();
1703
1704        // Send on channel 1 (0xB0), trigger expects channel 2
1705        let r = process_triggers(&mut state, &preset, 0xB0, 100, 127);
1706        assert!(!state.button_active[0]);
1707        assert!(!r.led_dirty);
1708    }
1709
1710    #[test]
1711    fn trigger_multiple_triggers_all_fire() {
1712        let mut triggers: heapless::Vec<Trigger, MAX_TRIGGERS> = heapless::Vec::new();
1713        triggers
1714            .push(Trigger {
1715                match_msg: TriggerMatch::Cc {
1716                    cc: 100,
1717                    channel: 1,
1718                    value_min: 0,
1719                    value_max: 127,
1720                },
1721                action: TriggerAction::Activate(0),
1722            })
1723            .ok();
1724        triggers
1725            .push(Trigger {
1726                match_msg: TriggerMatch::Cc {
1727                    cc: 100,
1728                    channel: 1,
1729                    value_min: 64,
1730                    value_max: 127,
1731                },
1732                action: TriggerAction::PresetSelect(2),
1733            })
1734            .ok();
1735        let preset = make_trigger_preset(triggers);
1736        let mut state = PresetState::default();
1737
1738        // CC#100 = 100 on ch1: matches both triggers
1739        let r = process_triggers(&mut state, &preset, 0xB0, 100, 100);
1740        assert!(state.button_active[0]);
1741        assert!(r.led_dirty);
1742        assert_eq!(r.system.len(), 1);
1743        assert_eq!(r.system[0], SystemAction::PresetSelect(2));
1744    }
1745}