Skip to main content

acorde_core/model/
validate.rs

1use super::gm::instrument_range;
2use super::score::{NotationSpannerKind, NoteAddr, Score};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// A structural error found by [`validate`].
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub enum ValidationError {
9    /// The score has no parts to validate.
10    EmptyScore,
11    /// A part has no staves.
12    PartWithoutStaves { part: usize },
13    /// A staff has no measures.
14    StaffWithoutMeasures { part: usize, staff: usize },
15    /// Staves in one part do not cover the same number of measures.
16    MeasureCountMismatch {
17        part: usize,
18        staff: usize,
19        expected: usize,
20        found: usize,
21    },
22    /// A time signature has an unsupported numerator or denominator.
23    InvalidTimeSignature {
24        part: usize,
25        staff: usize,
26        measure: usize,
27        numerator: u8,
28        denominator: u8,
29    },
30    /// Beat-count mismatch: the notes in a voice don't fill the time signature.
31    BeatCount {
32        part: usize,
33        staff: usize,
34        measure: usize,
35        voice: usize,
36        expected_beats: f64,
37        found_beats: f64,
38    },
39    /// A note pitch lies outside the practical range for the part's GM instrument.
40    OutOfRange {
41        part_index: usize,
42        staff_index: usize,
43        measure_index: usize,
44        note_index: usize,
45        pitch_midi: u8,
46        instrument_range: (u8, u8),
47    },
48    /// Tablature staff metadata is internally inconsistent.
49    InvalidTablature {
50        part: usize,
51        staff: usize,
52        reason: TablatureValidationReason,
53    },
54    /// A note's explicit string is not present on its tablature staff.
55    TabPositionOutOfRange {
56        part: usize,
57        staff: usize,
58        measure: usize,
59        voice: usize,
60        note: usize,
61        string: u8,
62        lines: u8,
63    },
64    /// A pitch's microtonal cents component is outside the canonical -99..99 range.
65    MicrotoneOutOfRange {
66        part: usize,
67        staff: usize,
68        measure: usize,
69        voice: usize,
70        note: usize,
71        pitch: usize,
72        microtone_cents: i16,
73    },
74    /// A harmony continuation points to a note address that does not exist.
75    InvalidHarmonyRange {
76        part: usize,
77        staff: usize,
78        measure: usize,
79        voice: usize,
80        note: usize,
81        end: NoteAddr,
82    },
83    /// A typed notation span must have a non-empty unique stable identity.
84    InvalidSpannerId { index: usize, id: String },
85    /// A typed notation span has a duplicate stable identity.
86    DuplicateSpannerId {
87        first: usize,
88        duplicate: usize,
89        id: String,
90    },
91    /// A typed notation span endpoint does not point to a canonical note.
92    InvalidSpannerEndpoint {
93        index: usize,
94        id: String,
95        kind: NotationSpannerKind,
96        endpoint: SpannerEndpoint,
97        address: NoteAddr,
98    },
99}
100
101/// Which endpoint of a typed notation spanner failed validation.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum SpannerEndpoint {
104    Start,
105    End,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub enum TablatureValidationReason {
110    InvalidLineCount { lines: u8 },
111    TooManyTunings { tuning_count: usize, lines: u8 },
112    TuningOutOfMidiRange { index: usize, midi: i16 },
113}
114
115/// A non-fatal advisory warning found by [`validate`].
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub enum ValidationWarning {
118    /// A measure's beat count is less than the time signature (incomplete bar).
119    IncompleteBar {
120        part: usize,
121        staff: usize,
122        measure: usize,
123        expected_beats: f64,
124        actual_beats: f64,
125    },
126    /// Two volta brackets in the same staff overlap or share the same number.
127    OverlappingVolta { part: usize, staff: usize },
128    /// A part has no notes across all measures.
129    EmptyPart { part: usize },
130    /// The same rehearsal mark text appears more than once.
131    DuplicateRehearsalMark { mark: String },
132}
133
134/// Combined result of [`validate`]: errors that indicate broken structure, plus advisory warnings.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ValidationReport {
137    pub errors: Vec<ValidationError>,
138    pub warnings: Vec<ValidationWarning>,
139}
140
141impl ValidationReport {
142    /// `true` when there are no errors (warnings may still be present).
143    pub fn is_valid(&self) -> bool {
144        self.errors.is_empty()
145    }
146}
147
148/// Check every voice in every measure for structural correctness.
149///
150/// Checks performed:
151/// - **Errors**: beat-count mismatch, out-of-range pitch.
152/// - **Warnings**: incomplete bar (underfull voice), overlapping volta brackets,
153///   empty parts, duplicate rehearsal marks.
154///
155/// Multi-rest placeholder measures and empty voices are skipped.
156/// Percussion parts (MIDI channel 9) are exempt from pitch-range checks.
157pub fn validate(score: &Score) -> ValidationReport {
158    let mut errors = Vec::new();
159    let mut warnings = Vec::new();
160
161    let mut rehearsal_counts: HashMap<String, usize> = HashMap::new();
162
163    if score.parts.is_empty() {
164        errors.push(ValidationError::EmptyScore);
165    }
166
167    let mut spanner_ids: HashMap<&str, usize> = HashMap::new();
168    for (index, spanner) in score.spanners.iter().enumerate() {
169        if spanner.id.trim().is_empty() {
170            errors.push(ValidationError::InvalidSpannerId {
171                index,
172                id: spanner.id.clone(),
173            });
174        } else if let Some(first) = spanner_ids.insert(spanner.id.as_str(), index) {
175            errors.push(ValidationError::DuplicateSpannerId {
176                first,
177                duplicate: index,
178                id: spanner.id.clone(),
179            });
180        }
181        for (endpoint, address) in [
182            (SpannerEndpoint::Start, &spanner.start),
183            (SpannerEndpoint::End, &spanner.end),
184        ] {
185            if !note_exists(score, address) {
186                errors.push(ValidationError::InvalidSpannerEndpoint {
187                    index,
188                    id: spanner.id.clone(),
189                    kind: spanner.kind.clone(),
190                    endpoint,
191                    address: address.clone(),
192                });
193            }
194        }
195    }
196
197    for (pi, part) in score.parts.iter().enumerate() {
198        let range = instrument_range(part.midi_program);
199        let is_percussion = part.midi_channel == 9;
200        let mut part_has_notes = false;
201
202        if part.staves.is_empty() {
203            errors.push(ValidationError::PartWithoutStaves { part: pi });
204            continue;
205        }
206
207        let expected_measure_count = part.staves[0].measures.len();
208
209        for (si, staff) in part.staves.iter().enumerate() {
210            if staff.measures.is_empty() {
211                errors.push(ValidationError::StaffWithoutMeasures {
212                    part: pi,
213                    staff: si,
214                });
215                continue;
216            }
217            if staff.measures.len() != expected_measure_count {
218                errors.push(ValidationError::MeasureCountMismatch {
219                    part: pi,
220                    staff: si,
221                    expected: expected_measure_count,
222                    found: staff.measures.len(),
223                });
224            }
225
226            if let Some(tab) = &staff.tablature {
227                if !(1..=64).contains(&tab.lines) {
228                    errors.push(ValidationError::InvalidTablature {
229                        part: pi,
230                        staff: si,
231                        reason: TablatureValidationReason::InvalidLineCount { lines: tab.lines },
232                    });
233                } else if tab.tuning_midi.len() > usize::from(tab.lines) {
234                    errors.push(ValidationError::InvalidTablature {
235                        part: pi,
236                        staff: si,
237                        reason: TablatureValidationReason::TooManyTunings {
238                            tuning_count: tab.tuning_midi.len(),
239                            lines: tab.lines,
240                        },
241                    });
242                }
243                for (index, &midi) in tab.tuning_midi.iter().enumerate() {
244                    if !(0..=127).contains(&midi) {
245                        errors.push(ValidationError::InvalidTablature {
246                            part: pi,
247                            staff: si,
248                            reason: TablatureValidationReason::TuningOutOfMidiRange { index, midi },
249                        });
250                    }
251                }
252            }
253
254            let mut current_ts = score.settings.time_signature.clone();
255            let mut volta_numbers_seen: Vec<u8> = Vec::new();
256
257            for (mi, measure) in staff.measures.iter().enumerate() {
258                if let Some(ts) = &measure.time_sig {
259                    current_ts = ts.clone();
260                }
261                if !valid_time_signature(&current_ts) {
262                    errors.push(ValidationError::InvalidTimeSignature {
263                        part: pi,
264                        staff: si,
265                        measure: mi,
266                        numerator: current_ts.numerator,
267                        denominator: current_ts.denominator,
268                    });
269                    continue;
270                }
271                if measure.multi_rest_count.is_some() {
272                    continue;
273                }
274
275                // Rehearsal mark deduplication
276                if let Some(ref mark) = measure.rehearsal {
277                    let entry = rehearsal_counts.entry(mark.clone()).or_insert(0);
278                    *entry += 1;
279                }
280
281                // Volta overlap detection
282                if let Some(ref volta) = measure.volta {
283                    if volta_numbers_seen.contains(&volta.number) {
284                        warnings.push(ValidationWarning::OverlappingVolta {
285                            part: pi,
286                            staff: si,
287                        });
288                    } else {
289                        volta_numbers_seen.push(volta.number);
290                    }
291                }
292
293                let expected = current_ts.total_beats();
294                for (vi, voice) in measure.voices.iter().enumerate() {
295                    if voice.is_empty() {
296                        continue;
297                    }
298                    let non_rest_count: usize = voice.iter().filter(|n| !n.is_rest).count();
299                    if non_rest_count > 0 {
300                        part_has_notes = true;
301                    }
302                    let total: f64 = voice.iter().map(|n| n.beats()).sum();
303                    if total > expected + 0.02 {
304                        errors.push(ValidationError::BeatCount {
305                            part: pi,
306                            staff: si,
307                            measure: mi,
308                            voice: vi,
309                            expected_beats: expected,
310                            found_beats: total,
311                        });
312                    } else if total < expected - 0.02 && non_rest_count > 0 {
313                        warnings.push(ValidationWarning::IncompleteBar {
314                            part: pi,
315                            staff: si,
316                            measure: mi,
317                            expected_beats: expected,
318                            actual_beats: total,
319                        });
320                    }
321
322                    for (ni, note) in voice.iter().enumerate() {
323                        if note.is_rest || note.is_grace {
324                            continue;
325                        }
326                        if let Some(chord) = &note.chord_symbol
327                            && let Some(end) = &chord.range_end
328                            && !note_exists(score, end)
329                        {
330                            errors.push(ValidationError::InvalidHarmonyRange {
331                                part: pi,
332                                staff: si,
333                                measure: mi,
334                                voice: vi,
335                                note: ni,
336                                end: end.clone(),
337                            });
338                        }
339                        for (pitch_index, pitch) in note.pitches.iter().enumerate() {
340                            if !(-99..=99).contains(&pitch.microtone_cents) {
341                                errors.push(ValidationError::MicrotoneOutOfRange {
342                                    part: pi,
343                                    staff: si,
344                                    measure: mi,
345                                    voice: vi,
346                                    note: ni,
347                                    pitch: pitch_index,
348                                    microtone_cents: pitch.microtone_cents,
349                                });
350                            }
351                        }
352                    }
353
354                    if !is_percussion {
355                        let transpose = staff.transpose_semitones;
356                        for (ni, note) in voice.iter().enumerate() {
357                            if note.is_rest || note.is_grace {
358                                continue;
359                            }
360                            if let Some(tab) = &staff.tablature {
361                                let positions =
362                                    note.tab_position.iter().chain(note.tab_positions.iter());
363                                for position in positions {
364                                    if position.string == 0 || position.string > tab.lines {
365                                        errors.push(ValidationError::TabPositionOutOfRange {
366                                            part: pi,
367                                            staff: si,
368                                            measure: mi,
369                                            voice: vi,
370                                            note: ni,
371                                            string: position.string,
372                                            lines: tab.lines,
373                                        });
374                                    }
375                                }
376                            }
377                            for pitch in &note.pitches {
378                                let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
379                                if midi < range.0 || midi > range.1 {
380                                    errors.push(ValidationError::OutOfRange {
381                                        part_index: pi,
382                                        staff_index: si,
383                                        measure_index: mi,
384                                        note_index: ni,
385                                        pitch_midi: midi,
386                                        instrument_range: range,
387                                    });
388                                }
389                            }
390                        }
391                    }
392                }
393            }
394        }
395
396        if !part_has_notes {
397            warnings.push(ValidationWarning::EmptyPart { part: pi });
398        }
399    }
400
401    for (mark, count) in &rehearsal_counts {
402        if *count > 1 {
403            warnings.push(ValidationWarning::DuplicateRehearsalMark { mark: mark.clone() });
404        }
405    }
406
407    ValidationReport { errors, warnings }
408}
409
410fn valid_time_signature(time: &super::notation::TimeSignature) -> bool {
411    time.numerator > 0 && matches!(time.denominator, 1 | 2 | 4 | 8 | 16 | 32 | 64)
412}
413
414fn note_exists(score: &Score, address: &NoteAddr) -> bool {
415    score
416        .parts
417        .get(address.part)
418        .and_then(|part| part.staves.get(address.staff))
419        .and_then(|staff| staff.measures.get(address.measure))
420        .and_then(|measure| measure.voices.get(address.voice))
421        .and_then(|voice| voice.get(address.note))
422        .is_some()
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::model::{
429        duration::Duration,
430        notation::ChordSymbol,
431        pitch::{Pitch, Step},
432        score::{Note, NoteAddr, Score},
433    };
434
435    #[test]
436    fn validate_clean_score_returns_empty_errors() {
437        let score = Score::new("T", 120, 4, 4, 0, 1);
438        assert!(validate(&score).errors.is_empty());
439    }
440
441    #[test]
442    fn validate_empty_score_returns_structural_error() {
443        let mut score = Score::new("T", 120, 4, 4, 0, 1);
444        score.parts.clear();
445        let report = validate(&score);
446        assert!(
447            report
448                .errors
449                .iter()
450                .any(|error| matches!(error, ValidationError::EmptyScore))
451        );
452    }
453
454    #[test]
455    fn validate_detects_missing_staves_and_measures() {
456        let mut score = Score::new("T", 120, 4, 4, 0, 1);
457        score.parts[0].staves.clear();
458        let report = validate(&score);
459        assert!(
460            report
461                .errors
462                .iter()
463                .any(|error| matches!(error, ValidationError::PartWithoutStaves { part: 0 }))
464        );
465
466        score.parts[0].staves.push(crate::model::score::Staff::new(
467            crate::model::notation::Clef::Treble,
468        ));
469        let report = validate(&score);
470        assert!(report.errors.iter().any(|error| matches!(
471            error,
472            ValidationError::StaffWithoutMeasures { part: 0, staff: 0 }
473        )));
474    }
475
476    #[test]
477    fn validate_detects_staff_measure_count_mismatch() {
478        let mut score = Score::template(crate::model::score::ScoreTemplate::Piano);
479        score.parts[0].staves[1].measures.pop();
480        let report = validate(&score);
481        assert!(report.errors.iter().any(|error| matches!(
482            error,
483            ValidationError::MeasureCountMismatch {
484                part: 0,
485                staff: 1,
486                expected: 4,
487                found: 3
488            }
489        )));
490    }
491
492    #[test]
493    fn validate_detects_invalid_time_signature() {
494        let mut score = Score::new("T", 120, 4, 4, 0, 1);
495        score.parts[0].staves[0].measures[0].time_sig =
496            Some(crate::model::notation::TimeSignature {
497                numerator: 0,
498                denominator: 3,
499            });
500        let report = validate(&score);
501        assert!(report.errors.iter().any(|error| matches!(
502            error,
503            ValidationError::InvalidTimeSignature {
504                part: 0,
505                staff: 0,
506                measure: 0,
507                numerator: 0,
508                denominator: 3
509            }
510        )));
511    }
512
513    #[test]
514    fn validate_overfull_measure_returns_error() {
515        let mut score = Score::new("T", 120, 4, 4, 0, 1);
516        score.parts[0].staves[0].measures[0].voices[0]
517            .push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
518        let report = validate(&score);
519        assert!(!report.errors.is_empty());
520        assert!(matches!(
521            report.errors[0],
522            ValidationError::BeatCount {
523                measure: 0,
524                voice: 0,
525                ..
526            }
527        ));
528    }
529
530    #[test]
531    fn validate_skips_multi_rest() {
532        let mut score = Score::new("T", 120, 4, 4, 0, 1);
533        score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
534        score.parts[0].staves[0].measures[0].voices[0].clear();
535        assert!(validate(&score).errors.is_empty());
536    }
537
538    #[test]
539    fn validate_rejects_harmony_range_to_missing_note() {
540        let mut score = Score::new("T", 120, 4, 4, 0, 1);
541        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
542        note.chord_symbol = Some(ChordSymbol {
543            root: "C".to_owned(),
544            kind: "major".to_owned(),
545            bass: None,
546            placement: None,
547            extender: true,
548            harmonic_degree: None,
549            harmony_function: None,
550            harmony_type: None,
551            chord_ref: None,
552            range_end: Some(NoteAddr {
553                part: 0,
554                staff: 0,
555                measure: 0,
556                voice: 0,
557                note: 9,
558            }),
559            degrees: Vec::new(),
560        });
561        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
562        assert!(validate(&score).errors.iter().any(|error| matches!(
563            error,
564            ValidationError::InvalidHarmonyRange { note: 0, end, .. }
565                if end.note == 9
566        )));
567    }
568
569    #[test]
570    fn validate_out_of_range_pitch_detected() {
571        // Piano (program 0): range 21–108. C9 (midi=120) is out of range.
572        let mut score = Score::new("T", 120, 4, 4, 0, 1);
573        score.parts[0].midi_program = 0;
574        score.parts[0].staves[0].measures[0].voices[0] =
575            vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
576        let report = validate(&score);
577        assert!(report.errors.iter().any(
578            |e| matches!(e, ValidationError::OutOfRange { pitch_midi, .. } if *pitch_midi == 120)
579        ));
580    }
581
582    #[test]
583    fn validate_percussion_channel_skips_range_check() {
584        // Channel 9 = percussion; even extreme pitches should not trigger OutOfRange.
585        let mut score = Score::new("T", 120, 4, 4, 0, 1);
586        score.parts[0].midi_channel = 9;
587        score.parts[0].midi_program = 0;
588        score.parts[0].staves[0].measures[0].voices[0] =
589            vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
590        let report = validate(&score);
591        assert!(
592            !report
593                .errors
594                .iter()
595                .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
596        );
597    }
598
599    #[test]
600    fn validate_in_range_pitch_ok() {
601        // Piano C4 (midi=60) is in range 21–108.
602        let mut score = Score::new("T", 120, 4, 4, 0, 1);
603        score.parts[0].midi_program = 0;
604        score.parts[0].staves[0].measures[0].voices[0] =
605            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
606        assert!(
607            !validate(&score)
608                .errors
609                .iter()
610                .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
611        );
612    }
613
614    #[test]
615    fn validate_rejects_deserialized_microtone_out_of_range() {
616        let mut score = Score::new("T", 120, 4, 4, 0, 1);
617        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
618        note.pitches[0].microtone_cents = 100;
619        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
620        let report = validate(&score);
621        assert!(report.errors.iter().any(|error| matches!(
622            error,
623            ValidationError::MicrotoneOutOfRange {
624                microtone_cents: 100,
625                ..
626            }
627        )));
628    }
629
630    #[test]
631    fn validate_rejects_invalid_and_duplicate_typed_spanners() {
632        let mut score = Score::new("T", 120, 4, 4, 0, 1);
633        score.parts[0].staves[0].measures[0].voices[0] =
634            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
635        let address = NoteAddr {
636            part: 0,
637            staff: 0,
638            measure: 0,
639            voice: 0,
640            note: 0,
641        };
642        score.spanners = vec![
643            super::super::score::NotationSpanner {
644                id: String::new(),
645                kind: NotationSpannerKind::Slur,
646                start: address.clone(),
647                end: address.clone(),
648                number: Some(1),
649                line_type: None,
650                text: None,
651                placement: None,
652                ottava_size: None,
653                ottava_type: None,
654            },
655            super::super::score::NotationSpanner {
656                id: "duplicate".to_string(),
657                kind: NotationSpannerKind::Pedal,
658                start: address.clone(),
659                end: NoteAddr { note: 9, ..address },
660                number: Some(2),
661                line_type: None,
662                text: None,
663                placement: None,
664                ottava_size: None,
665                ottava_type: None,
666            },
667            super::super::score::NotationSpanner {
668                id: "duplicate".to_string(),
669                kind: NotationSpannerKind::Ottava,
670                start: NoteAddr {
671                    part: 9,
672                    staff: 0,
673                    measure: 0,
674                    voice: 0,
675                    note: 0,
676                },
677                end: NoteAddr {
678                    part: 0,
679                    staff: 0,
680                    measure: 0,
681                    voice: 0,
682                    note: 0,
683                },
684                number: None,
685                line_type: None,
686                text: None,
687                placement: None,
688                ottava_size: Some(8),
689                ottava_type: None,
690            },
691        ];
692
693        let report = validate(&score);
694        assert!(
695            report
696                .errors
697                .iter()
698                .any(|error| matches!(error, ValidationError::InvalidSpannerId { index: 0, .. }))
699        );
700        assert!(report.errors.iter().any(|error| matches!(
701            error,
702            ValidationError::DuplicateSpannerId {
703                first: 1,
704                duplicate: 2,
705                ..
706            }
707        )));
708        assert_eq!(
709            report
710                .errors
711                .iter()
712                .filter(|error| matches!(error, ValidationError::InvalidSpannerEndpoint { .. }))
713                .count(),
714            2
715        );
716    }
717
718    #[test]
719    fn validate_rejects_invalid_tablature_metadata_and_positions() {
720        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
721        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
722            lines: 6,
723            tuning_midi: vec![64, 59, 55, 50, 45, 40, 35],
724            capo: 0,
725        });
726        let mut note = Note::new(Pitch::new(Step::E, 4), Duration::Whole);
727        note.tab_position = Some(super::super::notation::TabPosition { string: 7, fret: 0 });
728        note.tab_positions = vec![super::super::notation::TabPosition { string: 8, fret: 3 }];
729        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
730
731        let report = validate(&score);
732        assert!(report.errors.iter().any(|error| matches!(
733            error,
734            ValidationError::InvalidTablature {
735                reason: TablatureValidationReason::TooManyTunings { .. },
736                ..
737            }
738        )));
739        assert!(
740            report
741                .errors
742                .iter()
743                .any(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
744        );
745        assert_eq!(
746            report
747                .errors
748                .iter()
749                .filter(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
750                .count(),
751            2
752        );
753    }
754
755    #[test]
756    fn validate_empty_part_warning() {
757        let score = Score::new("T", 120, 4, 4, 0, 1);
758        let report = validate(&score);
759        assert!(
760            report
761                .warnings
762                .iter()
763                .any(|w| matches!(w, ValidationWarning::EmptyPart { part: 0 }))
764        );
765    }
766
767    #[test]
768    fn validate_duplicate_rehearsal_mark_warning() {
769        use crate::model::score::Score;
770        let mut score = Score::new("T", 120, 4, 4, 0, 2);
771        score.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
772        score.parts[0].staves[0].measures[1].rehearsal = Some("A".to_string());
773        let report = validate(&score);
774        assert!(report.warnings.iter().any(
775            |w| matches!(w, ValidationWarning::DuplicateRehearsalMark { mark } if mark == "A")
776        ));
777    }
778}