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