Skip to main content

treble_lang/parser/
mini.rs

1//! Mini-notation parser using nom combinators.
2//!
3//! Parses the content inside double-quoted pattern strings into a
4//! [`MiniNotation`] AST.
5
6use nom::{
7    IResult, Parser,
8    branch::alt,
9    bytes::complete::tag,
10    character::complete::{char, digit1, one_of, space0, space1},
11    combinator::{map, map_res, opt, recognize, value},
12    multi::{many0, separated_list1},
13    sequence::{delimited, preceded},
14};
15
16use crate::ast::mini::*;
17use crate::ast::program::{Accidental, NoteLetter, Ramp};
18
19/// Parse a mini-notation string into a [`MiniNotation`].
20pub fn parse_mini(input: &str) -> Result<MiniNotation, String> {
21    let trimmed = input.trim();
22    if trimmed.is_empty() {
23        return Ok(MiniNotation {
24            sequence: Sequence { steps: vec![] },
25        });
26    }
27    match parse_sequence(trimmed) {
28        Ok(("", seq)) => {
29            let notation = MiniNotation { sequence: seq };
30            validate_sequence(&notation.sequence)?;
31            Ok(notation)
32        }
33        Ok((rest, _)) => Err(format!("unexpected trailing input: '{}'", rest)),
34        // A non-recoverable failure is always a real authoring mistake rather
35        // than a wrong turn in `alt`. nom's error carries only an `ErrorKind`,
36        // so the few of them are told apart by which kind they raise.
37        Err(nom::Err::Failure(error)) => Err(match error.code {
38            FAIL_DOUBLE_VELOCITY => "a step takes one velocity, not two".to_string(),
39            FAIL_ACCENT_VELOCITY => {
40                "'X' is already a velocity of 1.0; write 'x:v' to choose one".to_string()
41            }
42            _ => "a group uses ',' for a chord or '|' for a random choice, not both".to_string(),
43        }),
44        Err(e) => Err(format!("mini-notation parse error: {}", e)),
45    }
46}
47
48/// Raised by [`parse_group`] when a group mixes `,` and `|`.
49const FAIL_MIXED_SEPARATORS: nom::error::ErrorKind = nom::error::ErrorKind::Verify;
50/// Raised by [`parse_step`] when one step carries two `:v` suffixes.
51const FAIL_DOUBLE_VELOCITY: nom::error::ErrorKind = nom::error::ErrorKind::Many1;
52/// Raised by [`parse_step`] for `X:v`, which asks for two velocities at once.
53const FAIL_ACCENT_VELOCITY: nom::error::ErrorKind = nom::error::ErrorKind::ManyMN;
54
55/// Check the invariants the grammar itself cannot express.
56///
57/// Runs after a successful parse so the messages can name the offending value,
58/// rather than surfacing a generic nom error.
59fn validate_sequence(sequence: &Sequence) -> Result<(), String> {
60    for step in sequence.steps.iter() {
61        for modifier in step.modifiers.iter() {
62            validate_modifier(modifier)?;
63        }
64        validate_velocity(step)?;
65        match &step.atom {
66            Atom::Group(group) => {
67                for layer in group.layers.iter() {
68                    // A chord layer is a fixed set of notes; a generated walk
69                    // has no single note to contribute to one.
70                    if group.mode == GroupMode::Chord
71                        && layer
72                            .steps
73                            .iter()
74                            .any(|step| matches!(step.atom, Atom::Solo(_)))
75                    {
76                        return Err(
77                            "solo(..) can't be a chord layer; put it in its own step".to_string()
78                        );
79                    }
80                    validate_sequence(layer)?;
81                }
82            }
83            Atom::Alternation(alternation) => validate_sequence(&alternation.sequence)?,
84            Atom::Solo(solo) => {
85                if solo.high <= solo.low {
86                    return Err(format!(
87                        "solo({}..{}) needs at least two degrees to walk between",
88                        solo.low, solo.high
89                    ));
90                }
91                if solo.steps.values().contains(&0) {
92                    return Err("solo needs at least one step per cycle".to_string());
93                }
94            }
95            _ => {}
96        }
97    }
98    Ok(())
99}
100
101/// `:v` (and the `X` it desugars from) must be a playable strike, on something
102/// that actually strikes.
103fn validate_velocity(step: &Step) -> Result<(), String> {
104    let Some(velocity) = &step.velocity else {
105        return Ok(());
106    };
107    match step.atom {
108        // Silence has no strike, and a hold sustains whatever it extends, so
109        // neither has a velocity of its own to set. Ignoring the suffix would
110        // hide the misunderstanding instead of correcting it.
111        Atom::Rest => {
112            return Err(
113                "a rest can't carry a velocity: '~' is silence, so ':v' has nothing to strike"
114                    .to_string(),
115            );
116        }
117        Atom::Hold => {
118            return Err("a hold can't carry a velocity: '_' sustains the event before it, which was already struck".to_string());
119        }
120        _ => {}
121    }
122    for value in velocity.values() {
123        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
124            return Err(format!(
125                "velocity {value} is outside the supported range 0.0-1.0"
126            ));
127        }
128    }
129    Ok(())
130}
131
132fn validate_modifier(modifier: &Modifier) -> Result<(), String> {
133    let positive = |value: u32, sigil: &str| {
134        if value == 0 {
135            Err(format!(
136                "'{sigil}0' is not a count; use at least '{sigil}1'"
137            ))
138        } else {
139            Ok(())
140        }
141    };
142    match modifier {
143        Modifier::Repeat(count) => {
144            // Every value a ramp passes through has to be legal, not just the
145            // one it starts on.
146            for value in count.values() {
147                positive(value, "*")?;
148            }
149            Ok(())
150        }
151        Modifier::Replicate(0) => Err("'!0' is not a count; use at least '!1'".to_string()),
152        Modifier::Slow(0) => Err("'/0' is not a count; use at least '/1'".to_string()),
153        Modifier::Weight(0) => Err("'@0' is not a weight; use at least '@1'".to_string()),
154        Modifier::Euclidean(_, positions, _) => {
155            if positions.values().contains(&0) {
156                return Err("a Euclidean rhythm needs at least one step".to_string());
157            }
158            Ok(())
159        }
160        Modifier::Drop(Some(probability)) => {
161            for value in probability.values() {
162                if !value.is_finite() || !(0.0..=1.0).contains(&value) {
163                    return Err(format!(
164                        "drop probability {value} is outside the supported range 0.0-1.0"
165                    ));
166                }
167            }
168            Ok(())
169        }
170        _ => Ok(()),
171    }
172}
173
174// --- Sequence ---
175
176fn parse_sequence(input: &str) -> IResult<&str, Sequence> {
177    let (input, _) = space0(input)?;
178    let (input, steps) = separated_list1(space1, parse_step).parse(input)?;
179    let (input, _) = space0(input)?;
180    Ok((input, Sequence { steps }))
181}
182
183// --- Step = Atom { Modifier } ---
184
185fn parse_step(input: &str) -> IResult<&str, Step> {
186    // `X` is stored as `x:1.0` rather than as an atom of its own, so a consumer
187    // only has to implement velocity once.
188    let (mut input, accented) = match parse_accent(input) {
189        Ok((rest, ())) => (rest, true),
190        Err(_) => (input, false),
191    };
192    let (rest, atom) = if accented {
193        (input, Atom::Trigger)
194    } else {
195        parse_atom(input)?
196    };
197    input = rest;
198    let mut modifiers = Vec::new();
199    let mut velocity = accented.then(|| Ramp::fixed(1.0));
200    // Modifiers and the velocity suffix may be written in either order, and a
201    // velocity means the same thing wherever it sits (see [`Step`]), so both
202    // are taken in one pass. Every one of them consumes at least its sigil, so
203    // the loop always advances.
204    loop {
205        if let Ok((rest, next)) = parse_velocity(input) {
206            if velocity.is_some() {
207                let code = if accented {
208                    FAIL_ACCENT_VELOCITY
209                } else {
210                    FAIL_DOUBLE_VELOCITY
211                };
212                return Err(nom::Err::Failure(nom::error::Error::new(input, code)));
213            }
214            velocity = Some(next);
215            input = rest;
216            continue;
217        }
218        match parse_modifier(input) {
219            Ok((rest, modifier)) => {
220                modifiers.push(modifier);
221                input = rest;
222            }
223            Err(_) => break,
224        }
225    }
226    Ok((
227        input,
228        Step {
229            atom,
230            modifiers,
231            velocity,
232        },
233    ))
234}
235
236/// `X` — an accented trigger. Unambiguous because note letters are `a`–`g`.
237fn parse_accent(input: &str) -> IResult<&str, ()> {
238    let (input, _) = char('X').parse(input)?;
239    if input
240        .chars()
241        .next()
242        .map(|c| c.is_ascii_alphanumeric())
243        .unwrap_or(false)
244    {
245        return Err(nom::Err::Error(nom::error::Error::new(
246            input,
247            nom::error::ErrorKind::Char,
248        )));
249    }
250    Ok((input, ()))
251}
252
253/// `:0.6`, `:0.3..0.9` — an explicit velocity for this step.
254fn parse_velocity(input: &str) -> IResult<&str, Ramp<f64>> {
255    preceded(char(':'), parse_ramp_f64).parse(input)
256}
257
258// --- Atom ---
259
260fn parse_atom(input: &str) -> IResult<&str, Atom> {
261    alt((
262        parse_solo,
263        parse_group,
264        parse_alternation,
265        parse_note_atom,
266        parse_degree_atom,
267        parse_trigger,
268        parse_rest,
269        parse_hold,
270    ))
271    .parse(input)
272}
273
274fn parse_note_atom(input: &str) -> IResult<&str, Atom> {
275    map(parse_note, Atom::Note).parse(input)
276}
277
278fn parse_note(input: &str) -> IResult<&str, Note> {
279    let (input, letter) = parse_note_letter(input)?;
280    let (input, accidental) = parse_accidental(input, letter)?;
281    let (input, octave) = parse_octave(input)?;
282    Ok((
283        input,
284        Note {
285            letter,
286            accidental,
287            octave,
288        },
289    ))
290}
291
292fn parse_note_letter(input: &str) -> IResult<&str, NoteLetter> {
293    alt((
294        value(NoteLetter::C, char('c')),
295        value(NoteLetter::D, char('d')),
296        value(NoteLetter::E, char('e')),
297        value(NoteLetter::F, char('f')),
298        value(NoteLetter::G, char('g')),
299        value(NoteLetter::A, char('a')),
300        value(NoteLetter::B, char('b')),
301    ))
302    .parse(input)
303}
304
305/// Parse accidental after a note letter.
306///
307/// Context-sensitive for the note B: `bb3` = B-flat 3, `b4` = B natural 4.
308fn parse_accidental(input: &str, letter: NoteLetter) -> IResult<&str, Accidental> {
309    if letter == NoteLetter::B {
310        // After 'b': '#'/'##' for sharp
311        if let Ok((rest, _)) = tag::<&str, &str, nom::error::Error<&str>>("##").parse(input) {
312            return Ok((rest, Accidental::DoubleSharp));
313        }
314        if let Ok((rest, _)) = char::<&str, nom::error::Error<&str>>('#').parse(input) {
315            return Ok((rest, Accidental::Sharp));
316        }
317        // 'b' followed by digit = flat
318        if let Some(after_b) = input.strip_prefix('b')
319            && after_b.starts_with(|c: char| c.is_ascii_digit())
320        {
321            return Ok((after_b, Accidental::Flat));
322        }
323        Ok((input, Accidental::Natural))
324    } else {
325        // Non-B note
326        let result: IResult<&str, Accidental> = alt((
327            value(Accidental::DoubleSharp, tag("##")),
328            value(Accidental::Sharp, char('#')),
329            value(Accidental::DoubleFlat, tag("bb")),
330            value(Accidental::Flat, char('b')),
331        ))
332        .parse(input);
333        match result {
334            Ok(r) => Ok(r),
335            Err(_) => Ok((input, Accidental::Natural)),
336        }
337    }
338}
339
340fn parse_octave(input: &str) -> IResult<&str, u8> {
341    map_res(one_of("0123456789"), |c: char| {
342        c.to_digit(10).map(|d| d as u8).ok_or("invalid octave")
343    })
344    .parse(input)
345}
346
347fn parse_degree_atom(input: &str) -> IResult<&str, Atom> {
348    // Negative degrees: -N
349    if input.starts_with('-') {
350        let (input, _) = char('-').parse(input)?;
351        let (input, digits) = digit1(input)?;
352        let n: i32 = digits.parse().map_err(|_| {
353            nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Digit))
354        })?;
355        return Ok((input, Atom::Degree(-n)));
356    }
357    // Positive degrees: just digits
358    let (input, digits) = digit1(input)?;
359    let n: i32 = digits.parse().map_err(|_| {
360        nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Digit))
361    })?;
362    Ok((input, Atom::Degree(n)))
363}
364
365fn parse_trigger(input: &str) -> IResult<&str, Atom> {
366    let (input, _) = char('x').parse(input)?;
367    // Ensure not followed by an alphanumeric char
368    if input
369        .chars()
370        .next()
371        .map(|c| c.is_ascii_alphanumeric())
372        .unwrap_or(false)
373    {
374        return Err(nom::Err::Error(nom::error::Error::new(
375            input,
376            nom::error::ErrorKind::Char,
377        )));
378    }
379    Ok((input, Atom::Trigger))
380}
381
382fn parse_rest(input: &str) -> IResult<&str, Atom> {
383    value(Atom::Rest, char('~')).parse(input)
384}
385
386fn parse_hold(input: &str) -> IResult<&str, Atom> {
387    value(Atom::Hold, char('_')).parse(input)
388}
389
390// --- Solo: solo(low..high, steps) ---
391
392fn parse_solo(input: &str) -> IResult<&str, Atom> {
393    let (input, _) = tag("solo(").parse(input)?;
394    let (input, _) = space0(input)?;
395    let (input, low) = parse_i32(input)?;
396    let (input, _) = tag("..").parse(input)?;
397    let (input, high) = parse_i32(input)?;
398    let (input, _) = delimited(space0, char(','), space0).parse(input)?;
399    let (input, steps) = parse_ramp_u32(input)?;
400    let (input, _) = space0(input)?;
401    let (input, _) = char(')').parse(input)?;
402    Ok((input, Atom::Solo(Solo { low, high, steps })))
403}
404
405fn parse_i32(input: &str) -> IResult<&str, i32> {
406    let (rest, negative) = opt(char('-')).parse(input)?;
407    let (rest, digits) = digit1(rest)?;
408    let value: i32 = digits
409        .parse()
410        .map_err(|_| nom::Err::Error(nom::error::Error::new(rest, nom::error::ErrorKind::Digit)))?;
411    Ok((rest, if negative.is_some() { -value } else { value }))
412}
413
414// --- Group: [ sequence { ("," | "|") sequence } ] ---
415
416fn parse_group(input: &str) -> IResult<&str, Atom> {
417    let (input, _) = char('[').parse(input)?;
418    // `parse_sequence` eats the surrounding whitespace of every layer.
419    let (mut input, first) = parse_sequence(input)?;
420    let mut layers = vec![first];
421    let mut chord = false;
422    let mut random = false;
423    while let Ok((rest, separator)) =
424        one_of::<&str, &str, nom::error::Error<&str>>(",|").parse(input)
425    {
426        let (rest, layer) = parse_sequence(rest)?;
427        match separator {
428            ',' => chord = true,
429            _ => random = true,
430        }
431        layers.push(layer);
432        input = rest;
433    }
434    let (input, _) = char(']').parse(input)?;
435    if chord && random {
436        // Non-recoverable on purpose: `alt` in `parse_atom` must not retry this
437        // as some other atom, and `parse_mini` turns it into a real message.
438        return Err(nom::Err::Failure(nom::error::Error::new(
439            input,
440            FAIL_MIXED_SEPARATORS,
441        )));
442    }
443    let mode = match (chord, random) {
444        (true, _) => GroupMode::Chord,
445        (_, true) => GroupMode::Random,
446        _ => GroupMode::Subdivide,
447    };
448    Ok((input, Atom::Group(Group { mode, layers })))
449}
450
451// --- Alternation: < sequence > ---
452
453fn parse_alternation(input: &str) -> IResult<&str, Atom> {
454    let (input, _) = char('<').parse(input)?;
455    let (input, _) = space0(input)?;
456    let (input, sequence) = parse_sequence(input)?;
457    let (input, _) = space0(input)?;
458    let (input, _) = char('>').parse(input)?;
459    Ok((input, Atom::Alternation(Alternation { sequence })))
460}
461
462// --- Modifiers ---
463
464fn parse_modifier(input: &str) -> IResult<&str, Modifier> {
465    alt((
466        parse_repeat,
467        parse_slow_mod,
468        parse_replicate,
469        parse_euclidean,
470        parse_drop,
471        parse_weight,
472    ))
473    .parse(input)
474}
475
476fn parse_repeat(input: &str) -> IResult<&str, Modifier> {
477    let (input, _) = char('*').parse(input)?;
478    let (input, n) = parse_ramp_u32(input)?;
479    Ok((input, Modifier::Repeat(n)))
480}
481
482fn parse_slow_mod(input: &str) -> IResult<&str, Modifier> {
483    let (input, _) = char('/').parse(input)?;
484    let (input, n) = parse_u32(input)?;
485    Ok((input, Modifier::Slow(n)))
486}
487
488fn parse_replicate(input: &str) -> IResult<&str, Modifier> {
489    let (input, _) = char('!').parse(input)?;
490    let (input, n) = parse_u32(input)?;
491    Ok((input, Modifier::Replicate(n)))
492}
493
494fn parse_euclidean(input: &str) -> IResult<&str, Modifier> {
495    let (input, _) = char('(').parse(input)?;
496    let (input, _) = space0(input)?;
497    let (input, beats) = parse_ramp_u32(input)?;
498    let (input, _) = delimited(space0, char(','), space0).parse(input)?;
499    let (input, steps) = parse_ramp_u32(input)?;
500    let (input, offset) =
501        opt(preceded(delimited(space0, char(','), space0), parse_u32)).parse(input)?;
502    let (input, _) = space0(input)?;
503    let (input, _) = char(')').parse(input)?;
504    Ok((input, Modifier::Euclidean(beats, steps, offset)))
505}
506
507fn parse_drop(input: &str) -> IResult<&str, Modifier> {
508    let (input, _) = char('?').parse(input)?;
509    // No space is allowed before the probability, so `x? 0.5` still reads as a
510    // bare drop followed by a separate step.
511    let (input, probability) = opt(parse_ramp_f64).parse(input)?;
512    Ok((input, Modifier::Drop(probability)))
513}
514
515fn parse_weight(input: &str) -> IResult<&str, Modifier> {
516    let (input, _) = char('@').parse(input)?;
517    let (input, n) = parse_u32(input)?;
518    Ok((input, Modifier::Weight(n)))
519}
520
521fn parse_u32(input: &str) -> IResult<&str, u32> {
522    map_res(digit1, |s: &str| s.parse::<u32>()).parse(input)
523}
524
525/// `4`, `4..16` or `2>4>8>16` — a value that may travel.
526///
527/// `digit1` followed by an optional fraction means `4..16` backtracks cleanly:
528/// the fraction needs a digit after its dot, and the second dot is not one.
529/// A `>` chain backtracks too, so the closing `>` of `<x*4>` is left alone.
530fn parse_ramp_u32(input: &str) -> IResult<&str, Ramp<u32>> {
531    if let Some(result) = parse_r_form(input, |part| {
532        part.parse::<u32>().map_err(|error| error.to_string())
533    }) {
534        return result;
535    }
536    let (input, first) = parse_u32(input)?;
537    if let Ok((rest, to)) = preceded(tag(".."), parse_u32).parse(input) {
538        return Ok((rest, Ramp::Sweep { from: first, to }));
539    }
540    let (input, steps) = many0(preceded(tag(">"), parse_u32)).parse(input)?;
541    Ok((input, Ramp::steps(first, steps)))
542}
543
544fn parse_ramp_f64(input: &str) -> IResult<&str, Ramp<f64>> {
545    if let Some(result) = parse_r_form(input, |part| {
546        part.parse::<f64>().map_err(|error| error.to_string())
547    }) {
548        return result;
549    }
550    let (input, first) = parse_f64(input)?;
551    if let Ok((rest, to)) = preceded(tag(".."), parse_f64).parse(input) {
552        return Ok((rest, Ramp::Sweep { from: first, to }));
553    }
554    let (input, steps) = many0(preceded(tag(">"), parse_f64)).parse(input)?;
555    Ok((input, Ramp::steps(first, steps)))
556}
557
558/// `r(...)` wherever a ramp value is legal — `x(r(2,16,8),4)` grows a
559/// Euclidean count over its own eight-beat window. Written without spaces, so
560/// the closing parenthesis is unambiguous; the interpretation is shared with
561/// the transform parser.
562fn parse_r_form<T: Copy>(
563    input: &str,
564    parse: impl Fn(&str) -> Result<T, String>,
565) -> Option<IResult<&str, Ramp<T>>> {
566    let inner = input.strip_prefix("r(")?;
567    let close = inner.find(')')?;
568    let text = format!("r({})", &inner[..close]);
569    Some(
570        match crate::parser::lines::ramp_from_text(&text, "r", &parse) {
571            Ok(ramp) => Ok((&inner[close + 1..], ramp)),
572            Err(_) => Err(nom::Err::Error(nom::error::Error::new(
573                input,
574                nom::error::ErrorKind::Verify,
575            ))),
576        },
577    )
578}
579
580fn parse_f64(input: &str) -> IResult<&str, f64> {
581    map_res(recognize((digit1, opt((char('.'), digit1)))), |s: &str| {
582        s.parse::<f64>()
583    })
584    .parse(input)
585}
586
587// ============================================================
588// Tests
589// ============================================================
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use crate::ast::program::RampCurve;
595    use pretty_assertions::assert_eq;
596
597    // --- Helper to build notes quickly ---
598    fn n(letter: NoteLetter, acc: Accidental, oct: u8) -> Atom {
599        Atom::Note(Note {
600            letter,
601            accidental: acc,
602            octave: oct,
603        })
604    }
605
606    fn nat(letter: NoteLetter, oct: u8) -> Atom {
607        n(letter, Accidental::Natural, oct)
608    }
609
610    // ---- Notes ----
611
612    #[test]
613    fn r_form_ranges_fit_inside_mini_notation() {
614        let m = parse_mini("x(r(2,16,8),4)").unwrap();
615        let modifiers = &m.sequence.steps[0].modifiers;
616        assert_eq!(
617            modifiers,
618            &vec![Modifier::Euclidean(
619                Ramp::Timed {
620                    ramp: Box::new(Ramp::Sweep { from: 2, to: 16 }),
621                    span_divisions: 8.0,
622                    curve: RampCurve::Linear,
623                },
624                Ramp::fixed(4),
625                None
626            )]
627        );
628
629        let m = parse_mini("x*r(2,8,4,osc)").unwrap();
630        assert_eq!(
631            m.sequence.steps[0].modifiers,
632            vec![Modifier::Repeat(Ramp::Timed {
633                ramp: Box::new(Ramp::Sweep { from: 2, to: 8 }),
634                span_divisions: 4.0,
635                curve: RampCurve::Oscillate,
636            })]
637        );
638
639        let m = parse_mini("x:r(0.3,1.0,2)").unwrap();
640        assert!(m.sequence.steps[0].velocity.as_ref().unwrap().travels());
641    }
642
643    #[test]
644    fn test_note_c4() {
645        let m = parse_mini("c4").unwrap();
646        assert_eq!(m.sequence.steps.len(), 1);
647        assert_eq!(m.sequence.steps[0].atom, nat(NoteLetter::C, 4));
648    }
649
650    #[test]
651    fn test_note_eb3() {
652        let m = parse_mini("eb3").unwrap();
653        assert_eq!(
654            m.sequence.steps[0].atom,
655            n(NoteLetter::E, Accidental::Flat, 3)
656        );
657    }
658
659    #[test]
660    fn test_note_f_sharp_5() {
661        let m = parse_mini("f#5").unwrap();
662        assert_eq!(
663            m.sequence.steps[0].atom,
664            n(NoteLetter::F, Accidental::Sharp, 5)
665        );
666    }
667
668    #[test]
669    fn test_note_bb3_is_b_flat() {
670        let m = parse_mini("bb3").unwrap();
671        assert_eq!(
672            m.sequence.steps[0].atom,
673            n(NoteLetter::B, Accidental::Flat, 3)
674        );
675    }
676
677    #[test]
678    fn test_note_b4_natural() {
679        let m = parse_mini("b4").unwrap();
680        assert_eq!(
681            m.sequence.steps[0].atom,
682            n(NoteLetter::B, Accidental::Natural, 4)
683        );
684    }
685
686    #[test]
687    fn test_note_b_sharp() {
688        let m = parse_mini("b#4").unwrap();
689        assert_eq!(
690            m.sequence.steps[0].atom,
691            n(NoteLetter::B, Accidental::Sharp, 4)
692        );
693    }
694
695    // ---- Sequences ----
696
697    #[test]
698    fn test_sequence_three_notes() {
699        let m = parse_mini("c4 e4 g4").unwrap();
700        assert_eq!(m.sequence.steps.len(), 3);
701        assert_eq!(m.sequence.steps[0].atom, nat(NoteLetter::C, 4));
702        assert_eq!(m.sequence.steps[1].atom, nat(NoteLetter::E, 4));
703        assert_eq!(m.sequence.steps[2].atom, nat(NoteLetter::G, 4));
704    }
705
706    #[test]
707    fn test_rest_and_hold() {
708        let m = parse_mini("c4 ~ _ e4").unwrap();
709        assert_eq!(m.sequence.steps.len(), 4);
710        assert_eq!(m.sequence.steps[0].atom, nat(NoteLetter::C, 4));
711        assert_eq!(m.sequence.steps[1].atom, Atom::Rest);
712        assert_eq!(m.sequence.steps[2].atom, Atom::Hold);
713        assert_eq!(m.sequence.steps[3].atom, nat(NoteLetter::E, 4));
714    }
715
716    #[test]
717    fn test_trigger() {
718        let m = parse_mini("x ~ x ~").unwrap();
719        assert_eq!(m.sequence.steps.len(), 4);
720        assert_eq!(m.sequence.steps[0].atom, Atom::Trigger);
721        assert_eq!(m.sequence.steps[1].atom, Atom::Rest);
722        assert_eq!(m.sequence.steps[2].atom, Atom::Trigger);
723        assert_eq!(m.sequence.steps[3].atom, Atom::Rest);
724    }
725
726    #[test]
727    fn test_scale_degrees() {
728        let m = parse_mini("0 2 4 6").unwrap();
729        assert_eq!(m.sequence.steps.len(), 4);
730        assert_eq!(m.sequence.steps[0].atom, Atom::Degree(0));
731        assert_eq!(m.sequence.steps[1].atom, Atom::Degree(2));
732        assert_eq!(m.sequence.steps[2].atom, Atom::Degree(4));
733        assert_eq!(m.sequence.steps[3].atom, Atom::Degree(6));
734    }
735
736    // ---- Modifiers ----
737
738    #[test]
739    fn test_repeat() {
740        let m = parse_mini("x*4").unwrap();
741        assert_eq!(m.sequence.steps[0].atom, Atom::Trigger);
742        assert_eq!(
743            m.sequence.steps[0].modifiers,
744            vec![Modifier::Repeat(Ramp::fixed(4))]
745        );
746    }
747
748    #[test]
749    fn test_slow_modifier() {
750        let m = parse_mini("[c4 e4 g4]/2").unwrap();
751        assert!(matches!(m.sequence.steps[0].atom, Atom::Group(_)));
752        assert_eq!(m.sequence.steps[0].modifiers, vec![Modifier::Slow(2)]);
753    }
754
755    #[test]
756    fn test_replicate() {
757        let m = parse_mini("c4!3").unwrap();
758        assert_eq!(m.sequence.steps[0].modifiers, vec![Modifier::Replicate(3)]);
759    }
760
761    #[test]
762    fn test_euclidean() {
763        let m = parse_mini("c4(3,8)").unwrap();
764        assert_eq!(
765            m.sequence.steps[0].modifiers,
766            vec![Modifier::Euclidean(Ramp::fixed(3), Ramp::fixed(8), None)]
767        );
768    }
769
770    #[test]
771    fn test_euclidean_with_offset() {
772        let m = parse_mini("x(5,8,2)").unwrap();
773        assert_eq!(
774            m.sequence.steps[0].modifiers,
775            vec![Modifier::Euclidean(Ramp::fixed(5), Ramp::fixed(8), Some(2))]
776        );
777    }
778
779    #[test]
780    fn test_drop() {
781        let m = parse_mini("c4?").unwrap();
782        assert_eq!(m.sequence.steps[0].modifiers, vec![Modifier::Drop(None)]);
783    }
784
785    #[test]
786    fn test_weight() {
787        let m = parse_mini("c4@3 e4").unwrap();
788        assert_eq!(m.sequence.steps[0].modifiers, vec![Modifier::Weight(3)]);
789        assert!(m.sequence.steps[1].modifiers.is_empty());
790    }
791
792    // ---- Groups and chords ----
793
794    #[test]
795    fn test_group() {
796        let m = parse_mini("[c4 e4] g4").unwrap();
797        assert_eq!(m.sequence.steps.len(), 2);
798        if let Atom::Group(ref g) = m.sequence.steps[0].atom {
799            assert_eq!(g.mode, GroupMode::Subdivide);
800            assert_eq!(g.layers.len(), 1);
801            assert_eq!(g.layers[0].steps.len(), 2);
802        } else {
803            panic!("expected group");
804        }
805        assert_eq!(m.sequence.steps[1].atom, nat(NoteLetter::G, 4));
806    }
807
808    #[test]
809    fn test_chord() {
810        let m = parse_mini("[c3,e3,g3]").unwrap();
811        assert_eq!(m.sequence.steps.len(), 1);
812        if let Atom::Group(ref g) = m.sequence.steps[0].atom {
813            assert_eq!(g.mode, GroupMode::Chord);
814            assert_eq!(g.layers.len(), 3);
815            assert_eq!(g.layers[0].steps[0].atom, nat(NoteLetter::C, 3));
816            assert_eq!(g.layers[1].steps[0].atom, nat(NoteLetter::E, 3));
817            assert_eq!(g.layers[2].steps[0].atom, nat(NoteLetter::G, 3));
818        } else {
819            panic!("expected group/chord");
820        }
821    }
822
823    #[test]
824    fn test_nested_groups() {
825        let m = parse_mini("[c4 [e4 g4]] b4").unwrap();
826        assert_eq!(m.sequence.steps.len(), 2);
827        if let Atom::Group(ref outer) = m.sequence.steps[0].atom {
828            assert_eq!(outer.layers[0].steps.len(), 2);
829            assert!(matches!(outer.layers[0].steps[1].atom, Atom::Group(_)));
830        } else {
831            panic!("expected nested group");
832        }
833    }
834
835    // ---- Alternation ----
836
837    #[test]
838    fn test_alternation() {
839        let m = parse_mini("<c4 e4 g4>").unwrap();
840        assert_eq!(m.sequence.steps.len(), 1);
841        if let Atom::Alternation(ref a) = m.sequence.steps[0].atom {
842            assert_eq!(a.sequence.steps.len(), 3);
843        } else {
844            panic!("expected alternation");
845        }
846    }
847
848    #[test]
849    fn test_alternation_in_sequence() {
850        let m = parse_mini("c4 <e4 g4> c5").unwrap();
851        assert_eq!(m.sequence.steps.len(), 3);
852        assert_eq!(m.sequence.steps[0].atom, nat(NoteLetter::C, 4));
853        assert!(matches!(m.sequence.steps[1].atom, Atom::Alternation(_)));
854        assert_eq!(m.sequence.steps[2].atom, nat(NoteLetter::C, 5));
855    }
856
857    // ---- Empty ----
858
859    #[test]
860    fn test_empty_notation() {
861        let m = parse_mini("").unwrap();
862        assert_eq!(m.sequence.steps.len(), 0);
863    }
864
865    #[test]
866    fn test_whitespace_only() {
867        let m = parse_mini("   ").unwrap();
868        assert_eq!(m.sequence.steps.len(), 0);
869    }
870
871    // ---- Complex patterns ----
872
873    #[test]
874    fn test_drum_pattern() {
875        let m = parse_mini("x ~ [x x] ~").unwrap();
876        assert_eq!(m.sequence.steps.len(), 4);
877        assert_eq!(m.sequence.steps[0].atom, Atom::Trigger);
878        assert_eq!(m.sequence.steps[1].atom, Atom::Rest);
879        assert!(matches!(m.sequence.steps[2].atom, Atom::Group(_)));
880        assert_eq!(m.sequence.steps[3].atom, Atom::Rest);
881    }
882
883    #[test]
884    fn test_chord_sequence() {
885        let m = parse_mini("[c3,e3,g3] ~ [f3,a3,c4] ~").unwrap();
886        assert_eq!(m.sequence.steps.len(), 4);
887        if let Atom::Group(ref g) = m.sequence.steps[0].atom {
888            assert_eq!(g.layers.len(), 3);
889        } else {
890            panic!("expected chord");
891        }
892    }
893
894    #[test]
895    fn test_repeat_group() {
896        let m = parse_mini("[c4 e4]*3").unwrap();
897        assert_eq!(m.sequence.steps.len(), 1);
898        assert!(matches!(m.sequence.steps[0].atom, Atom::Group(_)));
899        assert_eq!(
900            m.sequence.steps[0].modifiers,
901            vec![Modifier::Repeat(Ramp::fixed(3))]
902        );
903    }
904
905    // ---- Stacked modifiers ----
906
907    #[test]
908    fn test_stacked_repeat_and_drop() {
909        let m = parse_mini("x*8?").unwrap();
910        assert_eq!(m.sequence.steps.len(), 1);
911        assert_eq!(m.sequence.steps[0].atom, Atom::Trigger);
912        assert_eq!(
913            m.sequence.steps[0].modifiers,
914            vec![Modifier::Repeat(Ramp::fixed(8)), Modifier::Drop(None)]
915        );
916    }
917
918    #[test]
919    fn test_stacked_euclidean_and_drop_keeps_written_order() {
920        let m = parse_mini("x(5,8)?0.25").unwrap();
921        assert_eq!(
922            m.sequence.steps[0].modifiers,
923            vec![
924                Modifier::Euclidean(Ramp::fixed(5), Ramp::fixed(8), None),
925                Modifier::Drop(Some(Ramp::fixed(0.25)))
926            ]
927        );
928    }
929
930    #[test]
931    fn test_stacked_weight_and_repeat() {
932        let m = parse_mini("[c4 e4]*2@3 g4").unwrap();
933        assert_eq!(
934            m.sequence.steps[0].modifiers,
935            vec![Modifier::Repeat(Ramp::fixed(2)), Modifier::Weight(3)]
936        );
937    }
938
939    // ---- Drop probability ----
940
941    #[test]
942    fn test_drop_with_probability() {
943        let m = parse_mini("x?0.3").unwrap();
944        assert_eq!(
945            m.sequence.steps[0].modifiers,
946            vec![Modifier::Drop(Some(Ramp::fixed(0.3)))]
947        );
948    }
949
950    // ---- Solo ----
951
952    #[test]
953    fn test_solo_atom() {
954        let m = parse_mini("solo(0..7, 8)").unwrap();
955        assert_eq!(
956            m.sequence.steps[0].atom,
957            Atom::Solo(Solo {
958                low: 0,
959                high: 7,
960                steps: Ramp::fixed(8)
961            })
962        );
963        // Negative degrees walk below the root octave.
964        let m = parse_mini("solo(-3..4,6)").unwrap();
965        assert_eq!(
966            m.sequence.steps[0].atom,
967            Atom::Solo(Solo {
968                low: -3,
969                high: 4,
970                steps: Ramp::fixed(6)
971            })
972        );
973        // The step count may travel across the line's ramp span.
974        let m = parse_mini("solo(0..12, 4..16)").unwrap();
975        assert_eq!(
976            m.sequence.steps[0].atom,
977            Atom::Solo(Solo {
978                low: 0,
979                high: 12,
980                steps: Ramp::Sweep { from: 4, to: 16 }
981            })
982        );
983    }
984
985    #[test]
986    fn test_solo_composes_with_sequences_and_modifiers() {
987        // Half a cycle of kick-space, half of solo.
988        let m = parse_mini("~ solo(0..7,4)").unwrap();
989        assert_eq!(m.sequence.steps.len(), 2);
990        assert!(matches!(m.sequence.steps[1].atom, Atom::Solo(_)));
991        // A drop chance applies to the whole generated slot.
992        let m = parse_mini("solo(0..7,8)?0.3").unwrap();
993        assert_eq!(
994            m.sequence.steps[0].modifiers,
995            vec![Modifier::Drop(Some(Ramp::fixed(0.3)))]
996        );
997    }
998
999    #[test]
1000    fn test_solo_validation() {
1001        // A walk needs somewhere to go, and at least one note.
1002        assert!(parse_mini("solo(5..5,8)").is_err());
1003        assert!(parse_mini("solo(7..0,8)").is_err());
1004        assert!(parse_mini("solo(0..7,0)").is_err());
1005        assert!(parse_mini("solo(0..7,4..0)").is_err());
1006        // A generated walk has no single note to lend a chord, but it can sit
1007        // beside other choice options or subdivision steps.
1008        assert!(parse_mini("[c4,solo(0..7,4)]").is_err());
1009        assert!(parse_mini("[c4|solo(0..7,4)]").is_ok());
1010        assert!(parse_mini("[c4 solo(0..7,4)]").is_ok());
1011    }
1012
1013    // ---- Ramps ----
1014
1015    #[test]
1016    fn test_euclidean_onsets_may_ramp() {
1017        // The density crescendo: four hits growing to sixteen.
1018        let m = parse_mini("x(4..16,4)").unwrap();
1019        assert_eq!(
1020            m.sequence.steps[0].modifiers,
1021            vec![Modifier::Euclidean(
1022                Ramp::Sweep { from: 4, to: 16 },
1023                Ramp::fixed(4),
1024                None
1025            )]
1026        );
1027    }
1028
1029    #[test]
1030    fn test_ranges_do_not_confuse_the_float_parser() {
1031        // `4..16` must not read the first dot as a decimal point.
1032        let m = parse_mini("x?0.1..0.9").unwrap();
1033        assert_eq!(
1034            m.sequence.steps[0].modifiers,
1035            vec![Modifier::Drop(Some(Ramp::Sweep { from: 0.1, to: 0.9 }))]
1036        );
1037        let m = parse_mini("x*2..8").unwrap();
1038        assert_eq!(
1039            m.sequence.steps[0].modifiers,
1040            vec![Modifier::Repeat(Ramp::Sweep { from: 2, to: 8 })]
1041        );
1042        // Positions can travel too, and an offset still follows.
1043        let m = parse_mini("x(3,4..16,1)").unwrap();
1044        assert_eq!(
1045            m.sequence.steps[0].modifiers,
1046            vec![Modifier::Euclidean(
1047                Ramp::fixed(3),
1048                Ramp::Sweep { from: 4, to: 16 },
1049                Some(1)
1050            )]
1051        );
1052    }
1053
1054    #[test]
1055    fn test_a_step_chain_holds_each_value_in_turn() {
1056        // The doubling build: not a smooth sweep but four held stages.
1057        let m = parse_mini("x(2>4>8>16,4)").unwrap();
1058        assert_eq!(
1059            m.sequence.steps[0].modifiers,
1060            vec![Modifier::Euclidean(
1061                Ramp::Steps {
1062                    first: 2,
1063                    rest: vec![4, 8, 16]
1064                },
1065                Ramp::fixed(4),
1066                None
1067            )]
1068        );
1069
1070        let m = parse_mini("x*4>8>16").unwrap();
1071        assert_eq!(
1072            m.sequence.steps[0].modifiers,
1073            vec![Modifier::Repeat(Ramp::Steps {
1074                first: 4,
1075                rest: vec![8, 16]
1076            })]
1077        );
1078
1079        let m = parse_mini("x?0.2>0.6>0.9").unwrap();
1080        assert_eq!(
1081            m.sequence.steps[0].modifiers,
1082            vec![Modifier::Drop(Some(Ramp::Steps {
1083                first: 0.2,
1084                rest: vec![0.6, 0.9]
1085            }))]
1086        );
1087    }
1088
1089    #[test]
1090    fn test_a_step_chain_does_not_swallow_an_alternation_close() {
1091        // `<x*4>` must still parse: the chain has to backtrack off the `>`.
1092        let m = parse_mini("<x*4 x*8>").unwrap();
1093        let Atom::Alternation(ref alternation) = m.sequence.steps[0].atom else {
1094            panic!(
1095                "expected an alternation, got {:?}",
1096                m.sequence.steps[0].atom
1097            );
1098        };
1099        assert_eq!(alternation.sequence.steps.len(), 2);
1100        assert_eq!(
1101            alternation.sequence.steps[0].modifiers,
1102            vec![Modifier::Repeat(Ramp::fixed(4))]
1103        );
1104        assert_eq!(
1105            alternation.sequence.steps[1].modifiers,
1106            vec![Modifier::Repeat(Ramp::fixed(8))]
1107        );
1108    }
1109
1110    #[test]
1111    fn test_every_stage_of_a_chain_is_validated() {
1112        assert!(parse_mini("x*2>0>8").is_err());
1113        assert!(parse_mini("x(3,4>0)").is_err());
1114        assert!(parse_mini("x?0.2>2.0").is_err());
1115    }
1116
1117    #[test]
1118    fn test_both_ends_of_a_range_are_validated() {
1119        assert!(parse_mini("x*1..0").is_err());
1120        assert!(parse_mini("x(3,4..0)").is_err());
1121        assert!(parse_mini("x?0.5..2").is_err());
1122    }
1123
1124    #[test]
1125    fn test_drop_probability_out_of_range_is_rejected() {
1126        let error = parse_mini("x?2").unwrap_err();
1127        assert!(error.contains("0.0-1.0"), "unexpected message: {error}");
1128    }
1129
1130    #[test]
1131    fn test_bare_drop_then_degree_is_two_steps() {
1132        // A space means the number is the next step, not the drop probability.
1133        let m = parse_mini("x? 0").unwrap();
1134        assert_eq!(m.sequence.steps.len(), 2);
1135        assert_eq!(m.sequence.steps[0].modifiers, vec![Modifier::Drop(None)]);
1136        assert_eq!(m.sequence.steps[1].atom, Atom::Degree(0));
1137    }
1138
1139    // ---- Velocity and accent ----
1140
1141    #[test]
1142    fn test_accent_is_a_full_velocity_trigger() {
1143        let m = parse_mini("X ~ x ~").unwrap();
1144        assert_eq!(m.sequence.steps[0].atom, Atom::Trigger);
1145        assert_eq!(m.sequence.steps[0].velocity, Some(Ramp::fixed(1.0)));
1146        // Lowercase keeps meaning "normal", i.e. take the line's `vel`.
1147        assert_eq!(m.sequence.steps[2].atom, Atom::Trigger);
1148        assert_eq!(m.sequence.steps[2].velocity, None);
1149        // And it is exactly the `:1.0` spelling.
1150        assert_eq!(parse_mini("X").unwrap(), parse_mini("x:1.0").unwrap());
1151    }
1152
1153    #[test]
1154    fn test_explicit_velocity_on_any_sounding_atom() {
1155        let velocity = |source: &str| {
1156            parse_mini(source).unwrap().sequence.steps[0]
1157                .velocity
1158                .clone()
1159        };
1160        assert_eq!(velocity("x:0.6"), Some(Ramp::fixed(0.6)));
1161        assert_eq!(velocity("c4:0.35"), Some(Ramp::fixed(0.35)));
1162        assert_eq!(velocity("3:0.5"), Some(Ramp::fixed(0.5)));
1163        assert_eq!(velocity("[c4 e4]:0.8"), Some(Ramp::fixed(0.8)));
1164        assert_eq!(velocity("<c4 e4>:0.8"), Some(Ramp::fixed(0.8)));
1165        assert_eq!(velocity("solo(0..7,8):0.7"), Some(Ramp::fixed(0.7)));
1166        // An inner step keeps its own, so a group's velocity is only a default.
1167        let m = parse_mini("[c4 e4:0.4]:0.9").unwrap();
1168        assert_eq!(m.sequence.steps[0].velocity, Some(Ramp::fixed(0.9)));
1169        let Atom::Group(ref group) = m.sequence.steps[0].atom else {
1170            panic!("expected a group");
1171        };
1172        assert_eq!(group.layers[0].steps[1].velocity, Some(Ramp::fixed(0.4)));
1173    }
1174
1175    #[test]
1176    fn test_velocity_composes_with_slot_generating_modifiers() {
1177        // Written once, whichever side of the modifier — one velocity for every
1178        // slot the step generates.
1179        let m = parse_mini("X*4").unwrap();
1180        assert_eq!(
1181            m.sequence.steps[0].modifiers,
1182            vec![Modifier::Repeat(Ramp::fixed(4))]
1183        );
1184        assert_eq!(m.sequence.steps[0].velocity, Some(Ramp::fixed(1.0)));
1185
1186        assert_eq!(
1187            parse_mini("x:0.6*4").unwrap(),
1188            parse_mini("x*4:0.6").unwrap()
1189        );
1190
1191        let m = parse_mini("x:0.6?0.25").unwrap();
1192        assert_eq!(
1193            m.sequence.steps[0].modifiers,
1194            vec![Modifier::Drop(Some(Ramp::fixed(0.25)))]
1195        );
1196        assert_eq!(m.sequence.steps[0].velocity, Some(Ramp::fixed(0.6)));
1197
1198        let m = parse_mini("x(3,8):0.9").unwrap();
1199        assert_eq!(
1200            m.sequence.steps[0].modifiers,
1201            vec![Modifier::Euclidean(Ramp::fixed(3), Ramp::fixed(8), None)]
1202        );
1203        assert_eq!(m.sequence.steps[0].velocity, Some(Ramp::fixed(0.9)));
1204    }
1205
1206    #[test]
1207    fn test_velocity_may_travel() {
1208        let m = parse_mini("x:0.3..0.9").unwrap();
1209        assert_eq!(
1210            m.sequence.steps[0].velocity,
1211            Some(Ramp::Sweep { from: 0.3, to: 0.9 })
1212        );
1213        assert!(m.sequence.steps[0].velocity.as_ref().unwrap().travels());
1214        let m = parse_mini("x:0.3>0.6>0.9").unwrap();
1215        assert_eq!(
1216            m.sequence.steps[0].velocity,
1217            Some(Ramp::Steps {
1218                first: 0.3,
1219                rest: vec![0.6, 0.9]
1220            })
1221        );
1222        // A closing `>` is still left for the alternation.
1223        let m = parse_mini("<x:0.4 x:0.8>").unwrap();
1224        let Atom::Alternation(ref alternation) = m.sequence.steps[0].atom else {
1225            panic!("expected an alternation");
1226        };
1227        assert_eq!(
1228            alternation.sequence.steps[1].velocity,
1229            Some(Ramp::fixed(0.8))
1230        );
1231    }
1232
1233    #[test]
1234    fn test_velocity_out_of_range_is_rejected_at_every_value() {
1235        let error = parse_mini("x:1.4").unwrap_err();
1236        assert!(
1237            error.contains("1.4") && error.contains("0.0-1.0"),
1238            "{error}"
1239        );
1240        assert!(parse_mini("x:0.5..1.2").unwrap_err().contains("1.2"));
1241        assert!(parse_mini("x:0.2>0.9>1.1").unwrap_err().contains("1.1"));
1242        // Validation reaches nested layers like every other check.
1243        assert!(parse_mini("[c4 e4:2]").is_err());
1244    }
1245
1246    #[test]
1247    fn test_velocity_on_a_rest_or_a_hold_is_rejected() {
1248        let error = parse_mini("~:0.5").unwrap_err();
1249        assert!(error.contains("rest"), "{error}");
1250        let error = parse_mini("x _:0.5").unwrap_err();
1251        assert!(error.contains("hold"), "{error}");
1252    }
1253
1254    #[test]
1255    fn test_one_velocity_per_step() {
1256        let error = parse_mini("x:0.6:0.8").unwrap_err();
1257        assert!(error.contains("one velocity"), "{error}");
1258        // `X` already set one.
1259        let error = parse_mini("X:0.6").unwrap_err();
1260        assert!(error.contains("already a velocity of 1.0"), "{error}");
1261    }
1262
1263    // ---- Random choice ----
1264
1265    #[test]
1266    fn test_random_choice_group() {
1267        let m = parse_mini("[c4|e4|g4]").unwrap();
1268        assert_eq!(m.sequence.steps.len(), 1);
1269        if let Atom::Group(ref g) = m.sequence.steps[0].atom {
1270            assert_eq!(g.mode, GroupMode::Random);
1271            assert_eq!(g.layers.len(), 3);
1272            assert_eq!(g.layers[0].steps[0].atom, nat(NoteLetter::C, 4));
1273            assert_eq!(g.layers[2].steps[0].atom, nat(NoteLetter::G, 4));
1274        } else {
1275            panic!("expected random-choice group");
1276        }
1277    }
1278
1279    #[test]
1280    fn test_random_choice_layers_may_be_sequences() {
1281        let m = parse_mini("[x x|x ~ x]").unwrap();
1282        if let Atom::Group(ref g) = m.sequence.steps[0].atom {
1283            assert_eq!(g.mode, GroupMode::Random);
1284            assert_eq!(g.layers[0].steps.len(), 2);
1285            assert_eq!(g.layers[1].steps.len(), 3);
1286        } else {
1287            panic!("expected random-choice group");
1288        }
1289    }
1290
1291    #[test]
1292    fn test_mixing_chord_and_random_separators_is_rejected() {
1293        let error = parse_mini("[c4,e4|g4]").unwrap_err();
1294        assert!(error.contains("not both"), "unexpected message: {error}");
1295    }
1296
1297    // ---- Validation ----
1298
1299    #[test]
1300    fn test_euclidean_accepts_more_onsets_than_steps() {
1301        // `(9,8)` is nine onsets over eight slots, so one slot subdivides.
1302        let m = parse_mini("x(9,8)").unwrap();
1303        assert_eq!(
1304            m.sequence.steps[0].modifiers,
1305            vec![Modifier::Euclidean(Ramp::fixed(9), Ramp::fixed(8), None)]
1306        );
1307        assert!(parse_mini("x(20,8)").is_ok());
1308    }
1309
1310    #[test]
1311    fn test_zero_counts_are_rejected() {
1312        assert!(parse_mini("x*0").is_err());
1313        assert!(parse_mini("x!0").is_err());
1314        assert!(parse_mini("x@0").is_err());
1315        assert!(parse_mini("x(3,0)").is_err());
1316        // A zero-onset Euclidean is a legitimate silence, not an error.
1317        assert!(parse_mini("x(0,8)").is_ok());
1318    }
1319
1320    #[test]
1321    fn test_validation_reaches_nested_layers() {
1322        assert!(parse_mini("[c4 e4*0]").is_err());
1323        assert!(parse_mini("<c4 e4?3>").is_err());
1324    }
1325}