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