Skip to main content

acorde_layout/
engine.rs

1use std::collections::HashMap;
2use acorde_core::{BeamState, HairpinKind, NoteAddr, OttavaKind, Score, Step, TupletInfo};
3use crate::{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
45    LayoutResult { vis_slots, rows, spans, concert_key_overrides, beam_groups, tuplet_groups, courtesy_accidentals }
46}
47
48// ── vis_slots ─────────────────────────────────────────────────────────────────
49
50fn build_vis_slots(score: &Score) -> Vec<usize> {
51    let measure_count = score.parts.first()
52        .and_then(|p| p.staves.first())
53        .map(|s| s.measures.len())
54        .unwrap_or(0);
55
56    let mut slots = Vec::new();
57    for phys in 0..measure_count {
58        let count = multi_rest_count(score, phys).max(1);
59        for _ in 0..count {
60            slots.push(phys);
61        }
62    }
63    slots
64}
65
66fn multi_rest_count(score: &Score, measure_index: usize) -> usize {
67    score.parts.iter()
68        .flat_map(|p| p.staves.iter())
69        .filter_map(|s| s.measures.get(measure_index))
70        .filter_map(|m| m.multi_rest_count)
71        .map(|c| c as usize)
72        .max()
73        .unwrap_or(1)
74}
75
76// ── rows ──────────────────────────────────────────────────────────────────────
77
78fn force_break_after(score: &Score, phys: usize) -> bool {
79    score.parts.iter()
80        .flat_map(|p| p.staves.iter())
81        .filter_map(|s| s.measures.get(phys))
82        .any(|m| m.system_break || m.page_break)
83}
84
85fn build_rows(
86    score: &Score,
87    vis_slots: &[usize],
88    per_row: usize,
89    first_row_limit: Option<usize>,
90) -> Vec<RowLayout> {
91    if vis_slots.is_empty() {
92        return vec![RowLayout { measure_indices: Vec::new() }];
93    }
94
95    let mut rows: Vec<RowLayout> = Vec::new();
96    let mut current: Vec<usize> = Vec::new();
97    let mut last_phys = usize::MAX;
98
99    for &phys in vis_slots {
100        if phys != last_phys {
101            let limit = if rows.is_empty() {
102                first_row_limit.unwrap_or(per_row)
103            } else {
104                per_row
105            };
106            if current.len() == limit {
107                rows.push(RowLayout { measure_indices: std::mem::take(&mut current) });
108            }
109            current.push(phys);
110            if force_break_after(score, phys) {
111                rows.push(RowLayout { measure_indices: std::mem::take(&mut current) });
112            }
113        }
114        last_phys = phys;
115    }
116
117    if !current.is_empty() {
118        rows.push(RowLayout { measure_indices: current });
119    }
120    rows
121}
122
123// ── span resolution ───────────────────────────────────────────────────────────
124
125fn resolve_spans(score: &Score) -> Vec<SpanMark> {
126    let mut spans = Vec::new();
127
128    for (pi, part) in score.parts.iter().enumerate() {
129        for (si, staff) in part.staves.iter().enumerate() {
130            for voice in 0..4usize {
131                let mut open_hairpin:    Option<(NoteAddr, HairpinKind)> = None;
132                let mut open_ottava:     Option<(NoteAddr, OttavaKind)>  = None;
133                let mut open_pedal:      Option<NoteAddr>                 = None;
134                let mut open_slur:       Option<NoteAddr>                 = None;
135                let mut open_trill_line: Option<NoteAddr>                 = None;
136
137                for (mi, measure) in staff.measures.iter().enumerate() {
138                    for (ni, note) in measure.voices[voice].iter().enumerate() {
139                        let addr = NoteAddr { part: pi, staff: si, measure: mi, voice, note: ni };
140
141                        if let Some(kind) = note.hairpin_start {
142                            open_hairpin = Some((addr.clone(), kind));
143                        }
144                        if note.hairpin_end
145                            && let Some((start, kind)) = open_hairpin.take() {
146                                spans.push(SpanMark::Hairpin { kind, start, end: addr.clone() });
147                            }
148
149                        if let Some(kind) = note.ottava_start {
150                            open_ottava = Some((addr.clone(), kind));
151                        }
152                        if note.ottava_end
153                            && let Some((start, kind)) = open_ottava.take() {
154                                spans.push(SpanMark::Ottava { kind, start, end: addr.clone() });
155                            }
156
157                        if note.pedal_start {
158                            open_pedal = Some(addr.clone());
159                        }
160                        if note.pedal_end
161                            && let Some(start) = open_pedal.take() {
162                                spans.push(SpanMark::Pedal { start, end: addr.clone() });
163                            }
164
165                        if note.slur_start {
166                            open_slur = Some(addr.clone());
167                        }
168                        if note.slur_end
169                            && let Some(start) = open_slur.take() {
170                                spans.push(SpanMark::Slur { start, end: addr.clone() });
171                            }
172
173                        if note.trill_line_start {
174                            open_trill_line = Some(addr.clone());
175                        }
176                        if note.trill_line_end
177                            && let Some(start) = open_trill_line.take() {
178                                spans.push(SpanMark::TrillLine { start, end: addr.clone() });
179                            }
180                    }
181                }
182            }
183        }
184    }
185    spans
186}
187
188// ── beam groups ───────────────────────────────────────────────────────────────
189
190fn collect_beam_groups(score: &Score) -> Vec<BeamGroup> {
191    let mut groups = Vec::new();
192    for (pi, part) in score.parts.iter().enumerate() {
193        for (si, staff) in part.staves.iter().enumerate() {
194            for (mi, measure) in staff.measures.iter().enumerate() {
195                for vi in 0..4usize {
196                    let voice = &measure.voices[vi];
197                    let mut current: Option<Vec<usize>> = None;
198
199                    for (ni, note) in voice.iter().enumerate() {
200                        match note.beam {
201                            BeamState::Begin => {
202                                current = Some(vec![ni]);
203                            }
204                            BeamState::BeginEnd => {
205                                groups.push(BeamGroup {
206                                    part: pi, staff: si, measure: mi, voice: vi,
207                                    note_indices: vec![ni],
208                                });
209                            }
210                            BeamState::Continue
211                            | BeamState::ForwardHook
212                            | BeamState::BackwardHook => {
213                                if let Some(ref mut g) = current {
214                                    g.push(ni);
215                                }
216                            }
217                            BeamState::End => {
218                                if let Some(mut g) = current.take() {
219                                    g.push(ni);
220                                    groups.push(BeamGroup {
221                                        part: pi, staff: si, measure: mi, voice: vi,
222                                        note_indices: g,
223                                    });
224                                }
225                            }
226                            BeamState::None => {
227                                current = None;
228                            }
229                        }
230                    }
231                }
232            }
233        }
234    }
235    groups
236}
237
238// ── tuplet groups ─────────────────────────────────────────────────────────────
239
240fn collect_tuplet_groups(score: &Score) -> Vec<TupletGroup> {
241    let mut groups = Vec::new();
242    for (pi, part) in score.parts.iter().enumerate() {
243        for (si, staff) in part.staves.iter().enumerate() {
244            for (mi, measure) in staff.measures.iter().enumerate() {
245                for vi in 0..4usize {
246                    let voice = &measure.voices[vi];
247                    let mut current_indices: Vec<usize> = Vec::new();
248                    let mut current_info: Option<TupletInfo> = None;
249
250                    let flush = |indices: &mut Vec<usize>, info: &mut Option<TupletInfo>, groups: &mut Vec<TupletGroup>| {
251                        if indices.len() >= 2
252                            && let Some(ti) = info.take() {
253                                groups.push(TupletGroup {
254                                    part: pi, staff: si, measure: mi, voice: vi,
255                                    note_indices: std::mem::take(indices),
256                                    actual_notes: ti.actual_notes,
257                                    normal_notes: ti.normal_notes,
258                                });
259                                return;
260                            }
261                        indices.clear();
262                        info.take();
263                    };
264
265                    for (ni, note) in voice.iter().enumerate() {
266                        match &note.tuplet {
267                            Some(ti) => {
268                                let same_group = current_info.as_ref()
269                                    .is_some_and(|prev| prev.actual_notes == ti.actual_notes && prev.normal_notes == ti.normal_notes);
270                                if !same_group {
271                                    flush(&mut current_indices, &mut current_info, &mut groups);
272                                    current_info = Some(ti.clone());
273                                }
274                                current_indices.push(ni);
275                            }
276                            None => {
277                                flush(&mut current_indices, &mut current_info, &mut groups);
278                            }
279                        }
280                    }
281                    flush(&mut current_indices, &mut current_info, &mut groups);
282                }
283            }
284        }
285    }
286    groups
287}
288
289fn step_idx(step: &Step) -> u8 {
290    match step {
291        Step::C => 0, Step::D => 1, Step::E => 2, Step::F => 3,
292        Step::G => 4, Step::A => 5, Step::B => 6,
293    }
294}
295
296/// Accidental implied by a key signature for a given step: 0, 1, or -1.
297fn key_alter(fifths: i8, step: &Step) -> i8 {
298    const SHARPS: [Step; 7] = [Step::F, Step::C, Step::G, Step::D, Step::A, Step::E, Step::B];
299    const FLATS:  [Step; 7] = [Step::B, Step::E, Step::A, Step::D, Step::G, Step::C, Step::F];
300    if fifths > 0 && SHARPS[..fifths as usize].contains(step) { 1 }
301    else if fifths < 0 && FLATS[..(-fifths) as usize].contains(step) { -1 }
302    else { 0 }
303}
304
305fn collect_courtesy_accidentals(score: &Score) -> Vec<CourtesyAccidental> {
306    let mut result = Vec::new();
307    let base_fifths = score.settings.key_signature.fifths;
308
309    for (pi, part) in score.parts.iter().enumerate() {
310        for (si, staff) in part.staves.iter().enumerate() {
311            // Maps (step_index, octave) -> alter from the previous measure.
312            let mut prev_alters: HashMap<(u8, i8), i8> = HashMap::new();
313            let mut current_fifths = base_fifths;
314
315            for (mi, measure) in staff.measures.iter().enumerate() {
316                if let Some(key_sig) = measure.key_sig.as_ref() {
317                    current_fifths = key_sig.fifths;
318                    prev_alters.clear();
319                }
320
321                // Phase 1: check this measure's notes against prev_alters.
322                for vi in 0..4usize {
323                    for (ni, note) in measure.voices[vi].iter().enumerate() {
324                        if note.tie_end { continue; }
325                        for (pitch_idx, pitch) in note.pitches.iter().enumerate() {
326                            let key = (step_idx(&pitch.step), pitch.octave);
327                            if prev_alters.contains_key(&key) {
328                                result.push(CourtesyAccidental {
329                                    part: pi, staff: si, measure: mi, voice: vi,
330                                    note_index: ni, pitch_index: pitch_idx,
331                                    alter: pitch.alter,
332                                });
333                            }
334                        }
335                    }
336                }
337
338                // Phase 2: rebuild prev_alters from this measure's chromatically altered pitches.
339                prev_alters.clear();
340                for vi in 0..4usize {
341                    for note in measure.voices[vi].iter() {
342                        for pitch in note.pitches.iter() {
343                            if pitch.alter != key_alter(current_fifths, &pitch.step) {
344                                prev_alters.insert(
345                                    (step_idx(&pitch.step), pitch.octave),
346                                    pitch.alter,
347                                );
348                            }
349                        }
350                    }
351                }
352            }
353        }
354    }
355    result
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use acorde_core::{Clef, Measure, Part, Score, Staff};
362
363    fn score_with_measures(n: usize) -> Score {
364        let mut score = Score::default();
365        score.parts.clear();
366        let mut staff = Staff::new(Clef::Treble);
367        for i in 0..n {
368            let mut m = Measure::empty(4, 4);
369            m.number = i as u32 + 1;
370            staff.measures.push(m);
371        }
372        let mut part = Part::new("P1", "P1");
373        part.staves.push(staff);
374        score.parts.push(part);
375        score
376    }
377
378    #[test]
379    fn default_score_has_one_row_of_four() {
380        // Score::default() ships with 4 measures; default measures_per_row=4
381        let score = Score::default();
382        let result = compute_layout(&score, &LayoutConfig::default());
383        assert_eq!(result.rows.len(), 1);
384        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2, 3]);
385        assert_eq!(result.vis_slots.len(), 4);
386        assert!(result.spans.is_empty());
387    }
388
389    #[test]
390    fn vis_slots_expand_multi_rest() {
391        let mut score = score_with_measures(1);
392        score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
393
394        let result = compute_layout(&score, &LayoutConfig { measures_per_row: 8, concert_pitch: false, first_row_measures: None });
395        assert_eq!(result.vis_slots.len(), 4);
396        assert!(result.vis_slots.iter().all(|&v| v == 0));
397    }
398
399    #[test]
400    fn measures_split_into_rows() {
401        let score = score_with_measures(6);
402        let result = compute_layout(&score, &LayoutConfig { measures_per_row: 4, concert_pitch: false, first_row_measures: None });
403        assert_eq!(result.rows.len(), 2);
404        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2, 3]);
405        assert_eq!(result.rows[1].measure_indices, vec![4, 5]);
406    }
407
408    #[test]
409    fn single_measure_single_row() {
410        let score = score_with_measures(1);
411        let result = compute_layout(&score, &LayoutConfig { measures_per_row: 4, concert_pitch: false, first_row_measures: None });
412        assert_eq!(result.rows.len(), 1);
413        assert_eq!(result.rows[0].measure_indices, vec![0]);
414    }
415
416    #[test]
417    fn exactly_full_rows() {
418        let score = score_with_measures(8);
419        let result = compute_layout(&score, &LayoutConfig { measures_per_row: 4, concert_pitch: false, first_row_measures: None });
420        assert_eq!(result.rows.len(), 2);
421        assert_eq!(result.rows[0].measure_indices.len(), 4);
422        assert_eq!(result.rows[1].measure_indices.len(), 4);
423    }
424
425    #[test]
426    fn concert_pitch_bb_clarinet_transposes_key() {
427        // Bb clarinet: transpose_semitones=-2, written key C major (fifths=0)
428        // Concert key = Bb major (fifths=-2)
429        let mut score = score_with_measures(1);
430        score.parts[0].staves[0].transpose_semitones = -2;
431        let result = compute_layout(&score, &LayoutConfig { measures_per_row: 4, concert_pitch: true, first_row_measures: None });
432        assert_eq!(result.concert_key_overrides.len(), 1);
433        assert_eq!(result.concert_key_overrides[0].part_index, 0);
434        assert_eq!(result.concert_key_overrides[0].staff_index, 0);
435        assert_eq!(result.concert_key_overrides[0].fifths, -2);
436    }
437
438    #[test]
439    fn concert_pitch_false_no_override() {
440        let mut score = score_with_measures(1);
441        score.parts[0].staves[0].transpose_semitones = -2;
442        let result = compute_layout(&score, &LayoutConfig { measures_per_row: 4, concert_pitch: false, first_row_measures: None });
443        assert!(result.concert_key_overrides.is_empty());
444    }
445
446    #[test]
447    fn concert_pitch_zero_transpose_no_override() {
448        // transpose_semitones=0 means concert pitch equals written pitch; no override needed.
449        let score = score_with_measures(1);
450        let result = compute_layout(&score, &LayoutConfig { measures_per_row: 4, concert_pitch: true, first_row_measures: None });
451        assert!(result.concert_key_overrides.is_empty());
452    }
453
454    #[test]
455    fn transpose_key_formula_eb_alto_sax() {
456        // Eb alto sax: transpose_semitones=-9, written key C major (fifths=0)
457        // Concert key = Eb major (fifths=-3): delta = (-9*7) rem_euclid 12 = 9 → 9-12=-3
458        assert_eq!(transpose_key_fifths(0, -9), -3);
459    }
460
461    #[test]
462    fn transpose_key_formula_perfect_fifth_up() {
463        // +7 semitones = P5 up → +1 sharp (G major = fifths=1)
464        assert_eq!(transpose_key_fifths(0, 7), 1);
465    }
466
467    #[test]
468    fn system_break_splits_row() {
469        // 3 measures; measure[1] has system_break → rows: [0,1] and [2]
470        let mut score = score_with_measures(3);
471        score.parts[0].staves[0].measures[1].system_break = true;
472        let result = compute_layout(&score, &LayoutConfig { measures_per_row: 4, concert_pitch: false, first_row_measures: None });
473        assert_eq!(result.rows.len(), 2);
474        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
475        assert_eq!(result.rows[1].measure_indices, vec![2]);
476    }
477
478    #[test]
479    fn page_break_splits_row() {
480        let mut score = score_with_measures(3);
481        score.parts[0].staves[0].measures[0].page_break = true;
482        let result = compute_layout(&score, &LayoutConfig { measures_per_row: 4, concert_pitch: false, first_row_measures: None });
483        assert_eq!(result.rows.len(), 2);
484        assert_eq!(result.rows[0].measure_indices, vec![0]);
485        assert_eq!(result.rows[1].measure_indices, vec![1, 2]);
486    }
487
488    #[test]
489    fn system_break_overrides_per_row() {
490        // per_row=4 but system_break after measure[1] → row ends early
491        let mut score = score_with_measures(5);
492        score.parts[0].staves[0].measures[1].system_break = true;
493        let result = compute_layout(&score, &LayoutConfig { measures_per_row: 4, concert_pitch: false, first_row_measures: None });
494        assert_eq!(result.rows.len(), 2);
495        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
496        assert_eq!(result.rows[1].measure_indices, vec![2, 3, 4]);
497    }
498
499    #[test]
500    fn no_break_unchanged() {
501        let score = score_with_measures(3);
502        let result = compute_layout(&score, &LayoutConfig { measures_per_row: 4, concert_pitch: false, first_row_measures: None });
503        assert_eq!(result.rows.len(), 1);
504        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2]);
505    }
506
507    // ── first_row_measures ────────────────────────────────────────────────────
508
509    #[test]
510    fn first_row_measures_limits_first_row() {
511        // 5 measures, per_row=4, first_row_measures=2 → row[0]=2, row[1]=3
512        let score = score_with_measures(5);
513        let result = compute_layout(&score, &LayoutConfig {
514            measures_per_row: 4,
515            concert_pitch: false,
516            first_row_measures: Some(2),
517        });
518        assert_eq!(result.rows.len(), 2);
519        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
520        assert_eq!(result.rows[1].measure_indices, vec![2, 3, 4]);
521    }
522
523    #[test]
524    fn first_row_measures_none_unchanged() {
525        // first_row_measures=None → same as using per_row for all rows
526        let score = score_with_measures(5);
527        let without = compute_layout(&score, &LayoutConfig { measures_per_row: 4, concert_pitch: false, first_row_measures: None });
528        let with_none = compute_layout(&score, &LayoutConfig::default());
529        assert_eq!(without.rows.len(), with_none.rows.len());
530        for (a, b) in without.rows.iter().zip(with_none.rows.iter()) {
531            assert_eq!(a.measure_indices, b.measure_indices);
532        }
533    }
534
535    #[test]
536    fn beam_groups_empty_for_default_score() {
537        let score = Score::default();
538        let result = compute_layout(&score, &LayoutConfig::default());
539        assert!(result.beam_groups.is_empty());
540    }
541
542    #[test]
543    fn beam_groups_begin_end_collected() {
544        use acorde_core::{BeamState, Duration, Note, Pitch, Step};
545        let mut score = score_with_measures(1);
546        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
547        n1.beam = BeamState::Begin;
548        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
549        n2.beam = BeamState::Continue;
550        let mut n3 = Note::new(Pitch::new(Step::E, 4), Duration::Eighth);
551        n3.beam = BeamState::End;
552        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2, n3];
553
554        let result = compute_layout(&score, &LayoutConfig::default());
555        assert_eq!(result.beam_groups.len(), 1);
556        assert_eq!(result.beam_groups[0].note_indices, vec![0, 1, 2]);
557        assert_eq!(result.beam_groups[0].part, 0);
558        assert_eq!(result.beam_groups[0].measure, 0);
559        assert_eq!(result.beam_groups[0].voice, 0);
560    }
561
562    #[test]
563    fn beam_groups_beginend_is_standalone_group() {
564        use acorde_core::{BeamState, Duration, Note, Pitch, Step};
565        let mut score = score_with_measures(1);
566        let mut n = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
567        n.beam = BeamState::BeginEnd;
568        score.parts[0].staves[0].measures[0].voices[0] = vec![n];
569
570        let result = compute_layout(&score, &LayoutConfig::default());
571        assert_eq!(result.beam_groups.len(), 1);
572        assert_eq!(result.beam_groups[0].note_indices, vec![0]);
573    }
574
575    #[test]
576    fn tuplet_groups_empty_for_default_score() {
577        let score = Score::default();
578        let result = compute_layout(&score, &LayoutConfig::default());
579        assert!(result.tuplet_groups.is_empty());
580    }
581
582    #[test]
583    fn tuplet_groups_triplet_collected() {
584        use acorde_core::{Duration, Note, Pitch, Step, TupletInfo};
585        let ti = TupletInfo { actual_notes: 3, normal_notes: 2 };
586        let mut score = score_with_measures(1);
587        let notes: Vec<Note> = (0..3).map(|i| {
588            let step = [Step::C, Step::D, Step::E][i].clone();
589            let mut n = Note::new(Pitch::new(step, 4), Duration::Quarter);
590            n.tuplet = Some(ti.clone());
591            n
592        }).collect();
593        score.parts[0].staves[0].measures[0].voices[0] = notes;
594
595        let result = compute_layout(&score, &LayoutConfig::default());
596        assert_eq!(result.tuplet_groups.len(), 1);
597        assert_eq!(result.tuplet_groups[0].note_indices, vec![0, 1, 2]);
598        assert_eq!(result.tuplet_groups[0].actual_notes, 3);
599        assert_eq!(result.tuplet_groups[0].normal_notes, 2);
600        assert_eq!(result.tuplet_groups[0].part, 0);
601        assert_eq!(result.tuplet_groups[0].measure, 0);
602    }
603
604    #[test]
605    fn tuplet_groups_no_group_for_single_tuplet_note() {
606        use acorde_core::{Duration, Note, Pitch, Step, TupletInfo};
607        let mut score = score_with_measures(1);
608        let mut n = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
609        n.tuplet = Some(TupletInfo { actual_notes: 3, normal_notes: 2 });
610        score.parts[0].staves[0].measures[0].voices[0] = vec![n];
611
612        let result = compute_layout(&score, &LayoutConfig::default());
613        // Single-note tuplet: not enough to form a group (need >= 2)
614        assert!(result.tuplet_groups.is_empty());
615    }
616
617    #[test]
618    fn hairpin_span_resolved() {
619        use acorde_core::{Duration, HairpinKind, Note, Pitch, Step};
620        let mut score = score_with_measures(1);
621        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
622        n1.hairpin_start = Some(HairpinKind::Crescendo);
623        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
624        n2.hairpin_end = true;
625        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
626
627        let result = compute_layout(&score, &LayoutConfig::default());
628        assert_eq!(result.spans.len(), 1);
629        if let SpanMark::Hairpin { kind, start, end } = &result.spans[0] {
630            assert_eq!(*kind, HairpinKind::Crescendo);
631            assert_eq!(start.note, 0);
632            assert_eq!(end.note, 1);
633        } else {
634            panic!("expected Hairpin span");
635        }
636    }
637
638    #[test]
639    fn slur_span_resolved() {
640        use acorde_core::{Duration, Note, Pitch, Step};
641        let mut score = score_with_measures(1);
642        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
643        n1.slur_start = true;
644        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
645        n2.slur_end = true;
646        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
647
648        let result = compute_layout(&score, &LayoutConfig::default());
649        let slur = result.spans.iter().find(|s| matches!(s, SpanMark::Slur { .. }));
650        assert!(slur.is_some(), "expected a Slur span");
651        if let Some(SpanMark::Slur { start, end }) = slur {
652            assert_eq!(start.note, 0);
653            assert_eq!(end.note, 1);
654        }
655    }
656
657    // ── CourtesyAccidental ────────────────────────────────────────────────────
658
659    #[test]
660    fn courtesy_none_for_clean_score() {
661        // Default score has no chromatic alterations — no courtesy accidentals.
662        let score = Score::default();
663        let result = compute_layout(&score, &LayoutConfig::default());
664        assert!(result.courtesy_accidentals.is_empty());
665    }
666
667    #[test]
668    fn courtesy_after_chromatic_alteration() {
669        // Measure 0: F# (alter=1). Measure 1: F natural (alter=0).
670        // The F in measure 1 should get a courtesy accidental.
671        use acorde_core::{Duration, Note, Pitch, Step};
672        let mut score = score_with_measures(2);
673
674        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
675        fsharp.pitches[0].alter = 1;
676        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
677
678        let fnat = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
679        // alter=0 is default; key signature is C major (fifths=0) so F natural has key_alter=0
680        score.parts[0].staves[0].measures[1].voices[0] = vec![fnat];
681
682        let result = compute_layout(&score, &LayoutConfig::default());
683        assert_eq!(result.courtesy_accidentals.len(), 1);
684        let ca = &result.courtesy_accidentals[0];
685        assert_eq!(ca.measure, 1);
686        assert_eq!(ca.alter, 0); // natural sign
687    }
688
689    #[test]
690    fn courtesy_same_alteration_no_courtesy() {
691        // Measure 0: F#. Measure 1: F# again.
692        // F# in measure 1 does NOT need a courtesy — no prior alteration conflict.
693        // (prev_alters records F# alter=1; measure 1 F# has same alter=1 → NOT in prev_alters
694        // because phase 2 only records notes that differ from key_alter.)
695        // Actually: F# IS in prev_alters → courtesy IS emitted, alter=1.
696        use acorde_core::{Duration, Note, Pitch, Step};
697        let mut score = score_with_measures(2);
698
699        let mut fsharp1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
700        fsharp1.pitches[0].alter = 1;
701        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp1];
702
703        let mut fsharp2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
704        fsharp2.pitches[0].alter = 1;
705        score.parts[0].staves[0].measures[1].voices[0] = vec![fsharp2];
706
707        let result = compute_layout(&score, &LayoutConfig::default());
708        // F# appears in prev_alters → courtesy emitted with alter=1
709        assert_eq!(result.courtesy_accidentals.len(), 1);
710        assert_eq!(result.courtesy_accidentals[0].alter, 1);
711    }
712
713    #[test]
714    fn courtesy_tied_note_excluded() {
715        // A note with tie_end=true should never get a courtesy accidental.
716        use acorde_core::{Duration, Note, Pitch, Step};
717        let mut score = score_with_measures(2);
718
719        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
720        fsharp.pitches[0].alter = 1;
721        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
722
723        let mut tied = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
724        tied.pitches[0].alter = 1;
725        tied.tie_end = true;
726        score.parts[0].staves[0].measures[1].voices[0] = vec![tied];
727
728        let result = compute_layout(&score, &LayoutConfig::default());
729        assert!(result.courtesy_accidentals.is_empty());
730    }
731
732    #[test]
733    fn courtesy_key_change_resets() {
734        // Measure 0: F# (alter=1). Measure 1: key changes to G major (fifths=1, F# implied).
735        // The key change clears prev_alters, so F in measure 1 won't get a courtesy.
736        use acorde_core::{Duration, KeySignature, Note, Pitch, Step};
737        let mut score = score_with_measures(2);
738
739        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
740        fsharp.pitches[0].alter = 1;
741        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
742
743        // Set key signature on measure 1 to G major (1 sharp = F#)
744        score.parts[0].staves[0].measures[1].key_sig =
745            Some(KeySignature { fifths: 1, mode: "major".to_string() });
746        // F# is now implied by the key — alter=1 == key_alter(1, F) → not in prev_alters
747        let mut fsharp2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
748        fsharp2.pitches[0].alter = 1;
749        score.parts[0].staves[0].measures[1].voices[0] = vec![fsharp2];
750
751        let result = compute_layout(&score, &LayoutConfig::default());
752        // key change clears prev_alters before phase 1 → no courtesy for measure 1
753        assert!(result.courtesy_accidentals.is_empty());
754    }
755}