Skip to main content

acorde_layout/
engine.rs

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