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