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 BeatCount {
11 part: usize,
12 staff: usize,
13 measure: usize,
14 voice: usize,
15 expected_beats: f64,
16 found_beats: f64,
17 },
18 OutOfRange {
20 part_index: usize,
21 staff_index: usize,
22 measure_index: usize,
23 note_index: usize,
24 pitch_midi: u8,
25 instrument_range: (u8, u8),
26 },
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub enum ValidationWarning {
32 IncompleteBar {
34 part: usize,
35 staff: usize,
36 measure: usize,
37 expected_beats: f64,
38 actual_beats: f64,
39 },
40 OverlappingVolta { part: usize, staff: usize },
42 EmptyPart { part: usize },
44 DuplicateRehearsalMark { mark: String },
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ValidationReport {
51 pub errors: Vec<ValidationError>,
52 pub warnings: Vec<ValidationWarning>,
53}
54
55impl ValidationReport {
56 pub fn is_valid(&self) -> bool {
58 self.errors.is_empty()
59 }
60}
61
62pub fn validate(score: &Score) -> ValidationReport {
72 let mut errors = Vec::new();
73 let mut warnings = Vec::new();
74
75 let mut rehearsal_counts: HashMap<String, usize> = HashMap::new();
76
77 for (pi, part) in score.parts.iter().enumerate() {
78 let range = instrument_range(part.midi_program);
79 let is_percussion = part.midi_channel == 9;
80 let mut part_has_notes = false;
81
82 for (si, staff) in part.staves.iter().enumerate() {
83 let mut current_ts = score.settings.time_signature.clone();
84 let mut volta_numbers_seen: Vec<u8> = Vec::new();
85
86 for (mi, measure) in staff.measures.iter().enumerate() {
87 if let Some(ts) = &measure.time_sig {
88 current_ts = ts.clone();
89 }
90 if measure.multi_rest_count.is_some() {
91 continue;
92 }
93
94 if let Some(ref mark) = measure.rehearsal {
96 let entry = rehearsal_counts.entry(mark.clone()).or_insert(0);
97 *entry += 1;
98 }
99
100 if let Some(ref volta) = measure.volta {
102 if volta_numbers_seen.contains(&volta.number) {
103 warnings.push(ValidationWarning::OverlappingVolta {
104 part: pi,
105 staff: si,
106 });
107 } else {
108 volta_numbers_seen.push(volta.number);
109 }
110 }
111
112 let expected = current_ts.total_beats();
113 for (vi, voice) in measure.voices.iter().enumerate() {
114 if voice.is_empty() {
115 continue;
116 }
117 let non_rest_count: usize = voice.iter().filter(|n| !n.is_rest).count();
118 if non_rest_count > 0 {
119 part_has_notes = true;
120 }
121 let total: f64 = voice.iter().map(|n| n.beats()).sum();
122 if total > expected + 0.02 {
123 errors.push(ValidationError::BeatCount {
124 part: pi,
125 staff: si,
126 measure: mi,
127 voice: vi,
128 expected_beats: expected,
129 found_beats: total,
130 });
131 } else if total < expected - 0.02 && non_rest_count > 0 {
132 warnings.push(ValidationWarning::IncompleteBar {
133 part: pi,
134 staff: si,
135 measure: mi,
136 expected_beats: expected,
137 actual_beats: total,
138 });
139 }
140
141 if !is_percussion {
142 let transpose = staff.transpose_semitones;
143 for (ni, note) in voice.iter().enumerate() {
144 if note.is_rest || note.is_grace {
145 continue;
146 }
147 for pitch in ¬e.pitches {
148 let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
149 if midi < range.0 || midi > range.1 {
150 errors.push(ValidationError::OutOfRange {
151 part_index: pi,
152 staff_index: si,
153 measure_index: mi,
154 note_index: ni,
155 pitch_midi: midi,
156 instrument_range: range,
157 });
158 }
159 }
160 }
161 }
162 }
163 }
164 }
165
166 if !part_has_notes {
167 warnings.push(ValidationWarning::EmptyPart { part: pi });
168 }
169 }
170
171 for (mark, count) in &rehearsal_counts {
172 if *count > 1 {
173 warnings.push(ValidationWarning::DuplicateRehearsalMark { mark: mark.clone() });
174 }
175 }
176
177 ValidationReport { errors, warnings }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::model::{
184 duration::Duration,
185 pitch::{Pitch, Step},
186 score::{Note, Score},
187 };
188
189 #[test]
190 fn validate_clean_score_returns_empty_errors() {
191 let score = Score::new("T", 120, 4, 4, 0, 1);
192 assert!(validate(&score).errors.is_empty());
193 }
194
195 #[test]
196 fn validate_overfull_measure_returns_error() {
197 let mut score = Score::new("T", 120, 4, 4, 0, 1);
198 score.parts[0].staves[0].measures[0].voices[0]
199 .push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
200 let report = validate(&score);
201 assert!(!report.errors.is_empty());
202 assert!(matches!(
203 report.errors[0],
204 ValidationError::BeatCount {
205 measure: 0,
206 voice: 0,
207 ..
208 }
209 ));
210 }
211
212 #[test]
213 fn validate_skips_multi_rest() {
214 let mut score = Score::new("T", 120, 4, 4, 0, 1);
215 score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
216 score.parts[0].staves[0].measures[0].voices[0].clear();
217 assert!(validate(&score).errors.is_empty());
218 }
219
220 #[test]
221 fn validate_out_of_range_pitch_detected() {
222 let mut score = Score::new("T", 120, 4, 4, 0, 1);
224 score.parts[0].midi_program = 0;
225 score.parts[0].staves[0].measures[0].voices[0] =
226 vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
227 let report = validate(&score);
228 assert!(report.errors.iter().any(
229 |e| matches!(e, ValidationError::OutOfRange { pitch_midi, .. } if *pitch_midi == 120)
230 ));
231 }
232
233 #[test]
234 fn validate_percussion_channel_skips_range_check() {
235 let mut score = Score::new("T", 120, 4, 4, 0, 1);
237 score.parts[0].midi_channel = 9;
238 score.parts[0].midi_program = 0;
239 score.parts[0].staves[0].measures[0].voices[0] =
240 vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
241 let report = validate(&score);
242 assert!(
243 !report
244 .errors
245 .iter()
246 .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
247 );
248 }
249
250 #[test]
251 fn validate_in_range_pitch_ok() {
252 let mut score = Score::new("T", 120, 4, 4, 0, 1);
254 score.parts[0].midi_program = 0;
255 score.parts[0].staves[0].measures[0].voices[0] =
256 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
257 assert!(
258 !validate(&score)
259 .errors
260 .iter()
261 .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
262 );
263 }
264
265 #[test]
266 fn validate_empty_part_warning() {
267 let score = Score::new("T", 120, 4, 4, 0, 1);
268 let report = validate(&score);
269 assert!(
270 report
271 .warnings
272 .iter()
273 .any(|w| matches!(w, ValidationWarning::EmptyPart { part: 0 }))
274 );
275 }
276
277 #[test]
278 fn validate_duplicate_rehearsal_mark_warning() {
279 use crate::model::score::Score;
280 let mut score = Score::new("T", 120, 4, 4, 0, 2);
281 score.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
282 score.parts[0].staves[0].measures[1].rehearsal = Some("A".to_string());
283 let report = validate(&score);
284 assert!(report.warnings.iter().any(
285 |w| matches!(w, ValidationWarning::DuplicateRehearsalMark { mark } if mark == "A")
286 ));
287 }
288}