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