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#[derive(Debug, Clone, Serialize, Deserialize)]
12pub enum ValidationError {
13 EmptyScore,
15 PartWithoutStaves { part: usize },
17 StaffWithoutMeasures { part: usize, staff: usize },
19 MeasureCountMismatch {
21 part: usize,
22 staff: usize,
23 expected: usize,
24 found: usize,
25 },
26 InvalidTimeSignature {
28 part: usize,
29 staff: usize,
30 measure: usize,
31 numerator: u8,
32 denominator: u8,
33 },
34 InvalidMeasureLength {
36 part: usize,
37 staff: usize,
38 measure: usize,
39 numerator: u32,
40 denominator: u32,
41 },
42 InvalidLyricVerse {
44 part: usize,
45 staff: usize,
46 measure: usize,
47 voice: usize,
48 note: usize,
49 verse: u8,
50 },
51 BeatCount {
53 part: usize,
54 staff: usize,
55 measure: usize,
56 voice: usize,
57 expected_beats: f64,
58 found_beats: f64,
59 },
60 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 InvalidTablature {
71 part: usize,
72 staff: usize,
73 reason: TablatureValidationReason,
74 },
75 InvalidStaffPresentation {
77 part: usize,
78 staff: usize,
79 reason: StaffPresentationValidationReason,
80 },
81 InvalidInstrumentDefinition {
83 part: usize,
84 reason: InstrumentDefinitionValidationReason,
85 },
86 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 InvalidScoreStyleOverride {
100 property: super::score::ViewStyleProperty,
101 value: f32,
102 },
103 InvalidObjectStyleOverride {
105 index: usize,
106 reason: ObjectStyleValidationReason,
107 },
108 TabPositionOutOfRange {
110 part: usize,
111 staff: usize,
112 measure: usize,
113 voice: usize,
114 note: usize,
115 string: u8,
116 lines: u8,
117 },
118 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 InvalidHarmonyRange {
138 part: usize,
139 staff: usize,
140 measure: usize,
141 voice: usize,
142 note: usize,
143 end: NoteAddr,
144 },
145 InvalidSpannerId { index: usize, id: String },
147 DuplicateSpannerId {
149 first: usize,
150 duplicate: usize,
151 id: String,
152 },
153 InvalidSpannerEndpoint {
155 index: usize,
156 id: String,
157 kind: NotationSpannerKind,
158 endpoint: SpannerEndpoint,
159 address: NoteAddr,
160 },
161}
162
163#[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#[derive(Debug, Clone, Serialize, Deserialize)]
294pub enum ValidationWarning {
295 IncompleteBar {
297 part: usize,
298 staff: usize,
299 measure: usize,
300 expected_beats: f64,
301 actual_beats: f64,
302 },
303 OverlappingVolta { part: usize, staff: usize },
305 EmptyPart { part: usize },
307 DuplicateRehearsalMark { mark: String },
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct ValidationReport {
314 pub errors: Vec<ValidationError>,
315 pub warnings: Vec<ValidationWarning>,
316}
317
318impl ValidationReport {
319 pub fn is_valid(&self) -> bool {
321 self.errors.is_empty()
322 }
323}
324
325pub 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(¤t_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 if let Some(ref mark) = measure.rehearsal {
574 let entry = rehearsal_counts.entry(mark.clone()).or_insert(0);
575 *entry += 1;
576 }
577
578 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 ¬e.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) = ¬e.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 ¬e.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 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 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 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}