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