Skip to main content

acorde_layout/
engine.rs

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