Skip to main content

acorde_layout/
engine.rs

1use crate::{
2    AccidentalMark, BeamGroup, ConcertKeyOverride, CourtesyAccidental, LayoutConfig, LayoutResult,
3    RowLayout, SourceVoiceAddress, SpanMark, TupletGroup,
4};
5use acorde_core::{AccidentalDisplay, BeamState, HairpinKind, NoteAddr, OttavaKind, Score, Step};
6use std::collections::HashMap;
7
8/// Shift `fifths` (circle-of-fifths key index) by `semitones` semitones.
9///
10/// Uses the modular relationship: 7 semitones = +1 fifth (sharp).
11/// Maps result to [-7, 7].
12fn transpose_key_fifths(fifths: i8, semitones: i8) -> i8 {
13    let raw = (semitones as i16 * 7).rem_euclid(12) as i8;
14    let delta = if raw > 6 { raw - 12 } else { raw };
15    (fifths + delta).clamp(-7, 7)
16}
17
18/// Compute a `LayoutResult` for the given score and configuration.
19///
20/// The result is independent of pixel dimensions so any renderer can consume it.
21pub fn compute_layout(score: &Score, config: &LayoutConfig) -> LayoutResult {
22    let per_row = config.measures_per_row.max(1);
23    let first_row = config.first_row_measures.map(|n| n.max(1));
24    let vis_slots = build_vis_slots(score);
25    let rows = build_rows(score, &vis_slots, per_row, first_row);
26    // Spans authored only as typed spanners pair up like their legacy note flags.
27    let spans = resolve_spans(&score.with_legacy_spanner_flags());
28
29    let mut concert_key_overrides = Vec::new();
30    if config.concert_pitch {
31        let base_fifths = score.settings.key_signature.fifths;
32        for (pi, part) in score.parts.iter().enumerate() {
33            for (si, staff) in part.staves.iter().enumerate() {
34                if staff.transpose_semitones != 0 {
35                    concert_key_overrides.push(ConcertKeyOverride {
36                        part_index: pi,
37                        staff_index: si,
38                        fifths: transpose_key_fifths(base_fifths, staff.transpose_semitones),
39                    });
40                }
41            }
42        }
43    }
44
45    let beam_groups = collect_beam_groups(score);
46    let tuplet_groups = collect_tuplet_groups(score);
47    let courtesy_accidentals = collect_courtesy_accidentals(score);
48    let accidentals = collect_accidental_marks(score);
49    let source_voice_addresses = collect_source_voice_addresses(score);
50
51    LayoutResult {
52        vis_slots,
53        rows,
54        spans,
55        typed_spanners: score.spanners.clone(),
56        concert_key_overrides,
57        beam_groups,
58        tuplet_groups,
59        courtesy_accidentals,
60        accidentals,
61        source_voice_addresses,
62    }
63}
64
65fn collect_source_voice_addresses(score: &Score) -> Vec<SourceVoiceAddress> {
66    let mut addresses = Vec::new();
67    for (part, part_value) in score.parts.iter().enumerate() {
68        for (staff, staff_value) in part_value.staves.iter().enumerate() {
69            for (measure, measure_value) in staff_value.measures.iter().enumerate() {
70                for (voice, source_voice_number) in
71                    measure_value.source_voice_numbers.iter().enumerate()
72                {
73                    if let Some(source_voice_number) = source_voice_number {
74                        addresses.push(SourceVoiceAddress {
75                            part,
76                            staff,
77                            measure,
78                            voice,
79                            source_voice_number: *source_voice_number,
80                        });
81                    }
82                }
83            }
84        }
85    }
86    addresses
87}
88
89// ── vis_slots ─────────────────────────────────────────────────────────────────
90
91fn build_vis_slots(score: &Score) -> Vec<usize> {
92    let measure_count = score
93        .parts
94        .first()
95        .and_then(|p| p.staves.first())
96        .map(|s| s.measures.len())
97        .unwrap_or(0);
98
99    let mut slots = Vec::new();
100    for phys in 0..measure_count {
101        let count = multi_rest_count(score, phys).max(1);
102        for _ in 0..count {
103            slots.push(phys);
104        }
105    }
106    slots
107}
108
109fn multi_rest_count(score: &Score, measure_index: usize) -> usize {
110    score
111        .parts
112        .iter()
113        .flat_map(|p| p.staves.iter())
114        .filter_map(|s| s.measures.get(measure_index))
115        .filter_map(|m| m.multi_rest_count)
116        .map(|c| c as usize)
117        .max()
118        .unwrap_or(1)
119}
120
121// ── rows ──────────────────────────────────────────────────────────────────────
122
123fn force_break_after(score: &Score, phys: usize) -> bool {
124    score
125        .parts
126        .iter()
127        .flat_map(|p| p.staves.iter())
128        .filter_map(|s| s.measures.get(phys))
129        .any(|m| m.system_break || m.page_break)
130}
131
132fn build_rows(
133    score: &Score,
134    vis_slots: &[usize],
135    per_row: usize,
136    first_row_limit: Option<usize>,
137) -> Vec<RowLayout> {
138    if vis_slots.is_empty() {
139        return vec![RowLayout {
140            measure_indices: Vec::new(),
141        }];
142    }
143
144    let mut rows: Vec<RowLayout> = Vec::new();
145    let mut current: Vec<usize> = Vec::new();
146    let mut current_width = 0usize;
147    let mut last_phys = usize::MAX;
148
149    for &phys in vis_slots {
150        if phys != last_phys {
151            let limit = if rows.is_empty() {
152                first_row_limit.unwrap_or(per_row)
153            } else {
154                per_row
155            };
156            let span_width = multi_rest_count(score, phys).max(1);
157            if !current.is_empty() && current_width.saturating_add(span_width) > limit {
158                rows.push(RowLayout {
159                    measure_indices: std::mem::take(&mut current),
160                });
161                current_width = 0;
162            }
163            current.push(phys);
164            current_width = current_width.saturating_add(span_width);
165            if force_break_after(score, phys) {
166                rows.push(RowLayout {
167                    measure_indices: std::mem::take(&mut current),
168                });
169                current_width = 0;
170            }
171        }
172        last_phys = phys;
173    }
174
175    if !current.is_empty() {
176        rows.push(RowLayout {
177            measure_indices: current,
178        });
179    }
180    rows
181}
182
183// ── span resolution ───────────────────────────────────────────────────────────
184
185fn resolve_spans(score: &Score) -> Vec<SpanMark> {
186    let mut spans = Vec::new();
187
188    for (pi, part) in score.parts.iter().enumerate() {
189        for (si, staff) in part.staves.iter().enumerate() {
190            for voice in 0..4usize {
191                let mut open_hairpin: Option<(NoteAddr, HairpinKind)> = None;
192                let mut open_ottava: Option<(NoteAddr, OttavaKind)> = None;
193                let mut open_pedal: Option<NoteAddr> = None;
194                let mut open_slur: Option<NoteAddr> = None;
195                let mut open_trill_line: Option<NoteAddr> = None;
196                let mut open_glissando: Option<NoteAddr> = None;
197
198                for (mi, measure) in staff.measures.iter().enumerate() {
199                    for (ni, note) in measure.voices[voice].iter().enumerate() {
200                        let addr = NoteAddr {
201                            part: pi,
202                            staff: si,
203                            measure: mi,
204                            voice,
205                            note: ni,
206                        };
207
208                        // A note can end one hairpin and start the next (cresc. into
209                        // dim.): close the earlier one first.
210                        let ends_earlier = note.hairpin_end && open_hairpin.is_some();
211                        if ends_earlier && let Some((start, kind)) = open_hairpin.take() {
212                            spans.push(SpanMark::Hairpin {
213                                kind,
214                                start,
215                                end: addr.clone(),
216                            });
217                        }
218                        if let Some(kind) = note.hairpin_start {
219                            open_hairpin = Some((addr.clone(), kind));
220                        }
221                        if note.hairpin_end
222                            && !ends_earlier
223                            && let Some((start, kind)) = open_hairpin.take()
224                        {
225                            spans.push(SpanMark::Hairpin {
226                                kind,
227                                start,
228                                end: addr.clone(),
229                            });
230                        }
231
232                        if let Some(kind) = note.ottava_start {
233                            open_ottava = Some((addr.clone(), kind));
234                        }
235                        if note.ottava_end
236                            && let Some((start, kind)) = open_ottava.take()
237                        {
238                            spans.push(SpanMark::Ottava {
239                                kind,
240                                start,
241                                end: addr.clone(),
242                            });
243                        }
244
245                        if note.pedal_start {
246                            open_pedal = Some(addr.clone());
247                        }
248                        if note.pedal_end
249                            && let Some(start) = open_pedal.take()
250                        {
251                            spans.push(SpanMark::Pedal {
252                                start,
253                                end: addr.clone(),
254                            });
255                        }
256
257                        if note.slur_start {
258                            open_slur = Some(addr.clone());
259                        }
260                        if note.slur_end
261                            && let Some(start) = open_slur.take()
262                        {
263                            spans.push(SpanMark::Slur {
264                                start,
265                                end: addr.clone(),
266                            });
267                        }
268
269                        if note.trill_line_start {
270                            open_trill_line = Some(addr.clone());
271                        }
272                        if note.trill_line_end
273                            && let Some(start) = open_trill_line.take()
274                        {
275                            spans.push(SpanMark::TrillLine {
276                                start,
277                                end: addr.clone(),
278                            });
279                        }
280
281                        if note.glissando_start {
282                            open_glissando = Some(addr.clone());
283                        }
284                        if note.glissando_end
285                            && let Some(start) = open_glissando.take()
286                        {
287                            spans.push(SpanMark::Glissando {
288                                start,
289                                end: addr.clone(),
290                            });
291                        }
292
293                        if let Some(chord) = &note.chord_symbol
294                            && let Some(end) = &chord.range_end
295                        {
296                            spans.push(SpanMark::Harmony {
297                                label: chord.display_text(),
298                                start: addr.clone(),
299                                end: end.clone(),
300                            });
301                        }
302                    }
303                }
304            }
305        }
306    }
307    spans
308}
309
310// ── beam groups ───────────────────────────────────────────────────────────────
311
312fn collect_beam_groups(score: &Score) -> Vec<BeamGroup> {
313    let mut groups = Vec::new();
314    for (pi, part) in score.parts.iter().enumerate() {
315        for (si, staff) in part.staves.iter().enumerate() {
316            for (mi, measure) in staff.measures.iter().enumerate() {
317                for vi in 0..4usize {
318                    let voice = &measure.voices[vi];
319                    let mut current: Option<Vec<usize>> = None;
320
321                    for (ni, note) in voice.iter().enumerate() {
322                        match note.beam {
323                            BeamState::Begin => {
324                                current = Some(vec![ni]);
325                            }
326                            BeamState::BeginEnd => {
327                                groups.push(BeamGroup {
328                                    part: pi,
329                                    staff: si,
330                                    measure: mi,
331                                    voice: vi,
332                                    note_indices: vec![ni],
333                                });
334                            }
335                            BeamState::Continue
336                            | BeamState::ForwardHook
337                            | BeamState::BackwardHook => {
338                                if let Some(ref mut g) = current {
339                                    g.push(ni);
340                                }
341                            }
342                            BeamState::End => {
343                                if let Some(mut g) = current.take() {
344                                    g.push(ni);
345                                    groups.push(BeamGroup {
346                                        part: pi,
347                                        staff: si,
348                                        measure: mi,
349                                        voice: vi,
350                                        note_indices: g,
351                                    });
352                                }
353                            }
354                            BeamState::None => {
355                                current = None;
356                            }
357                        }
358                    }
359                }
360            }
361        }
362    }
363    groups
364}
365
366// ── tuplet groups ─────────────────────────────────────────────────────────────
367
368fn collect_tuplet_groups(score: &Score) -> Vec<TupletGroup> {
369    let mut groups = Vec::new();
370    for (pi, part) in score.parts.iter().enumerate() {
371        for (si, staff) in part.staves.iter().enumerate() {
372            for (mi, measure) in staff.measures.iter().enumerate() {
373                for vi in 0..4usize {
374                    let voice = &measure.voices[vi];
375                    for (first, last) in acorde_core::tuplet_group_ranges(voice) {
376                        let note_indices: Vec<_> = (first..=last)
377                            .filter(|&index| {
378                                !voice[index].is_grace && voice[index].tuplet.is_some()
379                            })
380                            .collect();
381                        if note_indices.len() >= 2
382                            && let Some(ti) = voice[first].tuplet.as_ref()
383                        {
384                            groups.push(TupletGroup {
385                                part: pi,
386                                staff: si,
387                                measure: mi,
388                                voice: vi,
389                                note_indices,
390                                actual_notes: ti.actual_notes,
391                                normal_notes: ti.normal_notes,
392                            });
393                        }
394                    }
395                }
396            }
397        }
398    }
399    groups
400}
401
402fn step_idx(step: &Step) -> u8 {
403    match step {
404        Step::C => 0,
405        Step::D => 1,
406        Step::E => 2,
407        Step::F => 3,
408        Step::G => 4,
409        Step::A => 5,
410        Step::B => 6,
411    }
412}
413
414/// Accidental implied by a key signature for a given step: 0, 1, or -1.
415fn key_alter(fifths: i8, step: &Step) -> i8 {
416    const SHARPS: [Step; 7] = [
417        Step::F,
418        Step::C,
419        Step::G,
420        Step::D,
421        Step::A,
422        Step::E,
423        Step::B,
424    ];
425    const FLATS: [Step; 7] = [
426        Step::B,
427        Step::E,
428        Step::A,
429        Step::D,
430        Step::G,
431        Step::C,
432        Step::F,
433    ];
434    if fifths > 0 && SHARPS[..fifths as usize].contains(step) {
435        1
436    } else if fifths < 0 && FLATS[..(-fifths) as usize].contains(step) {
437        -1
438    } else {
439        0
440    }
441}
442
443fn collect_courtesy_accidentals(score: &Score) -> Vec<CourtesyAccidental> {
444    let mut result = Vec::new();
445    let base_fifths = score.settings.key_signature.fifths;
446
447    for (pi, part) in score.parts.iter().enumerate() {
448        for (si, staff) in part.staves.iter().enumerate() {
449            // Maps (step_index, octave) -> alter from the previous measure.
450            let mut prev_alters: HashMap<(u8, i8), i8> = HashMap::new();
451            let mut current_fifths = base_fifths;
452
453            for (mi, measure) in staff.measures.iter().enumerate() {
454                if let Some(key_sig) = measure.key_sig.as_ref() {
455                    current_fifths = key_sig.fifths;
456                    prev_alters.clear();
457                }
458
459                // Phase 1: check this measure's notes against prev_alters.
460                for vi in 0..4usize {
461                    for (ni, note) in measure.voices[vi].iter().enumerate() {
462                        for (pitch_idx, pitch) in note.pitches.iter().enumerate() {
463                            let key = (step_idx(&pitch.step), pitch.octave);
464                            // A parenthesized accidental the source asked for is always drawn.
465                            let requested = note.accidental_display(pitch_idx)
466                                == AccidentalDisplay::Parenthesized;
467                            if requested || (!note.tie_end && prev_alters.contains_key(&key)) {
468                                result.push(CourtesyAccidental {
469                                    part: pi,
470                                    staff: si,
471                                    measure: mi,
472                                    voice: vi,
473                                    note_index: ni,
474                                    pitch_index: pitch_idx,
475                                    alter: pitch.alter,
476                                });
477                            }
478                        }
479                    }
480                }
481
482                // Phase 2: rebuild prev_alters from this measure's chromatically altered pitches.
483                prev_alters.clear();
484                for vi in 0..4usize {
485                    for note in measure.voices[vi].iter() {
486                        for pitch in note.pitches.iter() {
487                            if pitch.alter != key_alter(current_fifths, &pitch.step) {
488                                prev_alters
489                                    .insert((step_idx(&pitch.step), pitch.octave), pitch.alter);
490                            }
491                        }
492                    }
493                }
494            }
495        }
496    }
497    result
498}
499
500// ── mandatory accidentals ────────────────────────────────────────────────────
501
502/// Collect mandatory (non-courtesy) accidental marks.
503///
504/// State is scoped per `(part, staff)` and reset every measure to the key signature's
505/// implied alteration — accidentals never carry across a barline. Within a measure, all
506/// voices on the staff share one accidental context (mirrors [`collect_courtesy_accidentals`]'s
507/// voice-major scan order). Tied continuations (`tie_end`) never need a fresh accidental.
508fn collect_accidental_marks(score: &Score) -> Vec<AccidentalMark> {
509    let mut result = Vec::new();
510    let base_fifths = score.settings.key_signature.fifths;
511
512    for (pi, part) in score.parts.iter().enumerate() {
513        for (si, staff) in part.staves.iter().enumerate() {
514            let mut current_fifths = base_fifths;
515
516            for (mi, measure) in staff.measures.iter().enumerate() {
517                if let Some(key_sig) = measure.key_sig.as_ref() {
518                    current_fifths = key_sig.fifths;
519                }
520
521                // Active alteration per (step, octave), established within this measure only.
522                let mut active: HashMap<(u8, i8), i8> = HashMap::new();
523
524                for vi in 0..4usize {
525                    for (ni, note) in measure.voices[vi].iter().enumerate() {
526                        for (pitch_idx, pitch) in note.pitches.iter().enumerate() {
527                            let key = (step_idx(&pitch.step), pitch.octave);
528                            let baseline = active
529                                .get(&key)
530                                .copied()
531                                .unwrap_or_else(|| key_alter(current_fifths, &pitch.step));
532                            let display = note.accidental_display(pitch_idx);
533                            let parenthesized = display == AccidentalDisplay::Parenthesized;
534                            if pitch.alter != baseline {
535                                // A parenthesized one is drawn with the courtesy marks.
536                                if !note.tie_end && !parenthesized {
537                                    result.push(AccidentalMark {
538                                        part: pi,
539                                        staff: si,
540                                        measure: mi,
541                                        voice: vi,
542                                        note_index: ni,
543                                        pitch_index: pitch_idx,
544                                        alter: pitch.alter,
545                                    });
546                                }
547                                active.insert(key, pitch.alter);
548                            } else if matches!(
549                                display,
550                                AccidentalDisplay::Cautionary | AccidentalDisplay::Editorial
551                            ) {
552                                // Cautionary and editorial accidentals are drawn even when the
553                                // key and the bar already imply them.
554                                result.push(AccidentalMark {
555                                    part: pi,
556                                    staff: si,
557                                    measure: mi,
558                                    voice: vi,
559                                    note_index: ni,
560                                    pitch_index: pitch_idx,
561                                    alter: pitch.alter,
562                                });
563                            }
564                        }
565                    }
566                }
567            }
568        }
569    }
570    result
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use acorde_core::{Clef, Measure, Part, Score, Staff};
577
578    fn score_with_measures(n: usize) -> Score {
579        let mut score = Score::default();
580        score.parts.clear();
581        let mut staff = Staff::new(Clef::Treble);
582        for i in 0..n {
583            let mut m = Measure::empty(4, 4);
584            m.number = i as u32 + 1;
585            staff.measures.push(m);
586        }
587        let mut part = Part::new("P1", "P1");
588        part.staves.push(staff);
589        score.parts.push(part);
590        score
591    }
592
593    #[test]
594    fn default_score_has_one_row_of_four() {
595        // Score::default() ships with 4 measures; default measures_per_row=4
596        let score = Score::default();
597        let result = compute_layout(&score, &LayoutConfig::default());
598        assert_eq!(result.rows.len(), 1);
599        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2, 3]);
600        assert_eq!(result.vis_slots.len(), 4);
601        assert!(result.spans.is_empty());
602    }
603
604    #[test]
605    fn vis_slots_expand_multi_rest() {
606        let mut score = score_with_measures(1);
607        score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
608
609        let result = compute_layout(
610            &score,
611            &LayoutConfig {
612                measures_per_row: 8,
613                concert_pitch: false,
614                first_row_measures: None,
615            },
616        );
617        assert_eq!(result.vis_slots.len(), 4);
618        assert!(result.vis_slots.iter().all(|&v| v == 0));
619    }
620
621    #[test]
622    fn measures_split_into_rows() {
623        let score = score_with_measures(6);
624        let result = compute_layout(
625            &score,
626            &LayoutConfig {
627                measures_per_row: 4,
628                concert_pitch: false,
629                first_row_measures: None,
630            },
631        );
632        assert_eq!(result.rows.len(), 2);
633        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2, 3]);
634        assert_eq!(result.rows[1].measure_indices, vec![4, 5]);
635    }
636
637    #[test]
638    fn single_measure_single_row() {
639        let score = score_with_measures(1);
640        let result = compute_layout(
641            &score,
642            &LayoutConfig {
643                measures_per_row: 4,
644                concert_pitch: false,
645                first_row_measures: None,
646            },
647        );
648        assert_eq!(result.rows.len(), 1);
649        assert_eq!(result.rows[0].measure_indices, vec![0]);
650    }
651
652    #[test]
653    fn exactly_full_rows() {
654        let score = score_with_measures(8);
655        let result = compute_layout(
656            &score,
657            &LayoutConfig {
658                measures_per_row: 4,
659                concert_pitch: false,
660                first_row_measures: None,
661            },
662        );
663        assert_eq!(result.rows.len(), 2);
664        assert_eq!(result.rows[0].measure_indices.len(), 4);
665        assert_eq!(result.rows[1].measure_indices.len(), 4);
666    }
667
668    #[test]
669    fn concert_pitch_bb_clarinet_transposes_key() {
670        // Bb clarinet: transpose_semitones=-2, written key C major (fifths=0)
671        // Concert key = Bb major (fifths=-2)
672        let mut score = score_with_measures(1);
673        score.parts[0].staves[0].transpose_semitones = -2;
674        let result = compute_layout(
675            &score,
676            &LayoutConfig {
677                measures_per_row: 4,
678                concert_pitch: true,
679                first_row_measures: None,
680            },
681        );
682        assert_eq!(result.concert_key_overrides.len(), 1);
683        assert_eq!(result.concert_key_overrides[0].part_index, 0);
684        assert_eq!(result.concert_key_overrides[0].staff_index, 0);
685        assert_eq!(result.concert_key_overrides[0].fifths, -2);
686    }
687
688    #[test]
689    fn concert_pitch_false_no_override() {
690        let mut score = score_with_measures(1);
691        score.parts[0].staves[0].transpose_semitones = -2;
692        let result = compute_layout(
693            &score,
694            &LayoutConfig {
695                measures_per_row: 4,
696                concert_pitch: false,
697                first_row_measures: None,
698            },
699        );
700        assert!(result.concert_key_overrides.is_empty());
701    }
702
703    #[test]
704    fn concert_pitch_zero_transpose_no_override() {
705        // transpose_semitones=0 means concert pitch equals written pitch; no override needed.
706        let score = score_with_measures(1);
707        let result = compute_layout(
708            &score,
709            &LayoutConfig {
710                measures_per_row: 4,
711                concert_pitch: true,
712                first_row_measures: None,
713            },
714        );
715        assert!(result.concert_key_overrides.is_empty());
716    }
717
718    #[test]
719    fn transpose_key_formula_eb_alto_sax() {
720        // Eb alto sax: transpose_semitones=-9, written key C major (fifths=0)
721        // Concert key = Eb major (fifths=-3): delta = (-9*7) rem_euclid 12 = 9 → 9-12=-3
722        assert_eq!(transpose_key_fifths(0, -9), -3);
723    }
724
725    #[test]
726    fn transpose_key_formula_perfect_fifth_up() {
727        // +7 semitones = P5 up → +1 sharp (G major = fifths=1)
728        assert_eq!(transpose_key_fifths(0, 7), 1);
729    }
730
731    #[test]
732    fn system_break_splits_row() {
733        // 3 measures; measure[1] has system_break → rows: [0,1] and [2]
734        let mut score = score_with_measures(3);
735        score.parts[0].staves[0].measures[1].system_break = true;
736        let result = compute_layout(
737            &score,
738            &LayoutConfig {
739                measures_per_row: 4,
740                concert_pitch: false,
741                first_row_measures: None,
742            },
743        );
744        assert_eq!(result.rows.len(), 2);
745        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
746        assert_eq!(result.rows[1].measure_indices, vec![2]);
747    }
748
749    #[test]
750    fn page_break_splits_row() {
751        let mut score = score_with_measures(3);
752        score.parts[0].staves[0].measures[0].page_break = true;
753        let result = compute_layout(
754            &score,
755            &LayoutConfig {
756                measures_per_row: 4,
757                concert_pitch: false,
758                first_row_measures: None,
759            },
760        );
761        assert_eq!(result.rows.len(), 2);
762        assert_eq!(result.rows[0].measure_indices, vec![0]);
763        assert_eq!(result.rows[1].measure_indices, vec![1, 2]);
764    }
765
766    #[test]
767    fn system_break_overrides_per_row() {
768        // per_row=4 but system_break after measure[1] → row ends early
769        let mut score = score_with_measures(5);
770        score.parts[0].staves[0].measures[1].system_break = true;
771        let result = compute_layout(
772            &score,
773            &LayoutConfig {
774                measures_per_row: 4,
775                concert_pitch: false,
776                first_row_measures: None,
777            },
778        );
779        assert_eq!(result.rows.len(), 2);
780        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
781        assert_eq!(result.rows[1].measure_indices, vec![2, 3, 4]);
782    }
783
784    #[test]
785    fn no_break_unchanged() {
786        let score = score_with_measures(3);
787        let result = compute_layout(
788            &score,
789            &LayoutConfig {
790                measures_per_row: 4,
791                concert_pitch: false,
792                first_row_measures: None,
793            },
794        );
795        assert_eq!(result.rows.len(), 1);
796        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2]);
797    }
798
799    // ── first_row_measures ────────────────────────────────────────────────────
800
801    #[test]
802    fn first_row_measures_limits_first_row() {
803        // 5 measures, per_row=4, first_row_measures=2 → row[0]=2, row[1]=3
804        let score = score_with_measures(5);
805        let result = compute_layout(
806            &score,
807            &LayoutConfig {
808                measures_per_row: 4,
809                concert_pitch: false,
810                first_row_measures: Some(2),
811            },
812        );
813        assert_eq!(result.rows.len(), 2);
814        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
815        assert_eq!(result.rows[1].measure_indices, vec![2, 3, 4]);
816    }
817
818    #[test]
819    fn first_row_measures_none_unchanged() {
820        // first_row_measures=None → same as using per_row for all rows
821        let score = score_with_measures(5);
822        let without = compute_layout(
823            &score,
824            &LayoutConfig {
825                measures_per_row: 4,
826                concert_pitch: false,
827                first_row_measures: None,
828            },
829        );
830        let with_none = compute_layout(&score, &LayoutConfig::default());
831        assert_eq!(without.rows.len(), with_none.rows.len());
832        for (a, b) in without.rows.iter().zip(with_none.rows.iter()) {
833            assert_eq!(a.measure_indices, b.measure_indices);
834        }
835    }
836
837    #[test]
838    fn beam_groups_empty_for_default_score() {
839        let score = Score::default();
840        let result = compute_layout(&score, &LayoutConfig::default());
841        assert!(result.beam_groups.is_empty());
842    }
843
844    #[test]
845    fn layout_exposes_source_voice_number_for_canonical_slot() {
846        let mut score = score_with_measures(1);
847        score.parts[0].staves[0].measures[0].source_voice_numbers[1] = Some(5);
848
849        let result = compute_layout(&score, &LayoutConfig::default());
850        assert_eq!(
851            result.source_voice_addresses,
852            vec![SourceVoiceAddress {
853                part: 0,
854                staff: 0,
855                measure: 0,
856                voice: 1,
857                source_voice_number: 5,
858            }]
859        );
860    }
861
862    #[test]
863    fn beam_groups_begin_end_collected() {
864        use acorde_core::{BeamState, Duration, Note, Pitch, Step};
865        let mut score = score_with_measures(1);
866        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
867        n1.beam = BeamState::Begin;
868        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
869        n2.beam = BeamState::Continue;
870        let mut n3 = Note::new(Pitch::new(Step::E, 4), Duration::Eighth);
871        n3.beam = BeamState::End;
872        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2, n3];
873
874        let result = compute_layout(&score, &LayoutConfig::default());
875        assert_eq!(result.beam_groups.len(), 1);
876        assert_eq!(result.beam_groups[0].note_indices, vec![0, 1, 2]);
877        assert_eq!(result.beam_groups[0].part, 0);
878        assert_eq!(result.beam_groups[0].measure, 0);
879        assert_eq!(result.beam_groups[0].voice, 0);
880    }
881
882    #[test]
883    fn beam_groups_beginend_is_standalone_group() {
884        use acorde_core::{BeamState, Duration, Note, Pitch, Step};
885        let mut score = score_with_measures(1);
886        let mut n = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
887        n.beam = BeamState::BeginEnd;
888        score.parts[0].staves[0].measures[0].voices[0] = vec![n];
889
890        let result = compute_layout(&score, &LayoutConfig::default());
891        assert_eq!(result.beam_groups.len(), 1);
892        assert_eq!(result.beam_groups[0].note_indices, vec![0]);
893    }
894
895    #[test]
896    fn tuplet_groups_empty_for_default_score() {
897        let score = Score::default();
898        let result = compute_layout(&score, &LayoutConfig::default());
899        assert!(result.tuplet_groups.is_empty());
900    }
901
902    #[test]
903    fn tuplet_groups_triplet_collected() {
904        use acorde_core::{Duration, Note, Pitch, Step, TupletInfo};
905        let ti = TupletInfo {
906            actual_notes: 3,
907            normal_notes: 2,
908        };
909        let mut score = score_with_measures(1);
910        let notes: Vec<Note> = (0..3)
911            .map(|i| {
912                let step = [Step::C, Step::D, Step::E][i].clone();
913                let mut n = Note::new(Pitch::new(step, 4), Duration::Quarter);
914                n.tuplet = Some(ti.clone());
915                n
916            })
917            .collect();
918        score.parts[0].staves[0].measures[0].voices[0] = notes;
919
920        let result = compute_layout(&score, &LayoutConfig::default());
921        assert_eq!(result.tuplet_groups.len(), 1);
922        assert_eq!(result.tuplet_groups[0].note_indices, vec![0, 1, 2]);
923        assert_eq!(result.tuplet_groups[0].actual_notes, 3);
924        assert_eq!(result.tuplet_groups[0].normal_notes, 2);
925        assert_eq!(result.tuplet_groups[0].part, 0);
926        assert_eq!(result.tuplet_groups[0].measure, 0);
927    }
928
929    #[test]
930    fn tuplet_groups_no_group_for_single_tuplet_note() {
931        use acorde_core::{Duration, Note, Pitch, Step, TupletInfo};
932        let mut score = score_with_measures(1);
933        let mut n = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
934        n.tuplet = Some(TupletInfo {
935            actual_notes: 3,
936            normal_notes: 2,
937        });
938        score.parts[0].staves[0].measures[0].voices[0] = vec![n];
939
940        let result = compute_layout(&score, &LayoutConfig::default());
941        // Single-note tuplet: not enough to form a group (need >= 2)
942        assert!(result.tuplet_groups.is_empty());
943    }
944
945    #[test]
946    fn hairpin_span_resolved() {
947        use acorde_core::{Duration, HairpinKind, Note, Pitch, Step};
948        let mut score = score_with_measures(1);
949        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
950        n1.hairpin_start = Some(HairpinKind::Crescendo);
951        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
952        n2.hairpin_end = true;
953        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
954
955        let result = compute_layout(&score, &LayoutConfig::default());
956        assert_eq!(result.spans.len(), 1);
957        if let SpanMark::Hairpin { kind, start, end } = &result.spans[0] {
958            assert_eq!(*kind, HairpinKind::Crescendo);
959            assert_eq!(start.note, 0);
960            assert_eq!(end.note, 1);
961        } else {
962            panic!("expected Hairpin span");
963        }
964    }
965
966    #[test]
967    fn slur_span_resolved() {
968        use acorde_core::{Duration, Note, Pitch, Step};
969        let mut score = score_with_measures(1);
970        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
971        n1.slur_start = true;
972        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
973        n2.slur_end = true;
974        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
975
976        let result = compute_layout(&score, &LayoutConfig::default());
977        let slur = result
978            .spans
979            .iter()
980            .find(|s| matches!(s, SpanMark::Slur { .. }));
981        assert!(slur.is_some(), "expected a Slur span");
982        if let Some(SpanMark::Slur { start, end }) = slur {
983            assert_eq!(start.note, 0);
984            assert_eq!(end.note, 1);
985        }
986    }
987
988    #[test]
989    fn typed_spanners_keep_stable_identity_in_layout_result() {
990        use acorde_core::{Duration, NotationSpanner, NotationSpannerKind, Note, Pitch, Step};
991        let mut score = score_with_measures(1);
992        score.parts[0].staves[0].measures[0].voices[0] = vec![
993            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
994            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
995        ];
996        score.spanners.push(NotationSpanner {
997            id: "typed-slur-7".to_string(),
998            kind: NotationSpannerKind::Slur,
999            start: NoteAddr {
1000                part: 0,
1001                staff: 0,
1002                measure: 0,
1003                voice: 0,
1004                note: 0,
1005            },
1006            end: NoteAddr {
1007                part: 0,
1008                staff: 0,
1009                measure: 0,
1010                voice: 0,
1011                note: 1,
1012            },
1013            number: Some(7),
1014            line_type: Some("dashed".to_string()),
1015            text: None,
1016            placement: Some("above".to_string()),
1017            ottava_size: None,
1018            ottava_type: None,
1019        });
1020
1021        let result = compute_layout(&score, &LayoutConfig::default());
1022        assert_eq!(result.typed_spanners.len(), 1);
1023        assert_eq!(result.typed_spanners[0].id, "typed-slur-7");
1024        assert_eq!(result.typed_spanners[0].number, Some(7));
1025    }
1026
1027    #[test]
1028    fn harmony_range_span_resolved_from_typed_end_address() {
1029        use acorde_core::{ChordSymbol, Duration, Note, NoteAddr, Pitch, Step};
1030        let mut score = score_with_measures(2);
1031        score.parts[0].staves[0].measures[0].voices[0] =
1032            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1033        score.parts[0].staves[0].measures[1].voices[0] =
1034            vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
1035        score.parts[0].staves[0].measures[0].voices[0][0].chord_symbol = Some(ChordSymbol {
1036            root: "C".to_owned(),
1037            kind: "major".to_owned(),
1038            bass: None,
1039            placement: None,
1040            extender: true,
1041            harmonic_degree: None,
1042            harmony_function: None,
1043            harmony_type: None,
1044            chord_ref: None,
1045            range_end: Some(NoteAddr {
1046                part: 0,
1047                staff: 0,
1048                measure: 1,
1049                voice: 0,
1050                note: 0,
1051            }),
1052            degrees: Vec::new(),
1053        });
1054
1055        let result = compute_layout(&score, &LayoutConfig::default());
1056        assert!(matches!(
1057            result.spans.as_slice(),
1058            [SpanMark::Harmony { label, start, end }]
1059                if label == "C" && start.measure == 0 && end.measure == 1
1060        ));
1061    }
1062
1063    // ── CourtesyAccidental ────────────────────────────────────────────────────
1064
1065    #[test]
1066    fn courtesy_none_for_clean_score() {
1067        // Default score has no chromatic alterations — no courtesy accidentals.
1068        let score = Score::default();
1069        let result = compute_layout(&score, &LayoutConfig::default());
1070        assert!(result.courtesy_accidentals.is_empty());
1071    }
1072
1073    #[test]
1074    fn courtesy_after_chromatic_alteration() {
1075        // Measure 0: F# (alter=1). Measure 1: F natural (alter=0).
1076        // The F in measure 1 should get a courtesy accidental.
1077        use acorde_core::{Duration, Note, Pitch, Step};
1078        let mut score = score_with_measures(2);
1079
1080        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1081        fsharp.pitches[0].alter = 1;
1082        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1083
1084        let fnat = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1085        // alter=0 is default; key signature is C major (fifths=0) so F natural has key_alter=0
1086        score.parts[0].staves[0].measures[1].voices[0] = vec![fnat];
1087
1088        let result = compute_layout(&score, &LayoutConfig::default());
1089        assert_eq!(result.courtesy_accidentals.len(), 1);
1090        let ca = &result.courtesy_accidentals[0];
1091        assert_eq!(ca.measure, 1);
1092        assert_eq!(ca.alter, 0); // natural sign
1093    }
1094
1095    #[test]
1096    fn courtesy_same_alteration_no_courtesy() {
1097        // Measure 0: F#. Measure 1: F# again.
1098        // F# in measure 1 does NOT need a courtesy — no prior alteration conflict.
1099        // (prev_alters records F# alter=1; measure 1 F# has same alter=1 → NOT in prev_alters
1100        // because phase 2 only records notes that differ from key_alter.)
1101        // Actually: F# IS in prev_alters → courtesy IS emitted, alter=1.
1102        use acorde_core::{Duration, Note, Pitch, Step};
1103        let mut score = score_with_measures(2);
1104
1105        let mut fsharp1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1106        fsharp1.pitches[0].alter = 1;
1107        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp1];
1108
1109        let mut fsharp2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1110        fsharp2.pitches[0].alter = 1;
1111        score.parts[0].staves[0].measures[1].voices[0] = vec![fsharp2];
1112
1113        let result = compute_layout(&score, &LayoutConfig::default());
1114        // F# appears in prev_alters → courtesy emitted with alter=1
1115        assert_eq!(result.courtesy_accidentals.len(), 1);
1116        assert_eq!(result.courtesy_accidentals[0].alter, 1);
1117    }
1118
1119    #[test]
1120    fn courtesy_tied_note_excluded() {
1121        // A note with tie_end=true should never get a courtesy accidental.
1122        use acorde_core::{Duration, Note, Pitch, Step};
1123        let mut score = score_with_measures(2);
1124
1125        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1126        fsharp.pitches[0].alter = 1;
1127        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1128
1129        let mut tied = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1130        tied.pitches[0].alter = 1;
1131        tied.tie_end = true;
1132        score.parts[0].staves[0].measures[1].voices[0] = vec![tied];
1133
1134        let result = compute_layout(&score, &LayoutConfig::default());
1135        assert!(result.courtesy_accidentals.is_empty());
1136    }
1137
1138    #[test]
1139    fn courtesy_key_change_resets() {
1140        // Measure 0: F# (alter=1). Measure 1: key changes to G major (fifths=1, F# implied).
1141        // The key change clears prev_alters, so F in measure 1 won't get a courtesy.
1142        use acorde_core::{Duration, KeySignature, Note, Pitch, Step};
1143        let mut score = score_with_measures(2);
1144
1145        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1146        fsharp.pitches[0].alter = 1;
1147        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1148
1149        // Set key signature on measure 1 to G major (1 sharp = F#)
1150        score.parts[0].staves[0].measures[1].key_sig = Some(KeySignature {
1151            fifths: 1,
1152            mode: "major".to_string(),
1153        });
1154        // F# is now implied by the key — alter=1 == key_alter(1, F) → not in prev_alters
1155        let mut fsharp2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1156        fsharp2.pitches[0].alter = 1;
1157        score.parts[0].staves[0].measures[1].voices[0] = vec![fsharp2];
1158
1159        let result = compute_layout(&score, &LayoutConfig::default());
1160        // key change clears prev_alters before phase 1 → no courtesy for measure 1
1161        assert!(result.courtesy_accidentals.is_empty());
1162    }
1163
1164    // ── AccidentalMark ────────────────────────────────────────────────────────
1165
1166    #[test]
1167    fn requested_accidentals_are_drawn_where_the_rules_imply_them() {
1168        use acorde_core::{Duration, Note, Pitch, Step};
1169        let mut score = score_with_measures(1);
1170        let mut cautionary = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1171        cautionary.set_accidental_display(0, AccidentalDisplay::Cautionary);
1172        let mut sharp = Note::new(Pitch::with_alter(Step::F, 4, 1), Duration::Quarter);
1173        sharp.set_accidental_display(0, AccidentalDisplay::Parenthesized);
1174        let mut bracketed = Note::new(Pitch::new(Step::G, 4), Duration::Quarter);
1175        bracketed.set_accidental_display(0, AccidentalDisplay::Parenthesized);
1176        let plain = Note::new(Pitch::new(Step::A, 4), Duration::Quarter);
1177        score.parts[0].staves[0].measures[0].voices[0] = vec![cautionary, sharp, bracketed, plain];
1178
1179        let result = compute_layout(&score, &LayoutConfig::default());
1180        let mandatory: Vec<usize> = result.accidentals.iter().map(|a| a.note_index).collect();
1181        let courtesy: Vec<usize> = result
1182            .courtesy_accidentals
1183            .iter()
1184            .map(|a| a.note_index)
1185            .collect();
1186        // The cautionary natural is drawn plain; the sharp and the natural asked for in
1187        // parentheses are drawn as courtesy marks, and the sharp is not drawn twice.
1188        assert_eq!(mandatory, vec![0]);
1189        assert_eq!(courtesy, vec![1, 2]);
1190    }
1191
1192    #[test]
1193    fn accidental_none_for_clean_score() {
1194        let score = Score::default();
1195        let result = compute_layout(&score, &LayoutConfig::default());
1196        assert!(result.accidentals.is_empty());
1197    }
1198
1199    #[test]
1200    fn accidental_first_chromatic_note_in_measure() {
1201        // C major, single F# in measure 0 → one mandatory accidental (sharp).
1202        use acorde_core::{Duration, Note, Pitch, Step};
1203        let mut score = score_with_measures(1);
1204        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1205        fsharp.pitches[0].alter = 1;
1206        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1207
1208        let result = compute_layout(&score, &LayoutConfig::default());
1209        assert_eq!(result.accidentals.len(), 1);
1210        assert_eq!(result.accidentals[0].alter, 1);
1211        assert_eq!(result.accidentals[0].measure, 0);
1212    }
1213
1214    #[test]
1215    fn accidental_repeat_same_alter_in_measure_not_marked_again() {
1216        // Two F#s in the same measure: only the first needs an accidental.
1217        use acorde_core::{Duration, Note, Pitch, Step};
1218        let mut score = score_with_measures(1);
1219        let mut f1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1220        f1.pitches[0].alter = 1;
1221        let mut f2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1222        f2.pitches[0].alter = 1;
1223        score.parts[0].staves[0].measures[0].voices[0] = vec![f1, f2];
1224
1225        let result = compute_layout(&score, &LayoutConfig::default());
1226        assert_eq!(result.accidentals.len(), 1);
1227    }
1228
1229    #[test]
1230    fn accidental_reverts_to_natural_within_same_measure() {
1231        // F# then F natural, same measure: the natural needs its own mark too.
1232        use acorde_core::{Duration, Note, Pitch, Step};
1233        let mut score = score_with_measures(1);
1234        let mut f1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1235        f1.pitches[0].alter = 1;
1236        let f2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter); // alter=0
1237        score.parts[0].staves[0].measures[0].voices[0] = vec![f1, f2];
1238
1239        let result = compute_layout(&score, &LayoutConfig::default());
1240        assert_eq!(result.accidentals.len(), 2);
1241        assert_eq!(result.accidentals[0].alter, 1);
1242        assert_eq!(result.accidentals[1].alter, 0);
1243    }
1244
1245    #[test]
1246    fn accidental_does_not_carry_across_barline() {
1247        // F# in measure 0; measure 1's F# needs its own mark (accidentals reset per measure).
1248        // This is the case that would otherwise double up with a CourtesyAccidental —
1249        // callers must prefer `accidentals` over `courtesy_accidentals` when both exist.
1250        use acorde_core::{Duration, Note, Pitch, Step};
1251        let mut score = score_with_measures(2);
1252        let mut f1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1253        f1.pitches[0].alter = 1;
1254        score.parts[0].staves[0].measures[0].voices[0] = vec![f1];
1255        let mut f2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1256        f2.pitches[0].alter = 1;
1257        score.parts[0].staves[0].measures[1].voices[0] = vec![f2];
1258
1259        let result = compute_layout(&score, &LayoutConfig::default());
1260        assert_eq!(result.accidentals.len(), 2);
1261        assert!(result.accidentals.iter().any(|a| a.measure == 1));
1262        // The same address also produced a courtesy mark — renderer precedence resolves it.
1263        assert_eq!(result.courtesy_accidentals.len(), 1);
1264        assert_eq!(result.courtesy_accidentals[0].measure, 1);
1265    }
1266
1267    #[test]
1268    fn accidental_tied_note_excluded() {
1269        use acorde_core::{Duration, Note, Pitch, Step};
1270        let mut score = score_with_measures(1);
1271        let mut tied = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1272        tied.pitches[0].alter = 1;
1273        tied.tie_end = true;
1274        score.parts[0].staves[0].measures[0].voices[0] = vec![tied];
1275
1276        let result = compute_layout(&score, &LayoutConfig::default());
1277        assert!(result.accidentals.is_empty());
1278    }
1279
1280    #[test]
1281    fn accidental_shared_across_voices_on_same_staff() {
1282        // Voice 0 introduces F#; voice 1's F# later in the same measure shares the context.
1283        use acorde_core::{Duration, Note, Pitch, Step};
1284        let mut score = score_with_measures(1);
1285        let mut f_v0 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1286        f_v0.pitches[0].alter = 1;
1287        score.parts[0].staves[0].measures[0].voices[0] = vec![f_v0];
1288        let mut f_v1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1289        f_v1.pitches[0].alter = 1;
1290        score.parts[0].staves[0].measures[0].voices[1] = vec![f_v1];
1291
1292        let result = compute_layout(&score, &LayoutConfig::default());
1293        assert_eq!(result.accidentals.len(), 1);
1294    }
1295
1296    #[test]
1297    fn accidental_double_sharp_marked() {
1298        use acorde_core::{Duration, Note, Pitch, Step};
1299        let mut score = score_with_measures(1);
1300        let mut n = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1301        n.pitches[0].alter = 2;
1302        score.parts[0].staves[0].measures[0].voices[0] = vec![n];
1303
1304        let result = compute_layout(&score, &LayoutConfig::default());
1305        assert_eq!(result.accidentals.len(), 1);
1306        assert_eq!(result.accidentals[0].alter, 2);
1307    }
1308}