Skip to main content

acorde_core/model/
validate.rs

1use super::gm::instrument_range;
2use super::notation::GuitarTechnique;
3use super::score::{
4    InstrumentDefinition, InstrumentRange, NotationSpannerKind, NoteAddr, PercussionInstrument,
5    Score, ScoreView, StaffKind,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// A structural error found by [`validate`].
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub enum ValidationError {
13    /// The score has no parts to validate.
14    EmptyScore,
15    /// A part has no staves.
16    PartWithoutStaves { part: usize },
17    /// A staff has no measures.
18    StaffWithoutMeasures { part: usize, staff: usize },
19    /// Staves in one part do not cover the same number of measures.
20    MeasureCountMismatch {
21        part: usize,
22        staff: usize,
23        expected: usize,
24        found: usize,
25    },
26    /// A time signature has an unsupported numerator or denominator.
27    InvalidTimeSignature {
28        part: usize,
29        staff: usize,
30        measure: usize,
31        numerator: u8,
32        denominator: u8,
33    },
34    /// A measure repeat names 0 or more than 4 measures, or more measures than precede it.
35    InvalidMeasureRepeat {
36        part: usize,
37        staff: usize,
38        measure: usize,
39        count: u8,
40    },
41    /// A measure's authored actual length is zero or larger than the supported maximum.
42    InvalidMeasureLength {
43        part: usize,
44        staff: usize,
45        measure: usize,
46        numerator: u32,
47        denominator: u32,
48    },
49    /// A mid-bar clef change is not strictly inside its bar or not after the previous one.
50    InvalidMidMeasureClef {
51        part: usize,
52        staff: usize,
53        measure: usize,
54        index: usize,
55    },
56    /// A note's additional lyric verses are outside 2..=32 or not strictly ascending.
57    InvalidLyricVerse {
58        part: usize,
59        staff: usize,
60        measure: usize,
61        voice: usize,
62        note: usize,
63        verse: u8,
64    },
65    /// Beat-count mismatch: the notes in a voice don't fill the time signature.
66    BeatCount {
67        part: usize,
68        staff: usize,
69        measure: usize,
70        voice: usize,
71        expected_beats: f64,
72        found_beats: f64,
73    },
74    /// Not produced: an out-of-range pitch is [`ValidationWarning::OutOfRange`], so a part whose
75    /// instrument is misidentified stays editable. Kept so existing matches compile.
76    OutOfRange {
77        part_index: usize,
78        staff_index: usize,
79        measure_index: usize,
80        note_index: usize,
81        pitch_midi: u8,
82        instrument_range: (u8, u8),
83    },
84    /// Tablature staff metadata is internally inconsistent.
85    InvalidTablature {
86        part: usize,
87        staff: usize,
88        reason: TablatureValidationReason,
89    },
90    /// Renderer-independent staff presentation is internally inconsistent.
91    InvalidStaffPresentation {
92        part: usize,
93        staff: usize,
94        reason: StaffPresentationValidationReason,
95    },
96    /// A part's stable instrument semantics are internally inconsistent.
97    InvalidInstrumentDefinition {
98        part: usize,
99        reason: InstrumentDefinitionValidationReason,
100    },
101    /// An editable percussion-kit entry is internally inconsistent.
102    InvalidPercussionInstrument {
103        part: usize,
104        instrument: usize,
105        id: String,
106        reason: PercussionInstrumentValidationReason,
107    },
108    InvalidScoreView {
109        index: usize,
110        id: String,
111        reason: ScoreViewValidationReason,
112    },
113    /// A score-wide presentation default is outside the portable style range.
114    InvalidScoreStyleOverride {
115        property: super::score::ViewStyleProperty,
116        value: f32,
117    },
118    /// An object-attached presentation override has an invalid target, value, or provenance.
119    InvalidObjectStyleOverride {
120        index: usize,
121        reason: ObjectStyleValidationReason,
122    },
123    /// A note's explicit string is not present on its tablature staff.
124    TabPositionOutOfRange {
125        part: usize,
126        staff: usize,
127        measure: usize,
128        voice: usize,
129        note: usize,
130        string: u8,
131        lines: u8,
132    },
133    /// A pitch's microtonal cents component is outside the canonical -99..99 range.
134    MicrotoneOutOfRange {
135        part: usize,
136        staff: usize,
137        measure: usize,
138        voice: usize,
139        note: usize,
140        pitch: usize,
141        microtone_cents: i16,
142    },
143    InvalidGuitarBendCurve {
144        part: usize,
145        staff: usize,
146        measure: usize,
147        voice: usize,
148        note: usize,
149        reason: GuitarBendCurveValidationReason,
150    },
151    /// A harmony continuation points to a note address that does not exist.
152    InvalidHarmonyRange {
153        part: usize,
154        staff: usize,
155        measure: usize,
156        voice: usize,
157        note: usize,
158        end: NoteAddr,
159    },
160    /// A typed notation span must have a non-empty unique stable identity.
161    InvalidSpannerId { index: usize, id: String },
162    /// A typed notation span has a duplicate stable identity.
163    DuplicateSpannerId {
164        first: usize,
165        duplicate: usize,
166        id: String,
167    },
168    /// A typed notation span endpoint does not point to a canonical note.
169    InvalidSpannerEndpoint {
170        index: usize,
171        id: String,
172        kind: NotationSpannerKind,
173        endpoint: SpannerEndpoint,
174        address: NoteAddr,
175    },
176}
177
178/// Which endpoint of a typed notation spanner failed validation.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180pub enum SpannerEndpoint {
181    Start,
182    End,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub enum TablatureValidationReason {
187    InvalidLineCount {
188        lines: u8,
189    },
190    TooManyTunings {
191        tuning_count: usize,
192        lines: u8,
193    },
194    TuningOutOfMidiRange {
195        index: usize,
196        midi: i16,
197    },
198    ChangeWithoutBase {
199        measure: usize,
200    },
201    ChangeLineCountMismatch {
202        measure: usize,
203        base_lines: u8,
204        changed_lines: u8,
205    },
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub enum StaffPresentationValidationReason {
210    InvalidLineCount { lines: u8 },
211    InvalidLineDistance { line_distance: f32 },
212    TablatureWithoutConfig,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub enum InstrumentDefinitionValidationReason {
217    EmptyId,
218    InvalidStaffCount {
219        staff_count: u8,
220    },
221    InvalidMidiChannel {
222        midi_channel: u8,
223    },
224    InvalidRange {
225        kind: InstrumentRangeKind,
226        range: InstrumentRange,
227    },
228}
229
230#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
231pub enum InstrumentRangeKind {
232    Written,
233    Sounding,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub enum PercussionInstrumentValidationReason {
238    EmptyId,
239    DuplicateId { first: usize },
240    InvalidStaffPosition { staff_position: i8 },
241    InvalidPreferredVoice { preferred_voice: u8 },
242    InvalidTechnique { technique: String },
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub enum ScoreViewValidationReason {
247    EmptyIdOrName,
248    DuplicateId {
249        first: usize,
250    },
251    EmptyPartSelection,
252    InvalidPart {
253        part: usize,
254    },
255    DuplicatePart {
256        part: usize,
257    },
258    InvalidStaff {
259        part: usize,
260        staff: usize,
261    },
262    HiddenStaffOutsideSelection {
263        part: usize,
264        staff: usize,
265    },
266    DuplicateStaffKindOverride {
267        part: usize,
268        staff: usize,
269    },
270    StaffKindOverrideOutsideSelection {
271        part: usize,
272        staff: usize,
273    },
274    TablatureOverrideWithoutConfig {
275        part: usize,
276        staff: usize,
277    },
278    InvalidMeasuresPerRow,
279    InvalidTypedStyleOverride {
280        property: super::score::ViewStyleProperty,
281        value: f32,
282    },
283    BreakOutOfRange {
284        measure: usize,
285    },
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub enum ObjectStyleValidationReason {
290    InvalidValue {
291        property: super::score::ViewStyleProperty,
292        value: f32,
293    },
294    MissingTarget,
295    InvalidProvenance,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub enum GuitarBendCurveValidationReason {
300    TooManyPoints { count: usize },
301    RequiresBendTechnique,
302    RequiresStartAtZero,
303    RequiresEndAtFullDuration,
304    PositionsNotStrictlyIncreasing,
305}
306
307/// A non-fatal advisory warning found by [`validate`].
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub enum ValidationWarning {
310    /// A measure's beat count is less than the time signature (incomplete bar).
311    IncompleteBar {
312        part: usize,
313        staff: usize,
314        measure: usize,
315        expected_beats: f64,
316        actual_beats: f64,
317    },
318    /// Two volta brackets in the same staff overlap or share the same number.
319    OverlappingVolta { part: usize, staff: usize },
320    /// A part has no notes across all measures.
321    EmptyPart { part: usize },
322    /// A measure repeat's stored copy no longer sounds like the measure it repeats, for example
323    /// after the original was edited.
324    MeasureRepeatContentDiffers {
325        part: usize,
326        staff: usize,
327        measure: usize,
328        source: usize,
329    },
330    /// The same rehearsal mark text appears more than once.
331    DuplicateRehearsalMark { mark: String },
332    /// A note pitch lies outside the practical range for the part's GM instrument. Advisory, as
333    /// in MuseScore: the range comes from the part's MIDI program, which may not name the real
334    /// instrument.
335    OutOfRange {
336        part_index: usize,
337        staff_index: usize,
338        measure_index: usize,
339        note_index: usize,
340        pitch_midi: u8,
341        instrument_range: (u8, u8),
342    },
343}
344
345/// Combined result of [`validate`]: errors that indicate broken structure, plus advisory warnings.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ValidationReport {
348    pub errors: Vec<ValidationError>,
349    pub warnings: Vec<ValidationWarning>,
350}
351
352impl ValidationReport {
353    /// `true` when there are no errors (warnings may still be present).
354    pub fn is_valid(&self) -> bool {
355        self.errors.is_empty()
356    }
357}
358
359/// Check every voice in every measure for structural correctness.
360///
361/// Checks performed:
362/// - **Errors**: beat-count mismatch and other broken structure.
363/// - **Warnings**: out-of-range pitch, incomplete bar (underfull voice), overlapping volta
364///   brackets, empty parts, duplicate rehearsal marks.
365///
366/// Multi-rest placeholder measures and empty voices are skipped.
367/// Percussion parts (MIDI channel 9) are exempt from pitch-range checks.
368pub fn validate(score: &Score) -> ValidationReport {
369    let mut errors = Vec::new();
370    let mut warnings = Vec::new();
371
372    let mut rehearsal_counts: HashMap<String, usize> = HashMap::new();
373
374    if score.parts.is_empty() {
375        errors.push(ValidationError::EmptyScore);
376    }
377
378    for override_ in &score.style_overrides {
379        if !valid_typed_style_value(override_.value) {
380            errors.push(ValidationError::InvalidScoreStyleOverride {
381                property: override_.property,
382                value: override_.value,
383            });
384        }
385    }
386    for (index, override_) in score.object_style_overrides.iter().enumerate() {
387        let reason = if !valid_typed_style_value(override_.value) {
388            Some(ObjectStyleValidationReason::InvalidValue {
389                property: override_.property,
390                value: override_.value,
391            })
392        } else if !object_style_target_exists(score, &override_.target) {
393            Some(ObjectStyleValidationReason::MissingTarget)
394        } else if override_.provenance.as_ref().is_some_and(|provenance| {
395            provenance.format.trim().is_empty()
396                || provenance.format.len() > 64
397                || provenance.source_location.trim().is_empty()
398                || provenance.source_location.len() > 2048
399        }) {
400            Some(ObjectStyleValidationReason::InvalidProvenance)
401        } else {
402            None
403        };
404        if let Some(reason) = reason {
405            errors.push(ValidationError::InvalidObjectStyleOverride { index, reason });
406        }
407    }
408
409    let mut view_ids: HashMap<String, usize> = HashMap::new();
410    for (index, view) in score.views.iter().enumerate() {
411        validate_score_view(index, view, score, &mut view_ids, &mut errors);
412    }
413
414    let mut spanner_ids: HashMap<&str, usize> = HashMap::new();
415    for (index, spanner) in score.spanners.iter().enumerate() {
416        if spanner.id.trim().is_empty() {
417            errors.push(ValidationError::InvalidSpannerId {
418                index,
419                id: spanner.id.clone(),
420            });
421        } else if let Some(first) = spanner_ids.insert(spanner.id.as_str(), index) {
422            errors.push(ValidationError::DuplicateSpannerId {
423                first,
424                duplicate: index,
425                id: spanner.id.clone(),
426            });
427        }
428        for (endpoint, address) in [
429            (SpannerEndpoint::Start, &spanner.start),
430            (SpannerEndpoint::End, &spanner.end),
431        ] {
432            if !note_exists(score, address) {
433                errors.push(ValidationError::InvalidSpannerEndpoint {
434                    index,
435                    id: spanner.id.clone(),
436                    kind: spanner.kind.clone(),
437                    endpoint,
438                    address: address.clone(),
439                });
440            }
441        }
442    }
443
444    for (pi, part) in score.parts.iter().enumerate() {
445        let range = instrument_range(part.midi_program);
446        let is_percussion = part.midi_channel == 9;
447        let mut part_has_notes = false;
448
449        if part.staves.is_empty() {
450            errors.push(ValidationError::PartWithoutStaves { part: pi });
451            continue;
452        }
453
454        if let Some(definition) = &part.instrument {
455            validate_instrument_definition(pi, definition, &mut errors);
456        }
457        validate_percussion_kit(pi, &part.percussion_instruments, &mut errors);
458
459        let expected_measure_count = part.staves[0].measures.len();
460
461        for (si, staff) in part.staves.iter().enumerate() {
462            if staff.measures.is_empty() {
463                errors.push(ValidationError::StaffWithoutMeasures {
464                    part: pi,
465                    staff: si,
466                });
467                continue;
468            }
469            if staff.measures.len() != expected_measure_count {
470                errors.push(ValidationError::MeasureCountMismatch {
471                    part: pi,
472                    staff: si,
473                    expected: expected_measure_count,
474                    found: staff.measures.len(),
475                });
476            }
477
478            if !(1..=64).contains(&staff.presentation.lines) {
479                errors.push(ValidationError::InvalidStaffPresentation {
480                    part: pi,
481                    staff: si,
482                    reason: StaffPresentationValidationReason::InvalidLineCount {
483                        lines: staff.presentation.lines,
484                    },
485                });
486            }
487            if !staff.presentation.line_distance.is_finite()
488                || !(0.1..=16.0).contains(&staff.presentation.line_distance)
489            {
490                errors.push(ValidationError::InvalidStaffPresentation {
491                    part: pi,
492                    staff: si,
493                    reason: StaffPresentationValidationReason::InvalidLineDistance {
494                        line_distance: staff.presentation.line_distance,
495                    },
496                });
497            }
498            if staff.presentation.kind == StaffKind::Tablature && staff.tablature.is_none() {
499                errors.push(ValidationError::InvalidStaffPresentation {
500                    part: pi,
501                    staff: si,
502                    reason: StaffPresentationValidationReason::TablatureWithoutConfig,
503                });
504            }
505
506            if let Some(tab) = &staff.tablature {
507                if !(1..=64).contains(&tab.lines) {
508                    errors.push(ValidationError::InvalidTablature {
509                        part: pi,
510                        staff: si,
511                        reason: TablatureValidationReason::InvalidLineCount { lines: tab.lines },
512                    });
513                } else if tab.tuning_midi.len() > usize::from(tab.lines) {
514                    errors.push(ValidationError::InvalidTablature {
515                        part: pi,
516                        staff: si,
517                        reason: TablatureValidationReason::TooManyTunings {
518                            tuning_count: tab.tuning_midi.len(),
519                            lines: tab.lines,
520                        },
521                    });
522                }
523                for (index, &midi) in tab.tuning_midi.iter().enumerate() {
524                    if !(0..=127).contains(&midi) {
525                        errors.push(ValidationError::InvalidTablature {
526                            part: pi,
527                            staff: si,
528                            reason: TablatureValidationReason::TuningOutOfMidiRange { index, midi },
529                        });
530                    }
531                }
532            }
533
534            let mut current_ts = score.settings.time_signature.clone();
535            let mut volta_numbers_seen: Vec<u8> = Vec::new();
536
537            for (mi, measure) in staff.measures.iter().enumerate() {
538                if let Some(change) = &measure.tablature_change {
539                    match &staff.tablature {
540                        None => errors.push(ValidationError::InvalidTablature {
541                            part: pi,
542                            staff: si,
543                            reason: TablatureValidationReason::ChangeWithoutBase { measure: mi },
544                        }),
545                        Some(base) if base.lines != change.lines => {
546                            errors.push(ValidationError::InvalidTablature {
547                                part: pi,
548                                staff: si,
549                                reason: TablatureValidationReason::ChangeLineCountMismatch {
550                                    measure: mi,
551                                    base_lines: base.lines,
552                                    changed_lines: change.lines,
553                                },
554                            });
555                        }
556                        Some(_) => {}
557                    }
558                    if !(1..=64).contains(&change.lines) {
559                        errors.push(ValidationError::InvalidTablature {
560                            part: pi,
561                            staff: si,
562                            reason: TablatureValidationReason::InvalidLineCount {
563                                lines: change.lines,
564                            },
565                        });
566                    } else if change.tuning_midi.len() > usize::from(change.lines) {
567                        errors.push(ValidationError::InvalidTablature {
568                            part: pi,
569                            staff: si,
570                            reason: TablatureValidationReason::TooManyTunings {
571                                tuning_count: change.tuning_midi.len(),
572                                lines: change.lines,
573                            },
574                        });
575                    }
576                    for (index, &midi) in change.tuning_midi.iter().enumerate() {
577                        if !(0..=127).contains(&midi) {
578                            errors.push(ValidationError::InvalidTablature {
579                                part: pi,
580                                staff: si,
581                                reason: TablatureValidationReason::TuningOutOfMidiRange {
582                                    index,
583                                    midi,
584                                },
585                            });
586                        }
587                    }
588                }
589                if let Some(ts) = &measure.time_sig {
590                    current_ts = ts.clone();
591                }
592                if !valid_time_signature(&current_ts) {
593                    errors.push(ValidationError::InvalidTimeSignature {
594                        part: pi,
595                        staff: si,
596                        measure: mi,
597                        numerator: current_ts.numerator,
598                        denominator: current_ts.denominator,
599                    });
600                    continue;
601                }
602                if measure.multi_rest_count.is_some() {
603                    continue;
604                }
605
606                // Rehearsal mark deduplication
607                if let Some(ref mark) = measure.rehearsal {
608                    let entry = rehearsal_counts.entry(mark.clone()).or_insert(0);
609                    *entry += 1;
610                }
611
612                // Volta overlap detection
613                if let Some(ref volta) = measure.volta {
614                    if volta_numbers_seen.contains(&volta.number) {
615                        warnings.push(ValidationWarning::OverlappingVolta {
616                            part: pi,
617                            staff: si,
618                        });
619                    } else {
620                        volta_numbers_seen.push(volta.number);
621                    }
622                }
623
624                if let Some(count) = measure.measure_repeat {
625                    let source = mi.checked_sub(usize::from(count));
626                    match source.filter(|_| (1..=4).contains(&count)) {
627                        None => errors.push(ValidationError::InvalidMeasureRepeat {
628                            part: pi,
629                            staff: si,
630                            measure: mi,
631                            count,
632                        }),
633                        Some(source) => {
634                            if !measure.same_sounding_content(&staff.measures[source]) {
635                                warnings.push(ValidationWarning::MeasureRepeatContentDiffers {
636                                    part: pi,
637                                    staff: si,
638                                    measure: mi,
639                                    source,
640                                });
641                            }
642                        }
643                    }
644                }
645                if let Some(length) = measure.actual_length
646                    && length.beats().is_none()
647                {
648                    errors.push(ValidationError::InvalidMeasureLength {
649                        part: pi,
650                        staff: si,
651                        measure: mi,
652                        numerator: length.numerator,
653                        denominator: length.denominator,
654                    });
655                }
656                let expected = measure
657                    .actual_length
658                    .and_then(|length| length.beats())
659                    .unwrap_or_else(|| current_ts.total_beats());
660                let mut previous_clef_offset = 0.0;
661                for (index, change) in measure.mid_clefs.iter().enumerate() {
662                    match change.offset.beats() {
663                        Some(offset)
664                            if offset > previous_clef_offset + 1e-9 && offset < expected - 1e-9 =>
665                        {
666                            previous_clef_offset = offset;
667                        }
668                        _ => errors.push(ValidationError::InvalidMidMeasureClef {
669                            part: pi,
670                            staff: si,
671                            measure: mi,
672                            index,
673                        }),
674                    }
675                }
676                for (vi, voice) in measure.voices.iter().enumerate() {
677                    if voice.is_empty() {
678                        continue;
679                    }
680                    let non_rest_count: usize = voice.iter().filter(|n| !n.is_rest).count();
681                    if non_rest_count > 0 {
682                        part_has_notes = true;
683                    }
684                    let total = crate::voice_duration_beats(voice, expected);
685                    if total > expected + 0.02 {
686                        errors.push(ValidationError::BeatCount {
687                            part: pi,
688                            staff: si,
689                            measure: mi,
690                            voice: vi,
691                            expected_beats: expected,
692                            found_beats: total,
693                        });
694                    } else if total < expected - 0.02 && non_rest_count > 0 {
695                        warnings.push(ValidationWarning::IncompleteBar {
696                            part: pi,
697                            staff: si,
698                            measure: mi,
699                            expected_beats: expected,
700                            actual_beats: total,
701                        });
702                    }
703
704                    for (ni, note) in voice.iter().enumerate() {
705                        let mut previous_verse = 1u8;
706                        for entry in &note.additional_lyrics {
707                            if entry.verse <= previous_verse
708                                || entry.verse > crate::VerseLyric::MAX_VERSE
709                            {
710                                errors.push(ValidationError::InvalidLyricVerse {
711                                    part: pi,
712                                    staff: si,
713                                    measure: mi,
714                                    voice: vi,
715                                    note: ni,
716                                    verse: entry.verse,
717                                });
718                            }
719                            previous_verse = previous_verse.max(entry.verse);
720                        }
721                        if note.is_rest || note.is_grace {
722                            continue;
723                        }
724                        if let Some(chord) = &note.chord_symbol
725                            && let Some(end) = &chord.range_end
726                            && !note_exists(score, end)
727                        {
728                            errors.push(ValidationError::InvalidHarmonyRange {
729                                part: pi,
730                                staff: si,
731                                measure: mi,
732                                voice: vi,
733                                note: ni,
734                                end: end.clone(),
735                            });
736                        }
737                        for (pitch_index, pitch) in note.pitches.iter().enumerate() {
738                            if !(-99..=99).contains(&pitch.microtone_cents) {
739                                errors.push(ValidationError::MicrotoneOutOfRange {
740                                    part: pi,
741                                    staff: si,
742                                    measure: mi,
743                                    voice: vi,
744                                    note: ni,
745                                    pitch: pitch_index,
746                                    microtone_cents: pitch.microtone_cents,
747                                });
748                            }
749                        }
750                        if !note.guitar_bend_curve.is_empty() {
751                            let reason = if note.guitar_bend_curve.len() > 32 {
752                                Some(GuitarBendCurveValidationReason::TooManyPoints {
753                                    count: note.guitar_bend_curve.len(),
754                                })
755                            } else if note.guitar_technique != Some(GuitarTechnique::Bend) {
756                                Some(GuitarBendCurveValidationReason::RequiresBendTechnique)
757                            } else if note
758                                .guitar_bend_curve
759                                .first()
760                                .map(|point| point.position_per_mille)
761                                != Some(0)
762                            {
763                                Some(GuitarBendCurveValidationReason::RequiresStartAtZero)
764                            } else if note
765                                .guitar_bend_curve
766                                .last()
767                                .map(|point| point.position_per_mille)
768                                != Some(1000)
769                            {
770                                Some(GuitarBendCurveValidationReason::RequiresEndAtFullDuration)
771                            } else if note.guitar_bend_curve.windows(2).any(|points| {
772                                points[0].position_per_mille >= points[1].position_per_mille
773                            }) {
774                                Some(
775                                    GuitarBendCurveValidationReason::PositionsNotStrictlyIncreasing,
776                                )
777                            } else {
778                                None
779                            };
780                            if let Some(reason) = reason {
781                                errors.push(ValidationError::InvalidGuitarBendCurve {
782                                    part: pi,
783                                    staff: si,
784                                    measure: mi,
785                                    voice: vi,
786                                    note: ni,
787                                    reason,
788                                });
789                            }
790                        }
791                    }
792
793                    if !is_percussion {
794                        let transpose = staff.transpose_semitones;
795                        for (ni, note) in voice.iter().enumerate() {
796                            if note.is_rest || note.is_grace {
797                                continue;
798                            }
799                            if let Some(tab) = &staff.tablature {
800                                let positions =
801                                    note.tab_position.iter().chain(note.tab_positions.iter());
802                                for position in positions {
803                                    if position.string == 0 || position.string > tab.lines {
804                                        errors.push(ValidationError::TabPositionOutOfRange {
805                                            part: pi,
806                                            staff: si,
807                                            measure: mi,
808                                            voice: vi,
809                                            note: ni,
810                                            string: position.string,
811                                            lines: tab.lines,
812                                        });
813                                    }
814                                }
815                            }
816                            for pitch in &note.pitches {
817                                let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
818                                if midi < range.0 || midi > range.1 {
819                                    warnings.push(ValidationWarning::OutOfRange {
820                                        part_index: pi,
821                                        staff_index: si,
822                                        measure_index: mi,
823                                        note_index: ni,
824                                        pitch_midi: midi,
825                                        instrument_range: range,
826                                    });
827                                }
828                            }
829                        }
830                    }
831                }
832            }
833        }
834
835        if !part_has_notes {
836            warnings.push(ValidationWarning::EmptyPart { part: pi });
837        }
838    }
839
840    for (mark, count) in &rehearsal_counts {
841        if *count > 1 {
842            warnings.push(ValidationWarning::DuplicateRehearsalMark { mark: mark.clone() });
843        }
844    }
845
846    ValidationReport { errors, warnings }
847}
848
849fn validate_instrument_definition(
850    part: usize,
851    definition: &InstrumentDefinition,
852    errors: &mut Vec<ValidationError>,
853) {
854    if definition.id.trim().is_empty() {
855        errors.push(ValidationError::InvalidInstrumentDefinition {
856            part,
857            reason: InstrumentDefinitionValidationReason::EmptyId,
858        });
859    }
860    if !(1..=64).contains(&definition.staff_count) {
861        errors.push(ValidationError::InvalidInstrumentDefinition {
862            part,
863            reason: InstrumentDefinitionValidationReason::InvalidStaffCount {
864                staff_count: definition.staff_count,
865            },
866        });
867    }
868    if definition.midi_channel > 15 {
869        errors.push(ValidationError::InvalidInstrumentDefinition {
870            part,
871            reason: InstrumentDefinitionValidationReason::InvalidMidiChannel {
872                midi_channel: definition.midi_channel,
873            },
874        });
875    }
876    for (kind, range) in [
877        (InstrumentRangeKind::Written, definition.written_range),
878        (InstrumentRangeKind::Sounding, definition.sounding_range),
879    ] {
880        if let Some(range) = range
881            && range.lowest > range.highest
882        {
883            errors.push(ValidationError::InvalidInstrumentDefinition {
884                part,
885                reason: InstrumentDefinitionValidationReason::InvalidRange { kind, range },
886            });
887        }
888    }
889}
890
891fn validate_percussion_kit(
892    part: usize,
893    instruments: &[PercussionInstrument],
894    errors: &mut Vec<ValidationError>,
895) {
896    let mut ids: HashMap<&str, usize> = HashMap::new();
897    for (instrument_index, instrument) in instruments.iter().enumerate() {
898        let invalid = |reason| ValidationError::InvalidPercussionInstrument {
899            part,
900            instrument: instrument_index,
901            id: instrument.id.clone(),
902            reason,
903        };
904        if instrument.id.trim().is_empty() {
905            errors.push(invalid(PercussionInstrumentValidationReason::EmptyId));
906        } else if let Some(first) = ids.insert(instrument.id.as_str(), instrument_index) {
907            errors.push(invalid(PercussionInstrumentValidationReason::DuplicateId {
908                first,
909            }));
910        }
911        if let Some(staff_position) = instrument.staff_position
912            && !(-32..=32).contains(&staff_position)
913        {
914            errors.push(invalid(
915                PercussionInstrumentValidationReason::InvalidStaffPosition { staff_position },
916            ));
917        }
918        if let Some(preferred_voice) = instrument.preferred_voice
919            && !(1..=4).contains(&preferred_voice)
920        {
921            errors.push(invalid(
922                PercussionInstrumentValidationReason::InvalidPreferredVoice { preferred_voice },
923            ));
924        }
925        for technique in &instrument.techniques {
926            if technique.trim().is_empty() || technique.len() > 128 {
927                errors.push(invalid(
928                    PercussionInstrumentValidationReason::InvalidTechnique {
929                        technique: technique.clone(),
930                    },
931                ));
932            }
933        }
934    }
935}
936
937fn validate_score_view(
938    index: usize,
939    view: &ScoreView,
940    score: &Score,
941    ids: &mut HashMap<String, usize>,
942    errors: &mut Vec<ValidationError>,
943) {
944    let invalid = |reason| ValidationError::InvalidScoreView {
945        index,
946        id: view.id.clone(),
947        reason,
948    };
949    if view.id.trim().is_empty() || view.name.trim().is_empty() {
950        errors.push(invalid(ScoreViewValidationReason::EmptyIdOrName));
951    } else if let Some(first) = ids.insert(view.id.clone(), index) {
952        errors.push(invalid(ScoreViewValidationReason::DuplicateId { first }));
953    }
954    if view.parts.is_empty() {
955        errors.push(invalid(ScoreViewValidationReason::EmptyPartSelection));
956        return;
957    }
958    let mut selected = vec![false; score.parts.len()];
959    for &part_index in &view.parts {
960        if part_index >= score.parts.len() {
961            errors.push(invalid(ScoreViewValidationReason::InvalidPart {
962                part: part_index,
963            }));
964        } else if std::mem::replace(&mut selected[part_index], true) {
965            errors.push(invalid(ScoreViewValidationReason::DuplicatePart {
966                part: part_index,
967            }));
968        }
969    }
970    if view.layout.measures_per_row.is_some_and(|value| value == 0) {
971        errors.push(invalid(ScoreViewValidationReason::InvalidMeasuresPerRow));
972    }
973    for override_ in &view.layout.typed_style_overrides {
974        if !valid_typed_style_value(override_.value) {
975            errors.push(invalid(
976                ScoreViewValidationReason::InvalidTypedStyleOverride {
977                    property: override_.property,
978                    value: override_.value,
979                },
980            ));
981        }
982    }
983    for reference in &view.layout.hidden_staves {
984        let Some(part) = score.parts.get(reference.part) else {
985            errors.push(invalid(ScoreViewValidationReason::InvalidPart {
986                part: reference.part,
987            }));
988            continue;
989        };
990        if reference.staff >= part.staves.len() {
991            errors.push(invalid(ScoreViewValidationReason::InvalidStaff {
992                part: reference.part,
993                staff: reference.staff,
994            }));
995        } else if !selected[reference.part] {
996            errors.push(invalid(
997                ScoreViewValidationReason::HiddenStaffOutsideSelection {
998                    part: reference.part,
999                    staff: reference.staff,
1000                },
1001            ));
1002        }
1003    }
1004    let mut overridden = HashMap::new();
1005    for override_ in &view.staff_kind_overrides {
1006        let reference = override_.staff;
1007        let Some(part) = score.parts.get(reference.part) else {
1008            errors.push(invalid(ScoreViewValidationReason::InvalidPart {
1009                part: reference.part,
1010            }));
1011            continue;
1012        };
1013        if reference.staff >= part.staves.len() {
1014            errors.push(invalid(ScoreViewValidationReason::InvalidStaff {
1015                part: reference.part,
1016                staff: reference.staff,
1017            }));
1018        } else if !selected[reference.part] {
1019            errors.push(invalid(
1020                ScoreViewValidationReason::StaffKindOverrideOutsideSelection {
1021                    part: reference.part,
1022                    staff: reference.staff,
1023                },
1024            ));
1025        } else if overridden
1026            .insert((reference.part, reference.staff), ())
1027            .is_some()
1028        {
1029            errors.push(invalid(
1030                ScoreViewValidationReason::DuplicateStaffKindOverride {
1031                    part: reference.part,
1032                    staff: reference.staff,
1033                },
1034            ));
1035        } else if override_.kind == StaffKind::Tablature
1036            && part.staves[reference.staff].tablature.is_none()
1037        {
1038            errors.push(invalid(
1039                ScoreViewValidationReason::TablatureOverrideWithoutConfig {
1040                    part: reference.part,
1041                    staff: reference.staff,
1042                },
1043            ));
1044        }
1045    }
1046    let measure_count = score.measure_count();
1047    for &measure in view
1048        .layout
1049        .system_breaks
1050        .iter()
1051        .chain(view.layout.page_breaks.iter())
1052    {
1053        if measure >= measure_count {
1054            errors.push(invalid(ScoreViewValidationReason::BreakOutOfRange {
1055                measure,
1056            }));
1057        }
1058    }
1059}
1060
1061fn valid_typed_style_value(value: f32) -> bool {
1062    value.is_finite() && (0.05..=64.0).contains(&value)
1063}
1064
1065fn object_style_target_exists(score: &Score, target: &super::score::ObjectStyleTarget) -> bool {
1066    use super::score::ObjectStyleTarget;
1067    match target {
1068        ObjectStyleTarget::ScoreText { text_index } => *text_index < score.texts.len(),
1069        ObjectStyleTarget::MeasureText {
1070            part,
1071            staff,
1072            measure,
1073            text_index,
1074        } => score
1075            .parts
1076            .get(*part)
1077            .and_then(|part| part.staves.get(*staff))
1078            .and_then(|staff| staff.measures.get(*measure))
1079            .is_some_and(|measure| *text_index < measure.texts.len()),
1080        ObjectStyleTarget::Note { address } => score
1081            .parts
1082            .get(address.part)
1083            .and_then(|part| part.staves.get(address.staff))
1084            .and_then(|staff| staff.measures.get(address.measure))
1085            .and_then(|measure| measure.voices.get(address.voice))
1086            .is_some_and(|voice| address.note < voice.len()),
1087    }
1088}
1089
1090fn valid_time_signature(time: &super::notation::TimeSignature) -> bool {
1091    time.numerator > 0 && matches!(time.denominator, 1 | 2 | 4 | 8 | 16 | 32 | 64)
1092}
1093
1094fn note_exists(score: &Score, address: &NoteAddr) -> bool {
1095    score
1096        .parts
1097        .get(address.part)
1098        .and_then(|part| part.staves.get(address.staff))
1099        .and_then(|staff| staff.measures.get(address.measure))
1100        .and_then(|measure| measure.voices.get(address.voice))
1101        .and_then(|voice| voice.get(address.note))
1102        .is_some()
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107    use super::*;
1108    use crate::model::{
1109        duration::Duration,
1110        notation::ChordSymbol,
1111        pitch::{Pitch, Step},
1112        score::{Note, NoteAddr, Score},
1113    };
1114
1115    #[test]
1116    fn validate_clean_score_returns_empty_errors() {
1117        let score = Score::new("T", 120, 4, 4, 0, 1);
1118        assert!(validate(&score).errors.is_empty());
1119    }
1120
1121    #[test]
1122    fn validate_empty_score_returns_structural_error() {
1123        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1124        score.parts.clear();
1125        let report = validate(&score);
1126        assert!(
1127            report
1128                .errors
1129                .iter()
1130                .any(|error| matches!(error, ValidationError::EmptyScore))
1131        );
1132    }
1133
1134    #[test]
1135    fn validate_detects_missing_staves_and_measures() {
1136        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1137        score.parts[0].staves.clear();
1138        let report = validate(&score);
1139        assert!(
1140            report
1141                .errors
1142                .iter()
1143                .any(|error| matches!(error, ValidationError::PartWithoutStaves { part: 0 }))
1144        );
1145
1146        score.parts[0].staves.push(crate::model::score::Staff::new(
1147            crate::model::notation::Clef::Treble,
1148        ));
1149        let report = validate(&score);
1150        assert!(report.errors.iter().any(|error| matches!(
1151            error,
1152            ValidationError::StaffWithoutMeasures { part: 0, staff: 0 }
1153        )));
1154    }
1155
1156    #[test]
1157    fn validate_detects_staff_measure_count_mismatch() {
1158        let mut score = Score::template(crate::model::score::ScoreTemplate::Piano);
1159        score.parts[0].staves[1].measures.pop();
1160        let report = validate(&score);
1161        assert!(report.errors.iter().any(|error| matches!(
1162            error,
1163            ValidationError::MeasureCountMismatch {
1164                part: 0,
1165                staff: 1,
1166                expected: 4,
1167                found: 3
1168            }
1169        )));
1170    }
1171
1172    #[test]
1173    fn validate_detects_invalid_time_signature() {
1174        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1175        score.parts[0].staves[0].measures[0].time_sig =
1176            Some(crate::model::notation::TimeSignature {
1177                numerator: 0,
1178                denominator: 3,
1179            });
1180        let report = validate(&score);
1181        assert!(report.errors.iter().any(|error| matches!(
1182            error,
1183            ValidationError::InvalidTimeSignature {
1184                part: 0,
1185                staff: 0,
1186                measure: 0,
1187                numerator: 0,
1188                denominator: 3
1189            }
1190        )));
1191    }
1192
1193    #[test]
1194    fn validate_overfull_measure_returns_error() {
1195        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1196        score.parts[0].staves[0].measures[0].voices[0]
1197            .push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
1198        let report = validate(&score);
1199        assert!(!report.errors.is_empty());
1200        assert!(matches!(
1201            report.errors[0],
1202            ValidationError::BeatCount {
1203                measure: 0,
1204                voice: 0,
1205                ..
1206            }
1207        ));
1208    }
1209
1210    #[test]
1211    fn validate_skips_multi_rest() {
1212        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1213        score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
1214        score.parts[0].staves[0].measures[0].voices[0].clear();
1215        assert!(validate(&score).errors.is_empty());
1216    }
1217
1218    #[test]
1219    fn validate_rejects_tablature_view_override_without_tablature_configuration() {
1220        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1221        score
1222            .views
1223            .push(ScoreView::linked_tablature_staff("tab", "Tab", 0, 0));
1224
1225        assert!(validate(&score).errors.iter().any(|error| matches!(
1226            error,
1227            ValidationError::InvalidScoreView {
1228                reason: ScoreViewValidationReason::TablatureOverrideWithoutConfig {
1229                    part: 0,
1230                    staff: 0,
1231                },
1232                ..
1233            }
1234        )));
1235    }
1236
1237    #[test]
1238    fn validate_rejects_non_finite_typed_view_style_override() {
1239        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1240        let mut view = ScoreView::linked_part("part", "Part", 0);
1241        view.layout
1242            .typed_style_overrides
1243            .push(super::super::score::ViewStyleOverride {
1244                property: super::super::score::ViewStyleProperty::TextScale,
1245                value: f32::NAN,
1246            });
1247        score.views.push(view);
1248        assert!(validate(&score).errors.iter().any(|error| matches!(
1249            error,
1250            ValidationError::InvalidScoreView {
1251                reason: ScoreViewValidationReason::InvalidTypedStyleOverride { .. },
1252                ..
1253            }
1254        )));
1255    }
1256
1257    #[test]
1258    fn validate_rejects_out_of_range_score_style_override() {
1259        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1260        score
1261            .style_overrides
1262            .push(super::super::score::ViewStyleOverride {
1263                property: super::super::score::ViewStyleProperty::StaffSpace,
1264                value: 0.01,
1265            });
1266
1267        assert!(validate(&score).errors.iter().any(|error| matches!(
1268            error,
1269            ValidationError::InvalidScoreStyleOverride {
1270                property: super::super::score::ViewStyleProperty::StaffSpace,
1271                value,
1272            } if (*value - 0.01).abs() < f32::EPSILON
1273        )));
1274    }
1275
1276    #[test]
1277    fn validate_rejects_object_style_override_with_missing_target() {
1278        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1279        score
1280            .object_style_overrides
1281            .push(super::super::score::ObjectStyleOverride {
1282                target: super::super::score::ObjectStyleTarget::ScoreText { text_index: 0 },
1283                property: super::super::score::ViewStyleProperty::TextScale,
1284                value: 1.1,
1285                provenance: None,
1286            });
1287        assert!(validate(&score).errors.iter().any(|error| matches!(
1288            error,
1289            ValidationError::InvalidObjectStyleOverride {
1290                reason: ObjectStyleValidationReason::MissingTarget,
1291                ..
1292            }
1293        )));
1294    }
1295
1296    #[test]
1297    fn validate_rejects_non_normalized_guitar_bend_curve() {
1298        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1299        score.parts[0].staves[0].measures[0].voices[0] =
1300            vec![Note::new(Pitch::new(Step::E, 4), Duration::Whole)];
1301        let note = &mut score.parts[0].staves[0].measures[0].voices[0][0];
1302        note.guitar_technique = Some(GuitarTechnique::Bend);
1303        note.guitar_bend_curve = vec![
1304            crate::GuitarBendPoint {
1305                position_per_mille: 100,
1306                alter_cents: 0,
1307            },
1308            crate::GuitarBendPoint {
1309                position_per_mille: 1000,
1310                alter_cents: 200,
1311            },
1312        ];
1313        assert!(validate(&score).errors.iter().any(|error| matches!(
1314            error,
1315            ValidationError::InvalidGuitarBendCurve {
1316                reason: GuitarBendCurveValidationReason::RequiresStartAtZero,
1317                ..
1318            }
1319        )));
1320    }
1321
1322    #[test]
1323    fn validate_rejects_harmony_range_to_missing_note() {
1324        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1325        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
1326        note.chord_symbol = Some(ChordSymbol {
1327            root: "C".to_owned(),
1328            kind: "major".to_owned(),
1329            bass: None,
1330            placement: None,
1331            extender: true,
1332            harmonic_degree: None,
1333            harmony_function: None,
1334            harmony_type: None,
1335            chord_ref: None,
1336            range_end: Some(NoteAddr {
1337                part: 0,
1338                staff: 0,
1339                measure: 0,
1340                voice: 0,
1341                note: 9,
1342            }),
1343            degrees: Vec::new(),
1344        });
1345        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1346        assert!(validate(&score).errors.iter().any(|error| matches!(
1347            error,
1348            ValidationError::InvalidHarmonyRange { note: 0, end, .. }
1349                if end.note == 9
1350        )));
1351    }
1352
1353    #[test]
1354    fn validate_out_of_range_pitch_detected() {
1355        // Piano (program 0): range 21–108. C9 (midi=120) is out of range.
1356        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1357        score.parts[0].midi_program = 0;
1358        score.parts[0].staves[0].measures[0].voices[0] =
1359            vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
1360        let report = validate(&score);
1361        assert!(
1362            report.is_valid(),
1363            "an out-of-range pitch does not block edits"
1364        );
1365        assert!(report.warnings.iter().any(
1366            |w| matches!(w, ValidationWarning::OutOfRange { pitch_midi, .. } if *pitch_midi == 120)
1367        ));
1368    }
1369
1370    #[test]
1371    fn validate_percussion_channel_skips_range_check() {
1372        // Channel 9 = percussion; even extreme pitches should not trigger OutOfRange.
1373        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1374        score.parts[0].midi_channel = 9;
1375        score.parts[0].midi_program = 0;
1376        score.parts[0].staves[0].measures[0].voices[0] =
1377            vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
1378        let report = validate(&score);
1379        assert!(
1380            !report
1381                .warnings
1382                .iter()
1383                .any(|w| matches!(w, ValidationWarning::OutOfRange { .. }))
1384        );
1385    }
1386
1387    #[test]
1388    fn validate_in_range_pitch_ok() {
1389        // Piano C4 (midi=60) is in range 21–108.
1390        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1391        score.parts[0].midi_program = 0;
1392        score.parts[0].staves[0].measures[0].voices[0] =
1393            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1394        assert!(
1395            !validate(&score)
1396                .warnings
1397                .iter()
1398                .any(|w| matches!(w, ValidationWarning::OutOfRange { .. }))
1399        );
1400    }
1401
1402    #[test]
1403    fn validate_rejects_deserialized_microtone_out_of_range() {
1404        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1405        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
1406        note.pitches[0].microtone_cents = 100;
1407        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1408        let report = validate(&score);
1409        assert!(report.errors.iter().any(|error| matches!(
1410            error,
1411            ValidationError::MicrotoneOutOfRange {
1412                microtone_cents: 100,
1413                ..
1414            }
1415        )));
1416    }
1417
1418    #[test]
1419    fn validate_rejects_invalid_and_duplicate_typed_spanners() {
1420        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1421        score.parts[0].staves[0].measures[0].voices[0] =
1422            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1423        let address = NoteAddr {
1424            part: 0,
1425            staff: 0,
1426            measure: 0,
1427            voice: 0,
1428            note: 0,
1429        };
1430        score.spanners = vec![
1431            super::super::score::NotationSpanner {
1432                id: String::new(),
1433                kind: NotationSpannerKind::Slur,
1434                start: address.clone(),
1435                end: address.clone(),
1436                number: Some(1),
1437                line_type: None,
1438                text: None,
1439                placement: None,
1440                ottava_size: None,
1441                ottava_type: None,
1442            },
1443            super::super::score::NotationSpanner {
1444                id: "duplicate".to_string(),
1445                kind: NotationSpannerKind::Pedal,
1446                start: address.clone(),
1447                end: NoteAddr { note: 9, ..address },
1448                number: Some(2),
1449                line_type: None,
1450                text: None,
1451                placement: None,
1452                ottava_size: None,
1453                ottava_type: None,
1454            },
1455            super::super::score::NotationSpanner {
1456                id: "duplicate".to_string(),
1457                kind: NotationSpannerKind::Ottava,
1458                start: NoteAddr {
1459                    part: 9,
1460                    staff: 0,
1461                    measure: 0,
1462                    voice: 0,
1463                    note: 0,
1464                },
1465                end: NoteAddr {
1466                    part: 0,
1467                    staff: 0,
1468                    measure: 0,
1469                    voice: 0,
1470                    note: 0,
1471                },
1472                number: None,
1473                line_type: None,
1474                text: None,
1475                placement: None,
1476                ottava_size: Some(8),
1477                ottava_type: None,
1478            },
1479        ];
1480
1481        let report = validate(&score);
1482        assert!(
1483            report
1484                .errors
1485                .iter()
1486                .any(|error| matches!(error, ValidationError::InvalidSpannerId { index: 0, .. }))
1487        );
1488        assert!(report.errors.iter().any(|error| matches!(
1489            error,
1490            ValidationError::DuplicateSpannerId {
1491                first: 1,
1492                duplicate: 2,
1493                ..
1494            }
1495        )));
1496        assert_eq!(
1497            report
1498                .errors
1499                .iter()
1500                .filter(|error| matches!(error, ValidationError::InvalidSpannerEndpoint { .. }))
1501                .count(),
1502            2
1503        );
1504    }
1505
1506    #[test]
1507    fn validate_rejects_invalid_tablature_metadata_and_positions() {
1508        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
1509        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
1510            lines: 6,
1511            tuning_midi: vec![64, 59, 55, 50, 45, 40, 35],
1512            capo: 0,
1513        });
1514        let mut note = Note::new(Pitch::new(Step::E, 4), Duration::Whole);
1515        note.tab_position = Some(super::super::notation::TabPosition { string: 7, fret: 0 });
1516        note.tab_positions = vec![super::super::notation::TabPosition { string: 8, fret: 3 }];
1517        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1518
1519        let report = validate(&score);
1520        assert!(report.errors.iter().any(|error| matches!(
1521            error,
1522            ValidationError::InvalidTablature {
1523                reason: TablatureValidationReason::TooManyTunings { .. },
1524                ..
1525            }
1526        )));
1527        assert!(
1528            report
1529                .errors
1530                .iter()
1531                .any(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
1532        );
1533        assert_eq!(
1534            report
1535                .errors
1536                .iter()
1537                .filter(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
1538                .count(),
1539            2
1540        );
1541    }
1542
1543    #[test]
1544    fn validate_rejects_invalid_staff_presentation() {
1545        let mut score = Score::new("Presentation", 120, 4, 4, 0, 1);
1546        let presentation = &mut score.parts[0].staves[0].presentation;
1547        presentation.kind = StaffKind::Tablature;
1548        presentation.lines = 0;
1549        presentation.line_distance = f32::NAN;
1550
1551        let report = validate(&score);
1552        assert!(report.errors.iter().any(|error| matches!(
1553            error,
1554            ValidationError::InvalidStaffPresentation {
1555                reason: StaffPresentationValidationReason::InvalidLineCount { lines: 0 },
1556                ..
1557            }
1558        )));
1559        assert!(report.errors.iter().any(|error| matches!(
1560            error,
1561            ValidationError::InvalidStaffPresentation {
1562                reason: StaffPresentationValidationReason::InvalidLineDistance { .. },
1563                ..
1564            }
1565        )));
1566        assert!(report.errors.iter().any(|error| matches!(
1567            error,
1568            ValidationError::InvalidStaffPresentation {
1569                reason: StaffPresentationValidationReason::TablatureWithoutConfig,
1570                ..
1571            }
1572        )));
1573    }
1574
1575    #[test]
1576    fn validate_rejects_tablature_change_without_matching_base_geometry() {
1577        let mut score = Score::new("Tab change", 120, 4, 4, 0, 1);
1578        score.parts[0].staves[0].measures[0].tablature_change =
1579            Some(super::super::notation::TablatureConfig {
1580                lines: 6,
1581                tuning_midi: vec![40, 45, 50, 55, 59, 64],
1582                capo: 2,
1583            });
1584        let report = validate(&score);
1585        assert!(report.errors.iter().any(|error| matches!(
1586            error,
1587            ValidationError::InvalidTablature {
1588                reason: TablatureValidationReason::ChangeWithoutBase { measure: 0 },
1589                ..
1590            }
1591        )));
1592
1593        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
1594            lines: 6,
1595            tuning_midi: vec![40, 45, 50, 55, 59, 64],
1596            capo: 0,
1597        });
1598        score.parts[0].staves[0].measures[0]
1599            .tablature_change
1600            .as_mut()
1601            .expect("change exists")
1602            .lines = 7;
1603        let report = validate(&score);
1604        assert!(report.errors.iter().any(|error| matches!(
1605            error,
1606            ValidationError::InvalidTablature {
1607                reason: TablatureValidationReason::ChangeLineCountMismatch {
1608                    measure: 0,
1609                    base_lines: 6,
1610                    changed_lines: 7
1611                },
1612                ..
1613            }
1614        )));
1615    }
1616
1617    #[test]
1618    fn validate_rejects_invalid_percussion_kit_entries() {
1619        let mut score = Score::new("Kit", 120, 4, 4, 0, 1);
1620        score.parts[0].percussion_instruments = vec![
1621            PercussionInstrument {
1622                id: "snare".to_string(),
1623                name: None,
1624                midi_unpitched: Some(38),
1625                staff_position: Some(40),
1626                notehead: None,
1627                preferred_voice: Some(5),
1628                techniques: vec!["".to_string()],
1629            },
1630            PercussionInstrument {
1631                id: "snare".to_string(),
1632                name: None,
1633                midi_unpitched: Some(38),
1634                staff_position: None,
1635                notehead: None,
1636                preferred_voice: None,
1637                techniques: Vec::new(),
1638            },
1639        ];
1640
1641        let report = validate(&score);
1642        assert!(report.errors.iter().any(|error| matches!(
1643            error,
1644            ValidationError::InvalidPercussionInstrument {
1645                reason: PercussionInstrumentValidationReason::DuplicateId { first: 0 },
1646                ..
1647            }
1648        )));
1649        assert!(report.errors.iter().any(|error| matches!(
1650            error,
1651            ValidationError::InvalidPercussionInstrument {
1652                reason: PercussionInstrumentValidationReason::InvalidStaffPosition {
1653                    staff_position: 40
1654                },
1655                ..
1656            }
1657        )));
1658        assert!(report.errors.iter().any(|error| matches!(
1659            error,
1660            ValidationError::InvalidPercussionInstrument {
1661                reason: PercussionInstrumentValidationReason::InvalidPreferredVoice {
1662                    preferred_voice: 5
1663                },
1664                ..
1665            }
1666        )));
1667    }
1668
1669    #[test]
1670    fn validate_rejects_invalid_instrument_definition() {
1671        let mut score = Score::new("Instrument", 120, 4, 4, 0, 1);
1672        score.parts[0].instrument = Some(InstrumentDefinition {
1673            id: String::new(),
1674            name: "Broken".to_string(),
1675            short_name: String::new(),
1676            family: None,
1677            transpose_semitones: 0,
1678            written_range: Some(InstrumentRange {
1679                lowest: 80,
1680                highest: 40,
1681            }),
1682            sounding_range: None,
1683            default_clefs: Vec::new(),
1684            staff_count: 0,
1685            staff_kind: StaffKind::Standard,
1686            midi_channel: 16,
1687            midi_program: 0,
1688            percussion_map_id: None,
1689        });
1690
1691        let report = validate(&score);
1692        assert!(report.errors.iter().any(|error| matches!(
1693            error,
1694            ValidationError::InvalidInstrumentDefinition {
1695                reason: InstrumentDefinitionValidationReason::EmptyId,
1696                ..
1697            }
1698        )));
1699        assert!(report.errors.iter().any(|error| matches!(
1700            error,
1701            ValidationError::InvalidInstrumentDefinition {
1702                reason: InstrumentDefinitionValidationReason::InvalidStaffCount { staff_count: 0 },
1703                ..
1704            }
1705        )));
1706        assert!(report.errors.iter().any(|error| matches!(
1707            error,
1708            ValidationError::InvalidInstrumentDefinition {
1709                reason: InstrumentDefinitionValidationReason::InvalidRange {
1710                    kind: InstrumentRangeKind::Written,
1711                    ..
1712                },
1713                ..
1714            }
1715        )));
1716    }
1717
1718    #[test]
1719    fn validate_empty_part_warning() {
1720        let score = Score::new("T", 120, 4, 4, 0, 1);
1721        let report = validate(&score);
1722        assert!(
1723            report
1724                .warnings
1725                .iter()
1726                .any(|w| matches!(w, ValidationWarning::EmptyPart { part: 0 }))
1727        );
1728    }
1729
1730    #[test]
1731    fn validate_duplicate_rehearsal_mark_warning() {
1732        use crate::model::score::Score;
1733        let mut score = Score::new("T", 120, 4, 4, 0, 2);
1734        score.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
1735        score.parts[0].staves[0].measures[1].rehearsal = Some("A".to_string());
1736        let report = validate(&score);
1737        assert!(report.warnings.iter().any(
1738            |w| matches!(w, ValidationWarning::DuplicateRehearsalMark { mark } if mark == "A")
1739        ));
1740    }
1741}