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}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub enum ValidationWarning {
53 IncompleteBar {
55 part: usize,
56 staff: usize,
57 measure: usize,
58 expected_beats: f64,
59 actual_beats: f64,
60 },
61 OverlappingVolta { part: usize, staff: usize },
63 EmptyPart { part: usize },
65 DuplicateRehearsalMark { mark: String },
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ValidationReport {
72 pub errors: Vec<ValidationError>,
73 pub warnings: Vec<ValidationWarning>,
74}
75
76impl ValidationReport {
77 pub fn is_valid(&self) -> bool {
79 self.errors.is_empty()
80 }
81}
82
83pub fn validate(score: &Score) -> ValidationReport {
93 let mut errors = Vec::new();
94 let mut warnings = Vec::new();
95
96 let mut rehearsal_counts: HashMap<String, usize> = HashMap::new();
97
98 if score.parts.is_empty() {
99 errors.push(ValidationError::EmptyScore);
100 }
101
102 for (pi, part) in score.parts.iter().enumerate() {
103 let range = instrument_range(part.midi_program);
104 let is_percussion = part.midi_channel == 9;
105 let mut part_has_notes = false;
106
107 if part.staves.is_empty() {
108 errors.push(ValidationError::PartWithoutStaves { part: pi });
109 continue;
110 }
111
112 let expected_measure_count = part.staves[0].measures.len();
113
114 for (si, staff) in part.staves.iter().enumerate() {
115 if staff.measures.is_empty() {
116 errors.push(ValidationError::StaffWithoutMeasures {
117 part: pi,
118 staff: si,
119 });
120 continue;
121 }
122 if staff.measures.len() != expected_measure_count {
123 errors.push(ValidationError::MeasureCountMismatch {
124 part: pi,
125 staff: si,
126 expected: expected_measure_count,
127 found: staff.measures.len(),
128 });
129 }
130
131 let mut current_ts = score.settings.time_signature.clone();
132 let mut volta_numbers_seen: Vec<u8> = Vec::new();
133
134 for (mi, measure) in staff.measures.iter().enumerate() {
135 if let Some(ts) = &measure.time_sig {
136 current_ts = ts.clone();
137 }
138 if !valid_time_signature(¤t_ts) {
139 errors.push(ValidationError::InvalidTimeSignature {
140 part: pi,
141 staff: si,
142 measure: mi,
143 numerator: current_ts.numerator,
144 denominator: current_ts.denominator,
145 });
146 continue;
147 }
148 if measure.multi_rest_count.is_some() {
149 continue;
150 }
151
152 if let Some(ref mark) = measure.rehearsal {
154 let entry = rehearsal_counts.entry(mark.clone()).or_insert(0);
155 *entry += 1;
156 }
157
158 if let Some(ref volta) = measure.volta {
160 if volta_numbers_seen.contains(&volta.number) {
161 warnings.push(ValidationWarning::OverlappingVolta {
162 part: pi,
163 staff: si,
164 });
165 } else {
166 volta_numbers_seen.push(volta.number);
167 }
168 }
169
170 let expected = current_ts.total_beats();
171 for (vi, voice) in measure.voices.iter().enumerate() {
172 if voice.is_empty() {
173 continue;
174 }
175 let non_rest_count: usize = voice.iter().filter(|n| !n.is_rest).count();
176 if non_rest_count > 0 {
177 part_has_notes = true;
178 }
179 let total: f64 = voice.iter().map(|n| n.beats()).sum();
180 if total > expected + 0.02 {
181 errors.push(ValidationError::BeatCount {
182 part: pi,
183 staff: si,
184 measure: mi,
185 voice: vi,
186 expected_beats: expected,
187 found_beats: total,
188 });
189 } else if total < expected - 0.02 && non_rest_count > 0 {
190 warnings.push(ValidationWarning::IncompleteBar {
191 part: pi,
192 staff: si,
193 measure: mi,
194 expected_beats: expected,
195 actual_beats: total,
196 });
197 }
198
199 if !is_percussion {
200 let transpose = staff.transpose_semitones;
201 for (ni, note) in voice.iter().enumerate() {
202 if note.is_rest || note.is_grace {
203 continue;
204 }
205 for pitch in ¬e.pitches {
206 let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
207 if midi < range.0 || midi > range.1 {
208 errors.push(ValidationError::OutOfRange {
209 part_index: pi,
210 staff_index: si,
211 measure_index: mi,
212 note_index: ni,
213 pitch_midi: midi,
214 instrument_range: range,
215 });
216 }
217 }
218 }
219 }
220 }
221 }
222 }
223
224 if !part_has_notes {
225 warnings.push(ValidationWarning::EmptyPart { part: pi });
226 }
227 }
228
229 for (mark, count) in &rehearsal_counts {
230 if *count > 1 {
231 warnings.push(ValidationWarning::DuplicateRehearsalMark { mark: mark.clone() });
232 }
233 }
234
235 ValidationReport { errors, warnings }
236}
237
238fn valid_time_signature(time: &super::notation::TimeSignature) -> bool {
239 time.numerator > 0 && matches!(time.denominator, 1 | 2 | 4 | 8 | 16 | 32 | 64)
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::model::{
246 duration::Duration,
247 pitch::{Pitch, Step},
248 score::{Note, Score},
249 };
250
251 #[test]
252 fn validate_clean_score_returns_empty_errors() {
253 let score = Score::new("T", 120, 4, 4, 0, 1);
254 assert!(validate(&score).errors.is_empty());
255 }
256
257 #[test]
258 fn validate_empty_score_returns_structural_error() {
259 let mut score = Score::new("T", 120, 4, 4, 0, 1);
260 score.parts.clear();
261 let report = validate(&score);
262 assert!(
263 report
264 .errors
265 .iter()
266 .any(|error| matches!(error, ValidationError::EmptyScore))
267 );
268 }
269
270 #[test]
271 fn validate_detects_missing_staves_and_measures() {
272 let mut score = Score::new("T", 120, 4, 4, 0, 1);
273 score.parts[0].staves.clear();
274 let report = validate(&score);
275 assert!(
276 report
277 .errors
278 .iter()
279 .any(|error| matches!(error, ValidationError::PartWithoutStaves { part: 0 }))
280 );
281
282 score.parts[0].staves.push(crate::model::score::Staff::new(
283 crate::model::notation::Clef::Treble,
284 ));
285 let report = validate(&score);
286 assert!(report.errors.iter().any(|error| matches!(
287 error,
288 ValidationError::StaffWithoutMeasures { part: 0, staff: 0 }
289 )));
290 }
291
292 #[test]
293 fn validate_detects_staff_measure_count_mismatch() {
294 let mut score = Score::template(crate::model::score::ScoreTemplate::Piano);
295 score.parts[0].staves[1].measures.pop();
296 let report = validate(&score);
297 assert!(report.errors.iter().any(|error| matches!(
298 error,
299 ValidationError::MeasureCountMismatch {
300 part: 0,
301 staff: 1,
302 expected: 4,
303 found: 3
304 }
305 )));
306 }
307
308 #[test]
309 fn validate_detects_invalid_time_signature() {
310 let mut score = Score::new("T", 120, 4, 4, 0, 1);
311 score.parts[0].staves[0].measures[0].time_sig =
312 Some(crate::model::notation::TimeSignature {
313 numerator: 0,
314 denominator: 3,
315 });
316 let report = validate(&score);
317 assert!(report.errors.iter().any(|error| matches!(
318 error,
319 ValidationError::InvalidTimeSignature {
320 part: 0,
321 staff: 0,
322 measure: 0,
323 numerator: 0,
324 denominator: 3
325 }
326 )));
327 }
328
329 #[test]
330 fn validate_overfull_measure_returns_error() {
331 let mut score = Score::new("T", 120, 4, 4, 0, 1);
332 score.parts[0].staves[0].measures[0].voices[0]
333 .push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
334 let report = validate(&score);
335 assert!(!report.errors.is_empty());
336 assert!(matches!(
337 report.errors[0],
338 ValidationError::BeatCount {
339 measure: 0,
340 voice: 0,
341 ..
342 }
343 ));
344 }
345
346 #[test]
347 fn validate_skips_multi_rest() {
348 let mut score = Score::new("T", 120, 4, 4, 0, 1);
349 score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
350 score.parts[0].staves[0].measures[0].voices[0].clear();
351 assert!(validate(&score).errors.is_empty());
352 }
353
354 #[test]
355 fn validate_out_of_range_pitch_detected() {
356 let mut score = Score::new("T", 120, 4, 4, 0, 1);
358 score.parts[0].midi_program = 0;
359 score.parts[0].staves[0].measures[0].voices[0] =
360 vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
361 let report = validate(&score);
362 assert!(report.errors.iter().any(
363 |e| matches!(e, ValidationError::OutOfRange { pitch_midi, .. } if *pitch_midi == 120)
364 ));
365 }
366
367 #[test]
368 fn validate_percussion_channel_skips_range_check() {
369 let mut score = Score::new("T", 120, 4, 4, 0, 1);
371 score.parts[0].midi_channel = 9;
372 score.parts[0].midi_program = 0;
373 score.parts[0].staves[0].measures[0].voices[0] =
374 vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
375 let report = validate(&score);
376 assert!(
377 !report
378 .errors
379 .iter()
380 .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
381 );
382 }
383
384 #[test]
385 fn validate_in_range_pitch_ok() {
386 let mut score = Score::new("T", 120, 4, 4, 0, 1);
388 score.parts[0].midi_program = 0;
389 score.parts[0].staves[0].measures[0].voices[0] =
390 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
391 assert!(
392 !validate(&score)
393 .errors
394 .iter()
395 .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
396 );
397 }
398
399 #[test]
400 fn validate_empty_part_warning() {
401 let score = Score::new("T", 120, 4, 4, 0, 1);
402 let report = validate(&score);
403 assert!(
404 report
405 .warnings
406 .iter()
407 .any(|w| matches!(w, ValidationWarning::EmptyPart { part: 0 }))
408 );
409 }
410
411 #[test]
412 fn validate_duplicate_rehearsal_mark_warning() {
413 use crate::model::score::Score;
414 let mut score = Score::new("T", 120, 4, 4, 0, 2);
415 score.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
416 score.parts[0].staves[0].measures[1].rehearsal = Some("A".to_string());
417 let report = validate(&score);
418 assert!(report.warnings.iter().any(
419 |w| matches!(w, ValidationWarning::DuplicateRehearsalMark { mark } if mark == "A")
420 ));
421 }
422}