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