use super::gm::instrument_range;
use super::score::{NotationSpannerKind, NoteAddr, Score};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ValidationError {
EmptyScore,
PartWithoutStaves { part: usize },
StaffWithoutMeasures { part: usize, staff: usize },
MeasureCountMismatch {
part: usize,
staff: usize,
expected: usize,
found: usize,
},
InvalidTimeSignature {
part: usize,
staff: usize,
measure: usize,
numerator: u8,
denominator: u8,
},
BeatCount {
part: usize,
staff: usize,
measure: usize,
voice: usize,
expected_beats: f64,
found_beats: f64,
},
OutOfRange {
part_index: usize,
staff_index: usize,
measure_index: usize,
note_index: usize,
pitch_midi: u8,
instrument_range: (u8, u8),
},
InvalidTablature {
part: usize,
staff: usize,
reason: TablatureValidationReason,
},
TabPositionOutOfRange {
part: usize,
staff: usize,
measure: usize,
voice: usize,
note: usize,
string: u8,
lines: u8,
},
MicrotoneOutOfRange {
part: usize,
staff: usize,
measure: usize,
voice: usize,
note: usize,
pitch: usize,
microtone_cents: i16,
},
InvalidHarmonyRange {
part: usize,
staff: usize,
measure: usize,
voice: usize,
note: usize,
end: NoteAddr,
},
InvalidSpannerId { index: usize, id: String },
DuplicateSpannerId {
first: usize,
duplicate: usize,
id: String,
},
InvalidSpannerEndpoint {
index: usize,
id: String,
kind: NotationSpannerKind,
endpoint: SpannerEndpoint,
address: NoteAddr,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpannerEndpoint {
Start,
End,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TablatureValidationReason {
InvalidLineCount { lines: u8 },
TooManyTunings { tuning_count: usize, lines: u8 },
TuningOutOfMidiRange { index: usize, midi: i16 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ValidationWarning {
IncompleteBar {
part: usize,
staff: usize,
measure: usize,
expected_beats: f64,
actual_beats: f64,
},
OverlappingVolta { part: usize, staff: usize },
EmptyPart { part: usize },
DuplicateRehearsalMark { mark: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationReport {
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationWarning>,
}
impl ValidationReport {
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
}
pub fn validate(score: &Score) -> ValidationReport {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let mut rehearsal_counts: HashMap<String, usize> = HashMap::new();
if score.parts.is_empty() {
errors.push(ValidationError::EmptyScore);
}
let mut spanner_ids: HashMap<&str, usize> = HashMap::new();
for (index, spanner) in score.spanners.iter().enumerate() {
if spanner.id.trim().is_empty() {
errors.push(ValidationError::InvalidSpannerId {
index,
id: spanner.id.clone(),
});
} else if let Some(first) = spanner_ids.insert(spanner.id.as_str(), index) {
errors.push(ValidationError::DuplicateSpannerId {
first,
duplicate: index,
id: spanner.id.clone(),
});
}
for (endpoint, address) in [
(SpannerEndpoint::Start, &spanner.start),
(SpannerEndpoint::End, &spanner.end),
] {
if !note_exists(score, address) {
errors.push(ValidationError::InvalidSpannerEndpoint {
index,
id: spanner.id.clone(),
kind: spanner.kind.clone(),
endpoint,
address: address.clone(),
});
}
}
}
for (pi, part) in score.parts.iter().enumerate() {
let range = instrument_range(part.midi_program);
let is_percussion = part.midi_channel == 9;
let mut part_has_notes = false;
if part.staves.is_empty() {
errors.push(ValidationError::PartWithoutStaves { part: pi });
continue;
}
let expected_measure_count = part.staves[0].measures.len();
for (si, staff) in part.staves.iter().enumerate() {
if staff.measures.is_empty() {
errors.push(ValidationError::StaffWithoutMeasures {
part: pi,
staff: si,
});
continue;
}
if staff.measures.len() != expected_measure_count {
errors.push(ValidationError::MeasureCountMismatch {
part: pi,
staff: si,
expected: expected_measure_count,
found: staff.measures.len(),
});
}
if let Some(tab) = &staff.tablature {
if !(1..=64).contains(&tab.lines) {
errors.push(ValidationError::InvalidTablature {
part: pi,
staff: si,
reason: TablatureValidationReason::InvalidLineCount { lines: tab.lines },
});
} else if tab.tuning_midi.len() > usize::from(tab.lines) {
errors.push(ValidationError::InvalidTablature {
part: pi,
staff: si,
reason: TablatureValidationReason::TooManyTunings {
tuning_count: tab.tuning_midi.len(),
lines: tab.lines,
},
});
}
for (index, &midi) in tab.tuning_midi.iter().enumerate() {
if !(0..=127).contains(&midi) {
errors.push(ValidationError::InvalidTablature {
part: pi,
staff: si,
reason: TablatureValidationReason::TuningOutOfMidiRange { index, midi },
});
}
}
}
let mut current_ts = score.settings.time_signature.clone();
let mut volta_numbers_seen: Vec<u8> = Vec::new();
for (mi, measure) in staff.measures.iter().enumerate() {
if let Some(ts) = &measure.time_sig {
current_ts = ts.clone();
}
if !valid_time_signature(¤t_ts) {
errors.push(ValidationError::InvalidTimeSignature {
part: pi,
staff: si,
measure: mi,
numerator: current_ts.numerator,
denominator: current_ts.denominator,
});
continue;
}
if measure.multi_rest_count.is_some() {
continue;
}
if let Some(ref mark) = measure.rehearsal {
let entry = rehearsal_counts.entry(mark.clone()).or_insert(0);
*entry += 1;
}
if let Some(ref volta) = measure.volta {
if volta_numbers_seen.contains(&volta.number) {
warnings.push(ValidationWarning::OverlappingVolta {
part: pi,
staff: si,
});
} else {
volta_numbers_seen.push(volta.number);
}
}
let expected = current_ts.total_beats();
for (vi, voice) in measure.voices.iter().enumerate() {
if voice.is_empty() {
continue;
}
let non_rest_count: usize = voice.iter().filter(|n| !n.is_rest).count();
if non_rest_count > 0 {
part_has_notes = true;
}
let total: f64 = voice.iter().map(|n| n.beats()).sum();
if total > expected + 0.02 {
errors.push(ValidationError::BeatCount {
part: pi,
staff: si,
measure: mi,
voice: vi,
expected_beats: expected,
found_beats: total,
});
} else if total < expected - 0.02 && non_rest_count > 0 {
warnings.push(ValidationWarning::IncompleteBar {
part: pi,
staff: si,
measure: mi,
expected_beats: expected,
actual_beats: total,
});
}
for (ni, note) in voice.iter().enumerate() {
if note.is_rest || note.is_grace {
continue;
}
if let Some(chord) = ¬e.chord_symbol
&& let Some(end) = &chord.range_end
&& !note_exists(score, end)
{
errors.push(ValidationError::InvalidHarmonyRange {
part: pi,
staff: si,
measure: mi,
voice: vi,
note: ni,
end: end.clone(),
});
}
for (pitch_index, pitch) in note.pitches.iter().enumerate() {
if !(-99..=99).contains(&pitch.microtone_cents) {
errors.push(ValidationError::MicrotoneOutOfRange {
part: pi,
staff: si,
measure: mi,
voice: vi,
note: ni,
pitch: pitch_index,
microtone_cents: pitch.microtone_cents,
});
}
}
}
if !is_percussion {
let transpose = staff.transpose_semitones;
for (ni, note) in voice.iter().enumerate() {
if note.is_rest || note.is_grace {
continue;
}
if let Some(tab) = &staff.tablature {
let positions =
note.tab_position.iter().chain(note.tab_positions.iter());
for position in positions {
if position.string == 0 || position.string > tab.lines {
errors.push(ValidationError::TabPositionOutOfRange {
part: pi,
staff: si,
measure: mi,
voice: vi,
note: ni,
string: position.string,
lines: tab.lines,
});
}
}
}
for pitch in ¬e.pitches {
let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
if midi < range.0 || midi > range.1 {
errors.push(ValidationError::OutOfRange {
part_index: pi,
staff_index: si,
measure_index: mi,
note_index: ni,
pitch_midi: midi,
instrument_range: range,
});
}
}
}
}
}
}
}
if !part_has_notes {
warnings.push(ValidationWarning::EmptyPart { part: pi });
}
}
for (mark, count) in &rehearsal_counts {
if *count > 1 {
warnings.push(ValidationWarning::DuplicateRehearsalMark { mark: mark.clone() });
}
}
ValidationReport { errors, warnings }
}
fn valid_time_signature(time: &super::notation::TimeSignature) -> bool {
time.numerator > 0 && matches!(time.denominator, 1 | 2 | 4 | 8 | 16 | 32 | 64)
}
fn note_exists(score: &Score, address: &NoteAddr) -> bool {
score
.parts
.get(address.part)
.and_then(|part| part.staves.get(address.staff))
.and_then(|staff| staff.measures.get(address.measure))
.and_then(|measure| measure.voices.get(address.voice))
.and_then(|voice| voice.get(address.note))
.is_some()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
duration::Duration,
notation::ChordSymbol,
pitch::{Pitch, Step},
score::{Note, NoteAddr, Score},
};
#[test]
fn validate_clean_score_returns_empty_errors() {
let score = Score::new("T", 120, 4, 4, 0, 1);
assert!(validate(&score).errors.is_empty());
}
#[test]
fn validate_empty_score_returns_structural_error() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts.clear();
let report = validate(&score);
assert!(
report
.errors
.iter()
.any(|error| matches!(error, ValidationError::EmptyScore))
);
}
#[test]
fn validate_detects_missing_staves_and_measures() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves.clear();
let report = validate(&score);
assert!(
report
.errors
.iter()
.any(|error| matches!(error, ValidationError::PartWithoutStaves { part: 0 }))
);
score.parts[0].staves.push(crate::model::score::Staff::new(
crate::model::notation::Clef::Treble,
));
let report = validate(&score);
assert!(report.errors.iter().any(|error| matches!(
error,
ValidationError::StaffWithoutMeasures { part: 0, staff: 0 }
)));
}
#[test]
fn validate_detects_staff_measure_count_mismatch() {
let mut score = Score::template(crate::model::score::ScoreTemplate::Piano);
score.parts[0].staves[1].measures.pop();
let report = validate(&score);
assert!(report.errors.iter().any(|error| matches!(
error,
ValidationError::MeasureCountMismatch {
part: 0,
staff: 1,
expected: 4,
found: 3
}
)));
}
#[test]
fn validate_detects_invalid_time_signature() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].time_sig =
Some(crate::model::notation::TimeSignature {
numerator: 0,
denominator: 3,
});
let report = validate(&score);
assert!(report.errors.iter().any(|error| matches!(
error,
ValidationError::InvalidTimeSignature {
part: 0,
staff: 0,
measure: 0,
numerator: 0,
denominator: 3
}
)));
}
#[test]
fn validate_overfull_measure_returns_error() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0]
.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
let report = validate(&score);
assert!(!report.errors.is_empty());
assert!(matches!(
report.errors[0],
ValidationError::BeatCount {
measure: 0,
voice: 0,
..
}
));
}
#[test]
fn validate_skips_multi_rest() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
score.parts[0].staves[0].measures[0].voices[0].clear();
assert!(validate(&score).errors.is_empty());
}
#[test]
fn validate_rejects_harmony_range_to_missing_note() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
note.chord_symbol = Some(ChordSymbol {
root: "C".to_owned(),
kind: "major".to_owned(),
bass: None,
placement: None,
extender: true,
harmonic_degree: None,
harmony_function: None,
harmony_type: None,
chord_ref: None,
range_end: Some(NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 9,
}),
degrees: Vec::new(),
});
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
assert!(validate(&score).errors.iter().any(|error| matches!(
error,
ValidationError::InvalidHarmonyRange { note: 0, end, .. }
if end.note == 9
)));
}
#[test]
fn validate_out_of_range_pitch_detected() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].midi_program = 0;
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
let report = validate(&score);
assert!(report.errors.iter().any(
|e| matches!(e, ValidationError::OutOfRange { pitch_midi, .. } if *pitch_midi == 120)
));
}
#[test]
fn validate_percussion_channel_skips_range_check() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].midi_channel = 9;
score.parts[0].midi_program = 0;
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
let report = validate(&score);
assert!(
!report
.errors
.iter()
.any(|e| matches!(e, ValidationError::OutOfRange { .. }))
);
}
#[test]
fn validate_in_range_pitch_ok() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].midi_program = 0;
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
assert!(
!validate(&score)
.errors
.iter()
.any(|e| matches!(e, ValidationError::OutOfRange { .. }))
);
}
#[test]
fn validate_rejects_deserialized_microtone_out_of_range() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
note.pitches[0].microtone_cents = 100;
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let report = validate(&score);
assert!(report.errors.iter().any(|error| matches!(
error,
ValidationError::MicrotoneOutOfRange {
microtone_cents: 100,
..
}
)));
}
#[test]
fn validate_rejects_invalid_and_duplicate_typed_spanners() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
let address = NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 0,
};
score.spanners = vec![
super::super::score::NotationSpanner {
id: String::new(),
kind: NotationSpannerKind::Slur,
start: address.clone(),
end: address.clone(),
number: Some(1),
line_type: None,
text: None,
placement: None,
ottava_size: None,
ottava_type: None,
},
super::super::score::NotationSpanner {
id: "duplicate".to_string(),
kind: NotationSpannerKind::Pedal,
start: address.clone(),
end: NoteAddr { note: 9, ..address },
number: Some(2),
line_type: None,
text: None,
placement: None,
ottava_size: None,
ottava_type: None,
},
super::super::score::NotationSpanner {
id: "duplicate".to_string(),
kind: NotationSpannerKind::Ottava,
start: NoteAddr {
part: 9,
staff: 0,
measure: 0,
voice: 0,
note: 0,
},
end: NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 0,
},
number: None,
line_type: None,
text: None,
placement: None,
ottava_size: Some(8),
ottava_type: None,
},
];
let report = validate(&score);
assert!(
report
.errors
.iter()
.any(|error| matches!(error, ValidationError::InvalidSpannerId { index: 0, .. }))
);
assert!(report.errors.iter().any(|error| matches!(
error,
ValidationError::DuplicateSpannerId {
first: 1,
duplicate: 2,
..
}
)));
assert_eq!(
report
.errors
.iter()
.filter(|error| matches!(error, ValidationError::InvalidSpannerEndpoint { .. }))
.count(),
2
);
}
#[test]
fn validate_rejects_invalid_tablature_metadata_and_positions() {
let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
lines: 6,
tuning_midi: vec![64, 59, 55, 50, 45, 40, 35],
capo: 0,
});
let mut note = Note::new(Pitch::new(Step::E, 4), Duration::Whole);
note.tab_position = Some(super::super::notation::TabPosition { string: 7, fret: 0 });
note.tab_positions = vec![super::super::notation::TabPosition { string: 8, fret: 3 }];
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let report = validate(&score);
assert!(report.errors.iter().any(|error| matches!(
error,
ValidationError::InvalidTablature {
reason: TablatureValidationReason::TooManyTunings { .. },
..
}
)));
assert!(
report
.errors
.iter()
.any(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
);
assert_eq!(
report
.errors
.iter()
.filter(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
.count(),
2
);
}
#[test]
fn validate_empty_part_warning() {
let score = Score::new("T", 120, 4, 4, 0, 1);
let report = validate(&score);
assert!(
report
.warnings
.iter()
.any(|w| matches!(w, ValidationWarning::EmptyPart { part: 0 }))
);
}
#[test]
fn validate_duplicate_rehearsal_mark_warning() {
use crate::model::score::Score;
let mut score = Score::new("T", 120, 4, 4, 0, 2);
score.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
score.parts[0].staves[0].measures[1].rehearsal = Some("A".to_string());
let report = validate(&score);
assert!(report.warnings.iter().any(
|w| matches!(w, ValidationWarning::DuplicateRehearsalMark { mark } if mark == "A")
));
}
}