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