1use super::gm::instrument_range;
2use super::score::{NotationSpannerKind, NoteAddr, Score};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub enum ValidationError {
9 EmptyScore,
11 PartWithoutStaves { part: usize },
13 StaffWithoutMeasures { part: usize, staff: usize },
15 MeasureCountMismatch {
17 part: usize,
18 staff: usize,
19 expected: usize,
20 found: usize,
21 },
22 InvalidTimeSignature {
24 part: usize,
25 staff: usize,
26 measure: usize,
27 numerator: u8,
28 denominator: u8,
29 },
30 BeatCount {
32 part: usize,
33 staff: usize,
34 measure: usize,
35 voice: usize,
36 expected_beats: f64,
37 found_beats: f64,
38 },
39 OutOfRange {
41 part_index: usize,
42 staff_index: usize,
43 measure_index: usize,
44 note_index: usize,
45 pitch_midi: u8,
46 instrument_range: (u8, u8),
47 },
48 InvalidTablature {
50 part: usize,
51 staff: usize,
52 reason: TablatureValidationReason,
53 },
54 TabPositionOutOfRange {
56 part: usize,
57 staff: usize,
58 measure: usize,
59 voice: usize,
60 note: usize,
61 string: u8,
62 lines: u8,
63 },
64 MicrotoneOutOfRange {
66 part: usize,
67 staff: usize,
68 measure: usize,
69 voice: usize,
70 note: usize,
71 pitch: usize,
72 microtone_cents: i16,
73 },
74 InvalidHarmonyRange {
76 part: usize,
77 staff: usize,
78 measure: usize,
79 voice: usize,
80 note: usize,
81 end: NoteAddr,
82 },
83 InvalidSpannerId { index: usize, id: String },
85 DuplicateSpannerId {
87 first: usize,
88 duplicate: usize,
89 id: String,
90 },
91 InvalidSpannerEndpoint {
93 index: usize,
94 id: String,
95 kind: NotationSpannerKind,
96 endpoint: SpannerEndpoint,
97 address: NoteAddr,
98 },
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum SpannerEndpoint {
104 Start,
105 End,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub enum TablatureValidationReason {
110 InvalidLineCount { lines: u8 },
111 TooManyTunings { tuning_count: usize, lines: u8 },
112 TuningOutOfMidiRange { index: usize, midi: i16 },
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub enum ValidationWarning {
118 IncompleteBar {
120 part: usize,
121 staff: usize,
122 measure: usize,
123 expected_beats: f64,
124 actual_beats: f64,
125 },
126 OverlappingVolta { part: usize, staff: usize },
128 EmptyPart { part: usize },
130 DuplicateRehearsalMark { mark: String },
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ValidationReport {
137 pub errors: Vec<ValidationError>,
138 pub warnings: Vec<ValidationWarning>,
139}
140
141impl ValidationReport {
142 pub fn is_valid(&self) -> bool {
144 self.errors.is_empty()
145 }
146}
147
148pub fn validate(score: &Score) -> ValidationReport {
158 let mut errors = Vec::new();
159 let mut warnings = Vec::new();
160
161 let mut rehearsal_counts: HashMap<String, usize> = HashMap::new();
162
163 if score.parts.is_empty() {
164 errors.push(ValidationError::EmptyScore);
165 }
166
167 let mut spanner_ids: HashMap<&str, usize> = HashMap::new();
168 for (index, spanner) in score.spanners.iter().enumerate() {
169 if spanner.id.trim().is_empty() {
170 errors.push(ValidationError::InvalidSpannerId {
171 index,
172 id: spanner.id.clone(),
173 });
174 } else if let Some(first) = spanner_ids.insert(spanner.id.as_str(), index) {
175 errors.push(ValidationError::DuplicateSpannerId {
176 first,
177 duplicate: index,
178 id: spanner.id.clone(),
179 });
180 }
181 for (endpoint, address) in [
182 (SpannerEndpoint::Start, &spanner.start),
183 (SpannerEndpoint::End, &spanner.end),
184 ] {
185 if !note_exists(score, address) {
186 errors.push(ValidationError::InvalidSpannerEndpoint {
187 index,
188 id: spanner.id.clone(),
189 kind: spanner.kind.clone(),
190 endpoint,
191 address: address.clone(),
192 });
193 }
194 }
195 }
196
197 for (pi, part) in score.parts.iter().enumerate() {
198 let range = instrument_range(part.midi_program);
199 let is_percussion = part.midi_channel == 9;
200 let mut part_has_notes = false;
201
202 if part.staves.is_empty() {
203 errors.push(ValidationError::PartWithoutStaves { part: pi });
204 continue;
205 }
206
207 let expected_measure_count = part.staves[0].measures.len();
208
209 for (si, staff) in part.staves.iter().enumerate() {
210 if staff.measures.is_empty() {
211 errors.push(ValidationError::StaffWithoutMeasures {
212 part: pi,
213 staff: si,
214 });
215 continue;
216 }
217 if staff.measures.len() != expected_measure_count {
218 errors.push(ValidationError::MeasureCountMismatch {
219 part: pi,
220 staff: si,
221 expected: expected_measure_count,
222 found: staff.measures.len(),
223 });
224 }
225
226 if let Some(tab) = &staff.tablature {
227 if !(1..=64).contains(&tab.lines) {
228 errors.push(ValidationError::InvalidTablature {
229 part: pi,
230 staff: si,
231 reason: TablatureValidationReason::InvalidLineCount { lines: tab.lines },
232 });
233 } else if tab.tuning_midi.len() > usize::from(tab.lines) {
234 errors.push(ValidationError::InvalidTablature {
235 part: pi,
236 staff: si,
237 reason: TablatureValidationReason::TooManyTunings {
238 tuning_count: tab.tuning_midi.len(),
239 lines: tab.lines,
240 },
241 });
242 }
243 for (index, &midi) in tab.tuning_midi.iter().enumerate() {
244 if !(0..=127).contains(&midi) {
245 errors.push(ValidationError::InvalidTablature {
246 part: pi,
247 staff: si,
248 reason: TablatureValidationReason::TuningOutOfMidiRange { index, midi },
249 });
250 }
251 }
252 }
253
254 let mut current_ts = score.settings.time_signature.clone();
255 let mut volta_numbers_seen: Vec<u8> = Vec::new();
256
257 for (mi, measure) in staff.measures.iter().enumerate() {
258 if let Some(ts) = &measure.time_sig {
259 current_ts = ts.clone();
260 }
261 if !valid_time_signature(¤t_ts) {
262 errors.push(ValidationError::InvalidTimeSignature {
263 part: pi,
264 staff: si,
265 measure: mi,
266 numerator: current_ts.numerator,
267 denominator: current_ts.denominator,
268 });
269 continue;
270 }
271 if measure.multi_rest_count.is_some() {
272 continue;
273 }
274
275 if let Some(ref mark) = measure.rehearsal {
277 let entry = rehearsal_counts.entry(mark.clone()).or_insert(0);
278 *entry += 1;
279 }
280
281 if let Some(ref volta) = measure.volta {
283 if volta_numbers_seen.contains(&volta.number) {
284 warnings.push(ValidationWarning::OverlappingVolta {
285 part: pi,
286 staff: si,
287 });
288 } else {
289 volta_numbers_seen.push(volta.number);
290 }
291 }
292
293 let expected = current_ts.total_beats();
294 for (vi, voice) in measure.voices.iter().enumerate() {
295 if voice.is_empty() {
296 continue;
297 }
298 let non_rest_count: usize = voice.iter().filter(|n| !n.is_rest).count();
299 if non_rest_count > 0 {
300 part_has_notes = true;
301 }
302 let total: f64 = voice.iter().map(|n| n.beats()).sum();
303 if total > expected + 0.02 {
304 errors.push(ValidationError::BeatCount {
305 part: pi,
306 staff: si,
307 measure: mi,
308 voice: vi,
309 expected_beats: expected,
310 found_beats: total,
311 });
312 } else if total < expected - 0.02 && non_rest_count > 0 {
313 warnings.push(ValidationWarning::IncompleteBar {
314 part: pi,
315 staff: si,
316 measure: mi,
317 expected_beats: expected,
318 actual_beats: total,
319 });
320 }
321
322 for (ni, note) in voice.iter().enumerate() {
323 if note.is_rest || note.is_grace {
324 continue;
325 }
326 if let Some(chord) = ¬e.chord_symbol
327 && let Some(end) = &chord.range_end
328 && !note_exists(score, end)
329 {
330 errors.push(ValidationError::InvalidHarmonyRange {
331 part: pi,
332 staff: si,
333 measure: mi,
334 voice: vi,
335 note: ni,
336 end: end.clone(),
337 });
338 }
339 for (pitch_index, pitch) in note.pitches.iter().enumerate() {
340 if !(-99..=99).contains(&pitch.microtone_cents) {
341 errors.push(ValidationError::MicrotoneOutOfRange {
342 part: pi,
343 staff: si,
344 measure: mi,
345 voice: vi,
346 note: ni,
347 pitch: pitch_index,
348 microtone_cents: pitch.microtone_cents,
349 });
350 }
351 }
352 }
353
354 if !is_percussion {
355 let transpose = staff.transpose_semitones;
356 for (ni, note) in voice.iter().enumerate() {
357 if note.is_rest || note.is_grace {
358 continue;
359 }
360 if let Some(tab) = &staff.tablature {
361 let positions =
362 note.tab_position.iter().chain(note.tab_positions.iter());
363 for position in positions {
364 if position.string == 0 || position.string > tab.lines {
365 errors.push(ValidationError::TabPositionOutOfRange {
366 part: pi,
367 staff: si,
368 measure: mi,
369 voice: vi,
370 note: ni,
371 string: position.string,
372 lines: tab.lines,
373 });
374 }
375 }
376 }
377 for pitch in ¬e.pitches {
378 let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
379 if midi < range.0 || midi > range.1 {
380 errors.push(ValidationError::OutOfRange {
381 part_index: pi,
382 staff_index: si,
383 measure_index: mi,
384 note_index: ni,
385 pitch_midi: midi,
386 instrument_range: range,
387 });
388 }
389 }
390 }
391 }
392 }
393 }
394 }
395
396 if !part_has_notes {
397 warnings.push(ValidationWarning::EmptyPart { part: pi });
398 }
399 }
400
401 for (mark, count) in &rehearsal_counts {
402 if *count > 1 {
403 warnings.push(ValidationWarning::DuplicateRehearsalMark { mark: mark.clone() });
404 }
405 }
406
407 ValidationReport { errors, warnings }
408}
409
410fn valid_time_signature(time: &super::notation::TimeSignature) -> bool {
411 time.numerator > 0 && matches!(time.denominator, 1 | 2 | 4 | 8 | 16 | 32 | 64)
412}
413
414fn note_exists(score: &Score, address: &NoteAddr) -> bool {
415 score
416 .parts
417 .get(address.part)
418 .and_then(|part| part.staves.get(address.staff))
419 .and_then(|staff| staff.measures.get(address.measure))
420 .and_then(|measure| measure.voices.get(address.voice))
421 .and_then(|voice| voice.get(address.note))
422 .is_some()
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use crate::model::{
429 duration::Duration,
430 notation::ChordSymbol,
431 pitch::{Pitch, Step},
432 score::{Note, NoteAddr, Score},
433 };
434
435 #[test]
436 fn validate_clean_score_returns_empty_errors() {
437 let score = Score::new("T", 120, 4, 4, 0, 1);
438 assert!(validate(&score).errors.is_empty());
439 }
440
441 #[test]
442 fn validate_empty_score_returns_structural_error() {
443 let mut score = Score::new("T", 120, 4, 4, 0, 1);
444 score.parts.clear();
445 let report = validate(&score);
446 assert!(
447 report
448 .errors
449 .iter()
450 .any(|error| matches!(error, ValidationError::EmptyScore))
451 );
452 }
453
454 #[test]
455 fn validate_detects_missing_staves_and_measures() {
456 let mut score = Score::new("T", 120, 4, 4, 0, 1);
457 score.parts[0].staves.clear();
458 let report = validate(&score);
459 assert!(
460 report
461 .errors
462 .iter()
463 .any(|error| matches!(error, ValidationError::PartWithoutStaves { part: 0 }))
464 );
465
466 score.parts[0].staves.push(crate::model::score::Staff::new(
467 crate::model::notation::Clef::Treble,
468 ));
469 let report = validate(&score);
470 assert!(report.errors.iter().any(|error| matches!(
471 error,
472 ValidationError::StaffWithoutMeasures { part: 0, staff: 0 }
473 )));
474 }
475
476 #[test]
477 fn validate_detects_staff_measure_count_mismatch() {
478 let mut score = Score::template(crate::model::score::ScoreTemplate::Piano);
479 score.parts[0].staves[1].measures.pop();
480 let report = validate(&score);
481 assert!(report.errors.iter().any(|error| matches!(
482 error,
483 ValidationError::MeasureCountMismatch {
484 part: 0,
485 staff: 1,
486 expected: 4,
487 found: 3
488 }
489 )));
490 }
491
492 #[test]
493 fn validate_detects_invalid_time_signature() {
494 let mut score = Score::new("T", 120, 4, 4, 0, 1);
495 score.parts[0].staves[0].measures[0].time_sig =
496 Some(crate::model::notation::TimeSignature {
497 numerator: 0,
498 denominator: 3,
499 });
500 let report = validate(&score);
501 assert!(report.errors.iter().any(|error| matches!(
502 error,
503 ValidationError::InvalidTimeSignature {
504 part: 0,
505 staff: 0,
506 measure: 0,
507 numerator: 0,
508 denominator: 3
509 }
510 )));
511 }
512
513 #[test]
514 fn validate_overfull_measure_returns_error() {
515 let mut score = Score::new("T", 120, 4, 4, 0, 1);
516 score.parts[0].staves[0].measures[0].voices[0]
517 .push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
518 let report = validate(&score);
519 assert!(!report.errors.is_empty());
520 assert!(matches!(
521 report.errors[0],
522 ValidationError::BeatCount {
523 measure: 0,
524 voice: 0,
525 ..
526 }
527 ));
528 }
529
530 #[test]
531 fn validate_skips_multi_rest() {
532 let mut score = Score::new("T", 120, 4, 4, 0, 1);
533 score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
534 score.parts[0].staves[0].measures[0].voices[0].clear();
535 assert!(validate(&score).errors.is_empty());
536 }
537
538 #[test]
539 fn validate_rejects_harmony_range_to_missing_note() {
540 let mut score = Score::new("T", 120, 4, 4, 0, 1);
541 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
542 note.chord_symbol = Some(ChordSymbol {
543 root: "C".to_owned(),
544 kind: "major".to_owned(),
545 bass: None,
546 placement: None,
547 extender: true,
548 harmonic_degree: None,
549 harmony_function: None,
550 harmony_type: None,
551 chord_ref: None,
552 range_end: Some(NoteAddr {
553 part: 0,
554 staff: 0,
555 measure: 0,
556 voice: 0,
557 note: 9,
558 }),
559 degrees: Vec::new(),
560 });
561 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
562 assert!(validate(&score).errors.iter().any(|error| matches!(
563 error,
564 ValidationError::InvalidHarmonyRange { note: 0, end, .. }
565 if end.note == 9
566 )));
567 }
568
569 #[test]
570 fn validate_out_of_range_pitch_detected() {
571 let mut score = Score::new("T", 120, 4, 4, 0, 1);
573 score.parts[0].midi_program = 0;
574 score.parts[0].staves[0].measures[0].voices[0] =
575 vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
576 let report = validate(&score);
577 assert!(report.errors.iter().any(
578 |e| matches!(e, ValidationError::OutOfRange { pitch_midi, .. } if *pitch_midi == 120)
579 ));
580 }
581
582 #[test]
583 fn validate_percussion_channel_skips_range_check() {
584 let mut score = Score::new("T", 120, 4, 4, 0, 1);
586 score.parts[0].midi_channel = 9;
587 score.parts[0].midi_program = 0;
588 score.parts[0].staves[0].measures[0].voices[0] =
589 vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
590 let report = validate(&score);
591 assert!(
592 !report
593 .errors
594 .iter()
595 .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
596 );
597 }
598
599 #[test]
600 fn validate_in_range_pitch_ok() {
601 let mut score = Score::new("T", 120, 4, 4, 0, 1);
603 score.parts[0].midi_program = 0;
604 score.parts[0].staves[0].measures[0].voices[0] =
605 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
606 assert!(
607 !validate(&score)
608 .errors
609 .iter()
610 .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
611 );
612 }
613
614 #[test]
615 fn validate_rejects_deserialized_microtone_out_of_range() {
616 let mut score = Score::new("T", 120, 4, 4, 0, 1);
617 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
618 note.pitches[0].microtone_cents = 100;
619 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
620 let report = validate(&score);
621 assert!(report.errors.iter().any(|error| matches!(
622 error,
623 ValidationError::MicrotoneOutOfRange {
624 microtone_cents: 100,
625 ..
626 }
627 )));
628 }
629
630 #[test]
631 fn validate_rejects_invalid_and_duplicate_typed_spanners() {
632 let mut score = Score::new("T", 120, 4, 4, 0, 1);
633 score.parts[0].staves[0].measures[0].voices[0] =
634 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
635 let address = NoteAddr {
636 part: 0,
637 staff: 0,
638 measure: 0,
639 voice: 0,
640 note: 0,
641 };
642 score.spanners = vec![
643 super::super::score::NotationSpanner {
644 id: String::new(),
645 kind: NotationSpannerKind::Slur,
646 start: address.clone(),
647 end: address.clone(),
648 number: Some(1),
649 line_type: None,
650 text: None,
651 placement: None,
652 ottava_size: None,
653 ottava_type: None,
654 },
655 super::super::score::NotationSpanner {
656 id: "duplicate".to_string(),
657 kind: NotationSpannerKind::Pedal,
658 start: address.clone(),
659 end: NoteAddr { note: 9, ..address },
660 number: Some(2),
661 line_type: None,
662 text: None,
663 placement: None,
664 ottava_size: None,
665 ottava_type: None,
666 },
667 super::super::score::NotationSpanner {
668 id: "duplicate".to_string(),
669 kind: NotationSpannerKind::Ottava,
670 start: NoteAddr {
671 part: 9,
672 staff: 0,
673 measure: 0,
674 voice: 0,
675 note: 0,
676 },
677 end: NoteAddr {
678 part: 0,
679 staff: 0,
680 measure: 0,
681 voice: 0,
682 note: 0,
683 },
684 number: None,
685 line_type: None,
686 text: None,
687 placement: None,
688 ottava_size: Some(8),
689 ottava_type: None,
690 },
691 ];
692
693 let report = validate(&score);
694 assert!(
695 report
696 .errors
697 .iter()
698 .any(|error| matches!(error, ValidationError::InvalidSpannerId { index: 0, .. }))
699 );
700 assert!(report.errors.iter().any(|error| matches!(
701 error,
702 ValidationError::DuplicateSpannerId {
703 first: 1,
704 duplicate: 2,
705 ..
706 }
707 )));
708 assert_eq!(
709 report
710 .errors
711 .iter()
712 .filter(|error| matches!(error, ValidationError::InvalidSpannerEndpoint { .. }))
713 .count(),
714 2
715 );
716 }
717
718 #[test]
719 fn validate_rejects_invalid_tablature_metadata_and_positions() {
720 let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
721 score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
722 lines: 6,
723 tuning_midi: vec![64, 59, 55, 50, 45, 40, 35],
724 capo: 0,
725 });
726 let mut note = Note::new(Pitch::new(Step::E, 4), Duration::Whole);
727 note.tab_position = Some(super::super::notation::TabPosition { string: 7, fret: 0 });
728 note.tab_positions = vec![super::super::notation::TabPosition { string: 8, fret: 3 }];
729 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
730
731 let report = validate(&score);
732 assert!(report.errors.iter().any(|error| matches!(
733 error,
734 ValidationError::InvalidTablature {
735 reason: TablatureValidationReason::TooManyTunings { .. },
736 ..
737 }
738 )));
739 assert!(
740 report
741 .errors
742 .iter()
743 .any(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
744 );
745 assert_eq!(
746 report
747 .errors
748 .iter()
749 .filter(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
750 .count(),
751 2
752 );
753 }
754
755 #[test]
756 fn validate_empty_part_warning() {
757 let score = Score::new("T", 120, 4, 4, 0, 1);
758 let report = validate(&score);
759 assert!(
760 report
761 .warnings
762 .iter()
763 .any(|w| matches!(w, ValidationWarning::EmptyPart { part: 0 }))
764 );
765 }
766
767 #[test]
768 fn validate_duplicate_rehearsal_mark_warning() {
769 use crate::model::score::Score;
770 let mut score = Score::new("T", 120, 4, 4, 0, 2);
771 score.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
772 score.parts[0].staves[0].measures[1].rehearsal = Some("A".to_string());
773 let report = validate(&score);
774 assert!(report.warnings.iter().any(
775 |w| matches!(w, ValidationWarning::DuplicateRehearsalMark { mark } if mark == "A")
776 ));
777 }
778}