use super::*;
use crate::model::{
Accidental, AlignedSymbolKind, LyricControl, RestVisibility, SlurRole, TieRole, TimedEvent,
TimedEventKind, TupletRole,
};
use crate::options::ParseOptions;
use crate::parse::{parse_document, parse_tune_report_from_document};
use crate::syntax::{
AnnotationPlacement, DecorationKind, MalformedSyntax, MalformedSyntaxKind, OctaveMark,
QuotedTextKind, VariantEndingPart,
};
fn events_for(source: &str) -> (Vec<Event>, Vec<Diagnostic>) {
let document = parse_document(source, ParseOptions::default()).value;
let report = parse_tune_report_from_document(&document);
(
report.value.expect("expected tune").events,
report.diagnostics,
)
}
fn count_diagnostics(diagnostics: &[Diagnostic], code: &'static str) -> usize {
diagnostics
.iter()
.filter(|diagnostic| diagnostic.code == code)
.count()
}
#[test]
fn normalizes_pitch_case_and_mixed_octave_marks() {
let (events, diagnostics) = events_for("X:1\nL:1/8\nK:C\nC C' c C,',\n");
assert!(diagnostics.is_empty());
let octaves = events
.iter()
.filter_map(|event| match event {
Event::Note { octave, .. } => Some(*octave),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(octaves, vec![4, 5, 5, 3]);
}
#[test]
fn recovers_standalone_octave_marks_without_attaching_to_neighbor_notes() {
let document_report = parse_document("X:1\nL:1/8\nK:C\n' , C\n", ParseOptions::default());
assert_eq!(
count_diagnostics(&document_report.diagnostics, "abc.music.malformed_octave"),
2
);
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let malformed = tune_music.lines[0]
.items
.iter()
.filter_map(|item| match item {
MusicItem::Malformed(item) => Some(item),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(malformed.len(), 2);
assert!(malformed.iter().all(|item| !item.span.is_empty()));
let tune_report = parse_tune_report_from_document(&document_report.value);
let events = tune_report.value.expect("expected tune").events;
assert!(matches!(
events.as_slice(),
[Event::Note {
step: 'C',
octave: 4,
accidental: None,
..
}]
));
}
#[test]
fn preserves_explicit_accidentals_in_semantic_events() {
let (events, diagnostics) = events_for("X:1\nL:1/8\nK:C\n^C _D =E ^^F __G\n");
assert!(diagnostics.is_empty());
let accidentals = events
.iter()
.filter_map(|event| match event {
Event::Note { accidental, .. } => Some(*accidental),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
accidentals,
vec![
Some(Accidental::Sharp),
Some(Accidental::Flat),
Some(Accidental::Natural),
Some(Accidental::DoubleSharp),
Some(Accidental::DoubleFlat),
]
);
}
#[test]
fn recovers_dangling_accidentals_without_leaking_into_later_notes() {
let document_report = parse_document("X:1\nL:1/8\nK:C\n^ _ = C\n", ParseOptions::default());
assert_eq!(
count_diagnostics(
&document_report.diagnostics,
"abc.music.malformed_accidental"
),
3
);
let tune_report = parse_tune_report_from_document(&document_report.value);
let events = tune_report.value.expect("expected tune").events;
assert!(matches!(
events.as_slice(),
[Event::Note {
step: 'C',
accidental: None,
..
}]
));
}
#[test]
fn recovers_accidental_separated_from_note_by_misplaced_length() {
let document_report = parse_document("X:1\nL:1/4\nK:C\n^/c c\n", ParseOptions::default());
assert_eq!(
count_diagnostics(&document_report.diagnostics, "abc.music.malformed_length"),
1
);
assert_eq!(
count_diagnostics(
&document_report.diagnostics,
"abc.music.malformed_accidental"
),
0
);
let tune_report = parse_tune_report_from_document(&document_report.value);
let events = tune_report.value.expect("expected tune").events;
assert!(matches!(
events.as_slice(),
[
Event::Note {
step: 'C',
accidental: Some(Accidental::Sharp),
duration: sharp_duration,
..
},
Event::Note {
step: 'C',
accidental: None,
duration: plain_duration,
..
},
] if sharp_duration == plain_duration
));
}
#[test]
fn lowers_fractional_lengths_and_slash_shorthand() {
let document = parse_document(
"X:1\nL:1/8\nK:C\nA2 A/ A// A3/2 A/4\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
assert!(report.diagnostics.is_empty());
assert_eq!(tune.divisions, 8);
let durations = tune
.events
.iter()
.filter_map(|event| match event {
Event::Note { duration, .. } => Some(*duration),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(durations, vec![8, 2, 1, 6, 1]);
}
#[test]
fn recovers_malformed_lengths_and_preserves_valid_neighbors() {
let document_report =
parse_document("X:1\nL:1/8\nK:C\nA0 B/0 C 3 / D\n", ParseOptions::default());
assert_eq!(
count_diagnostics(&document_report.diagnostics, "abc.music.malformed_length"),
4
);
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let malformed_spans = tune_music.lines[0]
.items
.iter()
.filter_map(|item| match item {
MusicItem::Malformed(item) => Some(item.span),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(malformed_spans.len(), 2);
assert!(
malformed_spans
.iter()
.all(|span| document_report.value.source.slice(*span).is_some())
);
let tune_report = parse_tune_report_from_document(&document_report.value);
let tune = tune_report.value.expect("expected tune");
let durations = tune
.events
.iter()
.filter_map(|event| match event {
Event::Note { duration, .. } => Some(*duration),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(durations, vec![4, 4, 4, 4]);
}
#[test]
fn lowers_multi_measure_rests_in_known_and_free_meter() {
let document = parse_document(
"X:1\nM:4/4\nL:1/4\nK:C\nCDEF|Z2|GABc|]\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
assert!(report.diagnostics.is_empty());
let tune = report.value.expect("expected tune");
let voice = &tune.score.parts[0].voices[0];
assert_eq!(voice.measures.len(), 4);
assert_eq!(
voice
.measures
.iter()
.map(|measure| measure.actual_duration)
.collect::<Vec<_>>(),
vec![Fraction::new(4, 4); 4]
);
let rest_measures = voice
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::Rest(rest) if rest.visibility == RestVisibility::Visible => {
Some(event.measure.number)
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(rest_measures, vec![2, 3]);
let document = parse_document("X:1\nM:none\nL:1/8\nK:C\nZ3\n", ParseOptions::default()).value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
assert!(
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.multirest.free_meter")
);
assert_eq!(tune.events.len(), 1);
assert!(matches!(tune.events[0], Event::Rest { duration: 12, .. }));
}
#[test]
fn lowers_visible_invisible_rests_and_spacers() {
let (events, diagnostics) = events_for("X:1\nL:1/8\nK:C\nz x y C\n");
assert!(diagnostics.is_empty());
let rests = events
.iter()
.filter_map(|event| match event {
Event::Rest {
visibility,
duration,
..
} => Some((*visibility, *duration)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
rests,
vec![(RestVisibility::Visible, 4), (RestVisibility::Invisible, 4),]
);
assert_eq!(
events
.iter()
.filter(|event| matches!(event, Event::Spacer { .. }))
.count(),
1
);
}
#[test]
fn malformed_rest_lengths_recover_to_safe_durations() {
let document_report = parse_document("X:1\nL:1/8\nK:C\nz0 x/0\n", ParseOptions::default());
assert_eq!(
count_diagnostics(&document_report.diagnostics, "abc.music.malformed_length"),
2
);
let tune_report = parse_tune_report_from_document(&document_report.value);
let rests = tune_report
.value
.expect("expected tune")
.events
.into_iter()
.filter_map(|event| match event {
Event::Rest {
visibility,
duration,
..
} => Some((visibility, duration)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
rests,
vec![(RestVisibility::Visible, 4), (RestVisibility::Invisible, 4),]
);
}
#[test]
fn lowers_basic_double_and_repeat_barlines() {
let (events, diagnostics) = events_for("X:1\nK:C\nC|D||E|:F:|G::A[|B|]c\n");
assert!(diagnostics.is_empty());
let barlines = events
.iter()
.filter_map(|event| match event {
Event::Barline { kind, .. } => Some(*kind),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
barlines,
vec![
BarlineKind::Regular,
BarlineKind::Double,
BarlineKind::RepeatStart,
BarlineKind::RepeatEnd,
BarlineKind::RepeatBoth,
BarlineKind::Initial,
BarlineKind::Final,
]
);
}
#[test]
fn recovers_invalid_barline_fragments_as_skipped_malformed_items() {
let document_report = parse_document("X:1\nK:C\nC : D\n", ParseOptions::default());
assert_eq!(
count_diagnostics(&document_report.diagnostics, "abc.music.invalid_barline"),
1
);
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
assert!(tune_music.lines[0].items.iter().any(|item| matches!(
item,
MusicItem::Malformed(MalformedSyntax {
kind: MalformedSyntaxKind::InvalidBarline,
..
})
)));
let tune_report = parse_tune_report_from_document(&document_report.value);
let notes = tune_report
.value
.expect("expected tune")
.events
.into_iter()
.filter(|event| matches!(event, Event::Note { .. }))
.count();
assert_eq!(notes, 2);
}
#[test]
fn parses_liberal_dotted_and_invisible_barlines_with_diagnostics() {
let report = parse_document("X:1\nK:C\nC |[| D .| E [|] F\n", ParseOptions::default());
assert!(
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.barline.liberal")
);
assert!(
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.barline.policy")
);
let tune_report = parse_tune_report_from_document(&report.value);
assert!(
tune_report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.barline_policy")
);
}
#[test]
fn trailing_final_barline_after_liberal_boundary_stays_on_previous_measure() {
let document = parse_document(
"X:1\nM:4/4\nL:1/4\nK:C\nCDEF:\n|]\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let measures = &tune.score.parts[0].voices[0].measures;
assert_eq!(measures.len(), 1);
assert!(
measures[0]
.barlines
.iter()
.any(|barline| barline.kind == BarlineKind::Final),
"explicit final |] after a liberal : boundary must survive: {:?}",
measures[0].barlines
);
}
#[test]
fn unclosed_inline_fields_groups_and_strings_are_recoverable_syntax() {
let document_report = parse_document(
"X:1\nK:C\nC [M:3/4\nD {ef\nE \"Am\nF [CE\nG\n",
ParseOptions::default(),
);
for code in [
"abc.music.unclosed_inline_field",
"abc.music.unclosed_grace",
"abc.music.unclosed_quoted_text",
"abc.music.unclosed_chord",
] {
assert!(
document_report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == code),
"expected diagnostic {code}"
);
}
let tune_report = parse_tune_report_from_document(&document_report.value);
let notes = tune_report
.value
.expect("expected tune")
.events
.into_iter()
.filter_map(|event| match event {
Event::Note { step, .. } => Some(step),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(notes, vec!['C', 'D', 'E', 'F', 'G']);
}
#[test]
fn parses_spec_attachment_order_around_note_group() {
let document_report =
parse_document("X:1\nL:1/8\nK:C\n\"Gm7\"v.=G,2\n", ParseOptions::default());
assert!(document_report.diagnostics.is_empty());
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let note = tune_music.lines[0]
.items
.iter()
.find_map(|item| match item {
MusicItem::Note(note) => Some(note),
_ => None,
})
.expect("expected note");
assert_eq!(note.attachments.chord_symbols[0].text, "Gm7");
assert_eq!(
note.attachments
.decorations
.iter()
.map(|decoration| decoration.name.as_str())
.collect::<Vec<_>>(),
vec!["downbow", "."]
);
assert_eq!(
note.accidental.map(|accidental| accidental.sign),
Some(Accidental::Natural)
);
assert_eq!(note.octave_marks[0].mark, OctaveMark::Lower);
assert_eq!(
note.length.as_ref().map(|length| length.raw.as_str()),
Some("2")
);
}
#[test]
fn classifies_quoted_chord_symbols_and_annotations() {
let document_report = parse_document(
"X:1\nL:1/8\nK:C\n\"Am7\"C \"^above\"D \"_below\"E \"<left\"F \">right\"G \"@free\"A\n",
ParseOptions::default(),
);
assert!(document_report.diagnostics.is_empty());
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let notes = tune_music.lines[0]
.items
.iter()
.filter_map(|item| match item {
MusicItem::Note(note) => Some(note),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(notes[0].attachments.chord_symbols[0].text, "Am7");
let placements = notes[1..]
.iter()
.map(|note| note.attachments.annotations[0].kind)
.collect::<Vec<_>>();
assert_eq!(
placements,
vec![
QuotedTextKind::Annotation(AnnotationPlacement::Above),
QuotedTextKind::Annotation(AnnotationPlacement::Below),
QuotedTextKind::Annotation(AnnotationPlacement::Left),
QuotedTextKind::Annotation(AnnotationPlacement::Right),
QuotedTextKind::Annotation(AnnotationPlacement::Free),
]
);
}
#[test]
fn quoted_text_before_grace_group_stays_in_main_note_bundle() {
let document_report = parse_document("X:1\nL:1/8\nK:C\n\"F\"{AB}c\n", ParseOptions::default());
assert!(document_report.diagnostics.is_empty());
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let note = tune_music.lines[0]
.items
.iter()
.find_map(|item| match item {
MusicItem::Note(note) => Some(note),
_ => None,
})
.expect("expected note");
assert_eq!(note.pitch.step, 'c');
assert_eq!(note.attachments.chord_symbols[0].text, "F");
assert_eq!(note.attachments.grace_groups.len(), 1);
let grace = ¬e.attachments.grace_groups[0];
assert!(grace.elements.iter().all(|element| match element {
crate::syntax::GraceElementSyntax::Note(grace_note) =>
grace_note.attachments.chord_symbols.is_empty(),
_ => true,
}));
}
#[test]
fn grace_group_internal_slur_tokens_do_not_warn() {
let source = "X:1\nL:1/8\nK:C\n{(fg)}a2 {(ef)}g2|]\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
count_diagnostics(&diagnostics, "abc.music.unknown_grace_token"),
0,
"grace-internal slur markers should not warn: {diagnostics:?}"
);
let notes = semantic_note_events(&tune);
assert_eq!(notes[0].attachments.grace_groups.len(), 1);
assert_eq!(notes[1].attachments.grace_groups.len(), 1);
for group in [
¬es[0].attachments.grace_groups[0],
¬es[1].attachments.grace_groups[0],
] {
assert_eq!(group.events.len(), 2);
assert_eq!(group.events[0].slurs.len(), 1);
assert_eq!(group.events[0].slurs[0].role, SlurRole::Start);
assert_eq!(group.events[1].slurs.len(), 1);
assert_eq!(group.events[1].slurs[0].role, SlurRole::Stop);
assert_eq!(
group.events[0].slurs[0].pair_id,
group.events[1].slurs[0].pair_id
);
}
assert_ne!(
notes[0].attachments.grace_groups[0].events[0].slurs[0].pair_id,
notes[1].attachments.grace_groups[0].events[0].slurs[0].pair_id
);
}
#[test]
fn chord_internal_slurs_bind_to_their_own_member() {
let (tune, _diagnostics) = tune_for("X:1\nL:1/4\nK:C\n[(C(E] [C)E)]\n");
let chords: Vec<&crate::model::ChordEvent> = tune.score.parts[0].voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::Chord(chord) => Some(chord),
_ => None,
})
.collect();
assert_eq!(chords.len(), 2, "two chords");
let role_of = |chord: &crate::model::ChordEvent, member: usize| {
let slurs = &chord.members[member].attachments.slurs;
assert_eq!(slurs.len(), 1, "each chord member carries exactly one slur");
slurs[0].role
};
assert_eq!(role_of(chords[0], 0), SlurRole::Start, "chord1 C start");
assert_eq!(role_of(chords[0], 1), SlurRole::Start, "chord1 E start");
assert_eq!(role_of(chords[1], 0), SlurRole::Stop, "chord2 C stop");
assert_eq!(role_of(chords[1], 1), SlurRole::Stop, "chord2 E stop");
}
#[test]
fn slur_start_anchors_on_a_following_rest() {
let (tune, diagnostics) = tune_for("X:1\nL:1/8\nK:C\n(z2 G2)\n");
assert_eq!(
count_diagnostics(&diagnostics, "abc.music.unmatched_slur"),
0,
"balanced slur over a rest should not warn: {diagnostics:?}"
);
let events = &tune.score.parts[0].voices[0].events;
let rest = events
.iter()
.find(|event| matches!(event.kind, TimedEventKind::Rest(_)))
.expect("expected a rest event");
assert_eq!(
rest.attachments.slurs.len(),
1,
"slur start should bind to the rest, not skip to the next note"
);
assert_eq!(rest.attachments.slurs[0].role, SlurRole::Start);
let note = events
.iter()
.find(|event| matches!(event.kind, TimedEventKind::Note(_)))
.expect("expected a note event");
assert_eq!(note.attachments.slurs.len(), 1);
assert_eq!(note.attachments.slurs[0].role, SlurRole::Stop);
assert_eq!(
rest.attachments.slurs[0].pair_id,
note.attachments.slurs[0].pair_id
);
}
#[test]
fn malformed_grace_group_internal_slurs_warn_and_skip_attachments() {
for (source, code) in [
("X:1\nL:1/8\nK:C\n{(fg}a|]\n", "abc.music.unclosed_slur"),
("X:1\nL:1/8\nK:C\n{fg)}a|]\n", "abc.music.unmatched_slur"),
] {
let (tune, diagnostics) = tune_for(source);
assert_eq!(
count_diagnostics(&diagnostics, code),
1,
"expected {code} for {source:?}: {diagnostics:?}"
);
let notes = semantic_note_events(&tune);
let grace = ¬es[0].attachments.grace_groups[0];
assert_eq!(grace.events.len(), 2);
assert!(
grace.events.iter().all(|event| event.slurs.is_empty()),
"malformed grace-internal slur markers should be skipped: {grace:?}"
);
}
}
#[test]
fn grace_group_internal_slur_can_span_chord_and_note() {
let source = "X:1\nL:1/8\nK:C\n{([fg]a)}c|]\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let notes = semantic_note_events(&tune);
let grace = ¬es[0].attachments.grace_groups[0];
assert_eq!(grace.events.len(), 2);
assert_eq!(grace.events[0].slurs.len(), 1);
assert_eq!(grace.events[0].slurs[0].role, SlurRole::Start);
assert_eq!(grace.events[1].slurs.len(), 1);
assert_eq!(grace.events[1].slurs[0].role, SlurRole::Stop);
assert_eq!(
grace.events[0].slurs[0].pair_id,
grace.events[1].slurs[0].pair_id
);
}
#[test]
fn bare_grace_slur_closes_on_last_grace_note() {
let source = "X:1\nL:1/8\nK:C\n({Bc})D|\n";
let (tune, diagnostics) = tune_for(source);
assert!(
diagnostics.is_empty(),
"bare grace slur should not warn: {diagnostics:?}"
);
let notes = semantic_note_events(&tune);
assert_eq!(notes.len(), 1);
assert!(
notes[0].attachments.slurs.is_empty(),
"slur should bind to the grace group, not the main note"
);
let grace = ¬es[0].attachments.grace_groups[0];
assert_eq!(grace.events.len(), 2);
assert_eq!(grace.slurs.len(), 1);
assert_eq!(grace.slurs[0].role, SlurRole::Start);
assert_eq!(grace.events[1].slurs.len(), 1);
assert_eq!(grace.events[1].slurs[0].role, SlurRole::Stop);
assert_eq!(grace.slurs[0].pair_id, grace.events[1].slurs[0].pair_id);
}
#[test]
fn bare_grace_slur_before_barline_resolves_as_after_grace() {
let source = "X:1\nL:1/8\nK:C\nA2({Bc})|\n";
let (tune, diagnostics) = tune_for(source);
assert!(
diagnostics.is_empty(),
"bare grace before barline should not warn: {diagnostics:?}"
);
let notes = semantic_note_events(&tune);
assert_eq!(notes.len(), 1);
assert!(notes[0].attachments.slurs.is_empty());
assert!(notes[0].attachments.grace_groups.is_empty());
let grace = ¬es[0].attachments.after_grace_groups[0];
assert_eq!(grace.events.len(), 2);
assert_eq!(grace.slurs.len(), 1);
assert_eq!(grace.slurs[0].role, SlurRole::Start);
assert_eq!(grace.events[1].slurs.len(), 1);
assert_eq!(grace.events[1].slurs[0].role, SlurRole::Stop);
assert_eq!(grace.slurs[0].pair_id, grace.events[1].slurs[0].pair_id);
}
#[test]
fn parses_user_defined_and_legacy_decoration_symbols_from_dialect_state() {
let user_symbol = parse_document("X:1\nU:W=!trill!\nK:C\nWC\n", ParseOptions::default());
assert!(user_symbol.diagnostics.is_empty());
let tune_music = user_symbol
.value
.music
.tune(0)
.expect("expected parsed tune music");
let note = tune_music.lines[0]
.items
.iter()
.find_map(|item| match item {
MusicItem::Note(note) => Some(note),
_ => None,
})
.expect("expected note");
assert_eq!(
note.attachments.decorations[0].kind,
DecorationKind::UserDefined
);
assert_eq!(note.attachments.decorations[0].name, "trill");
let legacy_allowed = parse_document(
"X:1\nI:decoration +\nK:C\n+trill+C\n",
ParseOptions::default(),
);
assert!(legacy_allowed.diagnostics.is_empty());
let tune_music = legacy_allowed
.value
.music
.tune(0)
.expect("expected parsed tune music");
let note = tune_music.lines[0]
.items
.iter()
.find_map(|item| match item {
MusicItem::Note(note) => Some(note),
_ => None,
})
.expect("expected note");
assert_eq!(
note.attachments.decorations[0].kind,
DecorationKind::LegacyNamed
);
let legacy_rejected = parse_document("X:1\nK:C\n+trill+C\n", ParseOptions::default());
assert!(
legacy_rejected
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.invalid_decoration")
);
}
#[test]
fn parses_chord_with_inside_and_outside_decorations() {
let document_report =
parse_document("X:1\nL:1/8\nK:C\n!trill![.CEG]\n", ParseOptions::default());
assert!(document_report.diagnostics.is_empty());
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let chord = tune_music.lines[0]
.items
.iter()
.find_map(|item| match item {
MusicItem::Chord(chord) => Some(chord),
_ => None,
})
.expect("expected chord");
assert_eq!(chord.attachments.decorations[0].name, "trill");
assert_eq!(chord.members.len(), 3);
assert_eq!(chord.members[0].note.attachments.decorations[0].name, ".");
}
#[test]
fn parses_chord_internal_tie_marker_on_member() {
let document_report = parse_document("X:1\nL:1/8\nK:C\n[DA-]\n", ParseOptions::default());
assert_eq!(
count_diagnostics(
&document_report.diagnostics,
"abc.music.unknown_chord_token"
),
0,
"chord-internal tie must not be reported as an unknown chord token"
);
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let chord = tune_music.lines[0]
.items
.iter()
.find_map(|item| match item {
MusicItem::Chord(chord) => Some(chord),
_ => None,
})
.expect("expected chord");
assert_eq!(chord.members.len(), 2);
assert!(
chord.members[0].tie.is_none(),
"D member must not carry a tie"
);
assert!(
chord.members[1].tie.is_some(),
"A member must carry the internal tie marker"
);
}
#[test]
fn unclosed_chord_scan_stops_at_barline() {
let document_report = parse_document(
"X:1\nL:1/8\nK:C\n[ |: CDEF | GABc |\n",
ParseOptions::default(),
);
assert_eq!(
count_diagnostics(&document_report.diagnostics, "abc.music.unclosed_chord"),
1,
"the unclosed bracket should yield exactly one unclosed-chord diagnostic"
);
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let note_letters = tune_music.lines[0]
.items
.iter()
.filter_map(|item| match item {
MusicItem::Note(note) => Some(note.pitch.step),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
note_letters,
vec!['C', 'D', 'E', 'F', 'G', 'A', 'B', 'c'],
"all notes after the unclosed bracket must survive"
);
}
#[test]
fn unclosed_chord_member_before_barline_recovers_as_note() {
let source = "X:1\nL:1/8\nK:C\ne2 E E2 ][ f |\n";
let document_report = parse_document(source, ParseOptions::default());
assert_eq!(
count_diagnostics(&document_report.diagnostics, "abc.music.unclosed_chord"),
1,
"the unclosed bracket should yield exactly one unclosed-chord diagnostic"
);
let diagnostic = document_report
.diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "abc.music.unclosed_chord")
.expect("expected unclosed-chord diagnostic");
assert_eq!(
&source[diagnostic.span.start..diagnostic.span.end],
"[ f ",
"the diagnostic span should stay on the malformed bracket run before the barline"
);
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let note_letters = tune_music.lines[0]
.items
.iter()
.filter_map(|item| match item {
MusicItem::Note(note) => Some(note.pitch.step),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
note_letters,
vec!['e', 'E', 'E', 'f'],
"the note inside the unclosed bracket should recover as an ordinary note"
);
let recovered_note_index = tune_music.lines[0]
.items
.iter()
.position(|item| matches!(item, MusicItem::Note(note) if note.pitch.step == 'f'))
.expect("expected recovered f note");
assert!(
tune_music.lines[0].items[recovered_note_index + 1..]
.iter()
.any(|item| matches!(item, MusicItem::Barline(_))),
"the barline that stopped the malformed chord scan must still be parsed"
);
let malformed = tune_music.lines[0]
.items
.iter()
.filter_map(|item| match item {
MusicItem::Malformed(item) if item.kind == MalformedSyntaxKind::UnclosedChord => {
Some(item)
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(malformed.len(), 1);
assert_eq!(
&source[malformed[0].span.start..malformed[0].span.end],
"[ f ",
"the malformed syntax span should stay on the malformed bracket run"
);
}
#[test]
fn unclosed_chord_quoted_run_before_barline_recovers_members_as_notes() {
let source = "X:1\nM:4/4\nL:1/8\nK:C\n|[F\"cont\" \"Am7\"GAB cAFA |\n";
let document_report = parse_document(source, ParseOptions::default());
assert_eq!(
count_diagnostics(&document_report.diagnostics, "abc.music.unclosed_chord"),
1,
"the unclosed bracket should yield exactly one unclosed-chord diagnostic"
);
let diagnostic = document_report
.diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "abc.music.unclosed_chord")
.expect("expected unclosed-chord diagnostic");
assert_eq!(
&source[diagnostic.span.start..diagnostic.span.end],
"[F\"cont\" \"Am7\"GAB cAFA ",
"the diagnostic span should stay on the malformed quoted bracket run before the barline"
);
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let note_letters = tune_music.lines[0]
.items
.iter()
.filter_map(|item| match item {
MusicItem::Note(note) => Some(note.pitch.step),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
note_letters,
vec!['F', 'G', 'A', 'B', 'c', 'A', 'F', 'A'],
"all notes inside the unclosed bracket should recover in source order"
);
}
#[test]
fn unclosed_chord_inside_grace_group_does_not_recover_members_as_main_notes() {
let source = "X:1\nL:1/8\nK:C\n{[CDE | G} A|\n";
let document_report = parse_document(source, ParseOptions::default());
assert_eq!(
count_diagnostics(&document_report.diagnostics, "abc.music.unclosed_chord"),
1,
"the grace-internal unclosed bracket should still diagnose once"
);
let diagnostic = document_report
.diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "abc.music.unclosed_chord")
.expect("expected unclosed-chord diagnostic");
assert_eq!(
&source[diagnostic.span.start..diagnostic.span.end],
"[CDE ",
"the diagnostic span should stay on the malformed grace-internal bracket run"
);
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let note_letters = tune_music.lines[0]
.items
.iter()
.filter_map(|item| match item {
MusicItem::Note(note) => Some(note.pitch.step),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
note_letters,
vec!['A'],
"grace-internal malformed chord members must not leak as mainline notes"
);
}
#[test]
fn valid_chord_before_barline_does_not_swallow_following_note() {
let document_report = parse_document("X:1\nL:1/8\nK:C\n[CEG]|D\n", ParseOptions::default());
assert_eq!(
count_diagnostics(&document_report.diagnostics, "abc.music.unclosed_chord"),
0,
"a closed chord must not report an unclosed-chord diagnostic"
);
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let chord = tune_music.lines[0]
.items
.iter()
.find_map(|item| match item {
MusicItem::Chord(chord) => Some(chord),
_ => None,
})
.expect("expected chord");
assert_eq!(chord.members.len(), 3);
let trailing_note = tune_music.lines[0]
.items
.iter()
.filter_map(|item| match item {
MusicItem::Note(note) => Some(note.pitch.step),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
trailing_note,
vec!['D'],
"the D after the barline must survive"
);
}
#[test]
fn lowers_chord_member_and_outer_duration_multipliers() {
let (events, diagnostics) = events_for("X:1\nL:1/8\nK:C\n[C2E2G2]3\n");
assert!(diagnostics.is_empty());
let notes = events
.iter()
.filter_map(|event| match event {
Event::Note {
step,
duration,
chord,
..
} => Some((*step, *duration, *chord)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
notes,
vec![('C', 24, false), ('E', 24, true), ('G', 24, true)]
);
}
#[test]
fn variable_duration_chord_members_emit_diagnostic() {
let document = parse_document("X:1\nL:1/8\nK:C\n[E2G,6]\n", ParseOptions::default()).value;
let report = parse_tune_report_from_document(&document);
assert!(
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.chord.variable_duration")
);
}
#[test]
fn broken_rhythm_is_transparent_across_grace_groups() {
let (left_events, left_diagnostics) = events_for("X:1\nL:1/8\nK:C\nA<{g}A\n");
let (right_events, right_diagnostics) = events_for("X:1\nL:1/8\nK:C\nA{g}<A\n");
assert!(left_diagnostics.is_empty());
assert!(right_diagnostics.is_empty());
let durations = |events: Vec<Event>| {
events
.into_iter()
.filter_map(|event| match event {
Event::Note { duration, .. } => Some(duration),
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(durations(left_events), durations(right_events));
}
#[test]
fn parses_staccato_triplet_without_spaces() {
let document_report = parse_document("X:1\nL:1/8\nK:C\n(3.a.b.c\n", ParseOptions::default());
assert!(document_report.diagnostics.is_empty());
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
assert!(matches!(tune_music.lines[0].items[0], MusicItem::Tuplet(_)));
let staccato_count = tune_music.lines[0]
.items
.iter()
.filter_map(|item| match item {
MusicItem::Note(note) => Some(¬e.attachments.decorations),
_ => None,
})
.filter(|decorations| decorations.iter().any(|decoration| decoration.name == "."))
.count();
assert_eq!(staccato_count, 3);
}
#[test]
fn parses_adjacent_repeat_endings_after_barlines() {
let document_report = parse_document("X:1\nK:C\n:|2 C|1D A:|2B\n", ParseOptions::default());
assert!(document_report.diagnostics.is_empty());
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let endings = tune_music.lines[0]
.items
.iter()
.filter(|item| matches!(item, MusicItem::VariantEnding(_)))
.count();
let repeat_ends = tune_music.lines[0]
.items
.iter()
.filter(|item| {
matches!(
item,
MusicItem::Barline(BarlineSyntax {
kind: BarlineKind::RepeatEnd,
..
})
)
})
.count();
assert_eq!(endings, 3);
assert_eq!(repeat_ends, 2);
}
#[test]
fn parses_bracketed_variant_ending_lists_and_ranges() {
let document_report = parse_document(
"X:1\nK:C\n[1 C | [2 D | [1,3] E | [1-3] F | [1,3,5-7] G\n",
ParseOptions::default(),
);
assert!(document_report.diagnostics.is_empty());
let tune_music = document_report
.value
.music
.tune(0)
.expect("expected parsed tune music");
let endings = tune_music.lines[0]
.items
.iter()
.filter_map(|item| match item {
MusicItem::VariantEnding(ending) => Some(ending),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(endings.len(), 5);
assert_eq!(endings[0].endings.len(), 1);
assert_eq!(endings[2].endings.len(), 2);
assert!(matches!(
endings[3].endings[0],
VariantEndingPart::Range { .. }
));
assert_eq!(endings[4].endings.len(), 3);
}
#[test]
fn repeat_ending_shorthand_must_be_adjacent() {
let legal = parse_document("X:1\nK:C\nC| [1D\n", ParseOptions::default());
assert!(
!legal
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.invalid_repeat_ending")
);
let spaced = parse_document("X:1\nK:C\nC| 1D\n", ParseOptions::default());
assert!(
spaced
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.invalid_repeat_ending")
);
}
#[test]
fn unclosed_slurs_are_recoverable_in_lowering() {
let document = parse_document("X:1\nK:C\n(C D\n", ParseOptions::default()).value;
let report = parse_tune_report_from_document(&document);
assert!(
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.unclosed_slur")
);
assert_eq!(
report
.value
.expect("expected tune")
.events
.iter()
.filter(|event| matches!(event, Event::Note { .. }))
.count(),
2
);
}
#[test]
fn non_music_lines_and_chords_do_not_leak_comments_or_directives() {
let document_report = parse_document(
"X:1\nT:ABC\n+:DEF\nK:C\n%%text GAB\n[CDE] C % FED\n",
ParseOptions::default(),
);
let report = parse_tune_report_from_document(&document_report.value);
let events = report.value.expect("expected tune").events;
let notes = events
.iter()
.filter(|event| matches!(event, Event::Note { .. }))
.count();
assert_eq!(notes, 4);
}
#[test]
fn lowers_sequential_body_voice_blocks_to_explicit_timelines() {
let document =
parse_document("X:1\nK:C\nV:1\nC D|\nV:2\nE F|\n", ParseOptions::default()).value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
assert_eq!(
tune.voices
.iter()
.map(|voice| voice.id.value.as_str())
.collect::<Vec<_>>(),
vec!["1", "2"]
);
let note_counts = tune
.voices
.iter()
.map(|voice| {
voice
.measures
.iter()
.flat_map(|measure| &measure.events)
.filter(|event| matches!(event.kind, TimelineEventKind::Note { .. }))
.count()
})
.collect::<Vec<_>>();
assert_eq!(note_counts, vec![2, 2]);
}
#[test]
fn lowers_inline_voice_switches_to_interleaved_timelines() {
let document = parse_document(
"X:1\nK:C\n[V:T1] C D| [V:T2] E F|\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
assert_eq!(tune.voices.len(), 2);
assert!(tune.voices.iter().any(|voice| voice.id.value == "T1"));
assert!(tune.voices.iter().any(|voice| voice.id.value == "T2"));
let inline_voice = document
.music
.tune(0)
.expect("expected parsed tune music")
.lines
.first()
.expect("expected music line")
.items
.iter()
.find_map(|item| match item {
MusicItem::InlineField(field) if field.code == 'V' => Some(field),
_ => None,
})
.expect("expected inline V field");
assert_eq!(inline_voice.value.value, "T1");
}
#[test]
fn inline_field_after_barline_is_not_swallowed_into_a_liberal_barline() {
let document = parse_document(
"X:1\nL:1/4\nM:6/8\nK:C\nC3|[M:3/8]E2E|[M:6/8]F2G|\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let non_empty: Vec<usize> = tune.voices[0]
.measures
.iter()
.map(|measure| {
measure
.events
.iter()
.filter(|event| event.alignable)
.count()
})
.collect();
assert_eq!(
non_empty,
vec![1, 2, 2],
"no spurious empty measures: {non_empty:?}"
);
let line = document
.music
.tune(0)
.expect("expected parsed tune music")
.lines
.first()
.expect("expected music line");
let inline_codes: Vec<char> = line
.items
.iter()
.filter_map(|item| match item {
MusicItem::InlineField(field) => Some(field.code),
_ => None,
})
.collect();
assert_eq!(inline_codes, vec!['M', 'M']);
}
#[test]
fn aligns_postponed_and_adjacent_lyrics_under_abc21_cursor_rules() {
let document = parse_document(
"X:1\nK:C\nC D E F|\nG A B c|\nw: doh re mi fa sol la ti doh\nw: alt verse words here more text ok done\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let lyrics = tune.voices[0]
.measures
.iter()
.flat_map(|measure| &measure.events)
.flat_map(|event| &event.lyrics)
.filter(|lyric| lyric.control == LyricControl::Syllable)
.map(|lyric| (lyric.verse, lyric.text.as_str()))
.collect::<Vec<_>>();
assert!(lyrics.contains(&(1, "doh")));
assert!(lyrics.contains(&(1, "doh")));
assert!(lyrics.contains(&(2, "alt")));
}
#[test]
fn empty_lyric_line_consumes_notes_and_later_lyrics_start_after_them() {
let document = parse_document(
"X:1\nK:C\nC D E F|\nw:\nG A B c|\nw: sol la ti doh\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let lyrics = tune.voices[0]
.measures
.iter()
.flat_map(|measure| &measure.events)
.filter_map(|event| {
event
.lyrics
.iter()
.find(|lyric| lyric.control == LyricControl::Syllable)
.map(|lyric| lyric.text.as_str())
})
.collect::<Vec<_>>();
assert_eq!(lyrics, vec!["sol", "la", "ti", "doh"]);
}
#[test]
fn lyrics_skip_rests_spacers_grace_notes_and_bar_marker_advances() {
let document = parse_document(
"X:1\nK:C\nC z y {g}D|E F|\nw: one | two three\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let aligned = tune.voices[0]
.measures
.iter()
.flat_map(|measure| &measure.events)
.filter_map(|event| {
event
.lyrics
.iter()
.find(|lyric| lyric.control == LyricControl::Syllable)
.map(|lyric| lyric.text.as_str())
})
.collect::<Vec<_>>();
assert_eq!(aligned, vec!["one", "two", "three"]);
}
#[test]
fn overlay_rewinds_to_previous_barline_and_warns_when_incomplete() {
let document = parse_document("X:1\nL:1/8\nK:C\nC D & E|\n", ParseOptions::default()).value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
assert!(
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.voice.overlay_incomplete_measure")
);
let overlay = &tune.voices[0].measures[0].overlays[0];
assert_eq!(overlay.events.len(), 1);
assert_eq!(overlay.events[0].onset, Fraction::zero());
}
#[test]
fn lyrics_align_to_overlay_notes_in_source_order() {
let document = parse_document(
"X:1\nM:C\nL:1/8\nK:G\nG2|A Ac2B BG2|ABc2B4|A3B c2B> A|(A2G2) E2F2|\nw:|||All a-lone and so lone-*ly\nG2F2 E2DC|D2D2 D2\"^*\"D> D&x6C> D|E2F E D2D2|E4 HA2||\nw:|***And tis down by the green-wood side-oh!\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let voice = &tune.voices[0];
let overlay = voice
.measures
.iter()
.flat_map(|measure| &measure.overlays)
.next()
.expect("expected overlay segment");
let overlay_lyrics = overlay
.events
.iter()
.filter(|event| event.alignable)
.map(|event| {
event
.lyrics
.iter()
.find(|lyric| lyric.control == LyricControl::Syllable)
.map(|lyric| lyric.text.as_str())
})
.collect::<Vec<_>>();
assert_eq!(overlay_lyrics, vec![Some("down"), Some("by")]);
let main_lyrics = voice
.measures
.iter()
.flat_map(|measure| &measure.events)
.flat_map(|event| &event.lyrics)
.filter(|lyric| lyric.control == LyricControl::Syllable)
.map(|lyric| lyric.text.as_str())
.collect::<Vec<_>>();
assert!(!main_lyrics.contains(&"down"));
assert!(!main_lyrics.contains(&"by"));
}
#[test]
fn symbol_lines_align_to_notes_and_preserve_symbol_kinds() {
let document = parse_document(
"X:1\nK:C\nC z D E F|\ns: \"C\" * !>! \"^slow\"\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let symbols = tune.voices[0]
.measures
.iter()
.flat_map(|measure| &measure.events)
.flat_map(|event| &event.symbols)
.map(|symbol| (symbol.text.as_str(), symbol.kind))
.collect::<Vec<_>>();
assert_eq!(
symbols,
vec![
("C", AlignedSymbolKind::ChordSymbol),
(">", AlignedSymbolKind::Decoration),
("^slow", AlignedSymbolKind::Annotation),
]
);
}
#[test]
fn preserves_score_directives_and_post_tune_words_in_lowered_tune() {
let document = parse_document(
"X:1\nK:C\n%%score (T1 T2)\nC\nW:after words\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
assert_eq!(tune.score_directives.len(), 1);
assert_eq!(tune.score_directives[0].value.text, "(T1 T2)");
assert_eq!(tune.post_tune_lyrics[0].text, "after words");
}
#[test]
fn preserves_header_score_directive_and_header_words() {
let document = parse_document(
"X:1\nI:score (A B)\nW:header words\nK:C\nC\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
assert_eq!(tune.score_directives.len(), 1);
assert_eq!(tune.score_directives[0].value.text, "(A B)");
assert_eq!(tune.post_tune_lyrics[0].text, "header words");
}
#[test]
fn body_voice_properties_override_header_definition_in_timeline() {
let document = parse_document(
"X:1\nV:1 name=Header\nK:C\nV:1 name=Body stem=down\nC\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
assert_eq!(
tune.voices[0]
.properties
.name
.as_ref()
.map(|name| name.text.as_str()),
Some("Body")
);
assert_eq!(
tune.voices[0].properties.stem,
Some(StemDirectionModel::Down)
);
}
#[test]
fn lyrics_controls_preserve_text_and_diagnose_excess_tokens() {
let source = "X:1\nK:C\nC D E F G A|\nw: time__ of~the \\-dash * extra too\n";
let document = parse_document(source, ParseOptions::default()).value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let note_lyrics = tune.voices[0].measures[0]
.events
.iter()
.filter(|event| event.alignable)
.map(|event| {
event
.lyrics
.iter()
.map(|lyric| (lyric.control, lyric.text.as_str()))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
assert_eq!(note_lyrics[0], vec![(LyricControl::Syllable, "time")]);
assert_eq!(note_lyrics[1], vec![(LyricControl::Extender, "")]);
assert_eq!(note_lyrics[2], vec![(LyricControl::Extender, "")]);
assert_eq!(note_lyrics[3], vec![(LyricControl::Syllable, "of the")]);
assert_eq!(note_lyrics[4], vec![(LyricControl::Syllable, "-dash")]);
assert!(note_lyrics[5].is_empty());
assert_eq!(
count_diagnostics(&report.diagnostics, "abc.lyric.syllable_count"),
2
);
}
#[test]
fn lyric_continuation_line_splits_on_inserted_space_not_newline() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F|\nw:a b\n+:c d\n";
let document = parse_document(source, ParseOptions::default()).value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let lyrics = tune.voices[0].measures[0]
.events
.iter()
.flat_map(|event| &event.lyrics)
.filter(|lyric| lyric.control == LyricControl::Syllable)
.map(|lyric| lyric.text.as_str())
.collect::<Vec<_>>();
assert_eq!(lyrics, vec!["a", "b", "c", "d"]);
assert_eq!(
count_diagnostics(&report.diagnostics, "abc.lyric.syllable_count"),
0
);
}
#[test]
fn lyric_bar_marker_is_ignored_when_cursor_is_already_at_measure_boundary() {
let document = parse_document(
"X:1\nK:C\nC D|E F|\nw: one two | three four\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let aligned = tune.voices[0]
.measures
.iter()
.flat_map(|measure| &measure.events)
.filter_map(|event| {
event
.lyrics
.iter()
.find(|lyric| lyric.control == LyricControl::Syllable)
.map(|lyric| lyric.text.as_str())
})
.collect::<Vec<_>>();
assert_eq!(aligned, vec!["one", "two", "three", "four"]);
}
fn syllables_per_measure(source: &str) -> Vec<Vec<String>> {
let document = parse_document(source, ParseOptions::default()).value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
tune.voices[0]
.measures
.iter()
.map(|measure| {
measure
.events
.iter()
.filter(|event| event.alignable)
.map(|event| {
event
.lyrics
.iter()
.find(|lyric| {
matches!(
lyric.control,
LyricControl::Syllable | LyricControl::Extender
)
})
.map(|lyric| lyric.text.clone())
.unwrap_or_default()
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
#[test]
fn leading_bar_marker_advances_past_a_filled_first_measure() {
let per_measure = syllables_per_measure("X:1\nL:1/4\nK:C\nG z|c d|e f|\nw: |Oh well\n");
assert_eq!(per_measure[0], vec!["".to_owned()]);
assert_eq!(per_measure[1], vec!["Oh".to_owned(), "well".to_owned()]);
}
#[test]
fn lyric_bar_marker_advances_across_rest_only_measure() {
let per_measure = syllables_per_measure("X:1\nL:1/4\nK:C\nz2|C D|E F|\nw: |one two\n");
assert!(per_measure[0].is_empty());
assert_eq!(per_measure[1], vec!["one".to_owned(), "two".to_owned()]);
assert_eq!(per_measure[2], vec!["".to_owned(), "".to_owned()]);
}
#[test]
fn lyric_bar_markers_after_rest_only_gap_start_from_previous_block() {
let per_measure = syllables_per_measure("X:1\nL:1/4\nK:C\nC|\nw: old\nz|z|D|E|\nw: ||new\n");
assert_eq!(per_measure[0], vec!["old".to_owned()]);
assert!(per_measure[1].is_empty());
assert!(per_measure[2].is_empty());
assert_eq!(per_measure[3], vec!["new".to_owned()]);
assert_eq!(per_measure[4], vec!["".to_owned()]);
}
#[test]
fn consecutive_bar_markers_each_advance_one_measure() {
let per_measure = syllables_per_measure("X:1\nL:1/4\nK:C\nG|c d|e f|g a|b c|\nw:|||All done\n");
assert!(per_measure[0].iter().all(|text| text.is_empty()));
assert!(per_measure[1].iter().all(|text| text.is_empty()));
assert!(per_measure[2].iter().all(|text| text.is_empty()));
assert_eq!(per_measure[3], vec!["All".to_owned(), "done".to_owned()]);
}
#[test]
fn double_hyphen_holds_a_blank_note_instead_of_a_literal_dash() {
let document = parse_document(
"X:1\nL:1/4\nK:C\nc d e|\nw: tri--umph\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let texts = tune.voices[0].measures[0]
.events
.iter()
.filter(|event| event.alignable)
.map(|event| {
event
.lyrics
.iter()
.find(|lyric| lyric.control == LyricControl::Syllable)
.map(|lyric| lyric.text.clone())
})
.collect::<Vec<_>>();
assert_eq!(
texts,
vec![Some("tri".to_owned()), None, Some("umph".to_owned())]
);
}
#[test]
fn symbol_bar_boundary_and_excess_symbol_are_diagnosed_without_realigning() {
let document = parse_document(
"X:1\nK:C\nC D|E F|\ns: \"C\" !>! | \"^slow\" !fermata! !extra!\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let aligned = tune.voices[0]
.measures
.iter()
.flat_map(|measure| &measure.events)
.flat_map(|event| &event.symbols)
.map(|symbol| (symbol.text.as_str(), symbol.kind))
.collect::<Vec<_>>();
assert_eq!(
aligned,
vec![
("C", AlignedSymbolKind::ChordSymbol),
(">", AlignedSymbolKind::Decoration),
("^slow", AlignedSymbolKind::Annotation),
("fermata", AlignedSymbolKind::Decoration),
]
);
assert_eq!(
count_diagnostics(&report.diagnostics, "abc.symbol.count"),
1
);
}
#[test]
fn lyrics_use_the_current_voice_cursor_in_interleaved_body_fields() {
let document = parse_document(
"X:1\nK:C\nV:1\nC D|\nw: one two\nV:2\nE F|\nw: three four\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let lyrics_for_voice = |voice_id: &str| {
tune.voices
.iter()
.find(|voice| voice.id.value == voice_id)
.expect("expected voice")
.measures
.iter()
.flat_map(|measure| &measure.events)
.flat_map(|event| &event.lyrics)
.filter(|lyric| lyric.control == LyricControl::Syllable)
.map(|lyric| lyric.text.as_str())
.collect::<Vec<_>>()
};
assert_eq!(lyrics_for_voice("1"), vec!["one", "two"]);
assert_eq!(lyrics_for_voice("2"), vec!["three", "four"]);
}
#[test]
fn body_voice_field_can_switch_voice_and_carry_same_line_music() {
let document = parse_document(
"X:1\nL:1/8\nK:C\nV:1 C D|\nV:2 E F|\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
assert!(!report.has_errors());
let tune = report
.value
.as_ref()
.expect("expected same-line voice music");
let notes_for_voice = |voice_id: &str| {
tune.voices
.iter()
.find(|voice| voice.id.value == voice_id)
.expect("expected voice")
.measures
.iter()
.flat_map(|measure| &measure.events)
.filter_map(|event| match &event.kind {
TimelineEventKind::Note { step, .. } => Some(*step),
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(notes_for_voice("1"), vec!['C', 'D']);
assert_eq!(notes_for_voice("2"), vec!['E', 'F']);
}
#[test]
fn body_voice_properties_are_not_treated_as_same_line_music() {
let document =
parse_document("X:1\nK:C\nV:1 clef=treble\nC D|\n", ParseOptions::default()).value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected following music line");
let voice = tune
.voices
.iter()
.find(|voice| voice.id.value == "1")
.expect("expected voice");
assert_eq!(
voice
.properties
.clef
.as_ref()
.map(|clef| clef.text.as_str()),
Some("treble")
);
assert_eq!(
voice.measures[0]
.events
.iter()
.filter(|event| matches!(event.kind, TimelineEventKind::Note { .. }))
.count(),
2
);
}
#[test]
fn body_voice_property_words_are_not_treated_as_same_line_music() {
let document = parse_document(
"X:1\nK:C\nV:1 Program 1 110 alto\nC D|\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected following music line");
let voice = tune
.voices
.iter()
.find(|voice| voice.id.value == "1")
.expect("expected voice");
assert_eq!(
count_diagnostics(&report.diagnostics, "abc.music.unknown_token"),
0
);
assert_eq!(
voice.measures[0]
.events
.iter()
.filter(|event| matches!(event.kind, TimelineEventKind::Note { .. }))
.count(),
2
);
}
#[test]
fn body_voice_field_same_line_music_with_leading_accidental() {
for accidental in ["=", "^", "_"] {
let source = format!("X:1\nL:1/8\nK:C\nV:1 C D|\nV:2 {accidental}E F|\n");
let document_report = parse_document(&source, ParseOptions::default());
assert_eq!(
count_diagnostics(
&document_report.diagnostics,
"abc.field.voice_property_ignored"
),
0,
"accidental `{accidental}` is music, not a discarded property"
);
let report = parse_tune_report_from_document(&document_report.value);
assert!(!report.has_errors());
let tune = report.value.expect("expected same-line voice music");
let steps = tune
.voices
.iter()
.find(|voice| voice.id.value == "2")
.expect("expected voice 2")
.measures
.iter()
.flat_map(|measure| &measure.events)
.filter_map(|event| match &event.kind {
TimelineEventKind::Note { step, .. } => Some(*step),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(steps, vec!['E', 'F'], "accidental `{accidental}`");
}
}
#[test]
fn body_voice_field_same_line_music_with_accidental_inside_first_token() {
let document = parse_document(
"X:1\nL:1/8\nK:C\nV:1 C D|\nV:2 E2=F2 G2A2|\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
assert!(!report.has_errors());
let tune = report.value.expect("expected same-line voice music");
let steps = tune
.voices
.iter()
.find(|voice| voice.id.value == "2")
.expect("expected voice 2")
.measures
.iter()
.flat_map(|measure| &measure.events)
.filter_map(|event| match &event.kind {
TimelineEventKind::Note { step, .. } => Some(*step),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(steps, vec!['E', 'F', 'G', 'A']);
}
#[test]
fn body_voice_field_unparseable_property_token_warns() {
let report = parse_document(
"X:1\nK:C\nV:1 Program =C2D2\nC D|\n",
ParseOptions::default(),
);
assert_eq!(
count_diagnostics(&report.diagnostics, "abc.field.voice_property_ignored"),
1
);
}
#[test]
fn overfull_overlay_measure_duration_emits_diagnostic() {
let document = parse_document("X:1\nL:1/8\nK:C\nC & D E|\n", ParseOptions::default()).value;
let report = parse_tune_report_from_document(&document);
assert_eq!(
count_diagnostics(&report.diagnostics, "abc.voice.overlay_overfull_measure"),
1
);
}
#[test]
fn ampersand_in_lyric_and_symbol_lines_is_not_music_overlay_syntax() {
let document = parse_document(
"X:1\nK:C\nC D E|\nw: Tom & Jerry\ns: & * !>!\n",
ParseOptions::default(),
)
.value;
let report = parse_tune_report_from_document(&document);
let tune = report.value.expect("expected tune");
let lyrics = tune.voices[0]
.measures
.iter()
.flat_map(|measure| &measure.events)
.flat_map(|event| &event.lyrics)
.filter(|lyric| lyric.control == LyricControl::Syllable)
.map(|lyric| lyric.text.as_str())
.collect::<Vec<_>>();
let symbols = tune.voices[0]
.measures
.iter()
.flat_map(|measure| &measure.events)
.flat_map(|event| &event.symbols)
.map(|symbol| (symbol.text.as_str(), symbol.kind))
.collect::<Vec<_>>();
assert_eq!(lyrics, vec!["Tom", "&", "Jerry"]);
assert_eq!(
symbols,
vec![
("&", AlignedSymbolKind::Raw),
(">", AlignedSymbolKind::Decoration)
]
);
assert!(tune.voices[0].measures[0].overlays.is_empty());
}
#[test]
fn body_unit_length_scopes_to_current_voice_only() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"V:1\n",
"L:1/8\n",
"CDEF|\n",
"V:2\n",
"C,D,E,F,|\n",
);
let (tune, _diagnostics) = tune_for(source);
let durations: Vec<Vec<_>> = tune
.score
.parts
.iter()
.map(|part| {
part.voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::Note(_) => Some(event.duration),
_ => None,
})
.collect()
})
.collect();
assert_eq!(durations.len(), 2);
assert!(durations[0].iter().all(|d| *d == Fraction::new(1, 8)));
assert!(durations[1].iter().all(|d| *d == Fraction::new(1, 4)));
}
fn tune_for(source: &str) -> (crate::model::Tune, Vec<Diagnostic>) {
let document = parse_document(source, ParseOptions::default());
let mut diagnostics = document.diagnostics;
let report = parse_tune_report_from_document(&document.value);
diagnostics.extend(report.diagnostics);
(report.value.expect("expected tune"), diagnostics)
}
fn diagnostic_span<'a>(
source: &'a str,
diagnostics: &'a [Diagnostic],
code: &'static str,
) -> &'a str {
let diagnostic = diagnostics
.iter()
.find(|diagnostic| diagnostic.code == code)
.expect("expected diagnostic");
&source[diagnostic.span.start..diagnostic.span.end]
}
fn semantic_note_events(tune: &crate::model::Tune) -> Vec<&TimedEvent> {
tune.score.parts[0].voices[0]
.events
.iter()
.filter(|event| matches!(event.kind, TimedEventKind::Note(_)))
.collect()
}
fn semantic_note_alters(tune: &crate::model::Tune) -> Vec<i8> {
semantic_note_events(tune)
.into_iter()
.map(|event| match &event.kind {
TimedEventKind::Note(note) => note.pitch.alter,
_ => unreachable!(),
})
.collect()
}
fn semantic_chord_member_alters(tune: &crate::model::Tune) -> Vec<Vec<i8>> {
tune.score.parts[0].voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::Chord(chord) => Some(
chord
.members
.iter()
.map(|member| member.pitch.alter)
.collect(),
),
_ => None,
})
.collect()
}
fn leading_grace_note_alters(tune: &crate::model::Tune) -> Vec<i8> {
semantic_note_events(tune)
.into_iter()
.flat_map(|event| grace_note_alters_in_attachments(&event.attachments))
.chain(
tune.score.parts[0].voices[0]
.events
.iter()
.flat_map(|event| match &event.kind {
TimedEventKind::Chord(chord) => chord
.members
.iter()
.flat_map(|member| grace_note_alters_in_attachments(&member.attachments))
.collect::<Vec<_>>(),
_ => Vec::new(),
}),
)
.collect()
}
fn grace_note_alters_in_attachments(attachments: &crate::model::EventAttachments) -> Vec<i8> {
attachments
.grace_groups
.iter()
.flat_map(|group| group.events.iter())
.flat_map(|event| match &event.kind {
crate::model::GraceEventKind::Note(note) => vec![note.pitch.alter],
crate::model::GraceEventKind::Chord(notes) => {
notes.iter().map(|note| note.pitch.alter).collect()
}
crate::model::GraceEventKind::Rest(_) => Vec::new(),
})
.collect()
}
#[test]
fn semantic_score_marks_pickup_and_keeps_fixed_measure_numbers() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC|D E F G|A B c d|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let measures = &tune.score.parts[0].voices[0].measures;
assert_eq!(measures[0].expected_duration, Some(Fraction::new(4, 4)));
assert_eq!(measures[0].actual_duration, Fraction::new(1, 4));
assert!(measures[0].pickup);
assert_eq!(
measures
.iter()
.map(|measure| measure.id.number)
.collect::<Vec<_>>(),
vec![1, 2, 3]
);
assert_eq!(measures[1].actual_duration, Fraction::new(4, 4));
assert!(measures[1].complete);
}
#[test]
fn complex_header_meter_uses_additive_duration() {
let source = "X:1\nM:(2+3+2)/8\nL:1/8\nK:C\nCDEFGAB|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let meter = tune.score.metadata.meter.as_ref().expect("header meter");
assert_eq!(meter.duration, Some(Fraction::new(7, 8)));
assert!(!meter.free_meter);
let measures = &tune.score.parts[0].voices[0].measures;
assert_eq!(measures[0].expected_duration, Some(Fraction::new(7, 8)));
assert_eq!(measures[0].actual_duration, Fraction::new(7, 8));
assert!(measures[0].complete);
}
#[test]
fn additive_extension_header_meter_uses_summed_duration() {
let source = "X:1\nM:3/4+2/4\nL:1/4\nK:C\nCDEFG|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let meter = tune.score.metadata.meter.as_ref().expect("header meter");
assert_eq!(meter.duration, Some(Fraction::new(5, 4)));
assert!(!meter.free_meter);
let measure = &tune.score.parts[0].voices[0].measures[0];
assert_eq!(measure.expected_duration, Some(Fraction::new(5, 4)));
assert_eq!(measure.actual_duration, Fraction::new(5, 4));
assert!(measure.complete);
}
#[test]
fn leading_repeat_start_stays_on_first_measure() {
let source = "X:1\nM:3/4\nL:1/4\nK:C\n|: G c d | E D C |\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let measures = &tune.score.parts[0].voices[0].measures;
assert_eq!(measures.len(), 2);
assert_eq!(measures[0].actual_duration, Fraction::new(3, 4));
assert_eq!(measures[1].actual_duration, Fraction::new(3, 4));
assert!(
measures[0]
.barlines
.iter()
.any(|barline| barline.kind == BarlineKind::RepeatStart)
);
let first_measure_steps = semantic_note_events(&tune)
.into_iter()
.take(3)
.map(|event| match &event.kind {
TimedEventKind::Note(note) => note.pitch.step,
_ => unreachable!(),
})
.collect::<Vec<_>>();
assert_eq!(first_measure_steps, vec!['G', 'C', 'D']);
}
#[test]
fn plain_leading_barline_does_not_create_empty_pickup_measure() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n| E | F G A B |\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let measures = &tune.score.parts[0].voices[0].measures;
assert_eq!(measures.len(), 2);
assert_eq!(measures[0].actual_duration, Fraction::new(1, 4));
assert!(measures[0].pickup);
assert_eq!(measures[1].actual_duration, Fraction::new(4, 4));
}
#[test]
fn semantic_accidentals_propagate_within_measure_and_reset_at_barline() {
let source = "X:1\nL:1/8\nK:C\n^F F|F\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
assert_eq!(semantic_note_alters(&tune), vec![1, 1, 0]);
assert!(tune.score.accidental_policy.reset_at_barlines);
}
#[test]
fn written_grace_accidental_propagates_to_main_notes_in_measure() {
let source = "X:1\nL:1/8\nK:C\n{^f}f2 f2 f4 |]\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
assert_eq!(leading_grace_note_alters(&tune), vec![1]);
assert_eq!(semantic_note_alters(&tune), vec![1, 1, 1]);
}
#[test]
fn grace_notes_inherit_measure_accidentals_from_main_notes() {
let source = "X:1\nL:1/8\nK:C\n^c2 {dc}d2 c4 |]\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
assert_eq!(semantic_note_alters(&tune), vec![1, 0, 1]);
assert_eq!(leading_grace_note_alters(&tune), vec![0, 1]);
}
#[test]
fn grace_accidental_ledger_uses_voice_octave_shift() {
let source = "X:1\nL:1/8\nK:C octave=1\n{^f}f2 f2 f4 |]\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
assert_eq!(leading_grace_note_alters(&tune), vec![1]);
assert_eq!(semantic_note_alters(&tune), vec![1, 1, 1]);
}
#[test]
fn flushed_grace_accidentals_precede_direct_graces_on_same_note() {
let source = "X:1\nL:1/8\nK:C\n{^f}[M:3/4]{=f}f2 f2 |]\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
assert_eq!(leading_grace_note_alters(&tune), vec![1, 0]);
assert_eq!(semantic_note_alters(&tune), vec![0, 0]);
}
#[test]
fn chord_member_grace_accidentals_do_not_affect_earlier_members() {
let source = "X:1\nL:1/8\nK:C\n[f{^f}f]2 |]\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
assert_eq!(leading_grace_note_alters(&tune), vec![1]);
assert_eq!(semantic_chord_member_alters(&tune), vec![vec![0, 1]]);
}
#[test]
fn semantic_accidental_carry_persists_across_standalone_meter_change() {
let source = "X:1\nL:1/4\nK:C\n_e\nM:3/2\ne\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
assert_eq!(semantic_note_alters(&tune), vec![-1, -1]);
}
#[test]
fn semantic_accidental_carry_persists_across_inline_meter_change() {
let source = "X:1\nL:1/4\nK:C\n_e [M:3/2] e\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
assert_eq!(semantic_note_alters(&tune), vec![-1, -1]);
}
#[test]
fn semantic_accidental_carry_persists_across_same_key_change() {
let source = "X:1\nL:1/4\nK:C\n_e\nK:C\ne\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
assert_eq!(semantic_note_alters(&tune), vec![-1, -1]);
}
#[test]
fn semantic_accidental_carry_persists_across_real_key_change() {
let source = "X:1\nL:1/4\nK:C\n_e\nK:G\ne f\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
assert_eq!(semantic_note_alters(&tune), vec![-1, -1, 1]);
}
#[test]
fn semantic_tuplets_and_broken_rhythm_keep_rational_durations() {
let source = "X:1\nL:1/8\nK:C\n(3CDE F>G\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let durations = semantic_note_events(&tune)
.into_iter()
.map(|event| event.duration)
.collect::<Vec<_>>();
assert_eq!(
durations,
vec![
Fraction::new(1, 12),
Fraction::new(1, 12),
Fraction::new(1, 12),
Fraction::new(3, 16),
Fraction::new(1, 16),
]
);
}
#[test]
fn semantic_voices_have_explicit_onsets_and_durations() {
let source = "X:1\nL:1/4\nK:C\nV:1\nC D|\nV:2\nE2 F|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let voice = tune
.score
.parts
.iter()
.flat_map(|part| &part.voices)
.find(|voice| voice.id.value == "2")
.expect("expected voice 2");
let notes = voice
.events
.iter()
.filter(|event| matches!(event.kind, TimedEventKind::Note(_)))
.map(|event| (event.onset, event.duration))
.collect::<Vec<_>>();
assert_eq!(
notes,
vec![
(Fraction::zero(), Fraction::new(2, 4)),
(Fraction::new(2, 4), Fraction::new(1, 4)),
]
);
}
#[test]
fn semantic_lyrics_symbols_and_prefix_attachments_stay_on_intended_event() {
let source = "X:1\nL:1/8\nK:C\n\"Am\"!trill!C D\nw: one two\ns: \"C\" !>!\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let notes = semantic_note_events(&tune);
let first = notes[0];
assert_eq!(first.attachments.chord_symbols[0].text, "Am");
assert_eq!(first.attachments.decorations[0].name, "trill");
assert_eq!(first.attachments.lyrics[0].text, "one");
assert_eq!(first.attachments.symbols[0].text, "C");
}
#[test]
fn chord_symbol_before_grace_group_binds_to_main_note() {
let source = "X:1\nL:1/8\nK:C\n\"F\"{AB}c d|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let notes = semantic_note_events(&tune);
let first = notes[0];
assert_eq!(first.attachments.chord_symbols[0].text, "F");
assert_eq!(first.attachments.grace_groups.len(), 1);
assert_eq!(first.attachments.grace_groups[0].note_count, 2);
assert!(notes[1].attachments.chord_symbols.is_empty());
}
#[test]
fn trailing_trill_grace_group_binds_as_after_grace_on_previous_note() {
let source = "X:1\nT:Trailing Grace\nM:4/4\nL:1/8\nK:C\nTe6{de}|d2f f2f|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let notes = semantic_note_events(&tune);
assert!(matches!(
¬es[0].kind,
TimedEventKind::Note(note) if note.pitch.step == 'E'
));
assert_eq!(notes[0].measure.number, 1);
assert_eq!(notes[0].attachments.grace_groups.len(), 0);
assert_eq!(notes[0].attachments.after_grace_groups.len(), 1);
assert_eq!(notes[0].attachments.after_grace_groups[0].note_count, 2);
assert!(matches!(
¬es[1].kind,
TimedEventKind::Note(note) if note.pitch.step == 'D'
));
assert_eq!(notes[1].measure.number, 2);
assert!(notes[1].attachments.grace_groups.is_empty());
assert!(notes[1].attachments.after_grace_groups.is_empty());
}
#[test]
fn trailing_grace_after_chord_member_trill_does_not_drain_into_member() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n[CE!trill!G]2{de}|A2 A2|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let events = tune
.score
.parts
.iter()
.flat_map(|part| &part.voices)
.flat_map(|voice| &voice.events)
.collect::<Vec<_>>();
let first_chord = events
.iter()
.find_map(|event| match &event.kind {
TimedEventKind::Chord(chord) => Some((event, chord)),
_ => None,
})
.expect("expected chord");
assert!(first_chord.0.attachments.after_grace_groups.is_empty());
assert!(
first_chord
.1
.members
.iter()
.all(|member| member.attachments.after_grace_groups.is_empty())
);
let following_a = events
.iter()
.find(|event| {
matches!(
&event.kind,
TimedEventKind::Note(note) if note.pitch.step == 'A'
)
})
.expect("expected following A");
assert_eq!(following_a.measure.number, 2);
assert_eq!(following_a.attachments.grace_groups.len(), 1);
assert_eq!(following_a.attachments.grace_groups[0].note_count, 2);
}
#[test]
fn pre_barline_grace_after_complete_measure_stays_before_barline() {
let source = "X:1\nM:2/4\nL:1/8\nK:D\nD>E FA {G}|FF EF|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let notes = semantic_note_events(&tune);
assert_eq!(notes.len(), 8);
let final_note_before_bar = notes[3];
assert_eq!(final_note_before_bar.measure.number, 1);
assert_eq!(
final_note_before_bar.attachments.after_grace_groups.len(),
1
);
assert_eq!(
final_note_before_bar.attachments.after_grace_groups[0].note_count,
1
);
let first_note_after_bar = notes[4];
assert_eq!(first_note_after_bar.measure.number, 2);
assert!(first_note_after_bar.attachments.grace_groups.is_empty());
}
#[test]
fn chord_symbol_before_slur_open_binds_to_first_slurred_note() {
let source = "X:1\nL:1/8\nK:C\n\"G7\"(DE) F|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let notes = semantic_note_events(&tune);
let first = notes[0];
assert_eq!(first.attachments.chord_symbols[0].text, "G7");
assert!(
first
.attachments
.slurs
.iter()
.any(|slur| slur.role == SlurRole::Start)
);
assert!(
notes[1]
.attachments
.slurs
.iter()
.any(|slur| slur.role == SlurRole::Stop)
);
assert!(notes[2].attachments.chord_symbols.is_empty());
}
#[test]
fn chord_symbol_before_grace_and_slur_binds_to_main_note() {
let source = "X:1\nL:1/8\nK:C\n\"F\"{AB}(cd)|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let notes = semantic_note_events(&tune);
let first = notes[0];
assert_eq!(first.attachments.chord_symbols[0].text, "F");
assert_eq!(first.attachments.grace_groups.len(), 1);
assert!(
first
.attachments
.slurs
.iter()
.any(|slur| slur.role == SlurRole::Start)
);
assert!(
notes[1]
.attachments
.slurs
.iter()
.any(|slur| slur.role == SlurRole::Stop)
);
}
#[test]
fn chord_symbol_before_tuplet_marker_binds_to_first_tuplet_note() {
let source = "X:1\nL:1/8\nK:C\n\"F\"(3CDE F|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let notes = semantic_note_events(&tune);
let first = notes[0];
assert_eq!(first.attachments.chord_symbols[0].text, "F");
assert!(
first
.attachments
.tuplets
.iter()
.any(|tuplet| tuplet.role == TupletRole::Start)
);
assert!(
notes[2]
.attachments
.tuplets
.iter()
.any(|tuplet| tuplet.role == TupletRole::Stop)
);
assert!(notes[3].attachments.chord_symbols.is_empty());
assert!(notes[3].attachments.tuplets.is_empty());
}
fn timed_tuplet_roles(tune: &crate::model::Tune) -> Vec<(&'static str, Vec<TupletRole>)> {
tune.score.parts[0].voices[0]
.events
.iter()
.filter_map(|event| {
let kind = match &event.kind {
TimedEventKind::Note(_) => "note",
TimedEventKind::Rest(_) => "rest",
TimedEventKind::Chord(_) => "chord",
_ => return None,
};
let roles = event
.attachments
.tuplets
.iter()
.map(|tuplet| tuplet.role)
.collect();
Some((kind, roles))
})
.collect()
}
#[test]
fn rest_led_tuplet_carries_start_role_on_the_rest() {
let source = "X:1\nL:1/8\nK:C\n(3zBA|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
assert_eq!(
timed_tuplet_roles(&tune),
vec![
("rest", vec![TupletRole::Start]),
("note", vec![TupletRole::Continue]),
("note", vec![TupletRole::Stop]),
]
);
}
#[test]
fn rest_closed_tuplet_carries_stop_role_on_the_rest() {
let source = "X:1\nL:1/8\nK:C\n(3BAz|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
assert_eq!(
timed_tuplet_roles(&tune),
vec![
("note", vec![TupletRole::Start]),
("note", vec![TupletRole::Continue]),
("rest", vec![TupletRole::Stop]),
]
);
}
#[test]
fn all_rest_tuplet_carries_roles_on_every_rest() {
let source = "X:1\nL:1/8\nK:C\n(3zzz|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
assert_eq!(
timed_tuplet_roles(&tune),
vec![
("rest", vec![TupletRole::Start]),
("rest", vec![TupletRole::Continue]),
("rest", vec![TupletRole::Stop]),
]
);
}
#[test]
fn one_note_tuplet_carries_start_and_stop_on_same_event() {
let source = "X:1\nL:1/8\nK:C\n(3:2:1G B|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
assert_eq!(
timed_tuplet_roles(&tune),
vec![
("note", vec![TupletRole::Start, TupletRole::Stop]),
("note", Vec::new()),
]
);
}
#[test]
fn nested_tuplets_carry_outer_and_inner_roles() {
let source = "X:1\nM:C\nL:1/4\nK:C\n(7:8:8(3A/A/A/ A/A/A/A/A/|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let events: Vec<_> = tune.score.parts[0].voices[0]
.events
.iter()
.filter(|event| matches!(event.kind, TimedEventKind::Note(_)))
.collect();
assert_eq!(events.len(), 8);
assert!(events[0].attachments.tuplets.iter().any(|tuplet| {
tuplet.actual_notes == 7 && tuplet.normal_notes == 8 && tuplet.role == TupletRole::Start
}));
assert!(events[0].attachments.tuplets.iter().any(|tuplet| {
tuplet.actual_notes == 3 && tuplet.normal_notes == 2 && tuplet.role == TupletRole::Start
}));
assert!(events[2].attachments.tuplets.iter().any(|tuplet| {
tuplet.actual_notes == 3 && tuplet.normal_notes == 2 && tuplet.role == TupletRole::Stop
}));
assert!(events[7].attachments.tuplets.iter().any(|tuplet| {
tuplet.actual_notes == 7 && tuplet.normal_notes == 8 && tuplet.role == TupletRole::Stop
}));
}
#[test]
fn rest_led_tuplet_keeps_prefix_attachments_on_the_rest() {
let source = "X:1\nL:1/8\nK:C\n(3\"C\"zBA|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let rest = tune.score.parts[0].voices[0]
.events
.iter()
.find(|event| matches!(event.kind, TimedEventKind::Rest(_)))
.expect("expected a rest event");
assert_eq!(rest.attachments.chord_symbols[0].text, "C");
assert!(
rest.attachments
.tuplets
.iter()
.any(|tuplet| tuplet.role == TupletRole::Start)
);
}
#[test]
fn quoted_texts_before_and_after_grace_group_keep_order() {
let source = "X:1\nL:1/8\nK:C\n\"F\"{AB}\"G\"c d|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let notes = semantic_note_events(&tune);
let first = notes[0];
assert_eq!(
first
.attachments
.chord_symbols
.iter()
.map(|symbol| symbol.text.as_str())
.collect::<Vec<_>>(),
vec!["F", "G"]
);
assert_eq!(first.attachments.grace_groups.len(), 1);
}
#[test]
fn decoration_before_grace_group_binds_to_main_note() {
let source = "X:1\nL:1/8\nK:C\n!trill!{AB}c d|\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let notes = semantic_note_events(&tune);
let first = notes[0];
assert_eq!(first.attachments.decorations[0].name, "trill");
assert_eq!(first.attachments.grace_groups.len(), 1);
assert!(notes[1].attachments.decorations.is_empty());
}
#[test]
fn quoted_text_after_grace_or_inside_slur_stays_bound() {
let (tune, diagnostics) = tune_for("X:1\nL:1/8\nK:C\n{AB}\"F\"c d|\n");
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let notes = semantic_note_events(&tune);
assert_eq!(notes[0].attachments.chord_symbols[0].text, "F");
assert_eq!(notes[0].attachments.grace_groups.len(), 1);
let (tune, diagnostics) = tune_for("X:1\nL:1/8\nK:C\n(\"G7\"DE) F|\n");
assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
let notes = semantic_note_events(&tune);
assert_eq!(notes[0].attachments.chord_symbols[0].text, "G7");
assert!(
notes[0]
.attachments
.slurs
.iter()
.any(|slur| slur.role == SlurRole::Start)
);
}
#[test]
fn unclosed_grace_group_after_quoted_text_still_diagnoses() {
let source = "X:1\nL:1/8\nK:C\n\"F\"{AB c\nd e|\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
count_diagnostics(&diagnostics, "abc.music.unclosed_grace"),
1
);
let notes = semantic_note_events(&tune);
let symbols: Vec<_> = notes
.iter()
.flat_map(|note| note.attachments.chord_symbols.iter())
.map(|symbol| symbol.text.as_str())
.collect();
assert_eq!(
symbols,
["F"],
"the recovered symbol binds once, to the first surviving note"
);
assert_eq!(
notes
.first()
.map(|note| note.attachments.chord_symbols.len()),
Some(1)
);
}
#[test]
fn broken_rhythm_without_neighbors_diagnoses_and_keeps_timing_stable() {
let source = "X:1\nL:1/8\nK:C\n< C D >|\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.broken_rhythm.missing_left"),
"<"
);
assert_eq!(
diagnostic_span(
source,
&diagnostics,
"abc.music.broken_rhythm.missing_right"
),
">"
);
let durations = semantic_note_events(&tune)
.into_iter()
.map(|event| event.duration)
.collect::<Vec<_>>();
assert_eq!(durations, vec![Fraction::new(1, 8), Fraction::new(1, 8)]);
assert_eq!(
tune.score.parts[0].voices[0].measures[0].actual_duration,
Fraction::new(2, 8)
);
}
#[test]
fn broken_rhythm_after_barline_does_not_bind_across_bar() {
let source = "X:1\nL:1/8\nK:C\nc2|>d e\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.broken_rhythm.missing_left"),
">"
);
let durations = semantic_note_events(&tune)
.into_iter()
.map(|event| event.duration)
.collect::<Vec<_>>();
assert_eq!(
durations,
vec![
Fraction::new(2, 8),
Fraction::new(1, 8),
Fraction::new(1, 8)
]
);
}
#[test]
fn broken_rhythm_after_barline_single_note_left_does_not_bind() {
let source = "X:1\nL:1/8\nK:C\nB|>d e\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.broken_rhythm.missing_left"),
">"
);
let durations = semantic_note_events(&tune)
.into_iter()
.map(|event| event.duration)
.collect::<Vec<_>>();
assert_eq!(
durations,
vec![
Fraction::new(1, 8),
Fraction::new(1, 8),
Fraction::new(1, 8)
]
);
}
#[test]
fn broken_rhythm_within_measure_still_applies() {
let source = "X:1\nL:1/8\nK:C\na>b\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let durations = semantic_note_events(&tune)
.into_iter()
.map(|event| event.duration)
.collect::<Vec<_>>();
assert_eq!(durations, vec![Fraction::new(3, 16), Fraction::new(1, 16)]);
}
#[test]
fn broken_rhythm_dangling_right_still_void() {
let source = "X:1\nL:1/8\nK:C\nA>|B c\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(
source,
&diagnostics,
"abc.music.broken_rhythm.missing_right"
),
">"
);
let durations = semantic_note_events(&tune)
.into_iter()
.map(|event| event.duration)
.collect::<Vec<_>>();
assert_eq!(
durations,
vec![
Fraction::new(1, 8),
Fraction::new(1, 8),
Fraction::new(1, 8)
]
);
}
#[test]
fn broken_rhythm_same_measure_multi_unit_left_still_applies() {
let source = "X:1\nL:1/8\nK:C\nc2>d e\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let durations = semantic_note_events(&tune)
.into_iter()
.map(|event| event.duration)
.collect::<Vec<_>>();
assert_eq!(
durations,
vec![
Fraction::new(3, 8),
Fraction::new(1, 16),
Fraction::new(1, 8)
]
);
}
#[test]
fn broken_rhythm_grace_transparency_still_applies() {
let source = "X:1\nL:1/8\nK:C\nc>{g}d\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let durations = semantic_note_events(&tune)
.into_iter()
.map(|event| event.duration)
.collect::<Vec<_>>();
assert_eq!(durations, vec![Fraction::new(3, 16), Fraction::new(1, 16)]);
}
#[test]
fn short_tuplet_does_not_consume_notes_after_barline() {
let source = "X:1\nL:1/8\nK:C\n(3C|D E|\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.tuplet.too_few_notes"),
"(3"
);
let durations = semantic_note_events(&tune)
.into_iter()
.map(|event| event.duration)
.collect::<Vec<_>>();
assert_eq!(
durations,
vec![
Fraction::new(1, 12),
Fraction::new(1, 8),
Fraction::new(1, 8)
]
);
assert_eq!(
tune.score.parts[0].voices[0].measures[1].actual_duration,
Fraction::new(2, 8)
);
}
#[test]
fn unmatched_tie_preserves_unmerged_note_events() {
let source = "X:1\nL:1/8\nK:C\nC- D E\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
"-"
);
let notes = semantic_note_events(&tune);
assert_eq!(notes.len(), 3);
assert!(notes.iter().all(|event| event.attachments.ties.is_empty()));
assert_eq!(
notes.iter().map(|event| event.duration).collect::<Vec<_>>(),
vec![
Fraction::new(1, 8),
Fraction::new(1, 8),
Fraction::new(1, 8)
]
);
}
#[test]
fn post_barline_tie_marker_does_not_bind_across_the_bar() {
let source = "X:1\nM:2/4\nL:1/4\nK:C\nC |- C D |\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
"-"
);
let notes = semantic_note_events(&tune);
assert!(
notes.iter().all(|event| event.attachments.ties.is_empty()),
"a post-barline tie marker must not produce any tie attachment"
);
}
#[test]
fn ties_resolve_across_barlines_without_changing_measure_timing() {
let source = "X:1\nM:2/4\nL:1/4\nK:C\nC- | C D |\n";
let (tune, diagnostics) = tune_for(source);
assert!(
!diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
);
let notes = semantic_note_events(&tune);
assert_eq!(notes.len(), 3);
assert_eq!(
notes
.iter()
.map(|event| event.attachments.ties.first().map(|tie| tie.role))
.collect::<Vec<_>>(),
vec![Some(TieRole::Start), Some(TieRole::Stop), None]
);
assert_eq!(
notes[0].attachments.ties[0].pair_id,
notes[1].attachments.ties[0].pair_id
);
assert_eq!(
notes.iter().map(|event| event.duration).collect::<Vec<_>>(),
vec![
Fraction::new(1, 4),
Fraction::new(1, 4),
Fraction::new(1, 4)
]
);
let measures = &tune.score.parts[0].voices[0].measures;
assert_eq!(
measures
.iter()
.map(|measure| measure.actual_duration)
.collect::<Vec<_>>(),
vec![Fraction::new(1, 4), Fraction::new(2, 4)]
);
assert!(measures[0].pickup);
assert!(measures[1].complete);
}
#[test]
fn dropped_tie_does_not_leak_accidental_across_barline() {
let source = "X:1\nL:1/8\nK:C\n^a- | b a a\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
"-"
);
assert_eq!(semantic_note_alters(&tune), vec![1, 0, 0, 0]);
let notes = semantic_note_events(&tune);
assert!(notes.iter().all(|event| event.attachments.ties.is_empty()));
}
#[test]
fn dropped_tie_at_rest_does_not_leak_accidental_across_barline() {
let source = "X:1\nL:1/8\nK:C\n^a- | z a\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
"-"
);
assert_eq!(semantic_note_alters(&tune), vec![1, 0]);
}
#[test]
fn chord_tie_partial_drop_keeps_matched_tie_and_drops_leaked_accidental() {
let source = "X:1\nL:1/8\nK:C\n[^ac]- | c a\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
"-"
);
let chord = tune.score.parts[0].voices[0]
.events
.iter()
.find_map(|event| match &event.kind {
TimedEventKind::Chord(chord) => Some(chord),
_ => None,
})
.expect("expected chord");
assert_eq!(
chord
.members
.iter()
.map(|member| (member.pitch.step, member.pitch.alter))
.collect::<Vec<_>>(),
vec![('A', 1), ('C', 0)]
);
assert!(chord.members[0].attachments.ties.is_empty());
assert_eq!(chord.members[1].attachments.ties.len(), 1);
assert_eq!(chord.members[1].attachments.ties[0].role, TieRole::Start);
assert_eq!(semantic_note_alters(&tune), vec![0, 0]);
let notes = semantic_note_events(&tune);
assert_eq!(notes.len(), 2);
assert_eq!(notes[0].attachments.ties.len(), 1);
assert_eq!(notes[0].attachments.ties[0].role, TieRole::Stop);
assert_eq!(
notes[0].attachments.ties[0].pair_id,
chord.members[1].attachments.ties[0].pair_id
);
assert!(notes[1].attachments.ties.is_empty());
}
#[test]
fn rewritten_accidental_after_dropped_tie_carries_within_measure() {
let source = "X:1\nL:1/8\nK:C\n^a- | b ^a a\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
"-"
);
assert_eq!(semantic_note_alters(&tune), vec![1, 0, 1, 1]);
}
#[test]
fn tie_across_barline_preserves_accidental_carry() {
let source = "X:1\nL:1/8\nK:C\n^a- | a\n";
let (tune, diagnostics) = tune_for(source);
assert!(
!diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
);
assert_eq!(semantic_note_alters(&tune), vec![1, 1]);
let notes = semantic_note_events(&tune);
assert_eq!(
notes
.iter()
.map(|event| event.attachments.ties.first().map(|tie| tie.role))
.collect::<Vec<_>>(),
vec![Some(TieRole::Start), Some(TieRole::Stop)]
);
assert_eq!(
notes[0].attachments.ties[0].pair_id,
notes[1].attachments.ties[0].pair_id
);
}
#[test]
fn same_measure_dropped_tie_keeps_written_accidental_carry() {
let source = "X:1\nL:1/8\nK:C\n^a-b a\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
"-"
);
assert_eq!(semantic_note_alters(&tune), vec![1, 0, 1]);
}
#[test]
fn matched_tie_stop_accidental_is_stop_note_only() {
let source = "X:1\nL:1/8\nK:C\n^a- | a b a\n";
let (tune, diagnostics) = tune_for(source);
assert!(
!diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
);
assert_eq!(semantic_note_alters(&tune), vec![1, 1, 0, 0]);
}
#[test]
fn chained_tie_across_barline_preserves_accidental_until_chain_ends() {
let source = "X:1\nL:1/8\nK:C\n^g- | g-g g\n";
let (tune, diagnostics) = tune_for(source);
assert!(
!diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
);
assert_eq!(semantic_note_alters(&tune), vec![1, 1, 1, 0]);
}
#[test]
fn tie_across_barline_with_rewritten_accidental_keeps_carry() {
let source = "X:1\nL:1/8\nK:C\n^a- | ^a a\n";
let (tune, diagnostics) = tune_for(source);
assert!(
!diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
);
assert_eq!(semantic_note_alters(&tune), vec![1, 1, 1]);
}
#[test]
fn tie_across_double_barline_preserves_accidental_carry() {
let source = "X:1\nL:1/8\nK:C\n^a- || a\n";
let (tune, diagnostics) = tune_for(source);
assert!(
!diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
);
assert_eq!(semantic_note_alters(&tune), vec![1, 1]);
let notes = semantic_note_events(&tune);
assert_eq!(
notes
.iter()
.map(|event| event.attachments.ties.first().map(|tie| tie.role))
.collect::<Vec<_>>(),
vec![Some(TieRole::Start), Some(TieRole::Stop)]
);
}
#[test]
fn crossing_slurs_diagnose_but_preserve_notes() {
let source = "X:1\nL:1/8\nK:C\n.(C (D .) E)\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.crossing_slur"),
".)"
);
assert_eq!(semantic_note_events(&tune).len(), 3);
assert_eq!(
semantic_note_events(&tune)
.into_iter()
.map(|event| event.duration)
.collect::<Vec<_>>(),
vec![
Fraction::new(1, 8),
Fraction::new(1, 8),
Fraction::new(1, 8)
]
);
}
#[test]
fn variable_chord_members_preserve_chord_shape_and_following_timing() {
let source = "X:1\nL:1/8\nK:C\n[E2G,6] C\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.music.chord.variable_duration"),
"[E2G,6]"
);
let events = &tune.score.parts[0].voices[0].events;
let chord = events
.iter()
.find_map(|event| match &event.kind {
TimedEventKind::Chord(chord) => Some(chord),
_ => None,
})
.expect("expected chord");
assert_eq!(chord.members.len(), 2);
assert_eq!(
chord
.members
.iter()
.map(|member| member.duration)
.collect::<Vec<_>>(),
vec![Fraction::new(2, 8), Fraction::new(6, 8)]
);
let note_after = events
.iter()
.find(|event| {
matches!(event.kind, TimedEventKind::Note(_)) && event.onset == Fraction::new(2, 8)
})
.expect("expected following note");
assert_eq!(note_after.onset, Fraction::new(2, 8));
}
#[test]
fn overlay_incomplete_duration_does_not_shift_base_timeline() {
let source = "X:1\nL:1/8\nK:C\nC D & E|\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.voice.overlay_incomplete_measure"),
"&"
);
let base_notes = semantic_note_events(&tune)
.into_iter()
.map(|event| (event.onset, event.duration))
.collect::<Vec<_>>();
assert_eq!(
base_notes,
vec![
(Fraction::zero(), Fraction::new(1, 8)),
(Fraction::new(1, 8), Fraction::new(1, 8)),
]
);
}
#[test]
fn lyric_count_mismatches_attach_valid_syllables_only() {
let source = "X:1\nL:1/8\nK:C\nC D E F|\nw: one two\nw: a b c d e\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
count_diagnostics(&diagnostics, "abc.lyric.syllable_count"),
2
);
let lyrics = semantic_note_events(&tune)
.into_iter()
.flat_map(|event| event.attachments.lyrics.iter())
.filter(|lyric| lyric.control == LyricControl::Syllable)
.map(|lyric| lyric.text.as_str())
.collect::<Vec<_>>();
assert_eq!(lyrics, vec!["one", "a", "two", "b", "c", "d"]);
assert_eq!(semantic_note_events(&tune).len(), 4);
}
#[test]
fn lyric_overflow_mid_hyphenated_word_drops_orphan_hyphen() {
let source = "X:1\nL:1/4\nK:C\nC|\nw: a-b\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
count_diagnostics(&diagnostics, "abc.lyric.syllable_count"),
1
);
let lyrics = semantic_note_events(&tune)[0]
.attachments
.lyrics
.iter()
.map(|lyric| (lyric.control, lyric.text.as_str()))
.collect::<Vec<_>>();
assert_eq!(lyrics, vec![(LyricControl::Syllable, "a")]);
let source = "X:1\nL:1/4\nK:C\nC D|\nw: a-b\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let lyrics = semantic_note_events(&tune)
.into_iter()
.map(|event| {
event
.attachments
.lyrics
.iter()
.map(|lyric| (lyric.control, lyric.text.as_str()))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
assert_eq!(
lyrics,
vec![
vec![(LyricControl::Syllable, "a"), (LyricControl::Hyphen, "-")],
vec![(LyricControl::Syllable, "b")]
]
);
}
#[test]
fn lyric_hyphen_can_continue_across_non_adjacent_blocks() {
let source = "X:1\nL:1/4\nK:C\nC|\nw: A-\nD|\nw: men\n";
let (tune, diagnostics) = tune_for(source);
assert!(diagnostics.is_empty());
let lyrics = semantic_note_events(&tune)
.into_iter()
.map(|event| {
event
.attachments
.lyrics
.iter()
.map(|lyric| (lyric.control, lyric.text.as_str()))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
assert_eq!(
lyrics,
vec![
vec![(LyricControl::Syllable, "A"), (LyricControl::Hyphen, "-")],
vec![(LyricControl::Syllable, "men")]
]
);
}
#[test]
fn invalid_state_changes_keep_previous_state_and_valid_music() {
let source = "X:1\nM:2/4\nL:1/8\nK:C\nC D|\nM:bad\nK:???\nL:bad\nE F|\n";
let document = parse_document(source, ParseOptions::default());
assert_eq!(
diagnostic_span(source, &document.diagnostics, "abc.field.invalid_l"),
"bad"
);
let report = parse_tune_report_from_document(&document.value);
let tune = report.value.expect("expected tune");
assert_eq!(
diagnostic_span(source, &report.diagnostics, "abc.field.invalid_m"),
"bad"
);
assert_eq!(
diagnostic_span(source, &report.diagnostics, "abc.field.invalid_k"),
"???"
);
let notes = semantic_note_events(&tune);
assert_eq!(notes.len(), 4);
assert!(
notes
.iter()
.all(|event| event.duration == Fraction::new(1, 8))
);
assert_eq!(
tune.score.parts[0].voices[0].measures[1].expected_duration,
Some(Fraction::new(2, 4))
);
}
#[test]
fn malformed_repeat_ending_keeps_barline_timing_intact() {
let source = "X:1\nL:1/8\nK:C\nC|[1- D|E|\n";
let document = parse_document(source, ParseOptions::default());
assert_eq!(
diagnostic_span(
source,
&document.diagnostics,
"abc.music.invalid_repeat_ending"
),
"[1-"
);
let report = parse_tune_report_from_document(&document.value);
let tune = report.value.expect("expected tune");
assert_eq!(semantic_note_events(&tune).len(), 3);
assert_eq!(
tune.score.parts[0].voices[0]
.measures
.iter()
.map(|measure| measure.actual_duration)
.collect::<Vec<_>>(),
vec![
Fraction::new(1, 8),
Fraction::new(1, 8),
Fraction::new(1, 8)
]
);
}
#[test]
fn inline_instruction_field_warns_and_changes_nothing() {
let source = "X:1\nL:1/8\nK:C\n(3CDE [I:tuplets 1 0 0](3CDE|\n";
let document = parse_document(source, ParseOptions::default());
let report = parse_tune_report_from_document(&document.value);
let tune = report.value.expect("expected tune");
assert_eq!(
diagnostic_span(source, &report.diagnostics, "abc.field.inline_ignored"),
"[I:tuplets 1 0 0]"
);
let notes = tune
.events
.iter()
.filter_map(|event| match event {
Event::Note {
step,
octave,
duration,
..
} => Some((*step, *octave, *duration)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(notes.len(), 6);
assert_eq!(notes[..3], notes[3..], "music differs across the inline I:");
}
#[test]
fn unsupported_directive_is_metadata_not_music() {
let source = "X:1\nK:C\n%%foo bar\nC D|\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostic_span(source, &diagnostics, "abc.directive.unsupported"),
"foo"
);
assert_eq!(tune.score.metadata.preserved_directives[0].name.text, "foo");
assert_eq!(semantic_note_events(&tune).len(), 2);
assert_eq!(
tune.score.parts[0].voices[0].measures[0].actual_duration,
Fraction::new(2, 8)
);
}
#[test]
fn mid_tune_key_and_meter_changes_reach_the_score() {
let source = "X:1\nL:1/4\nK:C\nCDEF|[K:F]GAB_B|[M:3/4]ABc|\n";
let doc = crate::parse_document(source, crate::ParseOptions::default());
let score = crate::lower_score(&doc.value, crate::LowerOptions)
.value
.expect("score");
let events = &score.parts[0].voices[0].events;
let keys: Vec<&crate::model::KeySignatureModel> = events
.iter()
.filter_map(|e| match &e.kind {
crate::TimedEventKind::KeyChange(k) => Some(k),
_ => None,
})
.collect();
let meters: Vec<&crate::model::MeterModel> = events
.iter()
.filter_map(|e| match &e.kind {
crate::TimedEventKind::MeterChange(m) => Some(m),
_ => None,
})
.collect();
assert_eq!(keys.len(), 1, "one key change event");
assert_eq!(keys[0].display, "F");
assert_eq!(keys[0].fifths, -1);
assert_eq!(meters.len(), 1, "one meter change event");
assert_eq!(meters[0].display, "3/4");
assert_eq!(
score.metadata.key.as_ref().map(|k| k.display.as_str()),
Some("C")
);
let key_idx = events
.iter()
.position(|e| matches!(e.kind, crate::TimedEventKind::KeyChange(_)))
.expect("key change present");
assert!(key_idx > 0, "key change is not the first event");
assert!(matches!(
events[key_idx - 1].kind,
crate::TimedEventKind::Barline(_)
));
}
#[test]
fn nospace_header_key_global_accidentals_preserve_base_key() {
let source = "X:1\nL:1/4\nK:D_B^g\nCDEF|\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostics
.iter()
.filter(|diagnostic| diagnostic.code == "abc.field.key.compact_accidentals_ignored")
.count(),
1,
"expected compact accidental warning for K:D_B^g: {diagnostics:?}"
);
let key = tune.score.metadata.key.as_ref().expect("header key");
assert_eq!(key.fifths, 2);
assert!(
key.explicit_accidentals.is_empty(),
"compact accidental tail is nonstandard and ignored"
);
}
#[test]
fn nospace_inline_key_global_accidentals_preserve_base_key() {
let source = "X:1\nL:1/4\nK:C\nCDEF|[K:D_B^g]GABc|\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostics
.iter()
.filter(|diagnostic| diagnostic.code == "abc.field.key.compact_accidentals_ignored")
.count(),
1,
"expected compact accidental warning for [K:D_B^g]: {diagnostics:?}"
);
let key_changes = tune.score.parts[0].voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::KeyChange(key) => Some(key),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(key_changes.len(), 1);
assert_eq!(key_changes[0].fifths, 2);
assert!(
key_changes[0].explicit_accidentals.is_empty(),
"compact accidental tail is nonstandard and ignored"
);
}
#[test]
fn body_key_none_resets_signature_and_is_not_rejected() {
let source = "X:1\nL:1/4\nK:D\nDEFG|\nK:none\nABcd|\n";
let (tune, diagnostics) = tune_for(source);
assert!(
diagnostics.iter().all(|d| d.code != "abc.field.invalid_k"),
"K:none must not be rejected as an invalid key: {diagnostics:?}"
);
assert_eq!(
part_key_change_fifths(&tune.score, 0),
vec![0],
"K:none records a single 0-fifths key change"
);
}
#[test]
fn key_tonic_trailing_junk_recovery_warns() {
let source = "X:1\nL:1/4\nK:Bb,\nCDEF|\n";
let (tune, diagnostics) = tune_for(source);
assert_eq!(
diagnostics
.iter()
.filter(|d| d.code == "abc.field.key.tonic_trailing_junk_ignored")
.count(),
1,
"K:Bb, recovery must warn: {diagnostics:?}"
);
assert_eq!(
tune.score.metadata.key.as_ref().map(|k| k.fifths),
Some(-2),
"tonic Bb is still recovered despite the trailing junk"
);
}
#[test]
fn clean_key_tonic_does_not_warn_trailing_junk() {
for source in ["X:1\nL:1/4\nK:Bb\nCDEF|\n", "X:1\nL:1/4\nK:C\nCDEF|\n"] {
let (_, diagnostics) = tune_for(source);
assert!(
diagnostics
.iter()
.all(|d| d.code != "abc.field.key.tonic_trailing_junk_ignored"),
"clean key must not warn trailing junk ({source:?}): {diagnostics:?}"
);
}
}
#[test]
fn redundant_chord_tie_marker_warns() {
for source in ["X:1\nL:1/4\nK:C\n[-CE]|\n", "X:1\nL:1/4\nK:C\n[CE--]|\n"] {
let (_, diagnostics) = tune_for(source);
assert!(
diagnostics
.iter()
.any(|d| d.code == "abc.music.redundant_tie"),
"expected redundant_tie warning for {source:?}: {diagnostics:?}"
);
}
}
#[test]
fn invalid_inline_unit_length_warns() {
let (_, diagnostics) = tune_for("X:1\nL:1/4\nK:C\nC[L:bad]D|\n");
assert!(
diagnostics.iter().any(|d| d.code == "abc.field.invalid_l"),
"expected invalid_l for inline [L:bad]: {diagnostics:?}"
);
}
#[test]
fn invalid_inline_key_change_warns() {
let (_, diagnostics) = tune_for("X:1\nL:1/4\nK:C\nC[K:???]D|\n");
assert!(
diagnostics.iter().any(|d| d.code == "abc.field.invalid_k"),
"expected invalid_k for inline [K:???]: {diagnostics:?}"
);
}
#[test]
fn valid_unsupported_inline_field_is_silently_skipped() {
for source in [
"X:1\nL:1/4\nK:C\nC[P:A]D|\n",
"X:1\nL:1/4\nK:C\nC[r:a remark]D|\n",
"X:1\nL:1/4\nK:C\nC[w:la la]D|\n",
] {
let (_, diagnostics) = tune_for(source);
assert!(
diagnostics
.iter()
.all(|d| d.code != "abc.field.inline_ignored"),
"valid unsupported inline field must be silent ({source:?}): {diagnostics:?}"
);
}
}
#[test]
fn unterminated_tempo_quote_warns() {
let (_, diagnostics) = tune_for("X:1\nL:1/4\nK:C\nC4|\nQ:\"Allegro\nD4|\n");
assert!(
diagnostics
.iter()
.any(|d| d.code == "abc.field.tempo.unterminated_quote"),
"expected unterminated_quote warning: {diagnostics:?}"
);
}
#[test]
fn line_leading_colon_repeat_start_warns() {
let (_, diagnostics) = tune_for("X:1\nL:1/4\nK:G\n:GABc:|\n");
assert!(
diagnostics
.iter()
.any(|d| d.code == "abc.music.barline.recovered_repeat"),
"expected recovered_repeat warning: {diagnostics:?}"
);
}
#[test]
fn supported_body_additive_meter_change_does_not_warn() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF|\nM:3/4+4/4\nGABcdef|\n";
let (tune, diagnostics) = tune_for(source);
assert!(
diagnostics
.iter()
.all(|diagnostic| diagnostic.code != "abc.music.meter.unsupported_complex"),
"supported additive meter should not warn: {diagnostics:?}"
);
let meter_changes = tune.score.parts[0].voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::MeterChange(meter) => Some(meter),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(meter_changes.len(), 1);
assert_eq!(meter_changes[0].display, "3/4+4/4");
assert_eq!(meter_changes[0].duration, Some(Fraction::new(7, 4)));
assert!(!meter_changes[0].free_meter);
assert_eq!(
tune.score.parts[0].voices[0].measures[1].actual_duration,
Fraction::new(7, 4)
);
}
fn part_note_steps_and_alters(score: &crate::model::Score, part_index: usize) -> Vec<(char, i8)> {
score.parts[part_index].voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::Note(note) => Some((note.pitch.step, note.pitch.alter)),
_ => None,
})
.collect()
}
fn part_key_change_fifths(score: &crate::model::Score, part_index: usize) -> Vec<i8> {
score.parts[part_index].voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::KeyChange(key) => Some(key.fifths),
_ => None,
})
.collect()
}
fn part_meter_change_displays(score: &crate::model::Score, part_index: usize) -> Vec<&str> {
score.parts[part_index].voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::MeterChange(meter) => Some(meter.display.as_str()),
_ => None,
})
.collect()
}
fn notes_before_first_key_change(score: &crate::model::Score, part_index: usize) -> usize {
let events = &score.parts[part_index].voices[0].events;
let key_index = events
.iter()
.position(|event| matches!(event.kind, TimedEventKind::KeyChange(_)))
.expect("key change");
events[..key_index]
.iter()
.filter(|event| matches!(event.kind, TimedEventKind::Note(_)))
.count()
}
fn notes_before_first_meter_change(score: &crate::model::Score, part_index: usize) -> usize {
let events = &score.parts[part_index].voices[0].events;
let meter_index = events
.iter()
.position(|event| matches!(event.kind, TimedEventKind::MeterChange(_)))
.expect("meter change");
events[..meter_index]
.iter()
.filter(|event| matches!(event.kind, TimedEventKind::Note(_)))
.count()
}
#[test]
fn standalone_body_key_line_scopes_to_current_voice_timeline() {
let late_body_voice = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:D\n",
"V:1\n",
"B B B B|\n",
"K:Dm\n",
"B B B B|\n",
"V:2\n",
"F C B F|\n",
"K:Dm\n",
"F C B F|\n",
);
let header_predeclared_late_voice = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"V:1\n",
"V:2\n",
"K:D\n",
"V:1\n",
"B B B B|\n",
"K:Dm\n",
"B B B B|\n",
"V:2\n",
"F C B F|\n",
"K:Dm\n",
"F C B F|\n",
);
for (label, source) in [
("late body voice", late_body_voice),
(
"header-predeclared late voice",
header_predeclared_late_voice,
),
] {
let doc = crate::parse_document(source, crate::ParseOptions::default());
let score = crate::lower_score(&doc.value, crate::LowerOptions)
.value
.expect("score");
assert_eq!(score.parts.len(), 2, "{label}: expected two parts");
assert_eq!(
part_key_change_fifths(&score, 0),
vec![-1],
"{label}: V1 records only its Dm change"
);
assert_eq!(
part_key_change_fifths(&score, 1),
vec![-1],
"{label}: V2 records only its own Dm change"
);
assert_eq!(
notes_before_first_key_change(&score, 1),
4,
"{label}: V2 must stay in the header key until its own K:Dm"
);
assert_eq!(
part_note_steps_and_alters(&score, 1),
vec![
('F', 1),
('C', 1),
('B', 0),
('F', 1),
('F', 0),
('C', 0),
('B', -1),
('F', 0),
],
"{label}: V2 first bar stays in D major, second bar changes to D minor"
);
}
}
#[test]
fn standalone_body_meter_line_scopes_to_current_voice_timeline() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"V:1\n",
"V:2\n",
"K:C\n",
"V:1\n",
"C D E F|\n",
"M:3/4\n",
"G A B|\n",
"V:2\n",
"C D E F|\n",
"M:3/4\n",
"G A B|\n",
);
let doc = crate::parse_document(source, crate::ParseOptions::default());
let score = crate::lower_score(&doc.value, crate::LowerOptions)
.value
.expect("score");
assert_eq!(score.parts.len(), 2, "expected two parts");
assert_eq!(part_meter_change_displays(&score, 0), vec!["3/4"]);
assert_eq!(part_meter_change_displays(&score, 1), vec!["3/4"]);
assert_eq!(
notes_before_first_meter_change(&score, 1),
4,
"V2 must stay in the header meter until its own M:3/4"
);
}