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                        let onset: f64 = measure.voices[voice]
317                            .iter()
318                            .take(ni)
319                            .map(|note| note.beats())
320                            .sum();
321                        let timed_here = |down| {
322                            measure.timed_pedals.iter().any(|pedal| {
323                                pedal.down == down && (pedal.position_beats - onset).abs() < 1e-9
324                            })
325                        };
326                        if note.pedal_start && !timed_here(true) {
327                            open_pedal = Some(addr.clone());
328                        }
329                        if note.pedal_end
330                            && !timed_here(false)
331                            && let Some(start) = open_pedal.take()
332                        {
333                            spans.push(SpanMark::Pedal {
334                                start,
335                                end: addr.clone(),
336                            });
337                        }
338
339                        // Close before opening: one note may end a phrase and
340                        // start the next. A stack preserves nested legacy slurs.
341                        // Typed endpoints are handled below with exact identities.
342                        if !typed_slur_endpoints.contains(&(pi, si, mi, voice, ni)) {
343                            if note.slur_end
344                                && let Some(start) = open_slurs.pop()
345                            {
346                                spans.push(SpanMark::Slur {
347                                    start,
348                                    end: addr.clone(),
349                                });
350                            }
351                            if note.slur_start {
352                                open_slurs.push(addr.clone());
353                            }
354                        }
355
356                        if note.trill_line_start {
357                            open_trill_line = Some(addr.clone());
358                        }
359                        if note.trill_line_end
360                            && let Some(start) = open_trill_line.take()
361                        {
362                            spans.push(SpanMark::TrillLine {
363                                start,
364                                end: addr.clone(),
365                            });
366                        }
367
368                        if note.glissando_start {
369                            open_glissando = Some(addr.clone());
370                        }
371                        if note.glissando_end
372                            && let Some(start) = open_glissando.take()
373                        {
374                            spans.push(SpanMark::Glissando {
375                                start,
376                                end: addr.clone(),
377                            });
378                        }
379
380                        if let Some(chord) = &note.chord_symbol
381                            && let Some(end) = &chord.range_end
382                        {
383                            spans.push(SpanMark::Harmony {
384                                label: chord.display_text(),
385                                start: addr.clone(),
386                                end: end.clone(),
387                            });
388                        }
389                    }
390                }
391            }
392        }
393    }
394
395    spans.extend(
396        typed_spanners
397            .iter()
398            .filter(|span| span.kind == NotationSpannerKind::Slur)
399            .map(|span| SpanMark::Slur {
400                start: span.start.clone(),
401                end: span.end.clone(),
402            }),
403    );
404    spans
405}
406
407// ── beam groups ───────────────────────────────────────────────────────────────
408
409fn collect_beam_groups(score: &Score, infer_unmarked: bool) -> Vec<BeamGroup> {
410    let mut groups = Vec::new();
411    for (pi, part) in score.parts.iter().enumerate() {
412        for (si, staff) in part.staves.iter().enumerate() {
413            let mut meter = score.settings.time_signature.clone();
414            for (mi, measure) in staff.measures.iter().enumerate() {
415                if let Some(change) = &measure.time_sig {
416                    meter = change.clone();
417                }
418                for vi in 0..4usize {
419                    let voice = &measure.voices[vi];
420                    let mut current: Option<Vec<usize>> = None;
421
422                    for (ni, note) in voice.iter().enumerate() {
423                        match note.beam {
424                            BeamState::Begin => {
425                                current = Some(vec![ni]);
426                            }
427                            BeamState::BeginEnd => {
428                                groups.push(BeamGroup {
429                                    part: pi,
430                                    staff: si,
431                                    measure: mi,
432                                    voice: vi,
433                                    note_indices: vec![ni],
434                                });
435                            }
436                            BeamState::Continue
437                            | BeamState::ForwardHook
438                            | BeamState::BackwardHook => {
439                                if let Some(ref mut g) = current {
440                                    g.push(ni);
441                                }
442                            }
443                            BeamState::End => {
444                                if let Some(mut g) = current.take() {
445                                    g.push(ni);
446                                    groups.push(BeamGroup {
447                                        part: pi,
448                                        staff: si,
449                                        measure: mi,
450                                        voice: vi,
451                                        note_indices: g,
452                                    });
453                                }
454                            }
455                            BeamState::None => {
456                                current = None;
457                            }
458                        }
459                    }
460                    if infer_unmarked {
461                        let recommended = compute_beams(voice, &meter);
462                        let mut candidate = Vec::new();
463                        let mut flush = |candidate: &mut Vec<usize>| {
464                            if candidate.len() >= 2 {
465                                groups.push(BeamGroup {
466                                    part: pi,
467                                    staff: si,
468                                    measure: mi,
469                                    voice: vi,
470                                    note_indices: std::mem::take(candidate),
471                                });
472                            } else {
473                                candidate.clear();
474                            }
475                        };
476                        for (ni, (note, suggested)) in voice.iter().zip(&recommended).enumerate() {
477                            if matches!(suggested, BeamState::Begin | BeamState::None)
478                                || matches!(
479                                    note.tuplet_boundary,
480                                    Some(TupletBoundary::Start | TupletBoundary::StartStop)
481                                )
482                            {
483                                flush(&mut candidate);
484                            }
485                            if *suggested != BeamState::None
486                                && note.beam == BeamState::None
487                                && !note.explicit_unbeamed
488                                && !note.is_grace
489                                && !note.is_cue
490                                && !note.hidden
491                            {
492                                candidate.push(ni);
493                            } else {
494                                flush(&mut candidate);
495                            }
496                            if *suggested == BeamState::End
497                                || matches!(
498                                    note.tuplet_boundary,
499                                    Some(TupletBoundary::Stop | TupletBoundary::StartStop)
500                                )
501                            {
502                                flush(&mut candidate);
503                            }
504                        }
505                        flush(&mut candidate);
506                    }
507                }
508            }
509        }
510    }
511    groups
512}
513
514// ── tuplet groups ─────────────────────────────────────────────────────────────
515
516fn collect_tuplet_groups(score: &Score) -> Vec<TupletGroup> {
517    let mut groups = Vec::new();
518    for (pi, part) in score.parts.iter().enumerate() {
519        for (si, staff) in part.staves.iter().enumerate() {
520            for (mi, measure) in staff.measures.iter().enumerate() {
521                for vi in 0..4usize {
522                    let voice = &measure.voices[vi];
523                    for (first, last) in acorde_core::tuplet_group_ranges(voice) {
524                        let note_indices: Vec<_> = (first..=last)
525                            .filter(|&index| {
526                                !voice[index].is_grace && voice[index].tuplet.is_some()
527                            })
528                            .collect();
529                        if note_indices.len() >= 2
530                            && let Some(ti) = voice[first].tuplet.as_ref()
531                        {
532                            groups.push(TupletGroup {
533                                part: pi,
534                                staff: si,
535                                measure: mi,
536                                voice: vi,
537                                note_indices,
538                                actual_notes: ti.actual_notes,
539                                normal_notes: ti.normal_notes,
540                            });
541                        }
542                    }
543                }
544            }
545        }
546    }
547    groups
548}
549
550fn step_idx(step: &Step) -> u8 {
551    match step {
552        Step::C => 0,
553        Step::D => 1,
554        Step::E => 2,
555        Step::F => 3,
556        Step::G => 4,
557        Step::A => 5,
558        Step::B => 6,
559    }
560}
561
562/// Accidental implied by a key signature for a given step: 0, 1, or -1.
563fn key_alter(fifths: i8, step: &Step) -> i8 {
564    const SHARPS: [Step; 7] = [
565        Step::F,
566        Step::C,
567        Step::G,
568        Step::D,
569        Step::A,
570        Step::E,
571        Step::B,
572    ];
573    const FLATS: [Step; 7] = [
574        Step::B,
575        Step::E,
576        Step::A,
577        Step::D,
578        Step::G,
579        Step::C,
580        Step::F,
581    ];
582    if fifths > 0 && SHARPS[..fifths as usize].contains(step) {
583        1
584    } else if fifths < 0 && FLATS[..(-fifths) as usize].contains(step) {
585        -1
586    } else {
587        0
588    }
589}
590
591fn collect_courtesy_accidentals(score: &Score) -> Vec<CourtesyAccidental> {
592    let mut result = Vec::new();
593    let base_fifths = score.settings.key_signature.fifths;
594
595    for (pi, part) in score.parts.iter().enumerate() {
596        for (si, staff) in part.staves.iter().enumerate() {
597            // Maps (step_index, octave) -> alter from the previous measure.
598            let mut prev_alters: HashMap<(u8, i8), i8> = HashMap::new();
599            let mut current_fifths = base_fifths;
600
601            for (mi, measure) in staff.measures.iter().enumerate() {
602                if let Some(key_sig) = measure.key_sig.as_ref() {
603                    current_fifths = key_sig.fifths;
604                    prev_alters.clear();
605                }
606
607                // Phase 1: check this measure's notes against prev_alters.
608                for vi in 0..4usize {
609                    for (ni, note) in measure.voices[vi].iter().enumerate() {
610                        for (pitch_idx, pitch) in note.pitches.iter().enumerate() {
611                            let key = (step_idx(&pitch.step), pitch.octave);
612                            // A parenthesized accidental the source asked for is always drawn.
613                            let requested = note.accidental_display(pitch_idx)
614                                == AccidentalDisplay::Parenthesized;
615                            // Consume the previous-bar state on the first occurrence. A
616                            // mandatory accidental already covers a chromatic pitch; do not
617                            // repeat it, or add a reminder for a key-signature sharp/flat.
618                            let previous = prev_alters.remove(&key);
619                            let natural_reminder = !note.tie_end
620                                && note.accidental_display(pitch_idx) == AccidentalDisplay::Auto
621                                && pitch.alter == 0
622                                && key_alter(current_fifths, &pitch.step) == 0
623                                && previous.is_some_and(|alter| alter != 0);
624                            if requested || natural_reminder {
625                                result.push(CourtesyAccidental {
626                                    part: pi,
627                                    staff: si,
628                                    measure: mi,
629                                    voice: vi,
630                                    note_index: ni,
631                                    pitch_index: pitch_idx,
632                                    alter: pitch.alter,
633                                });
634                            }
635                        }
636                    }
637                }
638
639                // Phase 2: rebuild prev_alters from this measure's chromatically altered pitches.
640                prev_alters.clear();
641                for vi in 0..4usize {
642                    for note in measure.voices[vi].iter() {
643                        for pitch in note.pitches.iter() {
644                            if pitch.alter != key_alter(current_fifths, &pitch.step) {
645                                prev_alters
646                                    .insert((step_idx(&pitch.step), pitch.octave), pitch.alter);
647                            }
648                        }
649                    }
650                }
651            }
652        }
653    }
654    result
655}
656
657// ── mandatory accidentals ────────────────────────────────────────────────────
658
659/// Collect mandatory (non-courtesy) accidental marks.
660///
661/// State is scoped per `(part, staff)` and reset every measure to the key signature's
662/// implied alteration — accidentals never carry across a barline. Within a measure, all
663/// voices on the staff share one accidental context (mirrors [`collect_courtesy_accidentals`]'s
664/// voice-major scan order). Tied continuations (`tie_end`) never need a fresh accidental.
665fn collect_accidental_marks(score: &Score) -> Vec<AccidentalMark> {
666    let mut result = Vec::new();
667    let base_fifths = score.settings.key_signature.fifths;
668
669    for (pi, part) in score.parts.iter().enumerate() {
670        for (si, staff) in part.staves.iter().enumerate() {
671            let mut current_fifths = base_fifths;
672
673            for (mi, measure) in staff.measures.iter().enumerate() {
674                if let Some(key_sig) = measure.key_sig.as_ref() {
675                    current_fifths = key_sig.fifths;
676                }
677
678                // Active alteration per (step, octave), established within this measure only.
679                let mut active: HashMap<(u8, i8), i8> = HashMap::new();
680
681                for vi in 0..4usize {
682                    for (ni, note) in measure.voices[vi].iter().enumerate() {
683                        for (pitch_idx, pitch) in note.pitches.iter().enumerate() {
684                            let key = (step_idx(&pitch.step), pitch.octave);
685                            let baseline = active
686                                .get(&key)
687                                .copied()
688                                .unwrap_or_else(|| key_alter(current_fifths, &pitch.step));
689                            let display = note.accidental_display(pitch_idx);
690                            let parenthesized = display == AccidentalDisplay::Parenthesized;
691                            if pitch.alter != baseline {
692                                // A parenthesized one is drawn with the courtesy marks.
693                                if (!note.tie_end || display == AccidentalDisplay::Explicit)
694                                    && !parenthesized
695                                {
696                                    result.push(AccidentalMark {
697                                        part: pi,
698                                        staff: si,
699                                        measure: mi,
700                                        voice: vi,
701                                        note_index: ni,
702                                        pitch_index: pitch_idx,
703                                        alter: pitch.alter,
704                                    });
705                                }
706                                active.insert(key, pitch.alter);
707                            } else if matches!(
708                                display,
709                                AccidentalDisplay::Explicit
710                                    | AccidentalDisplay::Cautionary
711                                    | AccidentalDisplay::Editorial
712                            ) {
713                                // Cautionary and editorial accidentals are drawn even when the
714                                // key and the bar already imply them.
715                                result.push(AccidentalMark {
716                                    part: pi,
717                                    staff: si,
718                                    measure: mi,
719                                    voice: vi,
720                                    note_index: ni,
721                                    pitch_index: pitch_idx,
722                                    alter: pitch.alter,
723                                });
724                            }
725                        }
726                    }
727                }
728            }
729        }
730    }
731    result
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use acorde_core::{Clef, Measure, Part, Score, Staff};
738
739    fn score_with_measures(n: usize) -> Score {
740        let mut score = Score::default();
741        score.parts.clear();
742        let mut staff = Staff::new(Clef::Treble);
743        for i in 0..n {
744            let mut m = Measure::empty(4, 4);
745            m.number = i as u32 + 1;
746            staff.measures.push(m);
747        }
748        let mut part = Part::new("P1", "P1");
749        part.staves.push(staff);
750        score.parts.push(part);
751        score
752    }
753
754    #[test]
755    fn default_score_has_one_row_of_four() {
756        // Score::default() ships with 4 measures; default measures_per_row=4
757        let score = Score::default();
758        let result = compute_layout(&score, &LayoutConfig::default());
759        assert_eq!(result.rows.len(), 1);
760        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2, 3]);
761        assert_eq!(result.vis_slots.len(), 4);
762        assert!(result.spans.is_empty());
763    }
764
765    #[test]
766    fn vis_slots_expand_multi_rest() {
767        let mut score = score_with_measures(1);
768        score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
769
770        let result = compute_layout(
771            &score,
772            &LayoutConfig {
773                measures_per_row: 8,
774                concert_pitch: false,
775                first_row_measures: None,
776            },
777        );
778        assert_eq!(result.vis_slots.len(), 4);
779        assert!(result.vis_slots.iter().all(|&v| v == 0));
780    }
781
782    #[test]
783    fn measures_split_into_rows() {
784        let score = score_with_measures(6);
785        let result = compute_layout(
786            &score,
787            &LayoutConfig {
788                measures_per_row: 4,
789                concert_pitch: false,
790                first_row_measures: None,
791            },
792        );
793        assert_eq!(result.rows.len(), 2);
794        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2, 3]);
795        assert_eq!(result.rows[1].measure_indices, vec![4, 5]);
796    }
797
798    #[test]
799    fn single_measure_single_row() {
800        let score = score_with_measures(1);
801        let result = compute_layout(
802            &score,
803            &LayoutConfig {
804                measures_per_row: 4,
805                concert_pitch: false,
806                first_row_measures: None,
807            },
808        );
809        assert_eq!(result.rows.len(), 1);
810        assert_eq!(result.rows[0].measure_indices, vec![0]);
811    }
812
813    #[test]
814    fn exactly_full_rows() {
815        let score = score_with_measures(8);
816        let result = compute_layout(
817            &score,
818            &LayoutConfig {
819                measures_per_row: 4,
820                concert_pitch: false,
821                first_row_measures: None,
822            },
823        );
824        assert_eq!(result.rows.len(), 2);
825        assert_eq!(result.rows[0].measure_indices.len(), 4);
826        assert_eq!(result.rows[1].measure_indices.len(), 4);
827    }
828
829    #[test]
830    fn concert_pitch_bb_clarinet_transposes_key() {
831        // Bb clarinet: transpose_semitones=-2, written key C major (fifths=0)
832        // Concert key = Bb major (fifths=-2)
833        let mut score = score_with_measures(1);
834        score.parts[0].staves[0].transpose_semitones = -2;
835        let result = compute_layout(
836            &score,
837            &LayoutConfig {
838                measures_per_row: 4,
839                concert_pitch: true,
840                first_row_measures: None,
841            },
842        );
843        assert_eq!(result.concert_key_overrides.len(), 1);
844        assert_eq!(result.concert_key_overrides[0].part_index, 0);
845        assert_eq!(result.concert_key_overrides[0].staff_index, 0);
846        assert_eq!(result.concert_key_overrides[0].fifths, -2);
847    }
848
849    #[test]
850    fn concert_pitch_false_no_override() {
851        let mut score = score_with_measures(1);
852        score.parts[0].staves[0].transpose_semitones = -2;
853        let result = compute_layout(
854            &score,
855            &LayoutConfig {
856                measures_per_row: 4,
857                concert_pitch: false,
858                first_row_measures: None,
859            },
860        );
861        assert!(result.concert_key_overrides.is_empty());
862    }
863
864    #[test]
865    fn concert_pitch_zero_transpose_no_override() {
866        // transpose_semitones=0 means concert pitch equals written pitch; no override needed.
867        let score = score_with_measures(1);
868        let result = compute_layout(
869            &score,
870            &LayoutConfig {
871                measures_per_row: 4,
872                concert_pitch: true,
873                first_row_measures: None,
874            },
875        );
876        assert!(result.concert_key_overrides.is_empty());
877    }
878
879    #[test]
880    fn transpose_key_formula_eb_alto_sax() {
881        // Eb alto sax: transpose_semitones=-9, written key C major (fifths=0)
882        // Concert key = Eb major (fifths=-3): delta = (-9*7) rem_euclid 12 = 9 → 9-12=-3
883        assert_eq!(transpose_key_fifths(0, -9), -3);
884    }
885
886    #[test]
887    fn transpose_key_formula_perfect_fifth_up() {
888        // +7 semitones = P5 up → +1 sharp (G major = fifths=1)
889        assert_eq!(transpose_key_fifths(0, 7), 1);
890    }
891
892    #[test]
893    fn system_break_splits_row() {
894        // 3 measures; measure[1] has system_break → rows: [0,1] and [2]
895        let mut score = score_with_measures(3);
896        score.parts[0].staves[0].measures[1].system_break = true;
897        let result = compute_layout(
898            &score,
899            &LayoutConfig {
900                measures_per_row: 4,
901                concert_pitch: false,
902                first_row_measures: None,
903            },
904        );
905        assert_eq!(result.rows.len(), 2);
906        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
907        assert_eq!(result.rows[1].measure_indices, vec![2]);
908    }
909
910    #[test]
911    fn page_break_splits_row() {
912        let mut score = score_with_measures(3);
913        score.parts[0].staves[0].measures[0].page_break = true;
914        let result = compute_layout(
915            &score,
916            &LayoutConfig {
917                measures_per_row: 4,
918                concert_pitch: false,
919                first_row_measures: None,
920            },
921        );
922        assert_eq!(result.rows.len(), 2);
923        assert_eq!(result.rows[0].measure_indices, vec![0]);
924        assert_eq!(result.rows[1].measure_indices, vec![1, 2]);
925    }
926
927    #[test]
928    fn system_break_overrides_per_row() {
929        // per_row=4 but system_break after measure[1] → row ends early
930        let mut score = score_with_measures(5);
931        score.parts[0].staves[0].measures[1].system_break = true;
932        let result = compute_layout(
933            &score,
934            &LayoutConfig {
935                measures_per_row: 4,
936                concert_pitch: false,
937                first_row_measures: None,
938            },
939        );
940        assert_eq!(result.rows.len(), 2);
941        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
942        assert_eq!(result.rows[1].measure_indices, vec![2, 3, 4]);
943    }
944
945    #[test]
946    fn no_break_unchanged() {
947        let score = score_with_measures(3);
948        let result = compute_layout(
949            &score,
950            &LayoutConfig {
951                measures_per_row: 4,
952                concert_pitch: false,
953                first_row_measures: None,
954            },
955        );
956        assert_eq!(result.rows.len(), 1);
957        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2]);
958    }
959
960    // ── first_row_measures ────────────────────────────────────────────────────
961
962    #[test]
963    fn first_row_measures_limits_first_row() {
964        // 5 measures, per_row=4, first_row_measures=2 → row[0]=2, row[1]=3
965        let score = score_with_measures(5);
966        let result = compute_layout(
967            &score,
968            &LayoutConfig {
969                measures_per_row: 4,
970                concert_pitch: false,
971                first_row_measures: Some(2),
972            },
973        );
974        assert_eq!(result.rows.len(), 2);
975        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
976        assert_eq!(result.rows[1].measure_indices, vec![2, 3, 4]);
977    }
978
979    #[test]
980    fn first_row_measures_none_unchanged() {
981        // first_row_measures=None → same as using per_row for all rows
982        let score = score_with_measures(5);
983        let without = compute_layout(
984            &score,
985            &LayoutConfig {
986                measures_per_row: 4,
987                concert_pitch: false,
988                first_row_measures: None,
989            },
990        );
991        let with_none = compute_layout(&score, &LayoutConfig::default());
992        assert_eq!(without.rows.len(), with_none.rows.len());
993        for (a, b) in without.rows.iter().zip(with_none.rows.iter()) {
994            assert_eq!(a.measure_indices, b.measure_indices);
995        }
996    }
997
998    #[test]
999    fn beam_groups_empty_for_default_score() {
1000        let score = Score::default();
1001        let result = compute_layout(&score, &LayoutConfig::default());
1002        assert!(result.beam_groups.is_empty());
1003    }
1004
1005    #[test]
1006    fn display_beams_follow_simple_and_compound_meter_without_changing_score() {
1007        use acorde_core::{Duration, Note, Pitch, Step, TimeSignature};
1008        let note = || Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
1009        let mut score = score_with_measures(2);
1010        score.parts[0].staves[0].measures[0].voices[0] = vec![note(); 4];
1011        score.parts[0].staves[0].measures[1].time_sig = Some(TimeSignature {
1012            numerator: 6,
1013            denominator: 8,
1014        });
1015        score.parts[0].staves[0].measures[1].voices[0] = vec![note(); 6];
1016        let original = score.clone();
1017
1018        assert!(
1019            compute_layout(&score, &LayoutConfig::default())
1020                .beam_groups
1021                .is_empty()
1022        );
1023        let layout = compute_layout_with_display_beams(&score, &LayoutConfig::default());
1024        let groups: Vec<_> = layout
1025            .beam_groups
1026            .iter()
1027            .map(|g| (g.measure, g.note_indices.clone()))
1028            .collect();
1029        assert_eq!(
1030            groups,
1031            vec![
1032                (0, vec![0, 1]),
1033                (0, vec![2, 3]),
1034                (1, vec![0, 1, 2]),
1035                (1, vec![3, 4, 5])
1036            ]
1037        );
1038        assert_eq!(
1039            serde_json::to_value(&score).unwrap(),
1040            serde_json::to_value(&original).unwrap()
1041        );
1042    }
1043
1044    #[test]
1045    fn display_beams_respect_authored_unbeamed_rest_grace_and_barlines() {
1046        use acorde_core::{BeamState, Duration, Note, Pitch, Step};
1047        let note = || Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
1048        let mut score = score_with_measures(2);
1049        let mut authored = note();
1050        authored.beam = BeamState::Begin;
1051        let mut authored_end = note();
1052        authored_end.beam = BeamState::End;
1053        let mut no = note();
1054        no.explicit_unbeamed = true;
1055        let mut grace = note();
1056        grace.is_grace = true;
1057        score.parts[0].staves[0].measures[0].voices[0] = vec![
1058            authored,
1059            authored_end,
1060            no,
1061            note(),
1062            Note::rest(Duration::Eighth),
1063            note(),
1064            grace,
1065            note(),
1066        ];
1067        score.parts[0].staves[0].measures[1].voices[0] = vec![note(), note()];
1068
1069        let layout = compute_layout_with_display_beams(&score, &LayoutConfig::default());
1070        let groups: Vec<_> = layout
1071            .beam_groups
1072            .iter()
1073            .map(|g| (g.measure, g.note_indices.clone()))
1074            .collect();
1075        assert_eq!(groups, vec![(0, vec![0, 1]), (1, vec![0, 1])]);
1076    }
1077
1078    #[test]
1079    fn display_beams_keep_pickup_and_tuplet_boundaries_separate() {
1080        use acorde_core::{Duration, Note, Pitch, Step, TupletBoundary, TupletInfo};
1081        let mut score = score_with_measures(2);
1082        let mut notes: Vec<_> = (0..6)
1083            .map(|_| Note::new(Pitch::new(Step::C, 4), Duration::Eighth))
1084            .collect();
1085        for note in &mut notes[..3] {
1086            note.tuplet = Some(TupletInfo {
1087                actual_notes: 3,
1088                normal_notes: 2,
1089            });
1090        }
1091        notes[0].tuplet_boundary = Some(TupletBoundary::Start);
1092        notes[2].tuplet_boundary = Some(TupletBoundary::Stop);
1093        // A short first measure is a pickup; its beam groups remain measure-local.
1094        score.parts[0].staves[0].measures[0].voices[0] = notes;
1095        score.parts[0].staves[0].measures[1].voices[0] = (0..2)
1096            .map(|_| Note::new(Pitch::new(Step::D, 4), Duration::Eighth))
1097            .collect();
1098
1099        let layout = compute_layout_with_display_beams(&score, &LayoutConfig::default());
1100        assert_eq!(layout.beam_groups[0].note_indices, vec![0, 1, 2]);
1101        assert!(
1102            layout
1103                .beam_groups
1104                .iter()
1105                .any(|group| group.measure == 1 && group.note_indices == vec![0, 1])
1106        );
1107        assert!(
1108            layout
1109                .beam_groups
1110                .iter()
1111                .all(|g| !g.note_indices.contains(&2) || !g.note_indices.contains(&3))
1112        );
1113    }
1114
1115    #[test]
1116    fn layout_exposes_source_voice_number_for_canonical_slot() {
1117        let mut score = score_with_measures(1);
1118        score.parts[0].staves[0].measures[0].source_voice_numbers[1] = Some(5);
1119
1120        let result = compute_layout(&score, &LayoutConfig::default());
1121        assert_eq!(
1122            result.source_voice_addresses,
1123            vec![SourceVoiceAddress {
1124                part: 0,
1125                staff: 0,
1126                measure: 0,
1127                voice: 1,
1128                source_voice_number: 5,
1129            }]
1130        );
1131    }
1132
1133    #[test]
1134    fn beam_groups_begin_end_collected() {
1135        use acorde_core::{BeamState, Duration, Note, Pitch, Step};
1136        let mut score = score_with_measures(1);
1137        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
1138        n1.beam = BeamState::Begin;
1139        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
1140        n2.beam = BeamState::Continue;
1141        let mut n3 = Note::new(Pitch::new(Step::E, 4), Duration::Eighth);
1142        n3.beam = BeamState::End;
1143        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2, n3];
1144
1145        let result = compute_layout(&score, &LayoutConfig::default());
1146        assert_eq!(result.beam_groups.len(), 1);
1147        assert_eq!(result.beam_groups[0].note_indices, vec![0, 1, 2]);
1148        assert_eq!(result.beam_groups[0].part, 0);
1149        assert_eq!(result.beam_groups[0].measure, 0);
1150        assert_eq!(result.beam_groups[0].voice, 0);
1151    }
1152
1153    #[test]
1154    fn beam_groups_beginend_is_standalone_group() {
1155        use acorde_core::{BeamState, Duration, Note, Pitch, Step};
1156        let mut score = score_with_measures(1);
1157        let mut n = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
1158        n.beam = BeamState::BeginEnd;
1159        score.parts[0].staves[0].measures[0].voices[0] = vec![n];
1160
1161        let result = compute_layout(&score, &LayoutConfig::default());
1162        assert_eq!(result.beam_groups.len(), 1);
1163        assert_eq!(result.beam_groups[0].note_indices, vec![0]);
1164    }
1165
1166    #[test]
1167    fn tuplet_groups_empty_for_default_score() {
1168        let score = Score::default();
1169        let result = compute_layout(&score, &LayoutConfig::default());
1170        assert!(result.tuplet_groups.is_empty());
1171    }
1172
1173    #[test]
1174    fn tuplet_groups_triplet_collected() {
1175        use acorde_core::{Duration, Note, Pitch, Step, TupletInfo};
1176        let ti = TupletInfo {
1177            actual_notes: 3,
1178            normal_notes: 2,
1179        };
1180        let mut score = score_with_measures(1);
1181        let notes: Vec<Note> = (0..3)
1182            .map(|i| {
1183                let step = [Step::C, Step::D, Step::E][i].clone();
1184                let mut n = Note::new(Pitch::new(step, 4), Duration::Quarter);
1185                n.tuplet = Some(ti.clone());
1186                n
1187            })
1188            .collect();
1189        score.parts[0].staves[0].measures[0].voices[0] = notes;
1190
1191        let result = compute_layout(&score, &LayoutConfig::default());
1192        assert_eq!(result.tuplet_groups.len(), 1);
1193        assert_eq!(result.tuplet_groups[0].note_indices, vec![0, 1, 2]);
1194        assert_eq!(result.tuplet_groups[0].actual_notes, 3);
1195        assert_eq!(result.tuplet_groups[0].normal_notes, 2);
1196        assert_eq!(result.tuplet_groups[0].part, 0);
1197        assert_eq!(result.tuplet_groups[0].measure, 0);
1198    }
1199
1200    #[test]
1201    fn tuplet_groups_no_group_for_single_tuplet_note() {
1202        use acorde_core::{Duration, Note, Pitch, Step, TupletInfo};
1203        let mut score = score_with_measures(1);
1204        let mut n = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1205        n.tuplet = Some(TupletInfo {
1206            actual_notes: 3,
1207            normal_notes: 2,
1208        });
1209        score.parts[0].staves[0].measures[0].voices[0] = vec![n];
1210
1211        let result = compute_layout(&score, &LayoutConfig::default());
1212        // Single-note tuplet: not enough to form a group (need >= 2)
1213        assert!(result.tuplet_groups.is_empty());
1214    }
1215
1216    #[test]
1217    fn hairpin_span_resolved() {
1218        use acorde_core::{Duration, HairpinKind, Note, Pitch, Step};
1219        let mut score = score_with_measures(1);
1220        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1221        n1.hairpin_start = Some(HairpinKind::Crescendo);
1222        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1223        n2.hairpin_end = true;
1224        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
1225
1226        let result = compute_layout(&score, &LayoutConfig::default());
1227        assert_eq!(result.spans.len(), 1);
1228        if let SpanMark::Hairpin { kind, start, end } = &result.spans[0] {
1229            assert_eq!(*kind, HairpinKind::Crescendo);
1230            assert_eq!(start.note, 0);
1231            assert_eq!(end.note, 1);
1232        } else {
1233            panic!("expected Hairpin span");
1234        }
1235    }
1236
1237    #[test]
1238    fn slur_span_resolved() {
1239        use acorde_core::{Duration, Note, Pitch, Step};
1240        let mut score = score_with_measures(1);
1241        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1242        n1.slur_start = true;
1243        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1244        n2.slur_end = true;
1245        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
1246
1247        let result = compute_layout(&score, &LayoutConfig::default());
1248        let slur = result
1249            .spans
1250            .iter()
1251            .find(|s| matches!(s, SpanMark::Slur { .. }));
1252        assert!(slur.is_some(), "expected a Slur span");
1253        if let Some(SpanMark::Slur { start, end }) = slur {
1254            assert_eq!(start.note, 0);
1255            assert_eq!(end.note, 1);
1256        }
1257    }
1258
1259    #[test]
1260    fn adjacent_legacy_slurs_share_an_endpoint_without_zero_length() {
1261        use acorde_core::{Duration, Note, Pitch, Step};
1262        let mut score = score_with_measures(1);
1263        let mut notes = [Step::C, Step::D, Step::E, Step::F]
1264            .map(|step| Note::new(Pitch::new(step, 4), Duration::Quarter));
1265        notes[0].slur_start = true;
1266        notes[1].slur_end = true;
1267        notes[1].slur_start = true;
1268        notes[2].slur_end = true;
1269        score.parts[0].staves[0].measures[0].voices[0] = notes.to_vec();
1270        let slurs = compute_layout(&score, &LayoutConfig::default())
1271            .spans
1272            .into_iter()
1273            .filter_map(|span| match span {
1274                SpanMark::Slur { start, end } => Some((start.note, end.note)),
1275                _ => None,
1276            })
1277            .collect::<Vec<_>>();
1278        assert_eq!(slurs, vec![(0, 1), (1, 2)]);
1279    }
1280
1281    #[test]
1282    fn nested_legacy_slurs_close_in_last_in_first_out_order() {
1283        use acorde_core::{Duration, Note, Pitch, Step};
1284        let mut score = score_with_measures(1);
1285        let mut notes = [Step::C, Step::D, Step::E, Step::F]
1286            .map(|step| Note::new(Pitch::new(step, 4), Duration::Quarter));
1287        notes[0].slur_start = true;
1288        notes[1].slur_start = true;
1289        notes[2].slur_end = true;
1290        notes[3].slur_end = true;
1291        score.parts[0].staves[0].measures[0].voices[0] = notes.to_vec();
1292        let slurs = compute_layout(&score, &LayoutConfig::default())
1293            .spans
1294            .into_iter()
1295            .filter_map(|span| match span {
1296                SpanMark::Slur { start, end } => Some((start.note, end.note)),
1297                _ => None,
1298            })
1299            .collect::<Vec<_>>();
1300        assert_eq!(slurs, vec![(1, 2), (0, 3)]);
1301    }
1302
1303    #[test]
1304    fn typed_slurs_keep_overlapping_pairs_without_legacy_duplicates() {
1305        use acorde_core::{Duration, Note, Pitch, Step};
1306        let mut score = score_with_measures(1);
1307        let mut notes = [Step::C, Step::D, Step::E, Step::F]
1308            .map(|step| Note::new(Pitch::new(step, 4), Duration::Quarter));
1309        notes[0].slur_start = true;
1310        notes[1].slur_start = true;
1311        notes[2].slur_end = true;
1312        notes[3].slur_end = true;
1313        score.parts[0].staves[0].measures[0].voices[0] = notes.to_vec();
1314        for (id, start, end) in [("outer", 0, 2), ("overlap", 1, 3)] {
1315            score.spanners.push(NotationSpanner {
1316                id: id.into(),
1317                kind: NotationSpannerKind::Slur,
1318                start: NoteAddr {
1319                    part: 0,
1320                    staff: 0,
1321                    measure: 0,
1322                    voice: 0,
1323                    note: start,
1324                },
1325                end: NoteAddr {
1326                    part: 0,
1327                    staff: 0,
1328                    measure: 0,
1329                    voice: 0,
1330                    note: end,
1331                },
1332                number: None,
1333                line_type: None,
1334                text: None,
1335                placement: None,
1336                ottava_size: None,
1337                ottava_type: None,
1338            });
1339        }
1340        let slurs = compute_layout(&score, &LayoutConfig::default())
1341            .spans
1342            .into_iter()
1343            .filter_map(|span| match span {
1344                SpanMark::Slur { start, end } => Some((start.note, end.note)),
1345                _ => None,
1346            })
1347            .collect::<Vec<_>>();
1348        assert_eq!(slurs, vec![(0, 2), (1, 3)]);
1349    }
1350
1351    #[test]
1352    fn typed_spanners_keep_stable_identity_in_layout_result() {
1353        use acorde_core::{Duration, NotationSpanner, NotationSpannerKind, Note, Pitch, Step};
1354        let mut score = score_with_measures(1);
1355        score.parts[0].staves[0].measures[0].voices[0] = vec![
1356            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
1357            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
1358        ];
1359        score.spanners.push(NotationSpanner {
1360            id: "typed-slur-7".to_string(),
1361            kind: NotationSpannerKind::Slur,
1362            start: NoteAddr {
1363                part: 0,
1364                staff: 0,
1365                measure: 0,
1366                voice: 0,
1367                note: 0,
1368            },
1369            end: NoteAddr {
1370                part: 0,
1371                staff: 0,
1372                measure: 0,
1373                voice: 0,
1374                note: 1,
1375            },
1376            number: Some(7),
1377            line_type: Some("dashed".to_string()),
1378            text: None,
1379            placement: Some("above".to_string()),
1380            ottava_size: None,
1381            ottava_type: None,
1382        });
1383
1384        let result = compute_layout(&score, &LayoutConfig::default());
1385        assert_eq!(result.typed_spanners.len(), 1);
1386        assert_eq!(result.typed_spanners[0].id, "typed-slur-7");
1387        assert_eq!(result.typed_spanners[0].number, Some(7));
1388    }
1389
1390    #[test]
1391    fn harmony_range_span_resolved_from_typed_end_address() {
1392        use acorde_core::{ChordSymbol, Duration, Note, NoteAddr, Pitch, Step};
1393        let mut score = score_with_measures(2);
1394        score.parts[0].staves[0].measures[0].voices[0] =
1395            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1396        score.parts[0].staves[0].measures[1].voices[0] =
1397            vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
1398        score.parts[0].staves[0].measures[0].voices[0][0].chord_symbol = Some(ChordSymbol {
1399            root: "C".to_owned(),
1400            kind: "major".to_owned(),
1401            bass: None,
1402            placement: None,
1403            extender: true,
1404            harmonic_degree: None,
1405            harmony_function: None,
1406            harmony_type: None,
1407            chord_ref: None,
1408            range_end: Some(NoteAddr {
1409                part: 0,
1410                staff: 0,
1411                measure: 1,
1412                voice: 0,
1413                note: 0,
1414            }),
1415            degrees: Vec::new(),
1416        });
1417
1418        let result = compute_layout(&score, &LayoutConfig::default());
1419        assert!(matches!(
1420            result.spans.as_slice(),
1421            [SpanMark::Harmony { label, start, end }]
1422                if label == "C" && start.measure == 0 && end.measure == 1
1423        ));
1424    }
1425
1426    // ── CourtesyAccidental ────────────────────────────────────────────────────
1427
1428    #[test]
1429    fn courtesy_none_for_clean_score() {
1430        // Default score has no chromatic alterations — no courtesy accidentals.
1431        let score = Score::default();
1432        let result = compute_layout(&score, &LayoutConfig::default());
1433        assert!(result.courtesy_accidentals.is_empty());
1434    }
1435
1436    #[test]
1437    fn courtesy_after_chromatic_alteration() {
1438        // Measure 0: F# (alter=1). Measure 1: F natural (alter=0).
1439        // The F in measure 1 should get a courtesy accidental.
1440        use acorde_core::{Duration, Note, Pitch, Step};
1441        let mut score = score_with_measures(2);
1442
1443        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1444        fsharp.pitches[0].alter = 1;
1445        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1446
1447        let fnat = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1448        // alter=0 is default; key signature is C major (fifths=0) so F natural has key_alter=0
1449        score.parts[0].staves[0].measures[1].voices[0] = vec![fnat];
1450
1451        let result = compute_layout(&score, &LayoutConfig::default());
1452        assert_eq!(result.courtesy_accidentals.len(), 1);
1453        let ca = &result.courtesy_accidentals[0];
1454        assert_eq!(ca.measure, 1);
1455        assert_eq!(ca.alter, 0); // natural sign
1456    }
1457
1458    #[test]
1459    fn courtesy_same_alteration_no_courtesy() {
1460        // Measure 0: F#. Measure 1: F# again.
1461        // The first F# needs a mandatory accidental, not an extra courtesy.
1462        use acorde_core::{Duration, Note, Pitch, Step};
1463        let mut score = score_with_measures(2);
1464
1465        let mut fsharp1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1466        fsharp1.pitches[0].alter = 1;
1467        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp1];
1468
1469        let mut fsharp2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1470        fsharp2.pitches[0].alter = 1;
1471        score.parts[0].staves[0].measures[1].voices[0] = vec![fsharp2];
1472
1473        let result = compute_layout(&score, &LayoutConfig::default());
1474        assert!(result.courtesy_accidentals.is_empty());
1475    }
1476
1477    #[test]
1478    fn courtesy_tied_note_excluded() {
1479        // A note with tie_end=true should never get a courtesy accidental.
1480        use acorde_core::{Duration, Note, Pitch, Step};
1481        let mut score = score_with_measures(2);
1482
1483        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1484        fsharp.pitches[0].alter = 1;
1485        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1486
1487        let mut tied = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1488        tied.pitches[0].alter = 1;
1489        tied.tie_end = true;
1490        score.parts[0].staves[0].measures[1].voices[0] = vec![tied];
1491
1492        let result = compute_layout(&score, &LayoutConfig::default());
1493        assert!(result.courtesy_accidentals.is_empty());
1494    }
1495
1496    #[test]
1497    fn courtesy_key_change_resets() {
1498        // Measure 0: F# (alter=1). Measure 1: key changes to G major (fifths=1, F# implied).
1499        // The key change clears prev_alters, so F in measure 1 won't get a courtesy.
1500        use acorde_core::{Duration, KeySignature, Note, Pitch, Step};
1501        let mut score = score_with_measures(2);
1502
1503        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1504        fsharp.pitches[0].alter = 1;
1505        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1506
1507        // Set key signature on measure 1 to G major (1 sharp = F#)
1508        score.parts[0].staves[0].measures[1].key_sig = Some(KeySignature {
1509            fifths: 1,
1510            mode: "major".to_string(),
1511        });
1512        // F# is now implied by the key — alter=1 == key_alter(1, F) → not in prev_alters
1513        let mut fsharp2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1514        fsharp2.pitches[0].alter = 1;
1515        score.parts[0].staves[0].measures[1].voices[0] = vec![fsharp2];
1516
1517        let result = compute_layout(&score, &LayoutConfig::default());
1518        // key change clears prev_alters before phase 1 → no courtesy for measure 1
1519        assert!(result.courtesy_accidentals.is_empty());
1520    }
1521
1522    // ── AccidentalMark ────────────────────────────────────────────────────────
1523
1524    #[test]
1525    fn requested_accidentals_are_drawn_where_the_rules_imply_them() {
1526        use acorde_core::{Duration, Note, Pitch, Step};
1527        let mut score = score_with_measures(1);
1528        let mut cautionary = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1529        cautionary.set_accidental_display(0, AccidentalDisplay::Cautionary);
1530        let mut sharp = Note::new(Pitch::with_alter(Step::F, 4, 1), Duration::Quarter);
1531        sharp.set_accidental_display(0, AccidentalDisplay::Parenthesized);
1532        let mut bracketed = Note::new(Pitch::new(Step::G, 4), Duration::Quarter);
1533        bracketed.set_accidental_display(0, AccidentalDisplay::Parenthesized);
1534        let plain = Note::new(Pitch::new(Step::A, 4), Duration::Quarter);
1535        score.parts[0].staves[0].measures[0].voices[0] = vec![cautionary, sharp, bracketed, plain];
1536
1537        let result = compute_layout(&score, &LayoutConfig::default());
1538        let mandatory: Vec<usize> = result.accidentals.iter().map(|a| a.note_index).collect();
1539        let courtesy: Vec<usize> = result
1540            .courtesy_accidentals
1541            .iter()
1542            .map(|a| a.note_index)
1543            .collect();
1544        // The cautionary natural is drawn plain; the sharp and the natural asked for in
1545        // parentheses are drawn as courtesy marks, and the sharp is not drawn twice.
1546        assert_eq!(mandatory, vec![0]);
1547        assert_eq!(courtesy, vec![1, 2]);
1548    }
1549
1550    #[test]
1551    fn accidental_none_for_clean_score() {
1552        let score = Score::default();
1553        let result = compute_layout(&score, &LayoutConfig::default());
1554        assert!(result.accidentals.is_empty());
1555    }
1556
1557    #[test]
1558    fn accidental_first_chromatic_note_in_measure() {
1559        // C major, single F# in measure 0 → one mandatory accidental (sharp).
1560        use acorde_core::{Duration, Note, Pitch, Step};
1561        let mut score = score_with_measures(1);
1562        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1563        fsharp.pitches[0].alter = 1;
1564        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1565
1566        let result = compute_layout(&score, &LayoutConfig::default());
1567        assert_eq!(result.accidentals.len(), 1);
1568        assert_eq!(result.accidentals[0].alter, 1);
1569        assert_eq!(result.accidentals[0].measure, 0);
1570    }
1571
1572    #[test]
1573    fn accidental_repeat_same_alter_in_measure_not_marked_again() {
1574        // Two F#s in the same measure: only the first needs an accidental.
1575        use acorde_core::{Duration, Note, Pitch, Step};
1576        let mut score = score_with_measures(1);
1577        let mut f1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1578        f1.pitches[0].alter = 1;
1579        let mut f2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1580        f2.pitches[0].alter = 1;
1581        score.parts[0].staves[0].measures[0].voices[0] = vec![f1, f2];
1582
1583        let result = compute_layout(&score, &LayoutConfig::default());
1584        assert_eq!(result.accidentals.len(), 1);
1585    }
1586
1587    #[test]
1588    fn accidental_reverts_to_natural_within_same_measure() {
1589        // F# then F natural, same measure: the natural needs its own mark too.
1590        use acorde_core::{Duration, Note, Pitch, Step};
1591        let mut score = score_with_measures(1);
1592        let mut f1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1593        f1.pitches[0].alter = 1;
1594        let f2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter); // alter=0
1595        score.parts[0].staves[0].measures[0].voices[0] = vec![f1, f2];
1596
1597        let result = compute_layout(&score, &LayoutConfig::default());
1598        assert_eq!(result.accidentals.len(), 2);
1599        assert_eq!(result.accidentals[0].alter, 1);
1600        assert_eq!(result.accidentals[1].alter, 0);
1601    }
1602
1603    #[test]
1604    fn accidental_does_not_carry_across_barline() {
1605        // F# in measure 0; measure 1's F# needs its own mark (accidentals reset per measure).
1606        // This is the case that would otherwise double up with a CourtesyAccidental —
1607        // callers must prefer `accidentals` over `courtesy_accidentals` when both exist.
1608        use acorde_core::{Duration, Note, Pitch, Step};
1609        let mut score = score_with_measures(2);
1610        let mut f1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1611        f1.pitches[0].alter = 1;
1612        score.parts[0].staves[0].measures[0].voices[0] = vec![f1];
1613        let mut f2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1614        f2.pitches[0].alter = 1;
1615        score.parts[0].staves[0].measures[1].voices[0] = vec![f2];
1616
1617        let result = compute_layout(&score, &LayoutConfig::default());
1618        assert_eq!(result.accidentals.len(), 2);
1619        assert!(result.accidentals.iter().any(|a| a.measure == 1));
1620        assert!(result.courtesy_accidentals.is_empty());
1621    }
1622
1623    #[test]
1624    fn accidental_tied_note_excluded() {
1625        use acorde_core::{Duration, Note, Pitch, Step};
1626        let mut score = score_with_measures(1);
1627        let mut tied = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1628        tied.pitches[0].alter = 1;
1629        tied.tie_end = true;
1630        score.parts[0].staves[0].measures[0].voices[0] = vec![tied];
1631
1632        let result = compute_layout(&score, &LayoutConfig::default());
1633        assert!(result.accidentals.is_empty());
1634    }
1635
1636    #[test]
1637    fn accidental_shared_across_voices_on_same_staff() {
1638        // Voice 0 introduces F#; voice 1's F# later in the same measure shares the context.
1639        use acorde_core::{Duration, Note, Pitch, Step};
1640        let mut score = score_with_measures(1);
1641        let mut f_v0 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1642        f_v0.pitches[0].alter = 1;
1643        score.parts[0].staves[0].measures[0].voices[0] = vec![f_v0];
1644        let mut f_v1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1645        f_v1.pitches[0].alter = 1;
1646        score.parts[0].staves[0].measures[0].voices[1] = vec![f_v1];
1647
1648        let result = compute_layout(&score, &LayoutConfig::default());
1649        assert_eq!(result.accidentals.len(), 1);
1650    }
1651
1652    #[test]
1653    fn accidental_double_sharp_marked() {
1654        use acorde_core::{Duration, Note, Pitch, Step};
1655        let mut score = score_with_measures(1);
1656        let mut n = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1657        n.pitches[0].alter = 2;
1658        score.parts[0].staves[0].measures[0].voices[0] = vec![n];
1659
1660        let result = compute_layout(&score, &LayoutConfig::default());
1661        assert_eq!(result.accidentals.len(), 1);
1662        assert_eq!(result.accidentals[0].alter, 2);
1663    }
1664}