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