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