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