use super::*;
use crate::{ParseOptions, export_musicxml, parse_document};
#[test]
fn simple_score_emits_partwise_and_core_attributes() {
let export =
export_musicxml("X:1\nT:Scale\nM:6/8\nL:1/8\nK:G\nC2 z x|\n").expect("score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<score-partwise version=\"4.0\">"));
assert!(export.musicxml.contains("<score-part id=\"P1\">"));
assert!(export.musicxml.contains("<part id=\"P1\">"));
assert!(export.musicxml.contains("<part-name>Scale</part-name>"));
assert!(export.musicxml.contains("<divisions>8</divisions>"));
assert!(export.musicxml.contains("<fifths>1</fifths>"));
assert!(export.musicxml.contains("<beats>6</beats>"));
assert!(export.musicxml.contains("<beat-type>8</beat-type>"));
assert!(export.musicxml.contains("<sign>G</sign>"));
assert!(export.musicxml.contains("<duration>8</duration>"));
assert!(export.musicxml.contains("<type>quarter</type>"));
assert!(export.musicxml.contains("<type>eighth</type>"));
assert!(export.musicxml.contains("<note print-object=\"no\">"));
}
#[test]
fn text_output_is_escaped_for_metadata_lyrics_harmony_and_directions() {
let source = concat!(
"X:1\n",
"T:A&B <T> \"Q\" 'R'\n",
"C:Comp & < > \" '\n",
"Q:Fast & < > \" '\n",
"L:1/8\n",
"K:C\n",
"\"G7&<>'\"\"^Ann & < > '\"C D|\n",
"w: lyr&<>' two\n",
"%%foo dir & < > \" '\n",
);
let export = export_musicxml(source).expect("escaped text score should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("A&B <T> "Q" 'R'")
);
assert!(
export
.musicxml
.contains("Comp & < > " '")
);
assert!(
export
.musicxml
.contains("Fast & < > " '")
);
assert!(export.musicxml.contains("G7&<>'"));
assert!(export.musicxml.contains("Ann & < > '"));
assert!(export.musicxml.contains("lyr&<>'"));
assert!(!export.musicxml.contains("%%foo"));
}
#[test]
fn slash_chord_symbols_export_bass_step_and_alter() {
let source = "X:1\nT:Slash Chords\nM:4/4\nL:1/4\nK:C\n\"C/E\"C \"Db/Ab\"D|\n";
let export = export_musicxml(source).expect("slash chords should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<root-step>C</root-step>"));
assert!(export.musicxml.contains("<bass-step>E</bass-step>"));
assert!(export.musicxml.contains("<root-step>D</root-step>"));
assert!(export.musicxml.contains("<root-alter>-1</root-alter>"));
assert!(export.musicxml.contains("<bass-step>A</bass-step>"));
assert!(export.musicxml.contains("<bass-alter>-1</bass-alter>"));
}
#[test]
fn harmony_text_carriers_bind_to_each_chord_symbol() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"[I:croma-harmony-text textless=1]\"A\"",
"[I:croma-harmony-text textless=1]\"B\"C4|\n",
);
let export = export_musicxml(source).expect("harmony text carriers should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 2);
assert_eq!(count(&export.musicxml, "<kind>major</kind>"), 2);
assert!(!export.musicxml.contains("<kind text=\"A\">major</kind>"));
assert!(!export.musicxml.contains("<kind text=\"B\">major</kind>"));
}
#[test]
fn malformed_quoted_chord_strings_export_as_words_not_fake_harmony() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"(A7)\"C \"C/\"D|\n";
let export = export_musicxml(source).expect("malformed chord text should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 0);
assert_eq!(count(&export.musicxml, "<root-step>"), 0);
assert!(export.musicxml.contains("<words>(A7)</words>"));
assert!(export.musicxml.contains("<words>C/</words>"));
}
#[test]
fn leading_whitespace_valid_chord_symbols_still_export_harmony() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\" G7\"C \" C/E\"D|\n";
let export = export_musicxml(source).expect("valid spaced chords should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 2);
assert!(export.musicxml.contains("<root-step>G</root-step>"));
assert!(export.musicxml.contains("<bass-step>E</bass-step>"));
assert!(!export.musicxml.contains("<words>G7</words>"));
assert!(!export.musicxml.contains("<words>C/E</words>"));
}
#[test]
fn chord_symbol_before_barline_binds_to_next_note() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC4 \"F\"| c4 |]\n";
let export = export_musicxml(source).expect("chord symbol at barline should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 1);
assert!(export.musicxml.contains("<root-step>F</root-step>"));
}
#[test]
fn non_harmony_quoted_text_before_barline_binds_to_barline_position() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E \"I\"| F G A B|\n";
let export = export_musicxml(source).expect("barline words should export");
assert_balanced_xml(&export.musicxml);
let measure1 = measure_body(&export.musicxml, "1");
let measure2 = measure_body(&export.musicxml, "2");
assert!(
measure1.contains("<words>I</words>"),
"barline-bound words should stay in measure 1: {measure1}"
);
assert!(
!measure2.contains("<words>I</words>"),
"barline-bound words must not move to measure 2: {measure2}"
);
}
#[test]
fn orphan_chord_symbol_between_double_bars_anchors_to_its_note_less_measure() {
let source = "X:1\nM:2/4\nL:1/8\nK:C\nGA Bc | DD EF ||\n\"B\"\n||\"Gm\"G4 |]\n";
let export = export_musicxml(source).expect("orphan chord should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
let m3 = measure_body(&export.musicxml, "3");
assert!(
m3.contains("<root-step>B</root-step>"),
"orphan `\"B\"` must anchor to its own measure m3: {m3}"
);
assert!(
has_barline(&measures[2], "right", Some("light-light"), None),
"the second `||` must emit light-light on the orphan measure: {}",
export.musicxml
);
let m4 = measure_body(&export.musicxml, "4");
assert!(
m4.contains("<root-step>G</root-step>"),
"the `\"Gm\"` chord stays on m4 with its note: {m4}"
);
assert_eq!(note_steps(&measures[3]), vec!['G']);
}
#[test]
fn annotation_on_note_less_spacer_measure_stays_on_that_measure() {
let source = "X:1\nM:2/4\nL:1/8\nK:C\nGA Bc | cc dd |] \"Final.\"y8 | f4 |]\n";
let export = export_musicxml(source).expect("spacer annotation should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
let m3 = measure_body(&export.musicxml, "3");
let m4 = measure_body(&export.musicxml, "4");
assert!(
m3.contains("<words>Final.</words>"),
"annotation must stay on the note-less spacer measure m3: {m3}"
);
assert!(
!m4.contains("<words>Final.</words>"),
"annotation must NOT defer to the `f` measure m4: {m4}"
);
assert_eq!(note_steps(&measures[3]), vec!['F']);
}
#[test]
fn chord_symbol_at_line_end_binds_to_note_on_next_line() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D \"Em7\"\nE F|\n";
let export = export_musicxml(source).expect("line-end chord symbol should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 1);
assert!(export.musicxml.contains("<root-step>E</root-step>"));
assert!(
export
.musicxml
.contains("<kind text=\"Em7\">minor-seventh</kind>")
);
}
#[test]
fn annotation_alone_on_line_binds_to_first_following_note() {
let source = "X:1\nM:C|\nK:G\n\"Single Reel\"\nB2 e2 g2 f2|\n";
let export = export_musicxml(source).expect("standalone annotation line should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<words>Single Reel</words>"));
}
#[test]
fn chord_symbol_before_multimeasure_rest_is_kept() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"Dm\"Z2 | C4 |]\n";
let export = export_musicxml(source).expect("chord before multirest should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 1);
assert!(export.musicxml.contains("<root-step>D</root-step>"));
}
#[test]
fn multimeasure_rest_exports_real_full_measure_rests() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF|Z2|GABc|]\n";
let export = export_musicxml(source).expect("multirest should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<measure number="), 4);
assert_eq!(count(&export.musicxml, "<rest measure=\"yes\"/>"), 2);
assert!(export.musicxml.contains("<multiple-rest>2</multiple-rest>"));
assert!(!export.musicxml.contains("<time-modification>"));
}
#[test]
fn full_measure_rest_omits_fabricated_type_for_inexpressible_duration() {
let source = "X:1\nM:5/4\nL:1/4\nK:C\nz5|\n";
let export = export_musicxml(source).expect("full-measure rest should export");
assert_balanced_xml(&export.musicxml);
let measure_rest = musicxml_note_blocks(&export.musicxml)
.into_iter()
.find(|note| note.contains("<rest measure=\"yes\"/>"))
.expect("expected a measure rest note");
assert!(measure_rest.contains("<duration>40</duration>"));
assert!(measure_rest.contains("<voice>1</voice>"));
assert!(!measure_rest.contains("<type>"));
assert!(!measure_rest.contains("<time-modification>"));
}
#[test]
fn chord_member_in_tuplet_keeps_time_modification() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n(3[CEG][DFA][EGB] z2 |\n";
let export = export_musicxml(source).expect("triplet of chords should export");
assert_balanced_xml(&export.musicxml);
let member_blocks: Vec<_> = musicxml_note_blocks(&export.musicxml)
.into_iter()
.filter(|note| note.contains("<chord/>"))
.collect();
assert!(
!member_blocks.is_empty(),
"expected chord-member notes:\n{}",
export.musicxml
);
for member in &member_blocks {
assert!(
member.contains("<time-modification>"),
"a chord member in a tuplet must keep its <time-modification>:\n{member}"
);
assert!(
!member.contains("<tuplet "),
"a chord member must NOT carry the <tuplet> notation bracket:\n{member}"
);
}
}
#[test]
fn partial_nondyadic_rest_omits_fabricated_tuplet() {
let source = "X:1\nM:8/4\nL:1/4\nK:C\nA4 z5/2 z3/2|\n";
let export = export_musicxml(source).expect("partial non-dyadic rest should export");
assert_balanced_xml(&export.musicxml);
assert!(
!export.musicxml.contains("<time-modification>"),
"a non-dyadic partial rest must not fabricate a tuplet (no real tuplet present):\n{}",
export.musicxml
);
let bare_rest = musicxml_note_blocks(&export.musicxml)
.into_iter()
.find(|note| note.contains("<rest/>") && !note.contains("<type>"));
assert!(
bare_rest.is_some(),
"the 5/2 rest must export as a bare typeless <rest/>:\n{}",
export.musicxml
);
}
#[test]
fn dangling_quoted_text_at_tune_end_warns_instead_of_silent_drop() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC4 \"X7\" |]\n";
let export = export_musicxml(source).expect("dangling quoted text should still export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 0);
assert!(
export
.diagnostics
.iter()
.any(|d| d.code == "abc.music.dangling_quoted_text"),
"expected dangling-quoted-text diagnostic, got: {:?}",
export
.diagnostics
.iter()
.map(|d| d.code)
.collect::<Vec<_>>()
);
}
#[test]
fn tuplet_member_written_type_factors_out_the_time_modification() {
let source = "X:1\nM:6/8\nL:1/8\nK:C\nCCC (4BABd|\n";
let export = export_musicxml(source).expect("quadruplet should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<type>eighth</type>"), 7);
assert!(!export.musicxml.contains("<type>16th</type>"));
assert_eq!(count(&export.musicxml, "<actual-notes>4</actual-notes>"), 4);
let source = "X:1\nM:4/4\nL:1/8\nK:C\n(3F>GG ABcd z2|\n";
let export = export_musicxml(source).expect("broken triplet should export");
assert_balanced_xml(&export.musicxml);
let f_note = export
.musicxml
.split("<step>F</step>")
.nth(1)
.expect("F note present");
let f_note = &f_note[..f_note.find("</note>").expect("note closes")];
assert!(f_note.contains("<type>eighth</type>"));
assert!(
f_note.contains("<dot/>"),
"lengthened F keeps its dot: {f_note}"
);
}
#[test]
fn very_long_durations_spell_as_long_and_maxima() {
let source = "X:1\nM:none\nL:1/4\nK:C\nC16 C32|\n";
let export = export_musicxml(source).expect("long values should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<type>long</type>"));
assert!(export.musicxml.contains("<type>maxima</type>"));
}
#[test]
fn chord_member_slurs_attach_to_their_chords() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n[(C2(E2] [C2)E2)]|\n";
let export = export_musicxml(source).expect("chord-member slurs should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<slur"), 4);
assert_eq!(count(&export.musicxml, "type=\"start\""), 2);
assert_eq!(count(&export.musicxml, "type=\"stop\""), 2);
assert!(
!export
.diagnostics
.iter()
.any(|d| d.code == "abc.music.unknown_chord_token")
);
}
#[test]
fn voice_slur_closing_on_chord_attaches_stop_to_chord_head() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n(C [EG])|\n";
let export = export_musicxml(source).expect("chord-closing slur should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<slur type=\"start\""), 1);
assert_eq!(count(&export.musicxml, "<slur type=\"stop\""), 1);
let stop_index = export
.musicxml
.find("<slur type=\"stop\"")
.expect("slur stop should be present");
let note_start = export.musicxml[..stop_index]
.rfind("<note")
.expect("slur stop should be inside a note");
let stop_note = &export.musicxml[note_start..stop_index];
assert!(
!stop_note.contains("<chord/>"),
"slur stop should land on the chord head, not a chord member: {stop_note}"
);
}
#[test]
fn cross_voice_slur_carrier_exports_slur_start_and_stop_in_different_voices() {
let source = concat!(
"X:1\n%%score (1 2)\nM:4/4\nL:1/4\nK:C\n",
"V:1\n[I:croma-xvoice-slur pair=1 role=start]C (DE) F |\n",
"V:2\n[I:croma-xvoice-slur pair=1 role=stop]G A B z |\n",
);
let export = export_musicxml(source).expect("cross-voice slur carrier should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<slur type=\"start\""), 2);
assert_eq!(count(&export.musicxml, "<slur type=\"stop\""), 2);
let c_note = note_block(&export.musicxml, "C");
assert!(
c_note.contains("<slur type=\"start\"") && c_note.contains("<voice>1</voice>"),
"cross-voice slur start must be on the C note in voice 1: {c_note}"
);
let g_note = note_block(&export.musicxml, "G");
assert!(
g_note.contains("<slur type=\"stop\"") && g_note.contains("<voice>2</voice>"),
"cross-voice slur stop must be on the G note in voice 2: {g_note}"
);
assert!(note_block(&export.musicxml, "D").contains("<slur type=\"start\""));
assert!(note_block(&export.musicxml, "E").contains("<slur type=\"stop\""));
assert!(!export.musicxml.contains("croma-xvoice-slur"));
assert_eq!(
slur_number(c_note, "start"),
slur_number(g_note, "stop"),
"cross-voice slur ends must carry matching slur numbers"
);
}
fn slur_number(note: &str, role: &str) -> String {
let marker = format!("<slur type=\"{role}\"");
let at = note.find(&marker).expect("slur marker present");
let tag = ¬e[at..note[at..].find("/>").expect("slur self-closes") + at];
let key = "number=\"";
let n = tag.find(key).expect("slur has a number") + key.len();
tag[n..tag[n..].find('"').expect("number closes") + n].to_owned()
}
fn note_block<'a>(xml: &'a str, step: &str) -> &'a str {
let needle = format!("<step>{step}</step>");
let at = xml
.find(&needle)
.unwrap_or_else(|| panic!("no note with step {step}"));
let start = xml[..at].rfind("<note").expect("step inside a note");
let end = xml[at..].find("</note>").expect("note must close") + at + "</note>".len();
&xml[start..end]
}
#[test]
fn lowercase_root_quoted_text_is_words_not_harmony() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"d\"C D \"D/f\"E F|\n";
let export = export_musicxml(source).expect("lowercase root should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 1);
assert!(export.musicxml.contains("<words>d</words>"));
assert!(export.musicxml.contains("<bass-step>F</bass-step>"));
}
#[test]
fn body_tempo_field_emits_metronome_direction() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nQ:1/4=132\nCDEF|GABc|]\n";
let export = export_musicxml(source).expect("body tempo should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<per-minute>132</per-minute>"));
assert!(export.musicxml.contains("<beat-unit>quarter</beat-unit>"));
}
#[test]
fn mid_tune_tempo_change_lands_at_its_measure() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nQ:1/4=100\nCDEF|\nQ:1/4=160\nGABc|]\n";
let export = export_musicxml(source).expect("mid-tune tempo should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<per-minute>100</per-minute>"));
assert!(export.musicxml.contains("<per-minute>160</per-minute>"));
let measure2 = export
.musicxml
.split("<measure number=\"2\">")
.nth(1)
.expect("measure 2 exists");
assert!(measure2.contains("<per-minute>160</per-minute>"));
}
#[test]
fn liberal_barline_runs_keep_their_strongest_meaning() {
let final_run =
export_musicxml("X:1\nM:4/4\nL:1/4\nK:C\nCDEF|GABc||]\n").expect("||] should export");
assert_balanced_xml(&final_run.musicxml);
assert!(
final_run
.musicxml
.contains("<bar-style>light-heavy</bar-style>")
);
let leading_dots = export_musicxml("X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|GABc:[|]|]\n")
.expect(":[|]|] should export");
assert_balanced_xml(&leading_dots.musicxml);
assert!(
leading_dots
.musicxml
.contains("<repeat direction=\"backward\"/>")
);
let sandwich = export_musicxml("X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF:|\n|:|GABc:|\n")
.expect("|:| should export");
assert_balanced_xml(&sandwich.musicxml);
assert_eq!(
count(&sandwich.musicxml, "<repeat direction=\"forward\"/>"),
2
);
}
#[test]
fn trailing_colon_after_final_bar_is_a_repeat_start_not_a_phantom_measure() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF|]:GABc|]\n";
let export = export_musicxml(source).expect("|]: should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<measure number="), 2);
assert_eq!(
count(&export.musicxml, "<repeat direction=\"forward\"/>"),
1
);
}
#[test]
fn line_leading_colon_after_repeat_end_does_not_emit_phantom_measure() {
let source = "X:1\nM:4/4\nL:1/8\nK:G\nGA|G2GGG2:|\n:gf|e2ddd2GA|\n";
let export = export_musicxml(source).expect("line-leading colon should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
assert_eq!(note_steps(&measures[2]), vec!['G', 'F']);
assert!(has_barline(
&measures[1],
"right",
Some("light-heavy"),
Some("backward")
));
assert!(has_barline(
&measures[2],
"left",
Some("heavy-light"),
Some("forward")
));
}
#[test]
fn mid_line_spaced_colon_is_not_a_forward_repeat() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F :G A B c|]\n";
let export = export_musicxml(source).expect("mid-line colon should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(&export.musicxml, "<repeat direction=\"forward\"/>"),
0,
"a mid-line spaced `:` must not fabricate a forward repeat: {}",
export.musicxml
);
}
#[test]
fn line_leading_colon_after_section_final_does_not_emit_phantom_measure() {
let source = "X:1\nM:2/4\nL:1/8\nK:C\nGA|]\n:d d/e/|d c/B/|cA:|\n";
let export = export_musicxml(source).expect("section-leading colon should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
assert_eq!(note_steps(&measures[1]), vec!['D', 'D', 'E']);
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
None
));
assert!(has_barline(
&measures[1],
"left",
Some("heavy-light"),
Some("forward")
));
}
#[test]
fn lone_colon_between_notes_is_a_liberal_measure_boundary() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF:GABc|cdef|]\n";
let export = export_musicxml(source).expect("lone colon should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<measure number="), 3);
assert!(!export.musicxml.contains("<repeat"));
assert!(
export
.diagnostics
.iter()
.any(|d| d.code == "abc.music.barline.liberal")
);
}
#[test]
fn multi_measure_volta_emits_ending_stop_at_closing_barline() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|[1 GABc|GGGG:|[2 cdef|]\n";
let export = export_musicxml(source).expect("multi-measure volta should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<ending number=\"1\" type=\"start\"/>")
);
assert!(
export
.musicxml
.contains("<ending number=\"1\" type=\"stop\"/>")
);
assert!(
export
.musicxml
.contains("<ending number=\"2\" type=\"start\"/>")
);
assert!(
export
.musicxml
.contains("<ending number=\"2\" type=\"stop\"/>")
);
}
#[test]
fn volta_ending_closed_by_thick_thin_bar_emits_stop() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|[1 GABc:|[2 cdef[|GGGG\n";
let export = export_musicxml(source).expect("volta closed by [| should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<ending number=\"2\" type=\"start\"/>"),
"ending 2 should start"
);
assert!(
export
.musicxml
.contains("<ending number=\"2\" type=\"stop\"/>"),
"ending 2 must be closed by the `[|` thick-thin terminator (§4.10), not left dangling"
);
}
#[test]
fn quoted_text_volta_extension_exports_ending_and_harmony() {
let source = "X:1\nM:4/4\nL:1/8\nK:C\n\"G\"B2A2 G2F2 |[\"cont\" \"Am7\"F2G2 A2B2 | \"D7\"c2d2 \"G\"e4 |]\n";
let export = export_musicxml(source).expect("quoted-text volta should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<ending number=\"33\" type=\"start\">cont</ending>"),
"quoted volta text should export as an ending start:\n{}",
export.musicxml
);
assert!(
export
.musicxml
.contains("<ending number=\"33\" type=\"stop\"/>"),
"quoted volta text should close at the final barline:\n{}",
export.musicxml
);
assert!(
!export.musicxml.contains("<ending number=\"cont\""),
"MusicXML ending-number must stay numeric for readers:\n{}",
export.musicxml
);
assert!(
export
.musicxml
.contains("<kind text=\"Am7\">minor-seventh</kind>"),
"harmony after the quoted volta label should still bind to F2:\n{}",
export.musicxml
);
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.unclosed_chord"),
"quoted volta text must not be parsed as an unclosed chord: {:?}",
export.diagnostics
);
}
#[test]
fn volta_unclosed_in_source_stays_open_at_part_end() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|[1 GABc:|[2 cdef|\n";
let export = export_musicxml(source).expect("volta to part end should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<ending number=\"2\" type=\"start\"/>")
);
assert!(
!export
.musicxml
.contains("<ending number=\"2\" type=\"stop\"/>")
);
}
#[test]
fn ending_precedes_repeat_inside_barline_element() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|[1 GABc:|[2 cdef|]\n";
let export = export_musicxml(source).expect("volta should export");
assert_balanced_xml(&export.musicxml);
let barline = export
.musicxml
.split("<barline location=\"right\">")
.find(|chunk| chunk.contains("<ending number=\"1\" type=\"stop\"/>"))
.expect("ending-1 stop barline exists");
let barline = &barline[..barline.find("</barline>").expect("closed")];
let ending_pos = barline.find("<ending").expect("ending present");
let repeat_pos = barline.find("<repeat").expect("repeat present");
assert!(
ending_pos < repeat_pos,
"ending must precede repeat, got: {barline}"
);
}
#[test]
fn dynamic_decoration_before_barline_binds_to_barline_position() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F !f!|\nG A B c|\n";
let export = export_musicxml(source).expect("dynamic at barline should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<f/>"), 1);
let measure1 = measure_body(&export.musicxml, "1");
let measure2 = measure_body(&export.musicxml, "2");
assert!(measure1.contains("<f/>"));
assert!(!measure2.contains("<f/>"));
assert!(
measure1.rfind("<note>").expect("measure 1 has notes")
< measure1
.find("<direction")
.expect("measure 1 has direction"),
"barline-bound dynamic should be written after the measure's notes: {measure1}"
);
}
#[test]
fn annotation_before_barline_binds_to_barline_position() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F \"_Fine\"|\nG A B c|\n";
let export = export_musicxml(source).expect("annotation at barline should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<words>Fine</words>"), 1);
let measure1 = measure_body(&export.musicxml, "1");
let measure2 = measure_body(&export.musicxml, "2");
assert!(measure1.contains("<words>Fine</words>"));
assert!(!measure2.contains("<words>Fine</words>"));
assert!(
measure1.rfind("<note>").expect("measure 1 has notes")
< measure1
.find("<direction")
.expect("measure 1 has direction"),
"barline-bound annotation should be written after the measure's notes: {measure1}"
);
}
#[test]
fn dynamic_decoration_at_line_end_without_barline_binds_to_next_note() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\n!f!\nG A B c|\n";
let export = export_musicxml(source).expect("line-end dynamic should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<f/>"), 1);
let measure1 = measure_body(&export.musicxml, "1");
let measure2 = measure_body(&export.musicxml, "2");
assert!(!measure1.contains("<f/>"));
assert!(measure2.contains("<f/>"));
}
#[test]
fn grace_group_before_barline_attaches_to_note_across_the_bar() {
let source = "X:1\nL:1/8\nM:6/8\nK:D\nGB2Af2{e/}|d3D2z|]\n";
let export = export_musicxml(source).expect("grace at barline should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<grace"), 1);
let measure2 = export
.musicxml
.split("<measure number=\"2\">")
.nth(1)
.expect("measure 2 exists");
assert!(measure2.contains("<grace"));
}
#[test]
fn trailing_grace_group_before_barline_stays_after_previous_note() {
let source = "X:1\nT:Trailing Grace\nM:4/4\nL:1/8\nK:C\nTe6{de}|d2f f2f|\n";
let export = export_musicxml(source).expect("trailing grace should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert_eq!(note_steps(&measures[0]), vec!['E', 'D', 'E']);
assert_eq!(note_grace_flags(&measures[0]), vec![false, true, true]);
assert_eq!(note_steps(&measures[1]), vec!['D', 'F', 'F', 'F']);
assert_eq!(
note_grace_flags(&measures[1]),
vec![false, false, false, false]
);
}
#[test]
fn dangling_grace_group_at_tune_end_warns_instead_of_silent_drop() {
let source = "X:1\nL:1/8\nK:C\nC4 {ab}|]\n";
let export = export_musicxml(source).expect("dangling grace should still export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<grace"), 0);
assert!(
export
.diagnostics
.iter()
.any(|d| d.code == "abc.music.dangling_grace_group"),
"expected dangling-grace diagnostic, got: {:?}",
export
.diagnostics
.iter()
.map(|d| d.code)
.collect::<Vec<_>>()
);
}
#[test]
fn chord_symbol_survives_legacy_plus_decoration() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"F\"+>+a4|\n";
let export = export_musicxml(source).expect("chord before +decoration+ should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 1);
assert!(export.musicxml.contains("<root-step>F</root-step>"));
}
#[test]
fn crescendo_and_diminuendo_decorations_emit_wedges_not_words() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"!<(!C D !<)!E F|!diminuendo(!G A !diminuendo)!B c|\n",
);
let export = export_musicxml(source).expect("wedge decorations should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<wedge type=\"crescendo\"/>"), 1);
assert_eq!(count(&export.musicxml, "<wedge type=\"diminuendo\"/>"), 1);
assert_eq!(count(&export.musicxml, "<wedge type=\"stop\"/>"), 2);
assert!(!export.musicxml.contains("<words><(</words>"));
assert!(!export.musicxml.contains("<words>diminuendo(</words>"));
}
#[test]
fn plus_decoration_emits_stopped_technical_not_words() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n!+!C D E F|\n";
let export = export_musicxml(source).expect("plus decoration should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<stopped/>"), 1);
assert!(!export.musicxml.contains("<words>+</words>"));
}
#[test]
fn fingering_arpeggio_and_slide_decorations_emit_notations_not_words() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n!3!C !arpeggio![CEG] !slide!D E|\n";
let export = export_musicxml(source).expect("decoration notations should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<fingering>3</fingering>"), 1);
assert_eq!(count(&export.musicxml, "<arpeggiate"), 1);
assert_eq!(count(&export.musicxml, "<scoop/>"), 1);
for text in ["3", "arpeggio", "slide"] {
assert!(
!export.musicxml.contains(&format!("<words>{text}</words>")),
"{text} decoration should not be emitted as words"
);
}
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
"supported decorations should not produce unsupported-decoration diagnostics"
);
}
#[test]
fn extended_articulation_decorations_emit_notations_not_words() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n!caesura!z !detached-legato!D !falloff!E !doit!F|\n";
let export = export_musicxml(source).expect("extended articulations should export");
assert_balanced_xml(&export.musicxml);
for tag in ["caesura", "detached-legato", "falloff", "doit"] {
assert_eq!(
count(&export.musicxml, &format!("<{tag}/>")),
1,
"{tag} should emit as an articulation"
);
assert!(
!export.musicxml.contains(&format!("<words>{tag}</words>")),
"{tag} decoration should not be emitted as words"
);
}
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
"supported extended articulations should not warn"
);
}
#[test]
fn musicxml_tremolo_carriers_emit_ornament_text_not_words() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"!musicxml-tremolo-single-2!C !musicxml-tremolo-start-3!D ",
"!musicxml-tremolo-stop-3!E !musicxml-tremolo-single-1!F|\n",
);
let export = export_musicxml(source).expect("tremolo carriers should export");
assert_balanced_xml(&export.musicxml);
for (tremolo_type, marks) in [
("single", "2"),
("start", "3"),
("stop", "3"),
("single", "1"),
] {
assert!(
export.musicxml.contains(&format!(
"<tremolo type=\"{tremolo_type}\">{marks}</tremolo>"
)),
"{tremolo_type} tremolo with {marks} marks should emit as ornament text"
);
}
for text in [
"musicxml-tremolo-single-2",
"musicxml-tremolo-start-3",
"musicxml-tremolo-stop-3",
"musicxml-tremolo-single-1",
] {
assert!(
!export.musicxml.contains(&format!("<words>{text}</words>")),
"{text} carrier should not be emitted as words"
);
}
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
"supported tremolo carriers should not warn"
);
}
#[test]
fn musicxml_technical_carriers_emit_technical_text_not_words() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"!musicxml-tech-string-4!!musicxml-tech-fret-11!C ",
"!musicxml-tech-fingering-hex-45!D ",
"!musicxml-tech-fingering-hex-310a320a2d!E F|\n",
);
let export = export_musicxml(source).expect("technical carriers should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<string>4</string>"), 1);
assert_eq!(count(&export.musicxml, "<fret>11</fret>"), 1);
assert_eq!(count(&export.musicxml, "<fingering>E</fingering>"), 1);
assert_eq!(count(&export.musicxml, "<fingering>1\n2\n-</fingering>"), 1);
for text in [
"musicxml-tech-string-4",
"musicxml-tech-fret-11",
"musicxml-tech-fingering-hex-45",
"musicxml-tech-fingering-hex-310a320a2d",
] {
assert!(
!export.musicxml.contains(&format!("<words>{text}</words>")),
"{text} carrier should not be emitted as words"
);
}
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
"supported technical carriers should not warn"
);
}
#[test]
fn invalid_musicxml_technical_carrier_does_not_emit_invalid_xml_text() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"!musicxml-tech-fingering-hex-01!C D E F|\n",
);
let export = export_musicxml(source).expect("invalid carrier should not break export");
assert_balanced_xml(&export.musicxml);
assert!(
!export.musicxml.contains('\u{1}'),
"invalid XML character from carrier must not reach MusicXML output"
);
assert!(
!export.musicxml.contains("<fingering>"),
"invalid carrier should not emit a fingering element"
);
assert!(
export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
"invalid carrier should fall back to an unsupported-decoration diagnostic"
);
}
#[test]
fn musicxml_spanner_carriers_emit_notations_not_words() {
let source = concat!(
"X:1\n",
"M:6/4\n",
"L:1/4\n",
"K:C\n",
"!musicxml-glissando-start-wavy-1-hex-676c6973732e!C ",
"!musicxml-glissando-stop-wavy-1!D ",
"!musicxml-slide-start-solid-1-hex-676c6973732e!E ",
"!musicxml-slide-stop-solid-1!F ",
"!musicxml-wavy-line-start-1!!musicxml-wavy-line-stop-1!G A|\n",
);
let export = export_musicxml(source).expect("spanner carriers should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(
&export.musicxml,
"<glissando type=\"start\" number=\"1\" line-type=\"wavy\">gliss.</glissando>"
),
1
);
assert_eq!(
count(
&export.musicxml,
"<glissando type=\"stop\" number=\"1\" line-type=\"wavy\"/>"
),
1
);
assert_eq!(
count(
&export.musicxml,
"<slide type=\"start\" number=\"1\" line-type=\"solid\">gliss.</slide>"
),
1
);
assert_eq!(
count(
&export.musicxml,
"<slide type=\"stop\" number=\"1\" line-type=\"solid\"/>"
),
1
);
assert_eq!(
count(&export.musicxml, "<wavy-line type=\"start\" number=\"1\"/>"),
1
);
assert_eq!(
count(&export.musicxml, "<wavy-line type=\"stop\" number=\"1\"/>"),
1
);
for text in [
"musicxml-glissando-start-wavy-1-hex-676c6973732e",
"musicxml-glissando-stop-wavy-1",
"musicxml-slide-start-solid-1-hex-676c6973732e",
"musicxml-slide-stop-solid-1",
"musicxml-wavy-line-start-1",
"musicxml-wavy-line-stop-1",
] {
assert!(
!export.musicxml.contains(&format!("<words>{text}</words>")),
"{text} carrier should not be emitted as words"
);
}
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
"supported spanner carriers should not warn"
);
}
#[test]
fn invalid_musicxml_spanner_carrier_does_not_emit_invalid_xml_text() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"!musicxml-glissando-start-wavy-1-hex-01!C D E F|\n",
);
let export = export_musicxml(source).expect("invalid carrier should not break export");
assert_balanced_xml(&export.musicxml);
assert!(
!export.musicxml.contains('\u{1}'),
"invalid XML character from carrier must not reach MusicXML output"
);
assert!(
!export.musicxml.contains("<glissando"),
"invalid carrier should not emit a glissando element"
);
assert!(
export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
"invalid carrier should fall back to an unsupported-decoration diagnostic"
);
}
#[test]
fn musicxml_tuplet_display_carrier_emits_tuplet_children() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"(4:2:2!musicxml-tuplet-detail-v1-byes-a2-quarter-0-n2-quarter-0!D4 A,4|\n",
);
let export = export_musicxml(source).expect("tuplet display carrier should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<tuplet type=\"start\" bracket=\"yes\">")
);
assert!(export.musicxml.contains("<tuplet-actual>"));
assert!(export.musicxml.contains("<tuplet-number>2</tuplet-number>"));
assert!(
export
.musicxml
.contains("<tuplet-type>quarter</tuplet-type>")
);
assert!(export.musicxml.contains("<tuplet-normal>"));
assert!(
!export
.musicxml
.contains("<words>musicxml-tuplet-detail-v1-byes"),
"tuplet display carrier should not be emitted as words"
);
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
"supported tuplet display carrier should not warn"
);
}
#[test]
fn musicxml_tuplet_bracket_carrier_emits_bracket_attr() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"(3:2:3!musicxml-tuplet-detail-v1-byes!C D E F|\n",
);
let export = export_musicxml(source).expect("tuplet bracket carrier should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(&export.musicxml, "<tuplet type=\"start\" bracket=\"yes\"/>"),
1
);
assert!(!export.musicxml.contains("<tuplet-actual>"));
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
"supported tuplet bracket carrier should not warn"
);
}
#[test]
fn invalid_musicxml_tuplet_display_carrier_is_not_emitted_as_words() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"(4:2:2!musicxml-tuplet-detail-v1-byes-a2-n2-quarter-0!D4 A,4|\n",
);
let export = export_musicxml(source).expect("invalid carrier should not break export");
assert_balanced_xml(&export.musicxml);
assert!(
!export.musicxml.contains("<tuplet-actual>"),
"invalid carrier should not emit tuplet display children"
);
assert!(
!export
.musicxml
.contains("<words>musicxml-tuplet-detail-v1-byes"),
"invalid private carrier should not be emitted as words"
);
assert!(
export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
"invalid carrier should fall back to an unsupported-decoration diagnostic"
);
}
#[test]
fn chord_qualities_map_to_musicxml_kinds_matching_abc2xml() {
let cases = [
("F#dim", "diminished"),
("Cdim7", "diminished-seventh"),
("Caug", "augmented"),
("C+", "augmented"),
("Co", "diminished"),
("C-", "minor"),
("Dsus4", "suspended-fourth"),
("Dsus2", "suspended-second"),
("Csus", "suspended-fourth"),
("Cmaj7", "major-seventh"),
("CM7", "major-seventh"),
("Cm6", "minor-sixth"),
("C6", "major-sixth"),
("Cm7", "minor-seventh"),
("C7", "dominant"),
("Cm", "minor"),
("C", "major"),
("C9", "dominant-ninth"),
("Cmaj9", "major-ninth"),
("Cm9", "minor-ninth"),
("C11", "dominant-11th"),
("Cm11", "minor-11th"),
("C13", "dominant-13th"),
("Cmaj13", "major-13th"),
("Cm13", "minor-13th"),
("Cm7b5", "half-diminished"),
("Cmin7b5", "minor-seventh"),
("C7sus4", "dominant"),
("Cmaj7sus4", "major-seventh"),
];
for (symbol, expected_kind) in cases {
let source = format!("X:1\nM:4/4\nL:1/4\nK:C\n\"{symbol}\"C4|\n");
let export =
export_musicxml(&source).unwrap_or_else(|_| panic!("chord {symbol} should export"));
assert_balanced_xml(&export.musicxml);
let expected = format!("<kind text=\"{symbol}\">{expected_kind}</kind>");
assert!(
export.musicxml.contains(&expected),
"chord {symbol} should map to {expected_kind}; got:\n{}",
export.musicxml
);
}
}
#[test]
fn power_chord_exports_major_kind_with_add_fifth_degree() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"A5\"C4|\n";
let export = export_musicxml(source).expect("power chord should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<root-step>A</root-step>"));
assert!(export.musicxml.contains(">major</kind>"));
assert!(export.musicxml.contains("<degree-value>5</degree-value>"));
assert!(export.musicxml.contains("<degree-alter>0</degree-alter>"));
assert!(export.musicxml.contains("<degree-type>add</degree-type>"));
}
#[test]
fn altered_trailing_degrees_export_as_add_with_alter() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"C7b9#5\"C4|\n";
let export = export_musicxml(source).expect("altered chord should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains(">dominant</kind>"));
assert!(export.musicxml.contains("<degree-value>9</degree-value>"));
assert!(export.musicxml.contains("<degree-alter>-1</degree-alter>"));
assert!(export.musicxml.contains("<degree-value>5</degree-value>"));
assert!(export.musicxml.contains("<degree-alter>1</degree-alter>"));
assert_eq!(count(&export.musicxml, "<degree>"), 2);
}
#[test]
fn add_and_omit_word_chords_export_as_words_not_harmony() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"Cadd9\"C \"C9no3\"D \"Cadd11\"E|\n";
let export = export_musicxml(source).expect("word chords should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 0);
assert!(export.musicxml.contains("<words>Cadd9</words>"));
assert!(export.musicxml.contains("<words>C9no3</words>"));
assert!(export.musicxml.contains("<words>Cadd11</words>"));
}
#[test]
fn double_accidental_and_garbage_roots_are_not_harmony() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"Cbb\"C \"C##\"D \"Cx\"E \"NC\"F|\n";
let export = export_musicxml(source).expect("garbage chords should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 0);
assert!(export.musicxml.contains("<words>Cbb</words>"));
assert!(export.musicxml.contains("<words>C##</words>"));
assert!(export.musicxml.contains("<words>Cx</words>"));
assert!(export.musicxml.contains("<words>NC</words>"));
}
#[test]
fn parenthesized_chord_suffix_is_suppressed() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"C(no3)\"C4|\n";
let export = export_musicxml(source).expect("parenthesized chord should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 1);
assert!(export.musicxml.contains(">major</kind>"));
assert_eq!(count(&export.musicxml, "<degree>"), 0);
}
#[test]
fn slash_chord_with_quality_keeps_kind_and_bass() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"Cm7/Bb\"C4|\n";
let export = export_musicxml(source).expect("slash chord should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains(">minor-seventh</kind>"));
assert!(export.musicxml.contains("<bass-step>B</bass-step>"));
assert!(export.musicxml.contains("<bass-alter>-1</bass-alter>"));
}
#[test]
fn tempo_beat_equals_bpm_emits_metronome() {
let source = "X:1\nM:4/4\nL:1/4\nQ:1/4=104\nK:C\nC4|\n";
let export = export_musicxml(source).expect("tempo score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<metronome>"));
assert!(export.musicxml.contains("<beat-unit>quarter</beat-unit>"));
assert!(export.musicxml.contains("<per-minute>104</per-minute>"));
assert!(export.musicxml.contains("<sound tempo=\"104.00\""));
assert!(!export.musicxml.contains("<words>1/4=104</words>"));
}
#[test]
fn tempo_dotted_beat_unit_emits_metronome_dot() {
let source = "X:1\nM:4/4\nL:1/4\nQ:3/8=100\nK:C\nC4|\n";
let export = export_musicxml(source).expect("tempo score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<beat-unit>quarter</beat-unit>"));
assert!(export.musicxml.contains("<beat-unit-dot"));
assert!(export.musicxml.contains("<per-minute>100</per-minute>"));
assert!(export.musicxml.contains("<sound tempo=\"150.00\""));
assert!(!export.musicxml.contains("<words>3/8=100</words>"));
}
#[test]
fn tempo_bare_number_uses_unit_note_length() {
let source = "X:1\nM:4/4\nL:1/8\nQ:120\nK:C\nC4|\n";
let export = export_musicxml(source).expect("tempo score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<beat-unit>eighth</beat-unit>"));
assert!(export.musicxml.contains("<per-minute>120</per-minute>"));
assert!(export.musicxml.contains("<sound tempo=\"60.00\""));
assert!(!export.musicxml.contains("<words>120</words>"));
}
#[test]
fn tempo_bare_number_with_trailing_dot_rejected_to_words() {
let source = "X:1\nM:4/4\nL:1/8\nQ:400.\nK:C\nC4|\n";
let export = export_musicxml(source).expect("tempo score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<words>400.</words>"));
assert_eq!(count(&export.musicxml, "<metronome>"), 0);
assert!(!export.musicxml.contains("<per-minute>"));
}
#[test]
fn tempo_bare_number_with_legacy_suffix_rejected_to_words() {
let source = "X:1\nM:4/4\nL:1/8\nQ:320s\nK:C\nC4|\n";
let export = export_musicxml(source).expect("tempo score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<words>320s</words>"));
assert_eq!(count(&export.musicxml, "<metronome>"), 0);
assert!(!export.musicxml.contains("<per-minute>"));
}
#[test]
fn tempo_bare_leading_digits_with_space_stays_words() {
let source = "X:1\nM:4/4\nL:1/4\nQ:3 dancers\nK:C\nC4|\n";
let export = export_musicxml(source).expect("tempo score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<metronome>"), 0);
assert!(!export.musicxml.contains("<per-minute>"));
}
#[test]
fn tempo_text_only_stays_words_without_synthetic_sound() {
let source = "X:1\nM:4/4\nL:1/4\nQ:\"Slow\"\nK:C\nC4|\n";
let export = export_musicxml(source).expect("tempo score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<words>Slow</words>"));
assert!(!export.musicxml.contains("<sound tempo=\"120.00\""));
assert_eq!(count(&export.musicxml, "<metronome>"), 0);
}
#[test]
fn tempo_text_preserves_unknown_backslash_escape() {
let source = "X:1\nM:4/4\nL:1/4\nQ:\"a\\q\"\nK:C\nC4|\n";
let export = export_musicxml(source).expect("tempo score should export");
assert_balanced_xml(&export.musicxml);
assert!(
export.musicxml.contains("<words>a\\q</words>"),
"unknown backslash escapes in Q: text must preserve the literal backslash:\n{}",
export.musicxml
);
}
#[test]
fn sound_tempo_carrier_emits_playback_without_metronome() {
let source = concat!(
"X:1\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"[I:croma-sound-tempo bpm=80 beat-n=1 beat-d=4 text=\"80\"]C4|\n",
);
let export = export_musicxml(source).expect("sound-tempo carrier should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<words>80</words>"));
assert!(export.musicxml.contains("<sound tempo=\"80.00\""));
assert_eq!(
count(&export.musicxml, "<metronome>"),
0,
"private playback-tempo carrier must not print a metronome:\n{}",
export.musicxml
);
}
#[test]
fn empty_tempo_field_emits_no_direction() {
let source = "X:1\nM:4/4\nL:1/4\nQ:\nK:C\nC4|\n";
let export = export_musicxml(source).expect("empty tempo score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<words>"), 0);
assert_eq!(count(&export.musicxml, "<direction>"), 0);
assert_eq!(count(&export.musicxml, "<direction "), 0);
}
#[test]
fn tempo_text_plus_beat_emits_words_and_metronome() {
let source = "X:1\nM:4/4\nL:1/4\nQ:\"allegretto\" 1/4=110\nK:C\nC4|\n";
let export = export_musicxml(source).expect("tempo score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<words>allegretto</words>"));
assert!(export.musicxml.contains("<metronome>"));
assert!(export.musicxml.contains("<beat-unit>quarter</beat-unit>"));
assert!(export.musicxml.contains("<per-minute>110</per-minute>"));
assert!(export.musicxml.contains("<sound tempo=\"110.00\""));
}
#[test]
fn later_header_tempo_overrides_earlier_header_tempo() {
let source = "X:1\nQ:1/4=120\nQ:\"Animato\"\nM:4/4\nL:1/4\nK:C\nC4|\n";
let export = export_musicxml(source).expect("tempo override score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<words>Animato</words>"));
assert!(!export.musicxml.contains("<sound tempo=\"120.00\""));
assert_eq!(count(&export.musicxml, "<metronome>"), 0);
assert!(!export.musicxml.contains("<per-minute>120</per-minute>"));
}
#[test]
fn midi_program_and_channel_become_part_list_instrument_not_words() {
let source = concat!(
"X:1\n",
"T:Test\n",
"M:C\n",
"L:1/8\n",
"Q:1/4=104\n",
"K:G\n",
"%%MIDI program 72\n",
"%%MIDI channel 1\n",
"CDEF GABc |\n",
);
let export = export_musicxml(source).expect("score with MIDI directives should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<instrument-name>piccolo</instrument-name>")
);
assert!(export.musicxml.contains("<midi-channel>1</midi-channel>"));
assert!(export.musicxml.contains("<midi-program>73</midi-program>"));
assert!(!export.musicxml.contains("%%MIDI"));
assert!(!export.musicxml.contains("%%"));
assert!(!export.musicxml.contains("<words>"));
assert!(export.musicxml.contains("<metronome>"));
assert!(export.musicxml.contains("<per-minute>104</per-minute>"));
assert!(export.musicxml.contains("<step>C</step>"));
assert!(export.musicxml.contains("<step>G</step>"));
}
#[test]
fn midi_program_header_emits_score_and_midi_instrument() {
let source = "X:1\nT:T\nL:1/8\n%%MIDI program 72\nK:C\nCDEF GABc |\n";
let export = export_musicxml(source).expect("program directive should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<score-instrument"));
assert!(
export
.musicxml
.contains("<instrument-name>piccolo</instrument-name>")
);
assert!(export.musicxml.contains("<midi-instrument"));
assert!(export.musicxml.contains("<midi-program>73</midi-program>"));
assert!(!export.musicxml.contains("<midi-channel>"));
assert!(!export.musicxml.contains("<words>"));
}
#[test]
fn midi_instrument_prefers_voice_instrument_name_metadata() {
let source =
"X:1\nT:T\nL:1/8\nV:1 name=\"Staff\" nm=\"Solo Violin\"\n%%MIDI program 40\nK:C\nCDEF|\n";
let export = export_musicxml(source).expect("named MIDI voice should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<part-name>Staff</part-name>"));
assert!(
export
.musicxml
.contains("<instrument-name>Solo Violin</instrument-name>"),
"voice nm/name should preserve the human instrument name instead of forcing GM fallback:\n{}",
export.musicxml
);
}
#[test]
fn midi_instrument_preserves_explicit_empty_voice_instrument_name() {
let source = "X:1\nT:T\nL:1/8\nV:1 name=\"Staff\" nm=\"\"\n%%MIDI program 40\nK:C\nCDEF|\n";
let export = export_musicxml(source).expect("empty-named MIDI voice should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<instrument-name></instrument-name>"),
"explicit empty voice nm/name should not fall back to the GM program name:\n{}",
export.musicxml
);
assert!(
!export.musicxml.contains("violin"),
"explicit empty voice nm/name must be authoritative over GM fallback:\n{}",
export.musicxml
);
}
#[test]
fn midi_program_two_integer_form_sets_channel_and_program() {
let source = "X:1\nT:T\nL:1/8\n%%MIDI program 1 5\nK:C\nCDEF GABc |\n";
let export = export_musicxml(source).expect("channel-prefixed program should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<midi-channel>1</midi-channel>"));
assert!(export.musicxml.contains("<midi-program>6</midi-program>"));
assert!(
export
.musicxml
.contains("<instrument-name>electric_piano_2</instrument-name>")
);
}
#[test]
fn midi_program_scopes_to_the_declaring_voice() {
let source = concat!(
"X:1\nT:T\nL:1/8\n",
"V:1\n%%MIDI program 40\n",
"V:2\n%%MIDI program 42\n",
"K:C\n",
"V:1\nCDEF|\n",
"V:2\nC,2C,2|\n",
);
let export = export_musicxml(source).expect("multi-voice programs should export");
assert_balanced_xml(&export.musicxml);
let (before_p2, p2) = export
.musicxml
.split_once("<score-part id=\"P2\"")
.expect("two score-parts");
assert!(before_p2.contains("<instrument-name>violin</instrument-name>"));
assert!(before_p2.contains("<midi-program>41</midi-program>"));
assert!(!before_p2.contains("cello"));
assert!(p2.contains("<instrument-name>cello</instrument-name>"));
assert!(p2.contains("<midi-program>43</midi-program>"));
}
#[test]
fn midi_program_scopes_to_body_declared_voice() {
let source = concat!(
"X:1\nT:T\nL:1/8\nK:C\n",
"V:1\n%%MIDI program 40\nCDEF|\n",
"V:2\n%%MIDI program 42\nC,2C,2|\n",
);
let export = export_musicxml(source).expect("body multi-voice programs should export");
assert_balanced_xml(&export.musicxml);
let (before_p2, p2) = export
.musicxml
.split_once("<score-part id=\"P2\"")
.expect("two score-parts");
assert!(before_p2.contains("<instrument-name>violin</instrument-name>"));
assert!(!before_p2.contains("cello"));
assert!(p2.contains("<instrument-name>cello</instrument-name>"));
}
#[test]
fn midi_playback_only_directives_emit_no_instrument() {
let source = "X:1\nT:T\nL:1/8\n%%MIDI gchord off\n%%MIDI nobarlines\nK:C\nCDEF|\n";
let export = export_musicxml(source).expect("playback-only directives should export");
assert_balanced_xml(&export.musicxml);
assert!(!export.musicxml.contains("<score-instrument"));
assert!(!export.musicxml.contains("<midi-instrument"));
assert!(!export.musicxml.contains("gchord"));
assert!(!export.musicxml.contains("%%MIDI"));
}
#[test]
fn midi_control_cc7_becomes_midi_volume() {
let source = "X:1\nT:T\nL:1/8\n%%MIDI control 7 120\nK:C\nCDEF|\n";
let export = export_musicxml(source).expect("control 7 should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<midi-instrument"));
assert!(export.musicxml.contains("<volume>94.49</volume>"));
assert!(
export
.musicxml
.contains("<instrument-name>T</instrument-name>")
);
assert!(!export.musicxml.contains("no name"));
}
#[test]
fn midi_control_cc10_becomes_midi_pan() {
let left = export_musicxml("X:1\nT:T\nL:1/8\n%%MIDI control 10 0\nK:C\nCDEF|\n")
.expect("control 10 0 should export");
assert_balanced_xml(&left.musicxml);
assert!(left.musicxml.contains("<pan>-90.00</pan>"));
let right = export_musicxml("X:1\nT:T\nL:1/8\n%%MIDI control 10 127\nK:C\nCDEF|\n")
.expect("control 10 127 should export");
assert!(right.musicxml.contains("<pan>90.00</pan>"));
let center = export_musicxml("X:1\nT:T\nL:1/8\n%%MIDI control 10 64\nK:C\nCDEF|\n")
.expect("control 10 64 should export");
assert!(
center.musicxml.contains("<pan>0.00</pan>"),
"center CC10 should emit exact MusicXML center pan, got:\n{}",
center.musicxml
);
}
#[test]
fn midi_other_controllers_are_not_translated() {
let source = "X:1\nT:T\nL:1/8\n%%MIDI control 2 70\nK:C\nCDEF|\n";
let export = export_musicxml(source).expect("other controllers should export");
assert_balanced_xml(&export.musicxml);
assert!(!export.musicxml.contains("<volume>"));
assert!(!export.musicxml.contains("<pan>"));
assert!(!export.musicxml.contains("<midi-instrument"));
}
#[test]
fn midi_channel_only_emits_instrument_with_part_name() {
let source = "X:1\nT:Tune\nL:1/8\n%%MIDI channel 3\nK:C\nCDEF|\n";
let export = export_musicxml(source).expect("channel-only should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<midi-channel>3</midi-channel>"));
assert!(
export
.musicxml
.contains("<instrument-name>Tune</instrument-name>")
);
assert!(!export.musicxml.contains("no name"));
assert!(!export.musicxml.contains("<midi-program>"));
}
#[test]
fn midi_inline_program_field_emits_instrument() {
let source = "X:1\nT:T\nL:1/8\nK:C\n[I: MIDI=program 42]CDEF GABc |\n";
let export = export_musicxml(source).expect("inline program should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<instrument-name>cello</instrument-name>")
);
assert!(export.musicxml.contains("<midi-program>43</midi-program>"));
}
#[test]
fn midi_inline_program_scopes_per_voice() {
let source = concat!(
"X:1\nT:T\nL:1/8\n",
"V:1\nV:2\nK:C\n",
"V:1\n[I: MIDI=program 40]CDEF|\n",
"V:2\n[I: MIDI=program 42]C,2C,2|\n",
);
let export = export_musicxml(source).expect("inline multi-voice programs should export");
assert_balanced_xml(&export.musicxml);
let (before_p2, p2) = export
.musicxml
.split_once("<score-part id=\"P2\"")
.expect("two score-parts");
assert!(before_p2.contains("<instrument-name>violin</instrument-name>"));
assert!(!before_p2.contains("cello"));
assert!(p2.contains("<instrument-name>cello</instrument-name>"));
}
#[test]
fn non_midi_inline_instruction_emits_no_instrument() {
let source = "X:1\nT:T\nL:1/8\nK:C\n[I:staffwidth 100]CDEF|\n";
let export = export_musicxml(source).expect("non-MIDI inline should export");
assert_balanced_xml(&export.musicxml);
assert!(!export.musicxml.contains("<score-instrument"));
assert!(!export.musicxml.contains("<midi-instrument"));
}
#[test]
fn croma_note_instrument_carrier_emits_note_instrument_ids() {
let source = concat!(
"X:1\n",
"T:Perc\n",
"L:1/4\n",
"K:C\n",
"%%croma-musicxml-instrument id=\"P1-I1\" name=\"Snare Drum\" channel=10 midi-unpitched=39\n",
"%%croma-musicxml-instrument id=\"P1-I2\" name=\"Wood Block\" channel=10 midi-unpitched=76\n",
"[I:croma-note-instrument id=\"P1-I1\"]D [I:croma-note-instrument id=\"P1-I2\"]C |\n",
);
let export = export_musicxml(source).expect("croma note instrument carrier should export");
assert_balanced_xml(&export.musicxml);
assert!(
export.musicxml.contains("<score-instrument id=\"P1-I1\">")
&& export
.musicxml
.contains("<instrument-name>Snare Drum</instrument-name>")
&& export
.musicxml
.contains("<midi-unpitched>39</midi-unpitched>")
&& export.musicxml.contains("<score-instrument id=\"P1-I2\">")
&& export
.musicxml
.contains("<instrument-name>Wood Block</instrument-name>")
&& export
.musicxml
.contains("<midi-unpitched>76</midi-unpitched>"),
"part-list instrument metadata must come from croma carriers:\n{}",
export.musicxml
);
assert!(
export.musicxml.contains("<instrument id=\"P1-I1\"/>")
&& export.musicxml.contains("<instrument id=\"P1-I2\"/>"),
"note-level instrument refs must be emitted before <voice>:\n{}",
export.musicxml
);
}
#[test]
fn croma_instrument_carrier_preserves_explicit_empty_name() {
let source = concat!(
"X:1\n",
"T:Empty Name\n",
"L:1/4\n",
"K:C\n",
"%%croma-musicxml-instrument id=\"P1-I1\" name=\"\" channel=1 program=0 volume-cc=100 pan-cc=64\n",
"[I:croma-note-instrument id=\"P1-I1\"]C |\n",
);
let export =
export_musicxml(source).expect("empty-name croma instrument carrier should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<instrument-name></instrument-name>"),
"explicit empty instrument name must remain empty:\n{}",
export.musicxml
);
assert!(
export.musicxml.contains("<midi-channel>1</midi-channel>")
&& export.musicxml.contains("<midi-program>1</midi-program>")
&& export.musicxml.contains("<volume>78.74</volume>")
&& export.musicxml.contains("<pan>0.00</pan>"),
"empty-name croma carrier must preserve the sibling MIDI fields:\n{}",
export.musicxml
);
assert!(
!export.musicxml.contains("acoustic_grand_piano"),
"empty croma instrument name must not fall back to the GM program name:\n{}",
export.musicxml
);
}
#[test]
fn midi_transpose_emits_transpose_attribute_without_shifting_pitch() {
let source = "X:1\nT:T\nL:1/8\n%%MIDI transpose -12\nK:C\nCDEF GABc |\n";
let export = export_musicxml(source).expect("transpose directive should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<transpose>"));
assert!(export.musicxml.contains("<chromatic>-12</chromatic>"));
let first_step = export
.musicxml
.split("<step>")
.nth(1)
.and_then(|tail| tail.split("</step>").next());
assert_eq!(first_step, Some("C"));
assert!(!export.musicxml.contains("<words>"));
}
#[test]
fn midi_transpose_scopes_to_the_declaring_voice() {
let source = concat!(
"X:1\nT:T\nL:1/8\n",
"V:1\n%%MIDI transpose -12\n",
"V:2\n%%MIDI transpose -24\n",
"K:C\n",
"V:1\nCDEF|\n",
"V:2\nC,2C,2|\n",
);
let export = export_musicxml(source).expect("multi-voice transpose should export");
assert_balanced_xml(&export.musicxml);
let (p1, p2) = export
.musicxml
.split_once("<part id=\"P2\"")
.expect("two parts");
assert!(p1.contains("<chromatic>-12</chromatic>"));
assert!(!p1.contains("<chromatic>-24"));
assert!(p2.contains("<chromatic>-24</chromatic>"));
}
#[test]
fn midi_transpose_coexists_with_program_in_one_voice() {
let source = "X:1\nT:T\nL:1/8\n%%MIDI program 40\n%%MIDI transpose -12\nK:C\nCDEF|\n";
let export = export_musicxml(source).expect("program + transpose should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<midi-program>41</midi-program>"));
assert!(export.musicxml.contains("<chromatic>-12</chromatic>"));
}
#[test]
fn placement_prefixed_annotations_remain_words() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"^slow\"C \"_soft\"D|\n";
let export = export_musicxml(source).expect("annotations should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony>"), 0);
assert!(export.musicxml.contains("<direction placement=\"above\">"));
assert!(export.musicxml.contains("<direction placement=\"below\">"));
assert!(export.musicxml.contains("<words>slow</words>"));
assert!(export.musicxml.contains("<words>soft</words>"));
}
#[test]
fn lyric_part_defaults_unplaced_dynamics_and_wedges_above() {
let lyric_part = concat!(
"X:1\n",
"T:Lyric part\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"V:P1 name=\"Voice\"\n",
"!p! C D !crescendo(! E F !crescendo)! |\n",
"w: la la la la\n",
);
let export = export_musicxml(lyric_part).expect("lyric score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(&export.musicxml, "<direction placement=\"below\">"),
0
);
assert_eq!(
count(&export.musicxml, "<direction placement=\"above\">"),
3
);
assert!(export.musicxml.contains("<dynamics>"));
assert!(export.musicxml.contains("<wedge type=\"crescendo\"/>"));
assert!(export.musicxml.contains("<wedge type=\"stop\"/>"));
}
#[test]
fn instrumental_part_defaults_unplaced_dynamics_and_wedges_below() {
let instrumental = concat!(
"X:1\n",
"T:Instrumental part\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"V:P1 name=\"Cello\"\n",
"!p! C D !crescendo(! E F !crescendo)! |\n",
);
let export = export_musicxml(instrumental).expect("instrumental score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(&export.musicxml, "<direction placement=\"below\">"),
3
);
assert_eq!(
count(&export.musicxml, "<direction placement=\"above\">"),
0
);
}
#[test]
fn explicit_dynamic_and_wedge_placements_override_defaults() {
let lyric_below = concat!(
"X:1\n",
"T:Lyric explicit below\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"V:P1 name=\"Voice\"\n",
"[I:croma-direction-placement placement=below]!p! C ",
"D [I:croma-direction-placement placement=below]!crescendo(! E ",
"F [I:croma-direction-placement placement=below]!crescendo)! |\n",
"w: la la la la\n",
);
let export = export_musicxml(lyric_below).expect("placed lyric score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(&export.musicxml, "<direction placement=\"below\">"),
3
);
assert_eq!(
count(&export.musicxml, "<direction placement=\"above\">"),
0
);
let instrumental_above = concat!(
"X:1\n",
"T:Instrumental explicit above\n",
"M:4/4\n",
"L:1/4\n",
"K:C\n",
"[I:croma-direction-placement placement=above]!p! C ",
"D [I:croma-direction-placement placement=above]!crescendo(! E ",
"F [I:croma-direction-placement placement=above]!crescendo)! |\n",
);
let export =
export_musicxml(instrumental_above).expect("placed instrumental score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(&export.musicxml, "<direction placement=\"below\">"),
0
);
assert_eq!(
count(&export.musicxml, "<direction placement=\"above\">"),
3
);
}
#[test]
fn initial_barlines_do_not_emit_musicxml_heavy_light() {
let source = "X:1\nT:Initial Barline\nM:4/4\nL:1/4\nK:C\nC |[| D |]\n";
let export = export_musicxml(source).expect("initial barline should export");
assert_balanced_xml(&export.musicxml);
assert!(
!export
.musicxml
.contains("<bar-style>heavy-light</bar-style>")
);
assert!(
export
.musicxml
.contains("<bar-style>light-heavy</bar-style>")
);
}
#[test]
fn leading_plain_barline_exports_notes_in_measure_one() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n| C D E F |]\n";
let export = export_musicxml(source).expect("leading barline should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(note_durations(&measures[0]), vec![8, 8, 8, 8]);
assert!(!measures[0].notes.iter().any(|note| note.rest));
}
#[test]
fn leading_repeat_start_exports_left_repeat_without_empty_measure() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F :|\n";
let export = export_musicxml(source).expect("leading repeat should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(note_durations(&measures[0]), vec![8, 8, 8, 8]);
assert!(has_barline(
&measures[0],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn repeat_barlines_emit_explicit_musicxml_bar_styles() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F :||: G A B c :|\n";
let export = export_musicxml(source).expect("repeat barlines should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert!(has_barline(
&measures[0],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
Some("backward")
));
assert!(has_barline(
&measures[1],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[1],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn repeat_ending_closes_before_trailing_repeat_start() {
let source = "X:1\nM:2/4\nL:1/4\nK:C\n|: C D |1 E F :|2 G A |: B c |]\n";
let export = export_musicxml(source).expect("repeat ending should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
assert!(has_ending(&measures[2], "right", "2", "stop"));
assert!(has_barline(
&measures[3],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(!has_ending(&measures[3], "right", "2", "stop"));
}
#[test]
fn repeat_ending_closes_before_next_leading_repeat_start() {
let source = "X:1\nM:2/4\nL:1/4\nK:C\n|: C D |1 E F :|2 G A |\n|: B c |]\n";
let export = export_musicxml(source).expect("repeat ending should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
assert!(has_ending(&measures[2], "right", "2", "stop"));
assert!(has_barline(
&measures[3],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(!has_ending(&measures[3], "right", "2", "stop"));
}
#[test]
fn mid_measure_repeat_ending_starts_a_pickup_measure() {
let source = "X:1\nM:4/4\nL:1/8\nK:C\n|: A2 A2 A2[1 B2 | C8 :|\n[2 D2 | E8 |]\n";
let export = export_musicxml(source).expect("mid-measure repeat ending should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4", "5"]);
assert_eq!(note_steps(&measures[0]), vec!['A', 'A', 'A']);
assert_eq!(note_steps(&measures[1]), vec!['B']);
assert!(has_ending(&measures[1], "left", "1", "start"));
assert!(!has_ending(&measures[0], "left", "1", "start"));
assert!(has_ending(&measures[2], "right", "1", "stop"));
assert_eq!(note_steps(&measures[3]), vec!['D']);
assert!(has_ending(&measures[3], "left", "2", "start"));
assert!(has_ending(&measures[4], "right", "2", "stop"));
}
#[test]
fn leading_double_and_final_barlines_do_not_create_empty_measure() {
for prefix in ["||", "|]"] {
let source = format!("X:1\nM:4/4\nL:1/4\nK:C\n{prefix} C D E F |]\n");
let export = export_musicxml(&source).expect("leading section barline should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"], "{prefix}");
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(note_durations(&measures[0]), vec![8, 8, 8, 8]);
assert!(
!measures[0]
.barlines
.iter()
.any(|barline| barline.location == "left")
);
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
None
));
}
}
#[test]
fn double_bar_across_backslash_continuation_is_coalesced() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\\\n| G A B c|]\n";
let export = export_musicxml(source).expect("seam || should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert!(
has_barline(&measures[0], "right", Some("light-light"), None),
"the `|\\`+`|` seam must coalesce into a light-light double bar: {}",
export.musicxml
);
}
#[test]
fn double_bar_across_backslash_continuation_before_variant_ending_is_coalesced() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\\\n|1 G A B c :|2 d e f g |]\n";
let export = export_musicxml(source).expect("seam ||1 should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert!(
has_barline(&measures[0], "right", Some("light-light"), None),
"the `|\\`+`|1` seam must coalesce into a light-light double bar: {}",
export.musicxml
);
assert!(
has_ending(&measures[1], "left", "1", "start"),
"ending 1 must start on the measure after the double bar, not fold in: {}",
export.musicxml
);
}
#[test]
fn backslash_seam_across_intervening_field_does_not_coalesce() {
let source = "X:1\nL:1/4\nK:C\nC D E F | \\\nM:2/4\n|1 G A :|2 c d |]\n";
let export = export_musicxml(source).expect("field-seam should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert!(
!has_barline(&measures[0], "right", Some("light-light"), None),
"a `\\` seam across an intervening field must NOT coalesce into `||`: {}",
export.musicxml
);
assert!(
has_ending(&measures[1], "left", "1", "start"),
"ending 1 still starts on the measure after the field: {}",
export.musicxml
);
}
#[test]
fn forward_repeat_and_second_ending_share_one_left_barline() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F |1 G A B c :|:[2 A B c d|]\n";
let export = export_musicxml(source).expect(":|:[2 should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
let last = measures.last().expect("second-ending measure");
let lefts: Vec<_> = last
.barlines
.iter()
.filter(|barline| barline.location == "left")
.collect();
assert_eq!(
lefts.len(),
1,
"the 2nd-ending measure must carry ONE merged left barline, not two: {}",
export.musicxml
);
assert_eq!(
lefts[0].repeat_direction.as_deref(),
Some("forward"),
"the forward repeat must survive on the merged left barline: {}",
export.musicxml
);
}
#[test]
fn liberal_colon_thick_barline_is_a_backward_repeat() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F | G A B c :]\n";
let export = export_musicxml(source).expect(":] should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
let last = measures.last().expect("at least one measure");
assert!(
has_barline(last, "right", Some("light-heavy"), Some("backward")),
"`:]` must export light-heavy + backward repeat: {}",
export.musicxml
);
}
#[test]
fn bare_colon_before_variant_ending_is_a_backward_repeat() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F |[1 G A B c :[2 d e f g |]\n";
let export = export_musicxml(source).expect("`:[2` should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
let ending1 = &measures[1];
assert!(
has_barline(ending1, "right", Some("light-heavy"), Some("backward")),
"the `:` before `[2` must close ending 1 with a backward repeat: {}",
export.musicxml
);
assert!(
has_ending(ending1, "right", "1", "stop"),
"ending 1 must stop at the `:` repeat-end: {}",
export.musicxml
);
assert!(
has_ending(&measures[2], "left", "2", "start"),
"ending 2 must start after the repeat-end: {}",
export.musicxml
);
}
#[test]
fn fused_final_then_repeat_start_emits_closer_and_left_repeat() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F |]: G A B c :|\n";
let export = export_musicxml(source).expect("|]: should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert!(
has_barline(&measures[0], "right", Some("light-heavy"), None),
"the `|]` closer must land on measure 1's right with no repeat: {}",
export.musicxml
);
assert!(
has_barline(&measures[1], "left", Some("heavy-light"), Some("forward")),
"the `:` forward repeat must lead measure 2: {}",
export.musicxml
);
}
#[test]
fn fused_thick_bar_repeat_start_closes_ending_and_starts_repeat() {
let source = "X:1\nM:3/4\nL:1/4\nK:C\nG A B |1 c d e :|[2 f g a ]||: C2 D | E3 :|\n";
let export = export_musicxml(source).expect("]||: should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
let ending2 = &measures[2];
assert_eq!(note_steps(ending2), vec!['F', 'G', 'A']);
assert!(
has_barline(ending2, "right", Some("light-heavy"), None),
"the `]` must close ending 2 with light-heavy and no repeat: {}",
export.musicxml
);
assert!(
has_ending(ending2, "right", "2", "stop"),
"ending 2 must stop at the `]` closer: {}",
export.musicxml
);
let repeated = &measures[3];
assert!(
has_barline(repeated, "left", Some("heavy-light"), Some("forward")),
"the `||:` forward repeat must lead the next section: {}",
export.musicxml
);
}
#[test]
fn leading_liberal_barline_diagnoses_and_keeps_measure_timing() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n[::] C D E F |]\n";
let export = export_musicxml(source).expect("liberal leading barline should recover");
assert_balanced_xml(&export.musicxml);
assert_diagnostic_span(
source,
&export.diagnostics,
"abc.music.barline.liberal",
"[::]",
);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(note_durations(&measures[0]), vec![8, 8, 8, 8]);
assert!(!measures[0].notes.iter().any(|note| note.rest));
}
#[test]
fn leading_double_repeat_start_exports_left_repeat_without_empty_measure() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n||: C D E F :||\n";
let export = export_musicxml(source).expect("combined leading repeat should export");
assert_balanced_xml(&export.musicxml);
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.barline.liberal")
);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert!(has_barline(
&measures[0],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn repeat_end_double_after_notes_exports_right_repeat_and_new_measure() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F :|| G A B c |]\n";
let export = export_musicxml(source).expect("combined repeat end should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn thick_thin_seam_barline_keeps_following_repeat_leading() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F ]|\n|: G A B c :|\n";
let export = export_musicxml(source).expect("`]|` seam should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert!(
has_barline(&measures[0], "right", Some("light-heavy"), None),
"m1 must close with the `]|` boundary (no stray extra bar): {}",
export.musicxml
);
assert!(
has_barline(&measures[1], "left", Some("heavy-light"), Some("forward")),
"the `|:` must lead measure 2 with a forward repeat: {}",
export.musicxml
);
}
#[test]
fn spaced_bar_seam_before_repeat_keeps_following_repeat_leading() {
for seam in ["| |", ": |"] {
let source = format!("X:1\nM:6/8\nL:1/8\nK:A\nGAB cde|fga ab2{seam}\n|:a|aga abc|]\n");
let export = export_musicxml(&source).expect("spaced seam should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(note_steps(&measures[2]), vec!['A'], "seam={seam}");
assert!(
has_barline(&measures[2], "left", Some("heavy-light"), Some("forward")),
"[{seam}] `|:` forward repeat must lead the pickup measure m3, not be \
deferred by the redundant spaced bar: {}",
export.musicxml
);
}
}
#[test]
fn repeat_both_between_sections_exports_right_then_left_repeat() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F :||: G A B c |]\n";
let export = export_musicxml(source).expect("repeat-both barline should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
Some("backward")
));
assert!(has_barline(
&measures[1],
"left",
Some("heavy-light"),
Some("forward")
));
assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
}
#[test]
fn triple_repeat_extensions_export_repeat_edges() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:: C D E F ::|\n";
let export = export_musicxml(source).expect("triple repeat barlines should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert!(has_barline(
&measures[0],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
Some("backward")
));
assert!(
measures[0]
.barlines
.iter()
.all(|barline| barline.repeat_times.is_none())
);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}
#[test]
fn split_final_after_repeat_end_does_not_duplicate_right_barline() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F:[|]|]\n";
let export = export_musicxml(source).expect("split final after repeat end should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
let right_barlines = measures[0]
.barlines
.iter()
.filter(|barline| barline.location == "right")
.collect::<Vec<_>>();
assert_eq!(
right_barlines.len(),
1,
"right barlines should be merged: {right_barlines:?}"
);
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn repeat_end_after_invisible_barline_closes_previous_measure() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F H[|]:|\n|:G A B c:|\n";
let export = export_musicxml(source).expect("invisible plus repeat end should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert!(has_barline(&measures[0], "right", Some("none"), None));
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
Some("backward")
));
assert!(has_barline(
&measures[1],
"left",
Some("heavy-light"),
Some("forward")
));
}
#[test]
fn leading_repeat_end_after_plain_bar_seam_closes_previous_measure() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:C D E F|\n:|G A B c|]\n";
let export = export_musicxml(source).expect("leading repeat-end seam should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert!(has_barline(
&measures[0],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(
has_barline(&measures[0], "right", Some("light-heavy"), Some("backward")),
"leading `:|` after a plain-bar seam must retro-close m1 with a backward repeat: {}",
export.musicxml
);
assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
}
#[test]
fn leading_repeat_end_after_key_change_seam_closes_previous_measure() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:C D E F|\nK:G\n:|G A B c|]\n";
let export =
export_musicxml(source).expect("leading repeat-end after key-change seam should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert!(
has_barline(&measures[0], "right", Some("light-heavy"), Some("backward")),
"leading `:|` after a key-change seam must retro-close m1 with a backward repeat: {}",
export.musicxml
);
assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
}
#[test]
fn direction_only_measure_preserves_trailing_invisible_barline() {
let source = "X:1\nK:C\nC|\"^VAR:\"[|]\\\n\"a.\"A [|] y8\n";
let export = export_musicxml(source).expect("direction-only invisible barline should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
assert!(has_barline(&measures[1], "right", Some("none"), None));
assert!(has_barline(&measures[2], "right", Some("none"), None));
}
#[test]
fn direction_only_measure_preserves_trailing_initial_barline() {
let source = "X:1\nK:C\nC|\"^B\"[|A|]\n";
let export = export_musicxml(source).expect("direction-only initial barline should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
assert!(has_barline(
&measures[1],
"right",
Some("heavy-light"),
None
));
assert!(has_barline(
&measures[2],
"right",
Some("light-heavy"),
None
));
}
#[test]
fn leading_invisible_barline_on_annotation_only_measure_is_emitted() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"^A\"[|] C D E F|]\n";
let export = export_musicxml(source).expect("leading invisible barline should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert!(
has_barline(&measures[0], "right", Some("none"), None),
"annotation-only measure 1 must keep its [|] none barline: {}",
export.musicxml
);
}
#[test]
fn closing_barline_on_directive_and_spacer_only_measure_is_emitted() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F|!segno!y |]\n";
let export = export_musicxml(source).expect("directive+spacer barline should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert!(
has_barline(
measures.last().expect("at least one measure"),
"right",
Some("light-heavy"),
None
),
"directive+spacer-only measure must keep its |] barline: {}",
export.musicxml
);
}
#[test]
fn empty_slur_pair_does_not_emit_degenerate_musicxml_slur() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC() D E F|]\n";
let export = export_musicxml(source).expect("empty slur pair should recover");
assert_balanced_xml(&export.musicxml);
assert!(
!export.musicxml.contains("<slur "),
"empty () must not produce a MusicXML slur: {}",
export.musicxml
);
assert!(
export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.unmatched_slur"),
"empty () should be diagnosed: {:?}",
export.diagnostics
);
}
#[test]
fn excessive_repeat_dots_resolve_to_their_unambiguous_direction() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|::: C D E F :::|\n";
let export = export_musicxml(source).expect("liberal repeat dots should recover");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(&export.musicxml, "<repeat direction=\"forward\"/>"),
1
);
assert_eq!(
count(&export.musicxml, "<repeat direction=\"backward\"/>"),
1
);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}
#[test]
fn trailing_split_barline_does_not_emit_phantom_empty_measure() {
let source = "X:1\nL:1/8\nK:C\nCDEF| |\n";
let export = export_musicxml(source).expect("trailing split barline should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}
#[test]
fn trailing_thin_thick_then_thin_barline_does_not_emit_phantom_measure() {
let source = "X:1\nL:1/8\nK:C\nCDEF|]|\n";
let export = export_musicxml(source).expect("trailing |]| should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}
#[test]
fn trailing_split_barline_with_space_does_not_emit_phantom_measure() {
let source = "X:1\nL:1/8\nK:C\nCDEF| | \n";
let export = export_musicxml(source).expect("trailing split barline with space should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}
#[test]
fn split_barline_between_content_is_a_single_boundary() {
let source = "X:1\nL:1/8\nK:C\nCDEF| | GABc|\n";
let export = export_musicxml(source).expect("interior split barline should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
}
#[test]
fn continued_section_leading_double_barline_does_not_close_empty_measure() {
let source = "X:1\nM:3/4\nL:1/4\nK:Am\nE \\\n|| AA>c | e3 ||\n|| aa>a | a3 ||\n";
let export = export_musicxml(source).expect("continued section double should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(
measure_numbers(&measures),
vec!["1", "2", "3", "4", "5", "6"]
);
assert_eq!(note_steps(&measures[0]), vec!['E']);
assert_eq!(note_steps(&measures[1]), vec!['A', 'A', 'C']);
assert_eq!(note_steps(&measures[2]), vec!['E']);
assert!(measures[3].notes.is_empty());
assert!(
measures[3].barlines.is_empty(),
"section-leading || empty measure must not carry barlines: {:?}",
measures[3].barlines
);
assert_eq!(note_steps(&measures[4]), vec!['A', 'A', 'A']);
assert_eq!(note_steps(&measures[5]), vec!['A']);
}
#[test]
fn thick_barline_then_repeat_start_run_is_a_single_boundary() {
let source = "X:1\nL:1/8\nM:C\nK:C\nCDEF]||: GA |\n";
let export = export_musicxml(source).expect("]||: barline run should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(note_steps(&measures[1]), vec!['G', 'A']);
assert!(has_barline(
&measures[1],
"left",
Some("heavy-light"),
Some("forward")
));
}
#[test]
fn thick_barline_run_after_second_ending_does_not_emit_phantom_measure() {
let source = "X:1\nL:1/8\nM:C\nK:C\n[1 C2:|[2 C3]||: GA |\n";
let export = export_musicxml(source).expect("second-ending ]||: run should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
assert_eq!(note_steps(&measures[0]), vec!['C']);
assert_eq!(note_steps(&measures[1]), vec!['C']);
assert_eq!(note_steps(&measures[2]), vec!['G', 'A']);
assert!(has_barline(
&measures[2],
"left",
Some("heavy-light"),
Some("forward")
));
}
#[test]
fn continued_thick_barline_run_after_second_ending_does_not_emit_phantom_measure() {
let source = "X:1\nL:1/8\nM:C\nK:C\n[1 C2:|[2 C3]\\\n||: GA |\n";
let export = export_musicxml(source).expect("continued second-ending ]||: run should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
assert_eq!(note_steps(&measures[0]), vec!['C']);
assert_eq!(note_steps(&measures[1]), vec!['C']);
assert_eq!(note_steps(&measures[2]), vec!['G', 'A']);
assert!(has_barline(
&measures[2],
"left",
Some("heavy-light"),
Some("forward")
));
}
#[test]
fn double_barline_repeat_start_between_content_is_a_single_boundary() {
let source = "X:1\nL:1/8\nK:C\nCDEF||: GABc|\n";
let export = export_musicxml(source).expect("||: repeat-start should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
assert!(has_barline(
&measures[1],
"left",
Some("heavy-light"),
Some("forward")
));
}
#[test]
fn mid_tune_final_then_repeat_start_does_not_emit_phantom_measure() {
let source = "X:1\nL:1/8\nK:C\nCDEF|]\n|: GABc :|\n";
let export = export_musicxml(source).expect("final then repeat-start should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
assert!(has_barline(
&measures[1],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[1],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn contiguous_double_barline_stays_single_measure() {
let source = "X:1\nL:1/8\nK:C\nCDEF||\n";
let export = export_musicxml(source).expect("double barline should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}
#[test]
fn separate_content_lines_keep_two_measures() {
let source = "X:1\nL:1/8\nK:C\nCDEF|\nGABc|\n";
let export = export_musicxml(source).expect("two content lines should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
}
#[test]
fn leading_rest_measure_is_preserved_not_phantom() {
let source = "X:1\nL:1/8\nK:C\nz4|CDEF|\n";
let export = export_musicxml(source).expect("leading rest measure should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert!(measures[0].notes.iter().any(|note| note.rest));
assert_eq!(note_steps(&measures[1]), vec!['C', 'D', 'E', 'F']);
}
#[test]
fn consecutive_leading_barlines_keep_single_empty_pickup_measure() {
let source = "X:1\nL:1/8\nK:C\n| | | |CDEF|\n";
let export = export_musicxml(source).expect("leading bar-line run should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert!(!measures[0].notes.iter().any(|note| note.rest));
assert!(measures[0].notes.is_empty());
assert_eq!(note_steps(&measures[1]), vec!['C', 'D', 'E', 'F']);
}
#[test]
fn multi_voice_tacet_barline_only_measure_is_kept_aligned() {
let source = concat!(
"X:1\nM:2/4\nL:1/8\nK:C\n",
"[V:1] CDEF |\n[V:2] EFGA |\n",
"[V:1] GABc |\n[V:2] EDCB, |\n",
"[V:1] cBAG |\n[V:2] | |\n",
);
let export = export_musicxml(source).expect("multi-voice tacet bar should export");
assert_balanced_xml(&export.musicxml);
let parts = part_bodies(&export.musicxml);
assert_eq!(parts.len(), 2, "expected two voices/parts");
let v1_measures = musicxml_measures(&parts[0]);
let v2_measures = musicxml_measures(&parts[1]);
assert_eq!(
v1_measures.len(),
3,
"V1 should have three measures: {:?}",
measure_numbers(&v1_measures)
);
assert_eq!(
v2_measures.len(),
v1_measures.len(),
"V2 tacet bar must be kept so voices stay measure-aligned: {:?} vs {:?}",
measure_numbers(&v2_measures),
measure_numbers(&v1_measures)
);
assert!(
v2_measures[2].notes.iter().all(|note| note.rest) || v2_measures[2].notes.is_empty(),
"V2 trailing measure should be a tacet bar, not real notes"
);
}
#[test]
fn multi_voice_empty_final_measure_keeps_final_barline() {
let source = concat!(
"X:1\nM:2/4\nL:1/8\nK:C\n",
"[V:1] CDEF |\n[V:2] EFGA |\n",
"[V:1] GABc |\n[V:2] |]\n",
);
let export = export_musicxml(source).expect("multi-voice empty final bar should export");
assert_balanced_xml(&export.musicxml);
let parts = part_bodies(&export.musicxml);
assert_eq!(parts.len(), 2, "expected two voices/parts");
let v1_measures = musicxml_measures(&parts[0]);
let v2_measures = musicxml_measures(&parts[1]);
assert_eq!(v1_measures.len(), 2);
assert_eq!(v2_measures.len(), 2);
assert!(v2_measures[1].notes.is_empty());
assert!(
has_barline(&v2_measures[1], "right", Some("light-heavy"), None),
"V2 empty final measure should retain its explicit |] right barline: {:?}",
v2_measures[1].barlines
);
}
#[test]
fn multi_voice_empty_measure_keeps_invisible_barline() {
let source = concat!(
"X:1\nM:2/4\nL:1/8\nK:C\n",
"[V:1] CDEF |\n[V:2] EFGA |\n",
"[V:1] GABc |\n[V:2] [|]\n",
);
let export = export_musicxml(source).expect("multi-voice empty [|] should export");
assert_balanced_xml(&export.musicxml);
let parts = part_bodies(&export.musicxml);
let v2_measures = musicxml_measures(&parts[1]);
assert_eq!(v2_measures.len(), 2);
assert!(v2_measures[1].notes.is_empty());
assert!(
has_barline(&v2_measures[1], "right", Some("none"), None),
"V2 empty measure must retain its [|] none barline: {:?}",
v2_measures[1].barlines
);
}
#[test]
fn words_containing_double_colon_pipe_do_not_emit_repeat_barlines() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F |]\nW::| Cross over two couples\n";
let export = export_musicxml(source).expect("words field should not affect barlines");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
None
));
assert!(
measures[0]
.barlines
.iter()
.all(|barline| barline.repeat_direction.is_none())
);
}
#[test]
fn tune_014868_style_leading_double_repeat_has_no_empty_measure() {
let source = concat!(
"X:260\n",
"T:Bag o' Spuds -- Am\n",
"M:4/4\n",
"R:Reel\n",
"K:Am\n",
"||:\"Am\"A2eA BAeA|ABcd ecdB|\"G\"G2BG DGBG|GABc \"Em\"dBcB|\n",
"\"Am\"A2eA BAeA|ABcd \"G\"ecdB|\"F\"ABcd efge|\"Em\"dBGB BAA2:|\n",
"|:\"Am\"a2ea ageg|agbg agef|\"G\"gedc BGBd|g2ga bgeg|\n",
"\"Am\"a2ea ageg|agbg ageg|\"G\"d3e g3e|\"Em\"dBGB BAA2:|\n",
);
let export = export_musicxml(source).expect("leading combined repeat should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(
measure_numbers(&measures),
(1..=16)
.map(|number| number.to_string())
.collect::<Vec<_>>()
);
assert_eq!(
note_steps(&measures[0]),
vec!['A', 'E', 'A', 'B', 'A', 'E', 'A']
);
assert_eq!(note_durations(&measures[0]), vec![8, 4, 4, 4, 4, 4, 4]);
assert!(has_barline(
&measures[0],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[7],
"right",
Some("light-heavy"),
Some("backward")
));
assert!(has_barline(
&measures[8],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[15],
"right",
Some("light-heavy"),
Some("backward")
));
assert!(!measures[0].notes.iter().any(|note| note.rest));
}
#[test]
fn bracketed_repeat_start_and_final_repeat_end_export_repeat_edges() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n[|: C D E F :|]\n";
let export = export_musicxml(source).expect("bracketed repeat barlines should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert!(has_barline(
&measures[0],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn pickup_repeat_start_places_forward_repeat_on_repeated_section() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nE|:CDEF|GABc:|]\n";
let export = export_musicxml(source).expect("pickup repeat should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
assert_eq!(note_steps(&measures[0]), vec!['E']);
assert_eq!(note_steps(&measures[1]), vec!['C', 'D', 'E', 'F']);
assert!(
!has_barline(&measures[0], "left", Some("heavy-light"), Some("forward")),
"pickup measure must not carry the forward repeat"
);
assert!(has_barline(
&measures[1],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[2],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn mid_tune_repeat_start_places_forward_repeat_on_repeated_section() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF|GABc|:cBAG|FEDC:|]\n";
let export = export_musicxml(source).expect("mid-tune repeat should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
assert_eq!(note_steps(&measures[2]), vec!['C', 'B', 'A', 'G']);
assert!(
!has_barline(&measures[1], "left", Some("heavy-light"), Some("forward")),
"measure preceding the repeat must not carry the forward repeat"
);
assert!(has_barline(
&measures[2],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[3],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn leading_repeat_start_after_header_stays_on_its_own_measure() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|GABc:|]\n";
let export = export_musicxml(source).expect("leading repeat should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert!(has_barline(
&measures[0],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[1],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn double_then_repeat_start_after_content_defers_forward_repeat() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF||:GABc|cBAG:|]\n";
let export = export_musicxml(source).expect("double-then-repeat should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
assert!(
!has_barline(&measures[0], "left", Some("heavy-light"), Some("forward")),
"first measure must not carry the forward repeat"
);
assert!(has_barline(
&measures[1],
"left",
Some("heavy-light"),
Some("forward")
));
assert!(has_barline(
&measures[2],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn section_final_barline_followed_by_regular_is_preserved() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF|GABc|]|cBAG|FEDC|]\n";
let export = export_musicxml(source).expect("section final barline should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
assert!(
has_barline(&measures[1], "right", Some("light-heavy"), None),
"the section final barline after measure 2 must be preserved"
);
}
#[test]
fn trailing_section_final_then_regular_spelling_keeps_final_barline() {
let source = "X:1\nL:1/8\nK:C\nCDEF|]|\n";
let export = export_musicxml(source).expect("trailing |]| should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert!(has_barline(
&measures[0],
"right",
Some("light-heavy"),
None
));
}
#[test]
fn adjacent_repeat_end_and_second_ending_starts_next_measure() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F |1 G A B c :|2 D E F G |]\n";
let export = export_musicxml(source).expect("adjacent repeat ending should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
assert!(has_ending(&measures[1], "left", "1", "start"));
assert!(has_ending(&measures[1], "right", "1", "stop"));
assert!(has_ending(&measures[2], "left", "2", "start"));
assert!(has_ending(&measures[2], "right", "2", "stop"));
assert!(has_barline(
&measures[1],
"right",
Some("light-heavy"),
Some("backward")
));
}
#[test]
fn internal_rest_measure_is_preserved_after_leading_barline_policy() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F | z4 | G A B c |]\n";
let export = export_musicxml(source).expect("internal rest measure should export");
assert_balanced_xml(&export.musicxml);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
assert_eq!(measures[1].notes.len(), 1);
assert!(measures[1].notes[0].rest);
assert_eq!(measures[1].notes[0].duration, Some(32));
assert_eq!(note_steps(&measures[2]), vec!['G', 'A', 'B', 'C']);
}
#[test]
fn chords_grace_tuplets_ties_slurs_and_lyrics_export() {
let source =
"X:1\nT:Features\nM:4/4\nL:1/8\nK:C\n{g}[CEG] (3D-D F (G A)|\nw: chord trip let slur end\n";
let export = export_musicxml(source).expect("feature score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<chord/>"), 2);
assert!(export.musicxml.contains("<grace/>"));
assert!(export.musicxml.contains("<time-modification>"));
assert!(export.musicxml.contains("<actual-notes>3</actual-notes>"));
assert!(export.musicxml.contains("<normal-notes>2</normal-notes>"));
assert!(export.musicxml.contains("<tuplet type=\"start\""));
assert!(export.musicxml.contains("<tuplet type=\"stop\""));
assert!(export.musicxml.contains("<tie type=\"start\"/>"));
assert!(export.musicxml.contains("<tied type=\"start\""));
assert!(export.musicxml.contains("<slur type=\"start\""));
assert!(export.musicxml.contains("<slur type=\"stop\""));
assert!(export.musicxml.contains("<text>chord</text>"));
}
#[test]
fn slur_opening_before_grace_group_starts_on_first_grace_note() {
let source = "X:1\nT:Grace Slur\nM:4/4\nL:1/4\nK:C\n({g}c4)|\n";
let export = export_musicxml(source).expect("grace-slur score should export");
assert_balanced_xml(&export.musicxml);
let grace_note_start = export
.musicxml
.find("<grace/>")
.expect("grace note should be present");
let grace_note_end = export.musicxml[grace_note_start..]
.find("</note>")
.map(|offset| grace_note_start + offset)
.expect("grace note should be terminated");
let grace_note = &export.musicxml[grace_note_start..grace_note_end];
assert!(
grace_note.contains("<notations>"),
"grace note should carry notations: {grace_note}"
);
assert!(
grace_note.contains("<slur type=\"start\" number=\"1\"/>"),
"grace note should carry the slur start: {grace_note}"
);
assert_eq!(
count(&export.musicxml, "<slur type=\"start\" number=\"1\"/>"),
1
);
assert_eq!(
count(&export.musicxml, "<slur type=\"stop\" number=\"1\"/>"),
1
);
let stop_index = export
.musicxml
.find("<slur type=\"stop\" number=\"1\"/>")
.expect("slur stop should be present");
let main_note_start = export.musicxml[..stop_index]
.rfind("<note")
.expect("slur stop should be inside a note");
let main_note = &export.musicxml[main_note_start..stop_index];
assert!(
!main_note.contains("<grace/>"),
"slur stop should land on the main note, not a grace note: {main_note}"
);
assert!(
!main_note.contains("<slur type=\"start\""),
"main note must not carry a degenerate slur start: {main_note}"
);
}
#[test]
fn bare_grace_slur_exports_start_and_stop_on_grace_notes() {
let source = "X:1\nT:Bare Grace Slur\nM:4/4\nL:1/8\nK:C\n({Bc})D|\n";
let export = export_musicxml(source).expect("bare grace slur score should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.diagnostics
.iter()
.all(|diagnostic| diagnostic.code != "abc.music.unmatched_slur"),
"bare grace slur should not warn: {:?}",
export.diagnostics
);
assert_eq!(
count(&export.musicxml, "<slur type=\"start\" number=\"1\"/>"),
1
);
assert_eq!(
count(&export.musicxml, "<slur type=\"stop\" number=\"1\"/>"),
1
);
let note_blocks = musicxml_note_blocks(&export.musicxml);
let grace_blocks = note_blocks
.iter()
.filter(|note| note.contains("<grace"))
.copied()
.collect::<Vec<_>>();
assert_eq!(grace_blocks.len(), 2);
assert!(
grace_blocks[0].contains("<slur type=\"start\" number=\"1\"/>"),
"first grace note should open the slur: {}",
grace_blocks[0]
);
assert!(
grace_blocks[1].contains("<slur type=\"stop\" number=\"1\"/>"),
"last grace note should close the slur: {}",
grace_blocks[1]
);
let main_note = note_blocks
.iter()
.find(|note| !note.contains("<grace"))
.expect("main note should be present");
assert!(
!main_note.contains("<slur"),
"main note should not carry the bare grace slur: {main_note}"
);
}
#[test]
fn bare_grace_slur_before_barline_exports_as_after_grace_pair() {
let source = "X:1\nT:Bare Grace Before Bar\nM:4/4\nL:1/8\nK:C\nA2({Bc})|\n";
let export = export_musicxml(source).expect("bare grace before barline should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.diagnostics
.iter()
.all(|diagnostic| diagnostic.code != "abc.music.dangling_grace_group"),
"bare grace before barline should not dangle: {:?}",
export.diagnostics
);
let note_blocks = musicxml_note_blocks(&export.musicxml);
let main_note = note_blocks
.iter()
.find(|note| !note.contains("<grace"))
.expect("main note should be present");
assert!(
!main_note.contains("<slur"),
"main note should not carry the bare grace slur: {main_note}"
);
let grace_blocks = note_blocks
.iter()
.filter(|note| note.contains("<grace"))
.copied()
.collect::<Vec<_>>();
assert_eq!(grace_blocks.len(), 2);
assert!(grace_blocks[0].contains("<slur type=\"start\" number=\"1\"/>"));
assert!(grace_blocks[1].contains("<slur type=\"stop\" number=\"1\"/>"));
}
#[test]
fn slur_opening_after_a_grace_starts_on_main_note_not_grace() {
let source = "X:1\nT:Grace Then Slur\nM:4/4\nL:1/4\nK:C\n{g}c(de)|\n";
let export = export_musicxml(source).expect("grace-then-slur score should export");
assert_balanced_xml(&export.musicxml);
let grace_note_start = export
.musicxml
.find("<grace/>")
.expect("grace note should be present");
let grace_note_end = export.musicxml[grace_note_start..]
.find("</note>")
.map(|offset| grace_note_start + offset)
.expect("grace note should be terminated");
let grace_note = &export.musicxml[grace_note_start..grace_note_end];
assert!(
!grace_note.contains("<slur"),
"grace note must not carry a slur when the slur opens after it: {grace_note}"
);
let start_index = export
.musicxml
.find("<slur type=\"start\" number=\"1\"/>")
.expect("slur start should be present");
let main_note_start = export.musicxml[..start_index]
.rfind("<note")
.expect("slur start should be inside a note");
let main_note = &export.musicxml[main_note_start..start_index];
assert!(
!main_note.contains("<grace/>"),
"slur start should land on the main note: {main_note}"
);
assert!(
export
.musicxml
.contains("<slur type=\"stop\" number=\"1\"/>")
);
}
#[test]
fn slur_without_grace_still_spans_first_to_last_note() {
let source = "X:1\nT:Plain Slur\nM:4/4\nL:1/4\nK:C\n(DEF) {g}A|\n";
let export = export_musicxml(source).expect("plain slur score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(&export.musicxml, "<slur type=\"start\" number=\"1\"/>"),
1
);
assert_eq!(
count(&export.musicxml, "<slur type=\"stop\" number=\"1\"/>"),
1
);
let grace_note_start = export
.musicxml
.find("<grace/>")
.expect("grace note should be present");
let grace_note_end = export.musicxml[grace_note_start..]
.find("</note>")
.map(|offset| grace_note_start + offset)
.expect("grace note should be terminated");
let grace_note = &export.musicxml[grace_note_start..grace_note_end];
assert!(
!grace_note.contains("<notations>") && !grace_note.contains("<slur"),
"plain grace note should carry no notations: {grace_note}"
);
}
#[test]
fn grace_before_slur_open_attaches_to_main_note_and_slur_starts_on_main() {
let source = "X:1\nT:Grace Before Slur\nM:4/4\nL:1/8\nK:C\n{g}(c4) d|\n";
let export = export_musicxml(source).expect("grace-before-slur score should export");
assert_balanced_xml(&export.musicxml);
let grace_note_start = export
.musicxml
.find("<grace/>")
.expect("grace note should be present");
let grace_note_end = export.musicxml[grace_note_start..]
.find("</note>")
.map(|offset| grace_note_start + offset)
.expect("grace note should be terminated");
let grace_note = &export.musicxml[grace_note_start..grace_note_end];
assert!(
!grace_note.contains("<slur"),
"grace note must not carry a slur when the slur opens after it: {grace_note}"
);
assert_eq!(
count(&export.musicxml, "<slur type=\"start\" number=\"1\"/>"),
1
);
assert_eq!(
count(&export.musicxml, "<slur type=\"stop\" number=\"1\"/>"),
1
);
let start_index = export
.musicxml
.find("<slur type=\"start\" number=\"1\"/>")
.expect("slur start should be present");
let main_note_start = export.musicxml[..start_index]
.rfind("<note")
.expect("slur start should be inside a note");
let main_note = &export.musicxml[main_note_start..start_index];
assert!(
!main_note.contains("<grace/>"),
"slur start should land on the main note, not the grace: {main_note}"
);
}
#[test]
fn grace_before_slur_open_does_not_regress_slur_before_grace() {
let source = "X:1\nT:Slur Before Grace\nM:4/4\nL:1/4\nK:C\n({g}c4) d|\n";
let export = export_musicxml(source).expect("slur-before-grace score should export");
assert_balanced_xml(&export.musicxml);
let grace_note_start = export
.musicxml
.find("<grace/>")
.expect("grace note should be present");
let grace_note_end = export.musicxml[grace_note_start..]
.find("</note>")
.map(|offset| grace_note_start + offset)
.expect("grace note should be terminated");
let grace_note = &export.musicxml[grace_note_start..grace_note_end];
assert!(
grace_note.contains("<slur type=\"start\" number=\"1\"/>"),
"slur opening before the grace should start on the grace note: {grace_note}"
);
}
#[test]
fn grace_before_plain_note_still_attaches() {
let source = "X:1\nT:Plain Grace\nM:4/4\nL:1/8\nK:C\n{g}c4 d|\n";
let export = export_musicxml(source).expect("plain grace score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<grace/>"), 1);
assert!(!export.musicxml.contains("<slur"));
}
#[test]
fn grace_group_internal_slurs_export_on_grace_notes_in_source_order() {
let source = "X:1\nT:Grace Internal Slurs\nM:4/4\nL:1/8\nK:C\n{(fg)}a2 {(ef)}g2|]\n";
let export = export_musicxml(source).expect("grace-internal slur score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
export
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.code == "abc.music.unknown_grace_token")
.count(),
0,
"grace-internal slur markers should not warn: {:?}",
export.diagnostics
);
assert_eq!(count(&export.musicxml, "<grace/>"), 4);
assert_eq!(count(&export.musicxml, "<slur type=\"start\""), 2);
assert_eq!(count(&export.musicxml, "<slur type=\"stop\""), 2);
let note_blocks = musicxml_note_blocks(&export.musicxml);
let grace_slurs = note_blocks
.iter()
.filter(|note| note.contains("<grace"))
.map(|note| {
(
element_text(note, "step")
.and_then(|text| text.chars().next())
.expect("grace note should have a pitch step"),
note.contains("<slur type=\"start\""),
note.contains("<slur type=\"stop\""),
)
})
.collect::<Vec<_>>();
assert_eq!(
grace_slurs,
vec![
('F', true, false),
('G', false, true),
('E', true, false),
('F', false, true),
]
);
assert!(
note_blocks
.iter()
.filter(|note| !note.contains("<grace"))
.all(|note| !note.contains("<slur")),
"main notes should not receive the grace-internal slurs"
);
}
#[test]
fn malformed_grace_group_internal_slurs_warn_without_musicxml_slur_pairs() {
for (source, code, marker) in [
(
"X:1\nT:t\nM:4/4\nK:C\n{(fg}a|]\n",
"abc.music.unclosed_slur",
"(",
),
(
"X:1\nT:t\nM:4/4\nK:C\n{fg)}a|]\n",
"abc.music.unmatched_slur",
")",
),
] {
let export = export_musicxml(source).expect("malformed grace slur should recover");
assert_balanced_xml(&export.musicxml);
assert_diagnostic_span(source, &export.diagnostics, code, marker);
assert_eq!(count(&export.musicxml, "<grace/>"), 2);
assert!(
!export.musicxml.contains("<slur "),
"malformed grace-internal slur should not export an unpaired MusicXML slur: {}",
export.musicxml
);
}
}
#[test]
fn grace_group_internal_slur_can_export_from_chord_to_note() {
let source = "X:1\nT:Grace Chord Slur\nM:4/4\nL:1/8\nK:C\n{([fg]a)}c|]\n";
let export = export_musicxml(source).expect("grace chord slur should export");
assert_balanced_xml(&export.musicxml);
assert!(export.diagnostics.is_empty(), "{:?}", export.diagnostics);
assert_eq!(count(&export.musicxml, "<grace/>"), 3);
assert_eq!(count(&export.musicxml, "<slur type=\"start\""), 1);
assert_eq!(count(&export.musicxml, "<slur type=\"stop\""), 1);
let grace_slurs = musicxml_note_blocks(&export.musicxml)
.iter()
.filter(|note| note.contains("<grace"))
.map(|note| {
(
element_text(note, "step")
.and_then(|text| text.chars().next())
.expect("grace note should have a pitch step"),
note.contains("<chord/>"),
note.contains("<slur type=\"start\""),
note.contains("<slur type=\"stop\""),
)
})
.collect::<Vec<_>>();
assert_eq!(
grace_slurs,
vec![
('F', false, true, false),
('G', true, false, false),
('A', false, false, true),
]
);
}
#[test]
fn grace_group_internal_slur_across_rest_stays_on_grace_note() {
let source = "X:1\nT:Grace Rest Slur\nM:4/4\nL:1/8\nK:C\n{(fz)}a|]\n";
let export = export_musicxml(source).expect("grace rest slur should export");
assert_balanced_xml(&export.musicxml);
assert!(export.diagnostics.is_empty(), "{:?}", export.diagnostics);
assert_eq!(count(&export.musicxml, "<grace/>"), 2);
assert_eq!(count(&export.musicxml, "<slur type=\"start\""), 1);
assert_eq!(count(&export.musicxml, "<slur type=\"stop\""), 1);
let note_blocks = musicxml_note_blocks(&export.musicxml);
let grace_slurs = note_blocks
.iter()
.filter(|note| note.contains("<grace"))
.map(|note| {
(
element_text(note, "step").and_then(|text| text.chars().next()),
note.contains("<rest/>"),
note.contains("<slur type=\"start\""),
note.contains("<slur type=\"stop\""),
)
})
.collect::<Vec<_>>();
assert_eq!(
grace_slurs,
vec![(Some('F'), false, true, true), (None, true, false, false)]
);
assert!(
note_blocks
.iter()
.filter(|note| !note.contains("<grace"))
.all(|note| !note.contains("<slur")),
"rest-adjacent grace slur should not leak to the main note"
);
}
#[test]
fn chord_symbol_before_grace_group_emits_harmony() {
let source = "X:1\nT:Harmony Before Grace\nM:4/4\nL:1/8\nK:C\n\"F\"{AB}c4 d|\n";
let export = export_musicxml(source).expect("harmony-before-grace score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<harmony"), 1);
assert_eq!(count(&export.musicxml, "<grace/>"), 2);
}
#[test]
fn grace_orphaned_before_barline_is_voided_without_panic() {
let carried = export_musicxml("X:1\nT:Orphan Grace Bar\nM:4/4\nL:1/4\nK:C\nc {g}| d|\n")
.expect("carried-grace score should export");
assert_balanced_xml(&carried.musicxml);
assert_eq!(count(&carried.musicxml, "<grace/>"), 1);
let orphan = export_musicxml("X:1\nT:Orphan Grace End\nM:4/4\nL:1/4\nK:C\nc {g}\n")
.expect("orphan-grace score should export");
assert_balanced_xml(&orphan.musicxml);
assert_eq!(count(&orphan.musicxml, "<grace/>"), 0);
assert!(
orphan
.diagnostics
.iter()
.any(|d| d.code == "abc.music.dangling_grace_group")
);
}
#[test]
fn lyric_hyphen_controls_do_not_export_as_sung_text() {
let source = "X:1\nT:Hyphen Lyrics\nM:4/4\nL:1/4\nK:C\nC D E F|\nw: A-des-te fi-del\n";
let export = export_musicxml(source).expect("hyphen lyric score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<lyric number=\"1\">"), 4);
assert!(export.musicxml.contains("<text>A</text>"));
assert!(export.musicxml.contains("<text>des</text>"));
assert!(export.musicxml.contains("<text>te</text>"));
assert!(export.musicxml.contains("<text>fi</text>"));
assert!(!export.musicxml.contains("<text>del</text>"));
assert!(!export.musicxml.contains("<text>-</text>"));
assert_eq!(
export
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.code == "abc.lyric.syllable_count")
.count(),
1
);
}
#[test]
fn lyric_hyphen_controls_export_syllabic_begin_middle_end() {
let source = "X:1\nT:Syllabic Lyrics\nM:4/4\nL:1/4\nK:C\nC D E F|\nw: A-des-te all\n";
let export = export_musicxml(source).expect("hyphen lyric score should export");
assert_balanced_xml(&export.musicxml);
let syllabic = export
.musicxml
.match_indices("<syllabic>")
.map(|(index, _)| {
let start = index + "<syllabic>".len();
let end = export.musicxml[start..]
.find("</syllabic>")
.map(|offset| start + offset)
.expect("syllabic should close");
&export.musicxml[start..end]
})
.collect::<Vec<_>>();
assert_eq!(syllabic, vec!["begin", "middle", "end", "single"]);
assert!(export.musicxml.contains("<text>A</text>"));
assert!(export.musicxml.contains("<text>des</text>"));
assert!(export.musicxml.contains("<text>te</text>"));
assert!(export.musicxml.contains("<text>all</text>"));
assert!(!export.musicxml.contains("<text>-</text>"));
assert!(export.diagnostics.is_empty());
}
#[test]
fn trailing_lyric_hyphen_keeps_syllabic_word_open() {
fn syllabic_values(source: &str) -> Vec<String> {
let export = export_musicxml(source).expect("trailing-hyphen lyric score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.diagnostics.is_empty());
export
.musicxml
.match_indices("<syllabic>")
.map(|(index, _)| {
let start = index + "<syllabic>".len();
let end = export.musicxml[start..]
.find("</syllabic>")
.map(|offset| start + offset)
.expect("syllabic should close");
export.musicxml[start..end].to_owned()
})
.collect()
}
assert_eq!(
syllabic_values("X:1\nT:Trailing Begin\nM:2/4\nL:1/4\nK:C\nC D|\nw: be-*\n"),
vec!["begin"]
);
assert_eq!(
syllabic_values("X:1\nT:Trailing Middle\nM:2/4\nL:1/4\nK:C\nC D|\nw: au- di-\n"),
vec!["begin", "middle"]
);
}
#[test]
fn orphan_lyric_hyphen_does_not_start_syllabic_word() {
let source = "X:1\nT:Orphan Hyphen\nM:1/4\nL:1/4\nK:C\nC|\nw: a-b\n";
let export = export_musicxml(source).expect("orphan hyphen score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<syllabic>single</syllabic>"));
assert!(export.musicxml.contains("<text>a</text>"));
assert!(!export.musicxml.contains("<syllabic>begin</syllabic>"));
assert!(!export.musicxml.contains("<text>b</text>"));
assert_eq!(
export
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.code == "abc.lyric.syllable_count")
.count(),
1
);
}
#[test]
fn lyric_hyphen_across_non_adjacent_blocks_exports_begin_end() {
let source = "X:1\nT:Block Hyphen\nM:1/4\nL:1/4\nK:C\nC|\nw: A-\nD|\nw: men\n";
let export = export_musicxml(source).expect("block hyphen score should export");
assert_balanced_xml(&export.musicxml);
let syllabic = export
.musicxml
.match_indices("<syllabic>")
.map(|(index, _)| {
let start = index + "<syllabic>".len();
let end = export.musicxml[start..]
.find("</syllabic>")
.map(|offset| start + offset)
.expect("syllabic should close");
&export.musicxml[start..end]
})
.collect::<Vec<_>>();
assert_eq!(syllabic, vec!["begin", "end"]);
assert!(export.musicxml.contains("<text>A</text>"));
assert!(export.musicxml.contains("<text>men</text>"));
assert!(export.diagnostics.is_empty());
}
#[test]
fn lyric_syllabic_state_follows_source_voice_across_overlays() {
let source = "X:1\nT:Overlay Lyrics\nM:3/4\nL:1/4\nK:C\nC & E F|\nw: A-men all\n";
let export = export_musicxml(source).expect("overlay lyric score should export");
assert_balanced_xml(&export.musicxml);
let syllabic = export
.musicxml
.match_indices("<syllabic>")
.map(|(index, _)| {
let start = index + "<syllabic>".len();
let end = export.musicxml[start..]
.find("</syllabic>")
.map(|offset| start + offset)
.expect("syllabic should close");
&export.musicxml[start..end]
})
.collect::<Vec<_>>();
assert_eq!(syllabic, vec!["begin", "end", "single"]);
assert!(export.musicxml.contains("<text>A</text>"));
assert!(export.musicxml.contains("<text>men</text>"));
assert!(export.musicxml.contains("<text>all</text>"));
}
#[test]
fn escaped_literal_hyphen_in_lyrics_still_exports_as_text() {
let source = "X:1\nT:Literal Hyphen Lyrics\nM:2/4\nL:1/4\nK:C\nC D|\nw: \\-dash end\n";
let export = export_musicxml(source).expect("literal hyphen lyric score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<text>-dash</text>"));
assert!(!export.musicxml.contains("<text>-</text>"));
assert!(export.diagnostics.is_empty());
}
#[test]
fn lyric_underscore_exports_melisma_extender_without_sung_text() {
let source = "X:1\nT:Melisma Lyrics\nM:3/4\nL:1/4\nK:C\nC D E|\nw: time_ day\n";
let export = export_musicxml(source).expect("melisma lyric score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<text>time</text>"));
assert!(export.musicxml.contains("<extend/>"));
assert!(export.musicxml.contains("<text>day</text>"));
assert!(!export.musicxml.contains("<text>_</text>"));
assert!(export.diagnostics.is_empty());
}
#[test]
fn lyric_extend_carrier_before_chord_attaches_to_chord_head() {
let source = "X:1\nL:1/8\nK:C\nC2 [I:croma-lyric-extend verse=1][FA]2 G2|\nw: a sing c\n";
let export = export_musicxml(source).expect("lyric-extend before chord should export");
assert_balanced_xml(&export.musicxml);
let sing = export
.musicxml
.find("<text>sing</text>")
.expect("sing syllable should be present");
let rest = &export.musicxml[sing..];
let next_note = rest.find("<note").unwrap_or(rest.len());
assert!(
rest[..next_note].contains("<extend/>"),
"chord head 'sing' must keep its <extend/>: {}",
&rest[..next_note.min(160)]
);
}
#[test]
fn lyric_duplicate_carrier_before_chord_attaches_to_chord_head() {
let source = "X:1\nL:1/8\nK:C\n[I:croma-lyric-duplicate verse=1 text=By][DF]2 G2|\nw: By c\n";
let export = export_musicxml(source).expect("lyric-duplicate before chord should export");
assert_balanced_xml(&export.musicxml);
let first_note = export
.musicxml
.find("<note")
.expect("a note should be present");
let after_first = first_note + "<note".len();
let second_note = export.musicxml[after_first..]
.find("<note")
.map(|offset| after_first + offset)
.expect("a second note should be present");
let head = &export.musicxml[first_note..second_note];
assert_eq!(
count(head, "<text>By</text>"),
2,
"chord head must carry both duplicate 'By' lyrics: {head}"
);
}
#[test]
fn clef_octave_suffix_shifts_notes_and_marks_the_clef() {
let source = "X:1\nL:1/4\nK:C\nV:1 clef=treble-8\nC2 C2|\n";
let export = export_musicxml(source).expect("treble-8 score should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<clef-octave-change>-1</clef-octave-change>")
);
assert!(export.musicxml.contains("<octave>3</octave>"));
assert!(!export.musicxml.contains("<octave>4</octave>"));
}
#[test]
fn voice_middle_modifier_shifts_octave() {
let source = "X:1\nT:Bass test\nM:C\nL:1/4\nK:C\nV:1 clef=bass middle=d\ne e e e |\n";
let export = export_musicxml(source).expect("bass middle=d score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<octave>3</octave>"));
assert!(!export.musicxml.contains("<octave>5</octave>"));
}
#[test]
fn bare_voice_switch_keeps_each_header_clef() {
let source = concat!(
"X:1\nL:1/4\n",
"V:1 clef=treble\nV:2 clef=bass\nK:C\n",
"V:1\nc c|\n",
"V:2\nC, C,|\n",
);
let export = export_musicxml(source).expect("multi-voice score should export");
assert_balanced_xml(&export.musicxml);
let p2 = export
.musicxml
.split("<part id=\"P2\">")
.nth(1)
.expect("part P2");
assert!(
p2.contains("<sign>F</sign>"),
"V:2 should keep its bass clef"
);
}
#[test]
fn key_field_octave_property_shifts_single_voice() {
let baseline = export_musicxml("X:1\nL:1/4\nK:C\nC2 C2|\n").expect("baseline should export");
assert!(baseline.musicxml.contains("<octave>4</octave>"));
assert!(!baseline.musicxml.contains("<octave>5</octave>"));
let shifted =
export_musicxml("X:1\nL:1/4\nK:C octave=1\nC2 C2|\n").expect("octave=1 should export");
assert_balanced_xml(&shifted.musicxml);
assert!(
shifted.musicxml.contains("<octave>5</octave>"),
"octave=1 on K: should shift C from octave 4 to 5"
);
assert!(!shifted.musicxml.contains("<octave>4</octave>"));
}
#[test]
fn oversized_octave_and_clef_shifts_clamp_instead_of_panicking() {
let export = export_musicxml("X:1\nL:1/4\nK:C\nV:1 clef=treble+15 octave=125\nC2 C2|\n")
.expect("oversized voice shifts should export");
assert_balanced_xml(&export.musicxml);
assert!(
export.musicxml.contains("<octave>15</octave>"),
"clef +15 (+2) and octave=125 (clamped +9) should shift C to octave 15"
);
let export = export_musicxml("X:1\nL:1/4\nK:C octave=99999\nC2 C2|\n")
.expect("oversized K: octave should export");
assert_balanced_xml(&export.musicxml);
assert!(
export.musicxml.contains("<octave>13</octave>"),
"octave=99999 on K: should clamp to +9 and shift C to octave 13"
);
let marks = ",".repeat(200);
let source = format!("X:1\nL:1/4\nK:C\nC{marks}DEF|\n");
let export = export_musicxml(&source).expect("long octave-mark run should export");
assert_balanced_xml(&export.musicxml);
}
#[test]
fn key_field_clef_shorthand_scopes_to_current_voice_only() {
let source = concat!(
"X:1\nL:1/4\n",
"K:C\n",
"V:1\n",
"K:C treble+8\n",
"C2 C2|\n",
"V:2\n",
"K:C treble-8\n",
"C2 C2|\n",
);
let export = export_musicxml(source).expect("multi-voice K: clef score should export");
assert_balanced_xml(&export.musicxml);
let p1 = export
.musicxml
.split("<part id=\"P2\">")
.next()
.expect("part P1 segment");
let p2 = export
.musicxml
.split("<part id=\"P2\">")
.nth(1)
.expect("part P2 segment");
assert!(
p1.contains("<octave>5</octave>"),
"V:1 K:C treble+8 should shift C to octave 5"
);
assert!(
!p1.contains("<octave>3</octave>"),
"V:1 shift must not be the V:2 (-8) shift"
);
assert!(
p2.contains("<octave>3</octave>"),
"V:2 K:C treble-8 should shift C to octave 3"
);
assert!(
!p2.contains("<octave>5</octave>"),
"V:1's +8 shift must not leak into V:2"
);
}
#[test]
fn unclosed_decoration_does_not_swallow_following_notes() {
let source = "X:1\nL:1/8\nK:C\nA !f2e2f2 | g2|\n";
let export = export_musicxml(source).expect("stray-bang score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<step>F</step>"), 2);
assert_eq!(count(&export.musicxml, "<step>E</step>"), 1);
assert_eq!(count(&export.musicxml, "<step>A</step>"), 1);
assert_eq!(count(&export.musicxml, "<step>G</step>"), 1);
}
#[test]
fn chords_adjacent_to_barlines_are_not_swallowed() {
let source =
"X:1\nL:1/8\nM:4/4\nK:C\n[G2C,2][c2C,2][A2F,2][e2C,2]|[F2D,2][D2D,2][A2D,2][d2D,2]|\n";
let export = export_musicxml(source).expect("bass-chord score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<measure "), 2);
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.barline.liberal"),
"no chord bracket should be read as a liberal barline"
);
}
#[test]
fn staccato_chord_keeps_its_length_suffix() {
let source = "X:1\nL:1/8\nK:C\n.[CE]2 [CE]|\n";
let export = export_musicxml(source).expect("staccato chord should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<staccato/>"));
assert_eq!(count(&export.musicxml, "<chord/>"), 2);
assert_eq!(count(&export.musicxml, "<type>quarter</type>"), 2);
assert_eq!(count(&export.musicxml, "<type>eighth</type>"), 2);
}
#[test]
fn decorations_map_to_notation_elements_not_words() {
let source = "X:1\nL:1/4\nK:C\n!fermata!.C !accent!D|!upbow!E !trill!F|\n";
let export = export_musicxml(source).expect("decorated score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<fermata type=\"upright\"/>"));
assert!(export.musicxml.contains("<articulations>"));
assert!(export.musicxml.contains("<staccato/>"));
assert!(export.musicxml.contains("<accent/>"));
assert!(export.musicxml.contains("<technical>"));
assert!(export.musicxml.contains("<up-bow/>"));
assert!(export.musicxml.contains("<ornaments>"));
assert!(export.musicxml.contains("<trill-mark/>"));
assert!(!export.musicxml.contains("<words>fermata</words>"));
assert!(!export.musicxml.contains("<words>accent</words>"));
}
#[test]
fn shorthand_decorations_map_to_notation_elements_not_words() {
let source = "X:1\nL:1/4\nK:C\nHC TD|uE vF|MG Pa|\n";
let export = export_musicxml(source).expect("shorthand decorations should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<fermata type=\"upright\"/>"));
assert!(export.musicxml.contains("<trill-mark/>"));
assert!(export.musicxml.contains("<up-bow/>"));
assert!(export.musicxml.contains("<down-bow/>"));
assert!(export.musicxml.contains("<mordent/>"));
assert!(export.musicxml.contains("<inverted-mordent/>"));
for raw in ["H", "T", "u", "v", "M", "P"] {
assert!(
!export.musicxml.contains(&format!("<words>{raw}</words>")),
"shorthand `{raw}` should not be emitted as <words>"
);
}
}
#[test]
fn shorthand_accent_maps_to_articulation_not_words() {
let source = "X:1\nL:1/4\nK:C\nLC D|\n";
let export = export_musicxml(source).expect("shorthand accent should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<articulations>"));
assert!(export.musicxml.contains("<accent/>"));
assert!(!export.musicxml.contains("<words>L</words>"));
}
#[test]
fn shorthand_segno_and_coda_map_to_direction_symbols_not_words() {
let source = "X:1\nL:1/4\nK:C\nSC OD|\n";
let export = export_musicxml(source).expect("shorthand segno/coda should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<segno/>"));
assert!(export.musicxml.contains("<coda/>"));
assert!(!export.musicxml.contains("<words>S</words>"));
assert!(!export.musicxml.contains("<words>O</words>"));
}
#[test]
fn shorthand_roll_emits_neither_words_nor_diagnostic() {
let source = "X:1\nL:1/4\nK:C\n~C D|\n";
let export = export_musicxml(source).expect("shorthand roll should export");
assert_balanced_xml(&export.musicxml);
assert!(!export.musicxml.contains("<words>~</words>"));
assert!(!export.musicxml.contains("<words>roll</words>"));
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
"roll should be suppressed without an unsupported-decoration diagnostic"
);
assert_eq!(count(&export.musicxml, "<note>"), 2);
}
#[test]
fn user_defined_symbol_expands_to_its_notation_not_words() {
let source = "X:1\nU:W=!trill!\nL:1/4\nK:C\nWC D|\n";
let export = export_musicxml(source).expect("user symbol should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<trill-mark/>"));
assert!(!export.musicxml.contains("<words>W</words>"));
}
#[test]
fn bare_user_defined_symbol_name_expands_to_its_notation_not_words() {
let source = "X:1\nU:t=tenuto\nL:1/4\nK:C\ntC D|\n";
let export = export_musicxml(source).expect("user symbol should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<tenuto/>"));
assert!(!export.musicxml.contains("<words>t</words>"));
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported")
);
}
#[test]
fn post_tune_words_export_as_credits_not_in_measure_directions() {
let source = "X:1\nT:Song\nL:1/4\nK:C\nC D E F|\nW: Verse one here\nW: Verse two here\nW:\n";
let export = export_musicxml(source).expect("post-tune words should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<credit-words>Verse one here</credit-words>")
);
assert!(
export
.musicxml
.contains("<credit-words>Verse two here</credit-words>")
);
assert!(!export.musicxml.contains("<words>Verse one here</words>"));
assert_eq!(count(&export.musicxml, "<credit-words>"), 2);
}
#[test]
fn staves_parenthesis_group_merges_voices_into_one_part() {
let source = concat!(
"X:1\nL:1/4\n%%staves 1 (2 3) 4\n",
"V:1\nV:2\nV:3\nV:4\nK:C\n",
"V:1\nC D|\nV:2\nE F|\nV:3\nG A|\nV:4\nc d|\n",
);
let export = export_musicxml(source).expect("grouped score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<part id="), 3);
}
#[test]
fn reader_style_hash_voice_ids_preserve_musicxml_voice_numbers() {
let source = concat!(
"X:1\nL:1/4\n%%score (P1 P1#5)\n",
"V:P1\nV:P1#5\nK:C\n",
"V:P1\nC |\n",
"V:P1#5\nG |\n",
);
let export = export_musicxml(source).expect("reader-style voice ids should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<part id="), 1);
assert!(
export.musicxml.contains("<voice>5</voice>"),
"reader-style voice id P1#5 must emit MusicXML voice 5, not voice 2:\n{}",
export.musicxml
);
assert!(
!export.musicxml.contains("<voice>2</voice>"),
"reader-style voice id P1#5 must not be normalized to voice 2:\n{}",
export.musicxml
);
}
#[test]
fn slur_number_collision_shared_part_uses_distinct_musicxml_numbers() {
let source = concat!(
"X:1\nM:4/4\nL:1/4\n%%staves 1 (2 3)\n",
"V:1\nV:2\nV:3\nK:C\n",
"V:1\nz z z z|z z z z|]\n",
"V:2\n(e e e e|e) z z z|]\n",
"V:3\n(c c c c|c) z z z|]\n",
);
let export = export_musicxml(source).expect("grouped slurs should export");
assert_balanced_xml(&export.musicxml);
let parts = musicxml_part_blocks(&export.musicxml);
assert_eq!(
parts.len(),
2,
"expected one solo part and one grouped part"
);
let shared_part = parts
.iter()
.find(|part| count(part, "<slur ") == 4)
.expect("grouped part should contain both slur pairs");
assert!(shared_part.contains("<voice>1</voice>"));
assert!(shared_part.contains("<voice>2</voice>"));
let slur_events = musicxml_note_blocks(shared_part)
.into_iter()
.filter_map(|note| {
let slur_start = note.find("<slur ")?;
let slur_end = note[slur_start..]
.find("/>")
.map(|end| slur_start + end + 2)
.expect("slur tag should close");
let slur = ¬e[slur_start..slur_end];
Some((
element_text(note, "voice").expect("slurred note should have voice"),
element_text(note, "step")
.and_then(|step| step.chars().next())
.expect("slurred note should have pitch step"),
attr_value(slur, "type").expect("slur should have type"),
attr_value(slur, "number").expect("slur should have number"),
))
})
.collect::<Vec<_>>();
assert_eq!(
slur_events,
vec![
("1".to_owned(), 'E', "start".to_owned(), "1".to_owned()),
("2".to_owned(), 'C', "start".to_owned(), "2".to_owned()),
("1".to_owned(), 'E', "stop".to_owned(), "1".to_owned()),
("2".to_owned(), 'C', "stop".to_owned(), "2".to_owned()),
]
);
}
#[test]
fn slur_number_collision_overlay_stop_uses_overlay_start_number() {
let source = concat!(
"X:1\nM:4/4\nL:1/4\n%%staves (1 2)\n",
"V:1\nV:2\nK:C\n",
"V:1\n(a a a a & (e e e e|e) a a a)|]\n",
"V:2\nz z z z|z z z z|]\n",
);
let export = export_musicxml(source).expect("overlay slurs should export");
assert_balanced_xml(&export.musicxml);
let slur_events = musicxml_note_blocks(&export.musicxml)
.into_iter()
.flat_map(slur_events_in_note)
.collect::<Vec<_>>();
assert_eq!(
slur_events,
vec![
SlurEvent::new("1", 'A', "start", "1"),
SlurEvent::new("3", 'E', "start", "2"),
SlurEvent::new("1", 'E', "stop", "2"),
SlurEvent::new("1", 'A', "stop", "1"),
]
);
}
#[test]
fn grouped_voice_overlays_use_part_unique_voice_numbers() {
let source = concat!(
"X:1\nM:4/4\nL:1/4\n%%staves (1 2)\n",
"V:1\nV:2\nK:C\n",
"V:1\nC D & E F|\n",
"V:2\nG A & B c|\n",
);
let export = export_musicxml(source).expect("grouped overlays should export");
assert_balanced_xml(&export.musicxml);
let parts = musicxml_part_blocks(&export.musicxml);
assert_eq!(parts.len(), 1, "voices should share one grouped part");
let part = parts[0];
for voice in ["1", "2", "3", "4"] {
assert!(
part.contains(&format!("<voice>{voice}</voice>")),
"expected grouped part to contain voice {voice}: {part}"
);
}
let voice_sequence = musicxml_note_blocks(part)
.into_iter()
.map(|note| element_text(note, "voice").expect("note should have voice"))
.collect::<Vec<_>>();
assert_eq!(voice_sequence, vec!["1", "1", "3", "3", "2", "2", "4", "4"]);
}
#[test]
fn grouped_voice_overlay_numbers_are_stable_across_measures() {
let source = concat!(
"X:1\nM:2/4\nL:1/4\n%%staves (1 2)\n",
"V:1\nV:2\nK:C\n",
"V:1\nC & E|D F|\n",
"V:2\nG A|B & c|\n",
);
let export = export_musicxml(source).expect("staggered overlays should export");
assert_balanced_xml(&export.musicxml);
let part = musicxml_part_blocks(&export.musicxml)
.into_iter()
.next()
.expect("grouped part should be present");
let voice_for_pitch = |step: char, octave: &str| {
musicxml_note_blocks(part)
.into_iter()
.find(|note| {
element_text(note, "step").is_some_and(|text| text.starts_with(step))
&& element_text(note, "octave").is_some_and(|text| text == octave)
})
.and_then(|note| element_text(note, "voice"))
.expect("pitch should have a voice")
};
assert_eq!(voice_for_pitch('E', "4"), "3");
assert_eq!(voice_for_pitch('C', "5"), "4");
}
#[test]
fn slur_number_allocator_preserves_high_pair_ids_and_remaps_collisions() {
let mut slur_numbers = SlurNumbers::default();
assert_eq!(
slur_numbers.number_for("voice-a", 17, crate::model::SlurRole::Start),
17
);
assert_eq!(
slur_numbers.number_for("voice-a", 17, crate::model::SlurRole::Stop),
17
);
assert_eq!(
slur_numbers.number_for("voice-a", 1, crate::model::SlurRole::Start),
1
);
assert_eq!(
slur_numbers.number_for("voice-b", 1, crate::model::SlurRole::Start),
2
);
assert_eq!(
slur_numbers.number_for("voice-a", 1, crate::model::SlurRole::Stop),
1
);
assert_eq!(
slur_numbers.number_for("voice-b", 1, crate::model::SlurRole::Stop),
2
);
assert_eq!(
slur_numbers.number_for("voice-c", 1, crate::model::SlurRole::Start),
1
);
}
#[test]
fn staves_bracket_group_keeps_one_part_per_voice() {
let source = concat!(
"X:1\nL:1/4\n%%staves [1 2 3]\n",
"V:1\nV:2\nV:3\nK:C\n",
"V:1\nC D|\nV:2\nE F|\nV:3\nG A|\n",
);
let export = export_musicxml(source).expect("bracketed score should export");
assert_eq!(count(&export.musicxml, "<part id="), 3);
}
#[test]
fn staves_bracket_group_orders_parts_by_directive() {
let source = concat!(
"X:1\nL:1/4\n%%staves [1 2 3]\n",
"V:1\nV:3\nV:2\nK:C\n",
"V:1\nC|\nV:2\nE|\nV:3\nG|\n",
);
let export = export_musicxml(source).expect("bracketed score should export");
let first_steps = musicxml_part_blocks(&export.musicxml)
.into_iter()
.map(|part| {
let first_note = musicxml_note_blocks(part)
.into_iter()
.find(|note| note.contains("<pitch>"))
.expect("part should contain a pitched note");
element_text(first_note, "step").expect("pitched note should have a step")
})
.collect::<Vec<_>>();
assert_eq!(first_steps, vec!["C", "E", "G"]);
}
#[test]
fn each_voice_becomes_its_own_part() {
let source = "X:1\nL:1/4\nK:C\nV:1\nC D|E F|\nV:2\nG A|B c|\nV:3\nc B|A G|\n";
let export = export_musicxml(source).expect("multi-voice score should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<score-partwise"), 1);
assert_eq!(count(&export.musicxml, "<part id="), 3);
for id in ["P1", "P2", "P3"] {
assert!(
export.musicxml.contains(&format!("<part id=\"{id}\"")),
"missing part {id}"
);
}
}
#[test]
fn single_voice_tune_stays_one_part() {
let source = "X:1\nL:1/4\nK:C\nC D E F|\n";
let export = export_musicxml(source).expect("single-voice score should export");
assert_eq!(count(&export.musicxml, "<part id="), 1);
}
#[test]
fn inline_key_change_applies_to_following_accidentals() {
let source = "X:1\nL:1/8\nK:C\nCEG c|[K:D]CEG c|\n";
let export = export_musicxml(source).expect("inline key change should export");
assert_balanced_xml(&export.musicxml);
let second = export
.musicxml
.split("<measure number=\"2\">")
.nth(1)
.expect("second measure");
assert!(
second.contains("<step>C</step>\n <alter>1</alter>"),
"second measure C should be sharp under inline K:D"
);
}
#[test]
fn tonic_less_key_accidental_modifies_prevailing_key() {
let source = "X:1\nL:1/8\nK:Gmin\nGABc|\nK:^F\nFBFB|\n";
let export = export_musicxml(source).expect("tonic-less key accidental should export");
assert_balanced_xml(&export.musicxml);
let second = export
.musicxml
.split("<measure number=\"2\">")
.nth(1)
.expect("second measure");
assert!(
second.contains("<step>F</step>\n <alter>1</alter>"),
"F must sound F# after `K:^F` modifies the prevailing G-minor: {second}"
);
assert!(
second.contains("<step>B</step>\n <alter>-1</alter>"),
"B must stay flat (prevailing G-minor inherited, not reset to F#-only): {second}"
);
}
#[test]
fn inline_clef_only_key_field_does_not_reset_the_signature() {
let source = "X:1\nL:1/8\nK:D\nFAd f|[K:clef=bass]FAd f|\n";
let export = export_musicxml(source).expect("inline clef change should export");
assert_balanced_xml(&export.musicxml);
let second = export
.musicxml
.split("<measure number=\"2\">")
.nth(1)
.expect("second measure");
assert!(
second.contains("<step>F</step>\n <alter>1</alter>"),
"F should stay sharp; clef-only inline key must not reset the signature"
);
}
#[test]
fn inline_clef_shorthand_key_field_emits_mid_tune_clef_and_shifts_notes() {
let source = "X:1\nL:1/4\nK:C\nC C|[K:treble-8]C C|[K:treble+8]C C|\n";
let export = export_musicxml(source).expect("inline clef shorthand should export");
assert_balanced_xml(&export.musicxml);
let first = export
.musicxml
.split("<measure number=\"2\">")
.next()
.expect("first measure");
let second = export
.musicxml
.split("<measure number=\"2\">")
.nth(1)
.and_then(|tail| tail.split("<measure number=\"3\">").next())
.expect("second measure");
let third = export
.musicxml
.split("<measure number=\"3\">")
.nth(1)
.expect("third measure");
assert!(
!first.contains("<clef-octave-change>"),
"the header clef must stay unshifted"
);
assert!(
second.contains("<clef-octave-change>-1</clef-octave-change>"),
"treble-8 should emit a mid-tune clef octave change"
);
assert!(
second.contains("<octave>3</octave>"),
"treble-8 should shift following C notes down to octave 3"
);
assert!(
third.contains("<clef-octave-change>1</clef-octave-change>"),
"treble+8 should emit a mid-tune clef octave change"
);
assert!(
third.contains("<octave>5</octave>"),
"treble+8 should shift following C notes up to octave 5"
);
}
#[test]
fn nospace_key_global_accidentals_export_base_key() {
let export = export_musicxml("X:1\nL:1/4\nK:D_B^g\nF B G|\n").expect("score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<fifths>2</fifths>"));
assert_eq!(count(&export.musicxml, "<key-step>"), 0);
assert_eq!(count(&export.musicxml, "<key-accidental>"), 0);
}
#[test]
fn escaped_literal_underscore_in_lyrics_still_exports_as_text() {
let source = "X:1\nT:Literal Underscore Lyrics\nM:2/4\nL:1/4\nK:C\nC D|\nw: \\_hold end\n";
let export = export_musicxml(source).expect("literal underscore lyric score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<text>_hold</text>"));
assert!(!export.musicxml.contains("<text>_</text>"));
assert!(export.diagnostics.is_empty());
}
#[test]
fn lyric_nbsp_inside_tune_000509_style_word_is_not_a_separator() {
let source = "X:1\nT:NBSP Melisma Lyrics\nM:6/4\nL:1/4\nK:C\nC D E F G A|\nw: A-ten-toÃ\u{00a0}a-do_ra\n";
let export = export_musicxml(source).expect("NBSP lyric score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<text>toÃ\u{00a0}a</text>"));
assert!(export.musicxml.contains("<text>do</text>"));
assert!(export.musicxml.contains("<extend/>"));
assert!(!export.musicxml.contains("<text>toÃ</text>"));
assert!(export.diagnostics.is_empty());
}
#[test]
fn ties_across_barlines_export_start_and_stop_without_diagnostic() {
let source = "X:1\nM:2/4\nL:1/4\nK:C\nC- | C D |\n";
let export = export_musicxml(source).expect("cross-bar tie should export");
assert_balanced_xml(&export.musicxml);
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
);
assert_eq!(count(&export.musicxml, "<tie type=\"start\"/>"), 1);
assert_eq!(count(&export.musicxml, "<tie type=\"stop\"/>"), 1);
assert_eq!(count(&export.musicxml, "<tied type=\"start\""), 1);
assert_eq!(count(&export.musicxml, "<tied type=\"stop\""), 1);
let measures = musicxml_measures(&export.musicxml);
assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
assert_eq!(measures[0].notes.len(), 1);
assert_eq!(measures[1].notes.len(), 2);
}
#[test]
fn whole_chord_tie_ties_every_matching_member() {
let source = "X:1\nM:4/4\nL:1/2\nK:C\n[CE]-[CE]|\n";
let export = export_musicxml(source).expect("whole-chord tie should export");
assert_balanced_xml(&export.musicxml);
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
);
assert_eq!(count(&export.musicxml, "<tie type=\"start\"/>"), 2);
assert_eq!(count(&export.musicxml, "<tie type=\"stop\"/>"), 2);
assert_eq!(count(&export.musicxml, "<tied type=\"start\""), 2);
assert_eq!(count(&export.musicxml, "<tied type=\"stop\""), 2);
}
#[test]
fn chord_internal_tie_ties_only_the_marked_member() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\n[DA-]2[FA]|\n";
let export = export_musicxml(source).expect("chord-internal tie should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<tie type=\"start\"/>"), 1);
assert_eq!(count(&export.musicxml, "<tie type=\"stop\"/>"), 1);
assert_eq!(count(&export.musicxml, "<tied type=\"start\""), 1);
assert_eq!(count(&export.musicxml, "<tied type=\"stop\""), 1);
}
#[test]
fn standalone_tie_remains_a_single_pair_and_untied_chord_has_no_tied() {
let single =
export_musicxml("X:1\nM:4/4\nL:1/2\nK:C\nC2-C2|\n").expect("single tie should export");
assert_eq!(count(&single.musicxml, "<tie type=\"start\"/>"), 1);
assert_eq!(count(&single.musicxml, "<tie type=\"stop\"/>"), 1);
assert_eq!(count(&single.musicxml, "<tied type=\"start\""), 1);
assert_eq!(count(&single.musicxml, "<tied type=\"stop\""), 1);
let chord =
export_musicxml("X:1\nM:4/4\nL:1/1\nK:C\n[CE]|\n").expect("untied chord should export");
assert!(!chord.musicxml.contains("<tied "));
assert!(!chord.musicxml.contains("<tie "));
}
#[test]
fn grace_notes_export_reference_compatible_display_types_without_duration() {
let source = "X:1\nT:Grace Display\nM:4/4\nL:1/4\nK:C\n{g}C {de}D|\n";
let export = export_musicxml(source).expect("grace note should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<grace/>"), 3);
assert_eq!(count(&export.musicxml, "<type>eighth</type>"), 1);
assert_eq!(count(&export.musicxml, "<type>16th</type>"), 2);
assert!(!export.musicxml.contains("<duration>0</duration>"));
}
#[test]
fn grace_note_length_modifiers_scale_display_type() {
let cases = [
("{B}C", "<type>eighth</type>", 1),
("{B/}C", "<type>16th</type>", 1),
("{AG}C", "<type>16th</type>", 2),
("{A/G/}C", "<type>32nd</type>", 2),
];
for (body, expected_type, expected_count) in cases {
let source = format!("X:1\nT:Grace Length\nM:4/4\nL:1/4\nK:C\n{body}|\n");
let export = export_musicxml(&source).expect("grace note should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(&export.musicxml, expected_type),
expected_count,
"grace body {body} should yield {expected_count}x {expected_type}",
);
assert!(
!export.musicxml.contains("<duration>0</duration>"),
"grace body {body} must not emit a zero <duration>",
);
}
}
#[test]
fn grace_notes_apply_implicit_key_signature_alter() {
let source = "X:1\nT:Grace Key\nM:4/4\nL:1/4\nK:D\n{f}A {=f}A|\n";
let export = export_musicxml(source).expect("grace key accidental should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(count(&export.musicxml, "<grace/>"), 2);
assert_eq!(count(&export.musicxml, "<alter>1</alter>"), 1);
assert!(export.musicxml.contains("<accidental>natural</accidental>"));
}
#[test]
fn grace_notes_export_inherited_measure_accidentals() {
let source = "X:1\nL:1/8\nK:C\n^c2 {dc}d2 c4 |]\n";
let export = export_musicxml(source).expect("grace accidental should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
note_steps_and_alters(&export.musicxml),
vec![('C', 1), ('D', 0), ('C', 1), ('D', 0), ('C', 1)]
);
}
#[test]
fn sequential_tuplets_reuse_musicxml_number_levels() {
let source = concat!(
"X:1\n",
"T:Many Tuplets\n",
"M:4/4\n",
"L:1/16\n",
"K:C\n",
"(3CDE (3DEF (3EFG (3FGA (3GAB (3ABc (3Bcd|\n",
);
let export = export_musicxml(source).expect("many sequential tuplets should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(&export.musicxml, "<tuplet type=\"start\" number=\"1\"/>"),
7
);
assert_eq!(
count(&export.musicxml, "<tuplet type=\"stop\" number=\"1\"/>"),
7
);
assert!(!export.musicxml.contains("number=\"7\""));
}
#[test]
fn one_note_tuplet_emits_balanced_start_and_stop() {
let source = "X:1\nT:One Note Tuplet\nL:1/8\nK:C\n(3:2:1G B|\n";
let export = export_musicxml(source).expect("one-note tuplet should export");
assert_balanced_xml(&export.musicxml);
assert_eq!(
count(&export.musicxml, "<tuplet type=\"start\" number=\"1\"/>"),
1
);
assert_eq!(
count(&export.musicxml, "<tuplet type=\"stop\" number=\"1\"/>"),
1
);
assert!(!export.musicxml.contains("number=\"2\""));
}
#[test]
fn nested_tuplets_emit_composite_time_modification_and_ordered_notations() {
let source = concat!(
"X:1\n",
"T:Nested Tuplets\n",
"M:C\n",
"L:1/4\n",
"K:C\n",
"(7:8:8(3A/A/A/ A/A/A/A/A/|\n",
);
let export = export_musicxml(source).expect("nested tuplets should export");
assert_balanced_xml(&export.musicxml);
assert!(
!export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.duration.unsupported_note_type"),
"diagnostics: {:?}",
export.diagnostics
);
assert_eq!(
count(&export.musicxml, "<tuplet type=\"start\" number=\"1\"/>"),
1
);
assert_eq!(
count(&export.musicxml, "<tuplet type=\"stop\" number=\"1\"/>"),
1
);
assert_eq!(
count(&export.musicxml, "<tuplet type=\"start\" number=\"2\"/>"),
1
);
assert_eq!(
count(&export.musicxml, "<tuplet type=\"stop\" number=\"2\"/>"),
1
);
assert!(export.musicxml.contains("<actual-notes>21</actual-notes>"));
assert!(export.musicxml.contains("<normal-notes>16</normal-notes>"));
assert!(export.musicxml.contains("<actual-notes>7</actual-notes>"));
assert!(export.musicxml.contains("<normal-notes>8</normal-notes>"));
}
#[test]
fn overflowing_nested_tuplet_product_warns_without_saturated_time_modification() {
let source = concat!(
"X:1\n",
"M:C\n",
"L:1/8\n",
"K:C\n",
"(9:8:1(9:8:1(9:8:1(9:8:1(9:8:1(9:8:1",
"(9:8:1(9:8:1(9:8:1(9:8:1(9:8:1A|\n",
);
let export = export_musicxml(source).expect("deep nested tuplets should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.tuplet.time_modification_overflow"),
"diagnostics: {:?}",
export.diagnostics
);
assert!(
!export.musicxml.contains("4294967295"),
"overflow must not saturate into MusicXML ratios: {}",
export.musicxml
);
assert_eq!(count(&export.musicxml, "<tuplet type=\"start\""), 11);
assert_eq!(count(&export.musicxml, "<tuplet type=\"stop\""), 11);
}
#[test]
fn reduced_duration_note_types_do_not_emit_spurious_tuplets() {
let source = "X:1\nT:Long notes\nM:4/4\nL:1/4\nK:C\nC2 D4|\n";
let export = export_musicxml(source).expect("long note types should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<type>half</type>"));
assert!(export.musicxml.contains("<type>whole</type>"));
assert!(!export.musicxml.contains("<time-modification>"));
}
#[test]
fn repeats_endings_multiple_voices_and_overlays_use_timeline_elements() {
let source = concat!(
"X:1\n",
"M:2/4\n",
"L:1/8\n",
"K:C\n",
"V:1\n",
"|: C D & E F :| [1 G A | [2 B c |]\n",
"V:2\n",
"C2 D2|E2 F2|\n",
);
let export = export_musicxml(source).expect("timeline score should export");
assert_balanced_xml(&export.musicxml);
assert!(export.musicxml.contains("<repeat direction=\"forward\"/>"));
assert!(export.musicxml.contains("<repeat direction=\"backward\"/>"));
assert!(
export
.musicxml
.contains("<ending number=\"1\" type=\"start\"/>")
);
assert!(
export
.musicxml
.contains("<ending number=\"2\" type=\"start\"/>")
);
assert_eq!(count(&export.musicxml, "<part id="), 2);
assert!(export.musicxml.contains("<part id=\"P2\""));
assert!(export.musicxml.contains("<backup>"));
assert!(export.musicxml.contains("<voice>2</voice>"));
assert!(!export.musicxml.contains("<voice>3</voice>"));
}
#[test]
fn multi_pass_volta_merges_passes_into_single_ending_element() {
let source = concat!(
"X:1\n",
"T:Volta\n",
"M:2/4\n",
"L:1/8\n",
"K:G\n",
"|: GABc |1,3 GA :|2,4 Bc :|\n",
);
let export = export_musicxml(source).expect("multi-pass volta score should export");
assert_balanced_xml(&export.musicxml);
assert!(
export
.musicxml
.contains("<ending number=\"1,3\" type=\"start\"")
);
assert!(
export
.musicxml
.contains("<ending number=\"2,4\" type=\"start\"")
);
assert!(
!export
.musicxml
.contains("<ending number=\"1\" type=\"start\"")
);
assert!(
!export
.musicxml
.contains("<ending number=\"3\" type=\"start\"")
);
assert!(
!export
.musicxml
.contains("<ending number=\"2\" type=\"start\"")
);
assert!(
!export
.musicxml
.contains("<ending number=\"4\" type=\"start\"")
);
}
#[test]
fn semantic_onset_gaps_emit_forward() {
let source = "X:1\nL:1/8\nK:C\nC D|\n";
let document = parse_document(source, ParseOptions::default());
let tune = crate::parse::parse_tune_report_from_document(&document.value)
.value
.expect("expected tune");
let mut score = tune.score;
score.parts[0].voices[0].events[1].onset = Fraction::new(2, 8);
let report = write_score_partwise(&score);
assert_balanced_xml(&report.value);
assert!(report.value.contains("<forward>"));
assert!(report.value.contains("<duration>4</duration>"));
}
#[test]
fn unsupported_decoration_diagnoses_without_dropping_note_or_timing() {
let source = "X:1\nL:1/8\nK:C\n!unknown!C D|\n";
let export = export_musicxml(source).expect("unsupported decoration should recover");
assert_balanced_xml(&export.musicxml);
assert_diagnostic_span(
source,
&export.diagnostics,
"abc.musicxml.decoration.unsupported",
"!unknown!",
);
assert!(!export.musicxml.contains("<words>unknown</words>"));
assert_eq!(count(&export.musicxml, "<note>"), 2);
assert_eq!(count(&export.musicxml, "<duration>4</duration>"), 2);
}
#[test]
fn variable_duration_chord_diagnoses_and_keeps_following_timing_valid() {
let source = "X:1\nL:1/8\nK:C\n[E2G6] C|\n";
let export = export_musicxml(source).expect("variable chord should recover");
assert_balanced_xml(&export.musicxml);
assert_diagnostic_span(
source,
&export.diagnostics,
"abc.music.chord.variable_duration",
"[E2G6]",
);
assert_diagnostic_span(
source,
&export.diagnostics,
"abc.musicxml.chord.variable_duration",
"E2G6",
);
assert_eq!(count(&export.musicxml, "<chord/>"), 1);
assert_eq!(count(&export.musicxml, "<note>"), 3);
assert!(!export.musicxml.contains("<backup>"));
}
#[test]
fn incomplete_overlay_diagnoses_and_later_measures_stay_stable() {
let source = "X:1\nL:1/8\nK:C\nC D & E|F G|\n";
let export = export_musicxml(source).expect("incomplete overlay should recover");
assert_balanced_xml(&export.musicxml);
assert_diagnostic_span(
source,
&export.diagnostics,
"abc.voice.overlay_incomplete_measure",
"&",
);
assert!(export.musicxml.contains("<measure number=\"2\">"));
assert!(export.musicxml.contains("<step>F</step>"));
assert!(export.musicxml.contains("<step>G</step>"));
}
#[test]
fn rest_led_tuplet_emits_tuplet_start_on_the_rest() {
let source = "X:1\nL:1/8\nK:C\n(3zBA F|\n";
let export = export_musicxml(source).expect("rest-led tuplet should export");
assert_balanced_xml(&export.musicxml);
let rest_note = export
.musicxml
.split("<note>")
.skip(1)
.find(|note| note.contains("<rest/>"))
.expect("expected a rest note");
assert!(rest_note.contains("<tuplet type=\"start\""));
assert!(export.musicxml.contains("<tuplet type=\"stop\""));
}
#[test]
fn bad_tuplet_count_diagnoses_without_bogus_tuplet_notation_pairs() {
let source = "X:1\nL:1/8\nK:C\n(3C|D E|\n";
let export = export_musicxml(source).expect("short tuplet should recover");
assert_balanced_xml(&export.musicxml);
assert_diagnostic_span(
source,
&export.diagnostics,
"abc.music.tuplet.too_few_notes",
"(3",
);
assert!(export.musicxml.contains("<time-modification>"));
assert!(!export.musicxml.contains("<tuplet type=\"start\""));
assert!(!export.musicxml.contains("<tuplet type=\"stop\""));
assert!(export.musicxml.contains("<measure number=\"2\">"));
}
#[test]
fn unmatched_tie_and_slur_do_not_create_musicxml_pairs() {
let source = "X:1\nL:1/8\nK:C\nC- D )E|\n";
let export = export_musicxml(source).expect("unmatched tie and slur should recover");
assert_balanced_xml(&export.musicxml);
assert_diagnostic_span(source, &export.diagnostics, "abc.music.unmatched_tie", "-");
assert_diagnostic_span(source, &export.diagnostics, "abc.music.unmatched_slur", ")");
assert!(!export.musicxml.contains("<tie "));
assert!(!export.musicxml.contains("<tied "));
assert!(!export.musicxml.contains("<slur "));
assert_eq!(count(&export.musicxml, "<note>"), 3);
}
#[test]
fn malformed_repeat_ending_keeps_measure_structure_valid() {
let source = "X:1\nL:1/8\nK:C\nC|[1- D|E|\n";
let export = export_musicxml(source).expect("malformed repeat ending should recover");
assert_balanced_xml(&export.musicxml);
assert_diagnostic_span(
source,
&export.diagnostics,
"abc.music.invalid_repeat_ending",
"[1-",
);
assert!(!export.musicxml.contains("<ending number=\"1-\""));
assert!(export.musicxml.contains("<measure number=\"1\">"));
assert!(export.musicxml.contains("<measure number=\"2\">"));
assert!(export.musicxml.contains("<measure number=\"3\">"));
assert_eq!(count(&export.musicxml, "<note>"), 3);
}
#[test]
fn non_integral_duration_reports_precise_writer_diagnostic() {
let source = "X:1\nL:1/8\nK:C\nC D|\n";
let document = parse_document(source, ParseOptions::default());
let tune = crate::parse::parse_tune_report_from_document(&document.value)
.value
.expect("expected tune");
let mut score = tune.score;
score.divisions = 1;
let report = write_score_partwise(&score);
assert_balanced_xml(&report.value);
assert!(
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.duration.non_integral")
);
assert!(report.value.contains("<duration>1</duration>"));
}
#[test]
fn unsupported_note_type_duration_reports_precise_writer_diagnostic() {
let source = "X:1\nL:1/8\nK:C\nC|\n";
let document = parse_document(source, ParseOptions::default());
let tune = crate::parse::parse_tune_report_from_document(&document.value)
.value
.expect("expected tune");
let mut score = tune.score;
score.divisions = 13;
score.parts[0].voices[0].events[0].duration = Fraction::new(7, 13);
let report = write_score_partwise(&score);
assert_balanced_xml(&report.value);
assert!(
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "abc.musicxml.duration.unsupported_note_type")
);
assert!(report.value.contains("<duration>28</duration>"));
}
#[test]
fn unknown_directive_is_direction_metadata_not_music() {
let source = "X:1\nK:C\n%%foo bar & < >\nC|\n";
let export = export_musicxml(source).expect("unknown directive should recover");
assert_balanced_xml(&export.musicxml);
assert_diagnostic_span(
source,
&export.diagnostics,
"abc.directive.unsupported",
"foo",
);
assert!(!export.musicxml.contains("%%foo"));
assert_eq!(count(&export.musicxml, "<note>"), 1);
}
#[test]
fn unclosed_chord_bracket_before_barline_preserves_following_measures() {
let source = "X:1\nM:2/4\nL:1/8\nK:C\n[ |: CDEF | GABc |\n";
let export = export_musicxml(source).expect("unclosed chord run should still export");
assert!(
count(&export.musicxml, "<measure ") >= 2,
"expected at least two surviving measures, got {}",
count(&export.musicxml, "<measure ")
);
for step in ['C', 'D', 'E', 'F', 'G', 'A', 'B'] {
assert!(
export.musicxml.contains(&format!("<step>{step}</step>")),
"expected note {step} to survive the unclosed chord"
);
}
}
#[test]
fn unclosed_chord_member_before_barline_exports_pickup_note() {
let source = "X:1\nM:4/4\nL:1/8\nK:C\ne2 E E2 ][ f |\n";
let export = export_musicxml(source).expect("unclosed chord pickup should still export");
assert_balanced_xml(&export.musicxml);
assert_diagnostic_span(
source,
&export.diagnostics,
"abc.music.unclosed_chord",
"[ f ",
);
let steps = musicxml_notes(&export.musicxml)
.iter()
.filter_map(|note| note.step)
.collect::<Vec<_>>();
assert_eq!(
steps,
vec!['E', 'E', 'E', 'F'],
"the F inside the unclosed bracket should survive as the pickup note"
);
}
#[test]
fn unclosed_chord_quoted_run_before_barline_exports_member_notes() {
let source = "X:1\nM:4/4\nL:1/8\nK:C\n|[F\"cont\" \"Am7\"GAB cAFA |\n";
let export = export_musicxml(source).expect("unclosed quoted chord run should still export");
assert_balanced_xml(&export.musicxml);
assert_diagnostic_span(
source,
&export.diagnostics,
"abc.music.unclosed_chord",
"[F\"cont\" \"Am7\"GAB cAFA ",
);
let steps = musicxml_notes(&export.musicxml)
.iter()
.filter_map(|note| note.step)
.collect::<Vec<_>>();
assert_eq!(
steps,
vec!['F', 'G', 'A', 'B', 'C', 'A', 'F', 'A'],
"all notes inside the quoted unclosed bracket should survive in order"
);
}
#[test]
fn unclosed_chord_with_quoted_text_before_barline_preserves_measures() {
let source = "X:1\nM:2/4\nL:1/8\nK:C\n[C\"x\"DEF | GABc |\n";
let export = export_musicxml(source).expect("unclosed quoted chord run should still export");
assert_diagnostic_span(
source,
&export.diagnostics,
"abc.music.unclosed_chord",
"[C\"x\"DEF ",
);
assert!(
count(&export.musicxml, "<measure ") >= 1,
"expected at least one surviving measure, got {}",
count(&export.musicxml, "<measure ")
);
for step in ['G', 'A', 'B'] {
assert!(
export.musicxml.contains(&format!("<step>{step}</step>")),
"expected note {step} after the unclosed quoted chord to survive"
);
}
}
fn count(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
fn musicxml_note_blocks(xml: &str) -> Vec<&str> {
let mut notes = Vec::new();
let mut index = 0;
while let Some(offset) = xml[index..].find("<note") {
let start = index + offset;
let open_end = xml[start..]
.find('>')
.map(|end| start + end)
.expect("note start tag should terminate");
let end_tag = "</note>";
let end = xml[open_end..]
.find(end_tag)
.map(|end| open_end + end + end_tag.len())
.expect("note should have closing tag");
notes.push(&xml[start..end]);
index = end;
}
notes
}
#[derive(Debug, PartialEq, Eq)]
struct SlurEvent {
voice: String,
step: char,
slur_type: String,
number: String,
}
impl SlurEvent {
fn new(voice: &str, step: char, slur_type: &str, number: &str) -> Self {
Self {
voice: voice.to_owned(),
step,
slur_type: slur_type.to_owned(),
number: number.to_owned(),
}
}
}
fn slur_events_in_note(note: &str) -> Vec<SlurEvent> {
if !note.contains("<slur ") {
return Vec::new();
}
let voice = element_text(note, "voice").expect("slurred note should have voice");
let step = element_text(note, "step")
.and_then(|step| step.chars().next())
.expect("slurred note should have pitch step");
let mut events = Vec::new();
let mut index = 0;
while let Some(offset) = note[index..].find("<slur ") {
let start = index + offset;
let end = note[start..]
.find("/>")
.map(|end| start + end + 2)
.expect("slur tag should close");
let slur = ¬e[start..end];
events.push(SlurEvent {
voice: voice.clone(),
step,
slur_type: attr_value(slur, "type").expect("slur should have type"),
number: attr_value(slur, "number").expect("slur should have number"),
});
index = end;
}
events
}
fn musicxml_part_blocks(xml: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut index = 0;
while let Some(offset) = xml[index..].find("<part id=") {
let start = index + offset;
let open_end = xml[start..]
.find('>')
.map(|end| start + end)
.expect("part start tag should terminate");
let end_tag = "</part>";
let end = xml[open_end..]
.find(end_tag)
.map(|end| open_end + end + end_tag.len())
.expect("part should have closing tag");
parts.push(&xml[start..end]);
index = end;
}
parts
}
#[derive(Debug)]
struct XmlMeasure {
number: String,
notes: Vec<XmlNote>,
barlines: Vec<XmlBarline>,
}
#[derive(Debug)]
struct XmlNote {
rest: bool,
step: Option<char>,
duration: Option<u32>,
grace: bool,
}
#[derive(Debug)]
struct XmlBarline {
location: String,
bar_style: Option<String>,
repeat_direction: Option<String>,
repeat_times: Option<String>,
endings: Vec<XmlEnding>,
}
#[derive(Debug)]
struct XmlEnding {
number: String,
kind: String,
}
fn musicxml_measures(xml: &str) -> Vec<XmlMeasure> {
let mut measures = Vec::new();
let mut index = 0;
while let Some(offset) = xml[index..].find("<measure ") {
let start = index + offset;
let open_end = xml[start..]
.find('>')
.map(|end| start + end)
.expect("measure start tag should terminate");
let end_tag = "</measure>";
let end = xml[open_end..]
.find(end_tag)
.map(|end| open_end + end)
.expect("measure should have closing tag");
let open_tag = &xml[start..=open_end];
let body = &xml[open_end + 1..end];
measures.push(XmlMeasure {
number: attr_value(open_tag, "number").expect("measure should have number"),
notes: musicxml_notes(body),
barlines: musicxml_barlines(body),
});
index = end + end_tag.len();
}
measures
}
fn measure_body<'a>(xml: &'a str, number: &str) -> &'a str {
let start_tag = format!("<measure number=\"{number}\">");
let start = xml.find(&start_tag).expect("measure should exist") + start_tag.len();
let end = xml[start..]
.find("</measure>")
.map(|end| start + end)
.expect("measure should close");
&xml[start..end]
}
fn musicxml_notes(xml: &str) -> Vec<XmlNote> {
let mut notes = Vec::new();
let mut index = 0;
while let Some(offset) = xml[index..].find("<note") {
let start = index + offset;
let open_end = xml[start..]
.find('>')
.map(|end| start + end)
.expect("note start tag should terminate");
let end_tag = "</note>";
let end = xml[open_end..]
.find(end_tag)
.map(|end| open_end + end)
.expect("note should have closing tag");
let body = &xml[open_end + 1..end];
notes.push(XmlNote {
rest: body.contains("<rest"),
step: element_text(body, "step").and_then(|text| text.chars().next()),
duration: element_text(body, "duration").and_then(|text| text.parse().ok()),
grace: body.contains("<grace"),
});
index = end + end_tag.len();
}
notes
}
fn musicxml_barlines(xml: &str) -> Vec<XmlBarline> {
let mut barlines = Vec::new();
let mut index = 0;
while let Some(offset) = xml[index..].find("<barline ") {
let start = index + offset;
let open_end = xml[start..]
.find('>')
.map(|end| start + end)
.expect("barline start tag should terminate");
let end_tag = "</barline>";
let end = xml[open_end..]
.find(end_tag)
.map(|end| open_end + end)
.expect("barline should have closing tag");
let open_tag = &xml[start..=open_end];
let body = &xml[open_end + 1..end];
let repeat_direction = body.find("<repeat ").and_then(|offset| {
let repeat_start = offset;
let repeat_end = body[repeat_start..]
.find('>')
.map(|end| repeat_start + end)?;
attr_value(&body[repeat_start..=repeat_end], "direction")
});
let repeat_times = body.find("<repeat ").and_then(|offset| {
let repeat_start = offset;
let repeat_end = body[repeat_start..]
.find('>')
.map(|end| repeat_start + end)?;
attr_value(&body[repeat_start..=repeat_end], "times")
});
barlines.push(XmlBarline {
location: attr_value(open_tag, "location").expect("barline should have location"),
bar_style: element_text(body, "bar-style"),
repeat_direction,
repeat_times,
endings: musicxml_endings(body),
});
index = end + end_tag.len();
}
barlines
}
fn musicxml_endings(xml: &str) -> Vec<XmlEnding> {
let mut endings = Vec::new();
let mut index = 0;
while let Some(offset) = xml[index..].find("<ending ") {
let start = index + offset;
let end = xml[start..]
.find('>')
.map(|end| start + end)
.expect("ending tag should terminate");
let tag = &xml[start..=end];
endings.push(XmlEnding {
number: attr_value(tag, "number").expect("ending should have number"),
kind: attr_value(tag, "type").expect("ending should have type"),
});
index = end + 1;
}
endings
}
fn measure_numbers(measures: &[XmlMeasure]) -> Vec<&str> {
measures
.iter()
.map(|measure| measure.number.as_str())
.collect()
}
fn note_steps(measure: &XmlMeasure) -> Vec<char> {
measure.notes.iter().filter_map(|note| note.step).collect()
}
fn note_durations(measure: &XmlMeasure) -> Vec<u32> {
measure
.notes
.iter()
.filter_map(|note| note.duration)
.collect()
}
fn note_grace_flags(measure: &XmlMeasure) -> Vec<bool> {
measure.notes.iter().map(|note| note.grace).collect()
}
fn has_barline(
measure: &XmlMeasure,
location: &str,
bar_style: Option<&str>,
repeat_direction: Option<&str>,
) -> bool {
measure.barlines.iter().any(|barline| {
barline.location == location
&& barline.bar_style.as_deref() == bar_style
&& barline.repeat_direction.as_deref() == repeat_direction
})
}
fn has_ending(measure: &XmlMeasure, location: &str, number: &str, kind: &str) -> bool {
measure.barlines.iter().any(|barline| {
barline.location == location
&& barline
.endings
.iter()
.any(|ending| ending.number == number && ending.kind == kind)
})
}
fn attr_value(tag: &str, attr: &str) -> Option<String> {
let pattern = format!("{attr}=\"");
let start = tag.find(&pattern)? + pattern.len();
let end = tag[start..].find('"')?;
Some(tag[start..start + end].to_owned())
}
fn element_text(block: &str, element: &str) -> Option<String> {
let open = format!("<{element}>");
let close = format!("</{element}>");
let start = block.find(&open)? + open.len();
let end = block[start..].find(&close)? + start;
Some(block[start..end].to_owned())
}
fn assert_diagnostic_span(
source: &str,
diagnostics: &[Diagnostic],
code: &'static str,
snippet: &str,
) {
let diagnostic = diagnostics
.iter()
.find(|diagnostic| diagnostic.code == code)
.unwrap_or_else(|| panic!("expected diagnostic {code}"));
assert_eq!(&source[diagnostic.span.start..diagnostic.span.end], snippet);
}
fn assert_balanced_xml(xml: &str) {
let mut stack: Vec<String> = Vec::new();
let mut index = 0;
while let Some(offset) = xml[index..].find('<') {
let start = index + offset;
let end = xml[start..]
.find('>')
.map(|end| start + end)
.unwrap_or_else(|| panic!("unterminated XML tag at byte {start}"));
let tag = &xml[start + 1..end];
if tag.starts_with('?') || tag.starts_with('!') {
index = end + 1;
continue;
}
if let Some(name) = tag.strip_prefix('/') {
let expected = stack.pop().expect("unexpected closing XML tag");
assert_eq!(name.trim(), expected);
} else if !tag.trim_end().ends_with('/') {
let name = tag
.split_whitespace()
.next()
.expect("XML tag should have a name")
.trim_end_matches('/');
stack.push(name.to_owned());
}
index = end + 1;
}
assert!(stack.is_empty(), "unclosed XML tags: {stack:?}");
}
fn note_steps_and_alters(xml: &str) -> Vec<(char, i8)> {
let mut out = Vec::new();
let mut index = 0;
while let Some(offset) = xml[index..].find("<step>") {
let start = index + offset + "<step>".len();
let end = start + xml[start..].find("</step>").expect("step end");
let step = xml[start..end].chars().next().expect("step char");
let pitch_end = end + xml[end..].find("</pitch>").expect("pitch end");
let alter = xml[end..pitch_end]
.find("<alter>")
.map(|alter_offset| {
let alter_start = end + alter_offset + "<alter>".len();
let alter_end =
alter_start + xml[alter_start..].find("</alter>").expect("alter end");
xml[alter_start..alter_end]
.parse::<i8>()
.expect("alter int")
})
.unwrap_or(0);
out.push((step, alter));
index = pitch_end;
}
out
}
fn part_bodies(xml: &str) -> Vec<String> {
let mut out = Vec::new();
let mut index = 0;
while let Some(offset) = xml[index..].find("<part ") {
let start = index + offset;
let body_start = start + xml[start..].find('>').expect("part open end") + 1;
let body_end = body_start + xml[body_start..].find("</part>").expect("part end");
out.push(xml[body_start..body_end].to_owned());
index = body_end;
}
out
}
#[test]
fn inline_key_change_scopes_to_current_voice_only() {
let source = concat!(
"X:1\n",
"M:2/4\n",
"L:1/8\n",
"K:G\n",
"V:1\n",
"A A A A | A A A A | [K:C] A A A A |\n",
"V:3\n",
"F F F F | F F F F | G G G G |\n",
);
let export = export_musicxml(source).expect("multi-voice score should export");
assert_balanced_xml(&export.musicxml);
let parts = part_bodies(&export.musicxml);
assert_eq!(parts.len(), 2, "expected two voices/parts");
let v3 = note_steps_and_alters(&parts[1]);
let f_notes: Vec<(char, i8)> = v3.into_iter().filter(|(step, _)| *step == 'F').collect();
assert_eq!(f_notes.len(), 8, "V3 should have eight F notes");
for (step, alter) in f_notes {
assert_eq!((step, alter), ('F', 1), "V3 F must stay F# under key G");
}
}
#[test]
fn standalone_body_key_change_keeps_late_voice_in_header_key_until_own_change() {
let source = 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 export = export_musicxml(source).expect("multi-voice score should export");
assert_balanced_xml(&export.musicxml);
let parts = part_bodies(&export.musicxml);
assert_eq!(parts.len(), 2, "expected two voices/parts");
let p2_measures = parts[1].split("<measure ").collect::<Vec<_>>();
assert!(p2_measures.len() >= 3, "expected two V2 measures");
assert!(
p2_measures[1].contains("<fifths>2</fifths>"),
"V2 measure 1 keeps the header D major key: {}",
p2_measures[1]
);
assert!(
!p2_measures[1].contains("<fifths>-1</fifths>"),
"V2 measure 1 must not receive V1's body K:Dm: {}",
p2_measures[1]
);
assert!(
p2_measures[2].contains("<fifths>-1</fifths>"),
"V2 measure 2 carries V2's own K:Dm: {}",
p2_measures[2]
);
assert_eq!(
note_steps_and_alters(&parts[1]),
vec![
('F', 1),
('C', 1),
('B', 0),
('F', 1),
('F', 0),
('C', 0),
('B', -1),
('F', 0),
],
"V2 first bar stays in D major, second bar changes to D minor"
);
}
#[test]
fn tie_across_barline_keeps_natural_against_flat_key() {
let source = "X:1\nM:4/4\nL:1/4\nK:F\nA G =B- | B A2 z |\n";
let export = export_musicxml(source).expect("tied score should export");
assert_balanced_xml(&export.musicxml);
let notes = note_steps_and_alters(&export.musicxml);
let b_notes: Vec<(char, i8)> = notes.into_iter().filter(|(step, _)| *step == 'B').collect();
assert_eq!(
b_notes.len(),
2,
"expected two B notes (tie start and stop)"
);
for (step, alter) in b_notes {
assert_eq!(
(step, alter),
('B', 0),
"tied B must stay natural across bar"
);
}
}
#[test]
fn tie_across_barline_keeps_flat_against_neutral_key() {
let source = "X:1\nM:4/4\nL:1/4\nK:C\nA G _B- | B A2 z |\n";
let export = export_musicxml(source).expect("tied score should export");
assert_balanced_xml(&export.musicxml);
let notes = note_steps_and_alters(&export.musicxml);
let b_notes: Vec<(char, i8)> = notes.into_iter().filter(|(step, _)| *step == 'B').collect();
assert_eq!(
b_notes.len(),
2,
"expected two B notes (tie start and stop)"
);
for (step, alter) in b_notes {
assert_eq!((step, alter), ('B', -1), "tied B must stay flat across bar");
}
}
#[test]
fn mid_tune_key_and_meter_changes_emit_attributes() {
let source = "X:1\nL:1/4\nK:C\nCDEF|[K:F]GAB_B|[M:3/4]ABc|\n";
let export = export_musicxml(source).expect("score should export");
let xml = export.musicxml;
let measures: Vec<&str> = xml.split("<measure ").collect();
assert!(measures.len() >= 4, "three measures: {xml}");
assert!(
measures[2].contains("<fifths>-1</fifths>"),
"measure 2 carries the key change: {}",
measures[2]
);
assert!(
measures[3].contains("<beats>3</beats>")
&& measures[3].contains("<beat-type>4</beat-type>"),
"measure 3 carries the meter change: {}",
measures[3]
);
assert!(measures[1].contains("<fifths>0</fifths>"));
}
#[test]
fn mid_measure_key_change_emits_attributes_between_notes() {
let source = "X:1\nL:1/4\nK:C\nCD[K:D]EF|\n";
let export = export_musicxml(source).expect("score should export");
let xml = export.musicxml;
let m1_start = xml.find("<measure ").expect("measure 1");
let m1 = &xml[m1_start..];
let change = m1
.find("<fifths>2</fifths>")
.expect("mid-measure key change");
let notes: Vec<usize> = m1.match_indices("<note>").map(|(i, _)| i).collect();
assert!(notes.len() >= 4);
assert!(
notes[1] < change && change < notes[2],
"key change sits between note 2 and note 3"
);
}
#[test]
fn grace_implicit_alter_uses_position_active_key() {
let source = "X:1\nL:1/4\nK:C\nCDEF|[K:D]{f}GABc|\n";
let export = export_musicxml(source).expect("score should export");
let xml = export.musicxml;
let grace_at = xml.find("<grace/>").expect("grace note present");
let after = &xml[grace_at..];
assert!(
after.contains("<step>F</step>")
&& after[..after.find("<octave>").expect("octave")].contains("<alter>1</alter>"),
"grace F carries the new key's sharp: {}",
&after[..300.min(after.len())]
);
}
#[test]
fn tie_keeps_pitch_across_mid_measure_key_change() {
for (source, want_alter) in [
("X:1\nM:4/4\nL:1/4\nK:D\nFGAF|F-[K:C]FGA|\n", "1"),
("X:1\nM:4/4\nL:1/4\nK:C\nFGAF|F-[K:D]FGA|\n", "0"),
] {
let export = export_musicxml(source).expect("score should export");
let xml = export.musicxml;
let stop = xml
.find("tied type=\"stop\"")
.or_else(|| xml.find("<tie type=\"stop\""))
.expect("tie stop present");
let before = &xml[..stop];
let note_start = before.rfind("<note>").expect("stop note");
let note = &xml[note_start..stop];
let alter = note
.find("<alter>")
.map(|i| ¬e[i + 7..i + 8])
.unwrap_or("0");
assert_eq!(alter, want_alter, "tie-stop alter for {source:?}: {note}");
}
}
#[test]
fn free_meter_change_emits_no_empty_attributes() {
let source = "X:1\nM:4/4\nL:1/8\nK:C\nCDEF|[M:none]GABcdefg|\n";
let export = export_musicxml(source).expect("score should export");
assert!(
!export.musicxml.contains("<attributes></attributes>")
&& !export.musicxml.contains("<attributes/>"),
"no empty attributes wrapper"
);
}
#[test]
fn missing_header_meter_stays_free_until_body_meter_change() {
let source = "X:1\nL:1/8\nK:C\nG4 E4 C4 D4\n[M:3/8]\n| GEA |\n";
let export = export_musicxml(source).expect("score should export");
let xml = export.musicxml;
assert_balanced_xml(&xml);
assert_eq!(count(&xml, "<time>"), 1);
assert!(!xml.contains("<beats>4</beats>"));
assert!(
xml.contains("<beats>3</beats>\n <beat-type>8</beat-type>"),
"body meter change should still export 3/8: {xml}"
);
}
#[test]
fn complex_header_meter_exports_additive_time() {
let source = "X:1\nM:(2+3+2)/8\nL:1/8\nK:C\nCDEFGAB|\n";
let export = export_musicxml(source).expect("score should export");
let xml = export.musicxml;
assert_balanced_xml(&xml);
assert_eq!(count(&xml, "<time>"), 1);
assert!(xml.contains("<beats>2+3+2</beats>"));
assert!(xml.contains("<beat-type>8</beat-type>"));
}
#[test]
fn additive_extension_header_meter_exports_composite_time() {
let source = "X:1\nM:3/4+2/4\nL:1/4\nK:C\nCDEFG|\n";
let export = export_musicxml(source).expect("score should export");
let xml = export.musicxml;
assert_balanced_xml(&xml);
assert_eq!(count(&xml, "<time>"), 1);
assert!(xml.contains("<beats>3</beats>"));
assert!(xml.contains("<beat-type>4</beat-type>"));
assert!(xml.contains("<beats>2</beats>"));
assert_eq!(count(&xml, "<beat-type>4</beat-type>"), 2);
}