1use super::gm::instrument_range;
2use super::score::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}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub enum TablatureValidationReason {
78 InvalidLineCount { lines: u8 },
79 TooManyTunings { tuning_count: usize, lines: u8 },
80 TuningOutOfMidiRange { index: usize, midi: i16 },
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub enum ValidationWarning {
86 IncompleteBar {
88 part: usize,
89 staff: usize,
90 measure: usize,
91 expected_beats: f64,
92 actual_beats: f64,
93 },
94 OverlappingVolta { part: usize, staff: usize },
96 EmptyPart { part: usize },
98 DuplicateRehearsalMark { mark: String },
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ValidationReport {
105 pub errors: Vec<ValidationError>,
106 pub warnings: Vec<ValidationWarning>,
107}
108
109impl ValidationReport {
110 pub fn is_valid(&self) -> bool {
112 self.errors.is_empty()
113 }
114}
115
116pub fn validate(score: &Score) -> ValidationReport {
126 let mut errors = Vec::new();
127 let mut warnings = Vec::new();
128
129 let mut rehearsal_counts: HashMap<String, usize> = HashMap::new();
130
131 if score.parts.is_empty() {
132 errors.push(ValidationError::EmptyScore);
133 }
134
135 for (pi, part) in score.parts.iter().enumerate() {
136 let range = instrument_range(part.midi_program);
137 let is_percussion = part.midi_channel == 9;
138 let mut part_has_notes = false;
139
140 if part.staves.is_empty() {
141 errors.push(ValidationError::PartWithoutStaves { part: pi });
142 continue;
143 }
144
145 let expected_measure_count = part.staves[0].measures.len();
146
147 for (si, staff) in part.staves.iter().enumerate() {
148 if staff.measures.is_empty() {
149 errors.push(ValidationError::StaffWithoutMeasures {
150 part: pi,
151 staff: si,
152 });
153 continue;
154 }
155 if staff.measures.len() != expected_measure_count {
156 errors.push(ValidationError::MeasureCountMismatch {
157 part: pi,
158 staff: si,
159 expected: expected_measure_count,
160 found: staff.measures.len(),
161 });
162 }
163
164 if let Some(tab) = &staff.tablature {
165 if !(1..=64).contains(&tab.lines) {
166 errors.push(ValidationError::InvalidTablature {
167 part: pi,
168 staff: si,
169 reason: TablatureValidationReason::InvalidLineCount { lines: tab.lines },
170 });
171 } else if tab.tuning_midi.len() > usize::from(tab.lines) {
172 errors.push(ValidationError::InvalidTablature {
173 part: pi,
174 staff: si,
175 reason: TablatureValidationReason::TooManyTunings {
176 tuning_count: tab.tuning_midi.len(),
177 lines: tab.lines,
178 },
179 });
180 }
181 for (index, &midi) in tab.tuning_midi.iter().enumerate() {
182 if !(0..=127).contains(&midi) {
183 errors.push(ValidationError::InvalidTablature {
184 part: pi,
185 staff: si,
186 reason: TablatureValidationReason::TuningOutOfMidiRange { index, midi },
187 });
188 }
189 }
190 }
191
192 let mut current_ts = score.settings.time_signature.clone();
193 let mut volta_numbers_seen: Vec<u8> = Vec::new();
194
195 for (mi, measure) in staff.measures.iter().enumerate() {
196 if let Some(ts) = &measure.time_sig {
197 current_ts = ts.clone();
198 }
199 if !valid_time_signature(¤t_ts) {
200 errors.push(ValidationError::InvalidTimeSignature {
201 part: pi,
202 staff: si,
203 measure: mi,
204 numerator: current_ts.numerator,
205 denominator: current_ts.denominator,
206 });
207 continue;
208 }
209 if measure.multi_rest_count.is_some() {
210 continue;
211 }
212
213 if let Some(ref mark) = measure.rehearsal {
215 let entry = rehearsal_counts.entry(mark.clone()).or_insert(0);
216 *entry += 1;
217 }
218
219 if let Some(ref volta) = measure.volta {
221 if volta_numbers_seen.contains(&volta.number) {
222 warnings.push(ValidationWarning::OverlappingVolta {
223 part: pi,
224 staff: si,
225 });
226 } else {
227 volta_numbers_seen.push(volta.number);
228 }
229 }
230
231 let expected = current_ts.total_beats();
232 for (vi, voice) in measure.voices.iter().enumerate() {
233 if voice.is_empty() {
234 continue;
235 }
236 let non_rest_count: usize = voice.iter().filter(|n| !n.is_rest).count();
237 if non_rest_count > 0 {
238 part_has_notes = true;
239 }
240 let total: f64 = voice.iter().map(|n| n.beats()).sum();
241 if total > expected + 0.02 {
242 errors.push(ValidationError::BeatCount {
243 part: pi,
244 staff: si,
245 measure: mi,
246 voice: vi,
247 expected_beats: expected,
248 found_beats: total,
249 });
250 } else if total < expected - 0.02 && non_rest_count > 0 {
251 warnings.push(ValidationWarning::IncompleteBar {
252 part: pi,
253 staff: si,
254 measure: mi,
255 expected_beats: expected,
256 actual_beats: total,
257 });
258 }
259
260 for (ni, note) in voice.iter().enumerate() {
261 if note.is_rest || note.is_grace {
262 continue;
263 }
264 for (pitch_index, pitch) in note.pitches.iter().enumerate() {
265 if !(-99..=99).contains(&pitch.microtone_cents) {
266 errors.push(ValidationError::MicrotoneOutOfRange {
267 part: pi,
268 staff: si,
269 measure: mi,
270 voice: vi,
271 note: ni,
272 pitch: pitch_index,
273 microtone_cents: pitch.microtone_cents,
274 });
275 }
276 }
277 }
278
279 if !is_percussion {
280 let transpose = staff.transpose_semitones;
281 for (ni, note) in voice.iter().enumerate() {
282 if note.is_rest || note.is_grace {
283 continue;
284 }
285 if let Some(tab) = &staff.tablature {
286 let positions =
287 note.tab_position.iter().chain(note.tab_positions.iter());
288 for position in positions {
289 if position.string == 0 || position.string > tab.lines {
290 errors.push(ValidationError::TabPositionOutOfRange {
291 part: pi,
292 staff: si,
293 measure: mi,
294 voice: vi,
295 note: ni,
296 string: position.string,
297 lines: tab.lines,
298 });
299 }
300 }
301 }
302 for pitch in ¬e.pitches {
303 let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
304 if midi < range.0 || midi > range.1 {
305 errors.push(ValidationError::OutOfRange {
306 part_index: pi,
307 staff_index: si,
308 measure_index: mi,
309 note_index: ni,
310 pitch_midi: midi,
311 instrument_range: range,
312 });
313 }
314 }
315 }
316 }
317 }
318 }
319 }
320
321 if !part_has_notes {
322 warnings.push(ValidationWarning::EmptyPart { part: pi });
323 }
324 }
325
326 for (mark, count) in &rehearsal_counts {
327 if *count > 1 {
328 warnings.push(ValidationWarning::DuplicateRehearsalMark { mark: mark.clone() });
329 }
330 }
331
332 ValidationReport { errors, warnings }
333}
334
335fn valid_time_signature(time: &super::notation::TimeSignature) -> bool {
336 time.numerator > 0 && matches!(time.denominator, 1 | 2 | 4 | 8 | 16 | 32 | 64)
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::model::{
343 duration::Duration,
344 pitch::{Pitch, Step},
345 score::{Note, Score},
346 };
347
348 #[test]
349 fn validate_clean_score_returns_empty_errors() {
350 let score = Score::new("T", 120, 4, 4, 0, 1);
351 assert!(validate(&score).errors.is_empty());
352 }
353
354 #[test]
355 fn validate_empty_score_returns_structural_error() {
356 let mut score = Score::new("T", 120, 4, 4, 0, 1);
357 score.parts.clear();
358 let report = validate(&score);
359 assert!(
360 report
361 .errors
362 .iter()
363 .any(|error| matches!(error, ValidationError::EmptyScore))
364 );
365 }
366
367 #[test]
368 fn validate_detects_missing_staves_and_measures() {
369 let mut score = Score::new("T", 120, 4, 4, 0, 1);
370 score.parts[0].staves.clear();
371 let report = validate(&score);
372 assert!(
373 report
374 .errors
375 .iter()
376 .any(|error| matches!(error, ValidationError::PartWithoutStaves { part: 0 }))
377 );
378
379 score.parts[0].staves.push(crate::model::score::Staff::new(
380 crate::model::notation::Clef::Treble,
381 ));
382 let report = validate(&score);
383 assert!(report.errors.iter().any(|error| matches!(
384 error,
385 ValidationError::StaffWithoutMeasures { part: 0, staff: 0 }
386 )));
387 }
388
389 #[test]
390 fn validate_detects_staff_measure_count_mismatch() {
391 let mut score = Score::template(crate::model::score::ScoreTemplate::Piano);
392 score.parts[0].staves[1].measures.pop();
393 let report = validate(&score);
394 assert!(report.errors.iter().any(|error| matches!(
395 error,
396 ValidationError::MeasureCountMismatch {
397 part: 0,
398 staff: 1,
399 expected: 4,
400 found: 3
401 }
402 )));
403 }
404
405 #[test]
406 fn validate_detects_invalid_time_signature() {
407 let mut score = Score::new("T", 120, 4, 4, 0, 1);
408 score.parts[0].staves[0].measures[0].time_sig =
409 Some(crate::model::notation::TimeSignature {
410 numerator: 0,
411 denominator: 3,
412 });
413 let report = validate(&score);
414 assert!(report.errors.iter().any(|error| matches!(
415 error,
416 ValidationError::InvalidTimeSignature {
417 part: 0,
418 staff: 0,
419 measure: 0,
420 numerator: 0,
421 denominator: 3
422 }
423 )));
424 }
425
426 #[test]
427 fn validate_overfull_measure_returns_error() {
428 let mut score = Score::new("T", 120, 4, 4, 0, 1);
429 score.parts[0].staves[0].measures[0].voices[0]
430 .push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
431 let report = validate(&score);
432 assert!(!report.errors.is_empty());
433 assert!(matches!(
434 report.errors[0],
435 ValidationError::BeatCount {
436 measure: 0,
437 voice: 0,
438 ..
439 }
440 ));
441 }
442
443 #[test]
444 fn validate_skips_multi_rest() {
445 let mut score = Score::new("T", 120, 4, 4, 0, 1);
446 score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
447 score.parts[0].staves[0].measures[0].voices[0].clear();
448 assert!(validate(&score).errors.is_empty());
449 }
450
451 #[test]
452 fn validate_out_of_range_pitch_detected() {
453 let mut score = Score::new("T", 120, 4, 4, 0, 1);
455 score.parts[0].midi_program = 0;
456 score.parts[0].staves[0].measures[0].voices[0] =
457 vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
458 let report = validate(&score);
459 assert!(report.errors.iter().any(
460 |e| matches!(e, ValidationError::OutOfRange { pitch_midi, .. } if *pitch_midi == 120)
461 ));
462 }
463
464 #[test]
465 fn validate_percussion_channel_skips_range_check() {
466 let mut score = Score::new("T", 120, 4, 4, 0, 1);
468 score.parts[0].midi_channel = 9;
469 score.parts[0].midi_program = 0;
470 score.parts[0].staves[0].measures[0].voices[0] =
471 vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
472 let report = validate(&score);
473 assert!(
474 !report
475 .errors
476 .iter()
477 .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
478 );
479 }
480
481 #[test]
482 fn validate_in_range_pitch_ok() {
483 let mut score = Score::new("T", 120, 4, 4, 0, 1);
485 score.parts[0].midi_program = 0;
486 score.parts[0].staves[0].measures[0].voices[0] =
487 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
488 assert!(
489 !validate(&score)
490 .errors
491 .iter()
492 .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
493 );
494 }
495
496 #[test]
497 fn validate_rejects_deserialized_microtone_out_of_range() {
498 let mut score = Score::new("T", 120, 4, 4, 0, 1);
499 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
500 note.pitches[0].microtone_cents = 100;
501 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
502 let report = validate(&score);
503 assert!(report.errors.iter().any(|error| matches!(
504 error,
505 ValidationError::MicrotoneOutOfRange {
506 microtone_cents: 100,
507 ..
508 }
509 )));
510 }
511
512 #[test]
513 fn validate_rejects_invalid_tablature_metadata_and_positions() {
514 let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
515 score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
516 lines: 6,
517 tuning_midi: vec![64, 59, 55, 50, 45, 40, 35],
518 capo: 0,
519 });
520 let mut note = Note::new(Pitch::new(Step::E, 4), Duration::Whole);
521 note.tab_position = Some(super::super::notation::TabPosition { string: 7, fret: 0 });
522 note.tab_positions = vec![super::super::notation::TabPosition { string: 8, fret: 3 }];
523 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
524
525 let report = validate(&score);
526 assert!(report.errors.iter().any(|error| matches!(
527 error,
528 ValidationError::InvalidTablature {
529 reason: TablatureValidationReason::TooManyTunings { .. },
530 ..
531 }
532 )));
533 assert!(
534 report
535 .errors
536 .iter()
537 .any(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
538 );
539 assert_eq!(
540 report
541 .errors
542 .iter()
543 .filter(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
544 .count(),
545 2
546 );
547 }
548
549 #[test]
550 fn validate_empty_part_warning() {
551 let score = Score::new("T", 120, 4, 4, 0, 1);
552 let report = validate(&score);
553 assert!(
554 report
555 .warnings
556 .iter()
557 .any(|w| matches!(w, ValidationWarning::EmptyPart { part: 0 }))
558 );
559 }
560
561 #[test]
562 fn validate_duplicate_rehearsal_mark_warning() {
563 use crate::model::score::Score;
564 let mut score = Score::new("T", 120, 4, 4, 0, 2);
565 score.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
566 score.parts[0].staves[0].measures[1].rehearsal = Some("A".to_string());
567 let report = validate(&score);
568 assert!(report.warnings.iter().any(
569 |w| matches!(w, ValidationWarning::DuplicateRehearsalMark { mark } if mark == "A")
570 ));
571 }
572}