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