1use std::collections::HashMap;
2use serde::{Deserialize, Serialize};
3use super::gm::instrument_range;
4use super::score::Score;
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 { part: pi, staff: si });
104 } else {
105 volta_numbers_seen.push(volta.number);
106 }
107 }
108
109 let expected = current_ts.total_beats();
110 for (vi, voice) in measure.voices.iter().enumerate() {
111 if voice.is_empty() {
112 continue;
113 }
114 let non_rest_count: usize = voice.iter().filter(|n| !n.is_rest).count();
115 if non_rest_count > 0 {
116 part_has_notes = true;
117 }
118 let total: f64 = voice.iter().map(|n| n.beats()).sum();
119 if total > expected + 0.02 {
120 errors.push(ValidationError::BeatCount {
121 part: pi,
122 staff: si,
123 measure: mi,
124 voice: vi,
125 expected_beats: expected,
126 found_beats: total,
127 });
128 } else if total < expected - 0.02 && non_rest_count > 0 {
129 warnings.push(ValidationWarning::IncompleteBar {
130 part: pi, staff: si, measure: mi,
131 expected_beats: expected, actual_beats: total,
132 });
133 }
134
135 if !is_percussion {
136 let transpose = staff.transpose_semitones;
137 for (ni, note) in voice.iter().enumerate() {
138 if note.is_rest || note.is_grace {
139 continue;
140 }
141 for pitch in ¬e.pitches {
142 let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
143 if midi < range.0 || midi > range.1 {
144 errors.push(ValidationError::OutOfRange {
145 part_index: pi,
146 staff_index: si,
147 measure_index: mi,
148 note_index: ni,
149 pitch_midi: midi,
150 instrument_range: range,
151 });
152 }
153 }
154 }
155 }
156 }
157 }
158 }
159
160 if !part_has_notes {
161 warnings.push(ValidationWarning::EmptyPart { part: pi });
162 }
163 }
164
165 for (mark, count) in &rehearsal_counts {
166 if *count > 1 {
167 warnings.push(ValidationWarning::DuplicateRehearsalMark { mark: mark.clone() });
168 }
169 }
170
171 ValidationReport { errors, warnings }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::model::{
178 duration::Duration,
179 pitch::{Pitch, Step},
180 score::{Note, Score},
181 };
182
183 #[test]
184 fn validate_clean_score_returns_empty_errors() {
185 let score = Score::new("T", 120, 4, 4, 0, 1);
186 assert!(validate(&score).errors.is_empty());
187 }
188
189 #[test]
190 fn validate_overfull_measure_returns_error() {
191 let mut score = Score::new("T", 120, 4, 4, 0, 1);
192 score.parts[0].staves[0].measures[0].voices[0]
193 .push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
194 let report = validate(&score);
195 assert!(!report.errors.is_empty());
196 assert!(matches!(report.errors[0], ValidationError::BeatCount { measure: 0, voice: 0, .. }));
197 }
198
199 #[test]
200 fn validate_skips_multi_rest() {
201 let mut score = Score::new("T", 120, 4, 4, 0, 1);
202 score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
203 score.parts[0].staves[0].measures[0].voices[0].clear();
204 assert!(validate(&score).errors.is_empty());
205 }
206
207 #[test]
208 fn validate_out_of_range_pitch_detected() {
209 let mut score = Score::new("T", 120, 4, 4, 0, 1);
211 score.parts[0].midi_program = 0;
212 score.parts[0].staves[0].measures[0].voices[0] =
213 vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
214 let report = validate(&score);
215 assert!(report.errors.iter().any(|e| matches!(e, ValidationError::OutOfRange { pitch_midi, .. } if *pitch_midi == 120)));
216 }
217
218 #[test]
219 fn validate_percussion_channel_skips_range_check() {
220 let mut score = Score::new("T", 120, 4, 4, 0, 1);
222 score.parts[0].midi_channel = 9;
223 score.parts[0].midi_program = 0;
224 score.parts[0].staves[0].measures[0].voices[0] =
225 vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
226 let report = validate(&score);
227 assert!(!report.errors.iter().any(|e| matches!(e, ValidationError::OutOfRange { .. })));
228 }
229
230 #[test]
231 fn validate_in_range_pitch_ok() {
232 let mut score = Score::new("T", 120, 4, 4, 0, 1);
234 score.parts[0].midi_program = 0;
235 score.parts[0].staves[0].measures[0].voices[0] =
236 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
237 assert!(!validate(&score).errors.iter().any(|e| matches!(e, ValidationError::OutOfRange { .. })));
238 }
239
240 #[test]
241 fn validate_empty_part_warning() {
242 let score = Score::new("T", 120, 4, 4, 0, 1);
243 let report = validate(&score);
244 assert!(report.warnings.iter().any(|w| matches!(w, ValidationWarning::EmptyPart { part: 0 })));
245 }
246
247 #[test]
248 fn validate_duplicate_rehearsal_mark_warning() {
249 use crate::model::score::Score;
250 let mut score = Score::new("T", 120, 4, 4, 0, 2);
251 score.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
252 score.parts[0].staves[0].measures[1].rehearsal = Some("A".to_string());
253 let report = validate(&score);
254 assert!(report.warnings.iter().any(|w| matches!(w, ValidationWarning::DuplicateRehearsalMark { mark } if mark == "A")));
255 }
256}