Skip to main content

acorde_layout/
engine.rs

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