use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::model::{Fraction, Score, TempoBeatRole, TimedEventKind};
use crate::musicxml::read::read_musicxml;
use crate::musicxml::write_score_partwise;
use crate::{AbcWriteOptions, export_musicxml, write_abc, write_musicxml};
fn export(abc: &str) -> String {
export_musicxml(abc)
.unwrap_or_else(|error| panic!("ABC should export: {error:?}"))
.musicxml
}
fn round_trip(abc: &str) -> (String, String, Score) {
let x1 = export(abc);
let report = read_musicxml(&x1);
let x2 = write_score_partwise(&report.value).value;
(x1, x2, report.value)
}
fn strip_stage2_attributes(xml: &str) -> String {
let mut out = String::with_capacity(xml.len());
let mut skip_until: Option<&'static str> = None;
for line in xml.lines() {
if let Some(close) = skip_until {
if line.trim() == close {
skip_until = None;
}
continue;
}
match line.trim() {
"<key>" => skip_until = Some("</key>"),
line_trimmed if line_trimmed == "<time>" || line_trimmed.starts_with("<time ") => {
skip_until = Some("</time>");
}
_ => {
out.push_str(line);
out.push('\n');
}
}
}
out
}
fn assert_idempotent(abc: &str) -> Score {
let (x1, x2, score) = round_trip(abc);
assert_eq!(
strip_stage2_attributes(&x1),
strip_stage2_attributes(&x2),
"write(read(write(score))) must equal write(score) on the S1-supported subset"
);
score
}
fn strip_stage3_blocks(xml: &str) -> String {
let mut out = String::with_capacity(xml.len());
let mut skip_until: Option<&'static str> = None;
for line in xml.lines() {
if let Some(close) = skip_until {
if line.trim() == close {
skip_until = None;
}
continue;
}
let trimmed = line.trim();
if trimmed.starts_with("<score-instrument") {
skip_until = Some("</score-instrument>");
} else if trimmed.starts_with("<midi-instrument") {
skip_until = Some("</midi-instrument>");
} else {
out.push_str(line);
out.push('\n');
}
}
out
}
fn assert_idempotent_s2(abc: &str) -> Score {
let (x1, x2, score) = round_trip(abc);
assert_eq!(
strip_stage3_blocks(&x1),
strip_stage3_blocks(&x2),
"write(read(write(score))) must equal write(score) on the S2-supported subset"
);
score
}
fn first_note_pitch(score: &Score) -> &crate::model::Pitch {
match &score.parts[0].voices[0].events[0].kind {
TimedEventKind::Note(note) => ¬e.pitch,
other => panic!("expected first event to be a note, got {other:?}"),
}
}
#[test]
fn malformed_xml_is_total_and_diagnosed() {
let report = read_musicxml("<score-partwise><part></broken>");
assert!(
report
.diagnostics
.iter()
.any(|d| d.code.starts_with("musicxml.read")),
"a malformed document must yield a reader diagnostic, got {:?}",
report.diagnostics
);
assert!(
report.value.parts.is_empty() || report.value.parts.iter().all(|p| p.voices.len() <= 1)
);
}
#[test]
fn non_partwise_root_is_diagnosed() {
let report = read_musicxml("<?xml version=\"1.0\"?><score-timewise></score-timewise>");
assert!(
report
.diagnostics
.iter()
.any(|d| d.code == "musicxml.read.unsupported_root"),
"a non-partwise root must warn, got {:?}",
report.diagnostics
);
}
#[test]
fn doctype_prefixed_document_is_read_not_rejected() {
let xml = concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<!DOCTYPE score-partwise PUBLIC \"-//Recordare//DTD MusicXML 3.0 Partwise//EN\" ",
"\"http://www.musicxml.org/dtds/partwise.dtd\">\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>Music</part-name></score-part></part-list>\n",
" <part id=\"P1\"><measure number=\"1\">\n",
" <attributes><divisions>2</divisions></attributes>\n",
" <note><pitch><step>D</step><octave>5</octave></pitch>\n",
" <duration>2</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure></part>\n",
"</score-partwise>\n",
);
let report = read_musicxml(xml);
assert!(
report
.diagnostics
.iter()
.all(|d| d.code != "musicxml.read.parse_error"),
"a DOCTYPE-prefixed document must not be rejected at the parse gate, got {:?}",
report.diagnostics
);
assert_eq!(report.value.parts.len(), 1, "expected exactly one part");
let pitch = first_note_pitch(&report.value);
assert_eq!(pitch.step, 'D');
assert_eq!(pitch.octave, 5);
assert_eq!(pitch.alter, 0);
assert_eq!(
report.value.parts[0].voices[0].events[0].duration,
Fraction::new(1, 4),
"a <duration>2</duration> at <divisions>2</divisions> is a quarter note"
);
}
#[test]
fn doctype_then_garbage_is_total_and_diagnosed() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<!DOCTYPE score-partwise PUBLIC \"-//Recordare//DTD MusicXML 3.0 Partwise//EN\" ",
"\"http://www.musicxml.org/dtds/partwise.dtd\">\n",
"<score-partwise><part></not-the-same-tag> &&& <<< broken",
);
let report = read_musicxml(xml);
assert!(
report
.diagnostics
.iter()
.any(|d| d.code.starts_with("musicxml.read")),
"a DTD-prefixed-but-malformed document must yield a reader diagnostic, got {:?}",
report.diagnostics
);
assert!(
report.value.parts.is_empty(),
"a malformed document must degrade to an empty Score, got {} part(s)",
report.value.parts.len()
);
}
#[test]
fn single_note_round_trips_and_reconstructs_pitch() {
let score = assert_idempotent("X:1\nT:One\nL:1/4\nK:C\nC\n");
let pitch = first_note_pitch(&score);
assert_eq!(pitch.step, 'C');
assert_eq!(pitch.octave, 4);
assert_eq!(pitch.alter, 0);
assert_eq!(
score.parts[0].voices[0].events[0].duration,
Fraction::new(1, 4),
"a quarter note must reconstruct as 1/4"
);
}
#[test]
fn altered_pitch_reconstructs_alter_and_written_accidental() {
let score = assert_idempotent("X:1\nT:Sharp\nL:1/4\nK:C\n^F\n");
let pitch = first_note_pitch(&score);
assert_eq!(pitch.step, 'F');
assert_eq!(pitch.alter, 1);
let written = match &score.parts[0].voices[0].events[0].kind {
TimedEventKind::Note(note) => note.written_accidental,
other => panic!("expected a note, got {other:?}"),
};
let written = written.expect("an explicit ^ accidental must reconstruct a written mark");
assert_eq!(written.kind, crate::model::Accidental::Sharp);
assert!(written.explicit);
}
fn foreign_pitch_score(alter_xml: &str) -> crate::parse::ParseReport<Score> {
let xml = format!(
r#"<?xml version="1.0"?>
<score-partwise>
<part-list><score-part id="P1"><part-name>P</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>8</divisions></attributes>
<note>
<pitch><step>C</step>{alter_xml}<octave>5</octave></pitch>
<duration>8</duration>
<voice>1</voice>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>"#
);
read_musicxml(&xml)
}
fn foreign_pitch_alter(alter_xml: &str) -> i8 {
let score = foreign_pitch_score(alter_xml).value;
first_note_pitch(&score).alter
}
#[test]
fn float_alter_sharp_reconstructs_as_one() {
assert_eq!(
foreign_pitch_alter("<alter>1.0</alter>"),
1,
"<alter>1.0</alter> must reconstruct a +1 (sharp) sounding alter"
);
}
#[test]
fn foreign_additive_meter_parenthesises_for_abc() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>P</part-name></score-part></part-list>\n",
" <part id=\"P1\"><measure number=\"1\">\n",
" <attributes><divisions>4</divisions>",
"<time><beats>11+7</beats><beat-type>16</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure></part>\n",
"</score-partwise>\n",
);
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("M:(11+7)/16"),
"an additive foreign meter must project as parenthesised ABC `M:(11+7)/16`; got:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("additive-meter ABC should export");
assert!(
roundtrip.musicxml.contains("<beats>11+7</beats>")
&& roundtrip.musicxml.contains("<beat-type>16</beat-type>"),
"round-trip MusicXML must restore the additive <beats>11+7</beats>:\n{}",
roundtrip.musicxml
);
}
#[test]
fn float_alter_flat_reconstructs_as_negative_one() {
assert_eq!(
foreign_pitch_alter("<alter>-1.0</alter>"),
-1,
"<alter>-1.0</alter> must reconstruct a -1 (flat) sounding alter"
);
}
#[test]
fn float_alter_double_sharp_reconstructs_as_two() {
assert_eq!(
foreign_pitch_alter("<alter>2.0</alter>"),
2,
"<alter>2.0</alter> must reconstruct a +2 (double-sharp) sounding alter"
);
}
#[test]
fn integer_alter_still_reconstructs_without_regression() {
assert_eq!(
foreign_pitch_alter("<alter>1</alter>"),
1,
"integer <alter>1</alter> must still reconstruct as +1"
);
}
#[test]
fn quarter_tone_alter_rounds_and_diagnoses_without_panic() {
let report = foreign_pitch_score("<alter>0.5</alter>");
let alter = first_note_pitch(&report.value).alter;
assert!(
alter == 0 || alter == 1,
"a 0.5 quarter-tone alter must round to the nearest semitone (0 or 1), got {alter}"
);
assert!(
report
.diagnostics
.iter()
.any(|d| d.code == "musicxml.read.fractional_alter"),
"a non-integer (quarter-tone) alter must emit a diagnostic"
);
}
#[test]
fn float_alter_note_round_trips_to_integer_alter_and_is_stable() {
let x1 = r#"<?xml version="1.0"?>
<score-partwise>
<part-list><score-part id="P1"><part-name>P</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>8</divisions></attributes>
<note>
<pitch><step>C</step><alter>1.0</alter><octave>5</octave></pitch>
<duration>8</duration>
<voice>1</voice>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>"#;
let score = read_musicxml(x1).value;
let x2 = write_score_partwise(&score).value;
assert!(
x2.contains("<alter>1</alter>"),
"re-emission must carry an INTEGER <alter>1</alter>, got:\n{x2}"
);
assert!(
!x2.contains("<alter>1.0</alter>"),
"the writer must never emit a decimal <alter>"
);
let reread = read_musicxml(&x2).value;
assert_eq!(
first_note_pitch(&reread).alter,
1,
"the re-read float-alter note must stay +1"
);
}
#[test]
fn rest_round_trips_and_reconstructs_kind() {
let score = assert_idempotent("X:1\nT:Rest\nL:1/4\nK:C\nz\n");
assert!(
matches!(
score.parts[0].voices[0].events[0].kind,
TimedEventKind::Rest(_)
),
"a z rest must reconstruct as a Rest event"
);
}
#[test]
fn dotted_and_typed_durations_round_trip() {
let score = assert_idempotent("X:1\nT:Dotted\nM:3/4\nL:1/4\nK:C\nC3/2 D/2 E\n");
assert_eq!(
score.parts[0].voices[0].events[0].duration,
Fraction::new(3, 8),
"C3/2 under L:1/4 is 3/8 of a whole note"
);
}
#[test]
fn multi_measure_round_trips_with_measure_ids() {
let score = assert_idempotent("X:1\nT:Two Bars\nM:4/4\nL:1/4\nK:C\nC D E F | G A B c |\n");
let measures = &score.parts[0].voices[0].measures;
assert_eq!(measures.len(), 2, "two written bars -> two measures");
assert_eq!(measures[0].id.number, 1);
assert_eq!(measures[1].id.number, 2);
assert_eq!(score.parts[0].voices[0].events.len(), 8);
}
#[test]
fn whole_measure_rest_reconstructs_measure_attribute() {
let x1 = export("X:1\nT:Full Rest\nM:4/4\nL:1/4\nK:C\nC D E F | z4 |\n");
assert!(
x1.contains("measure=\"yes\""),
"precondition: a full-bar rest must emit measure=\"yes\""
);
let score = assert_idempotent("X:1\nT:Full Rest\nM:4/4\nL:1/4\nK:C\nC D E F | z4 |\n");
let second = &score.parts[0].voices[0].measures[1];
assert_eq!(second.expected_duration, Some(Fraction::new(1, 1)));
assert_eq!(second.actual_duration, Fraction::new(1, 1));
}
#[test]
fn invisible_rest_round_trips() {
let abc = "X:1\nT:Hidden\nM:4/4\nL:1/4\nK:C\nC x C x |\n";
let x1 = export(abc);
assert!(
x1.contains("print-object=\"no\""),
"precondition: invisible rest emits print-object=\"no\""
);
assert_idempotent(abc);
}
#[test]
fn multi_part_skeleton_round_trips_with_names() {
let abc = "X:1\nT:Duet\nM:4/4\nL:1/4\n%%score (V1 V2)\nV:1 name=\"Flute\"\nV:2 name=\"Cello\"\nK:C\n[V:1] C D E F |\n[V:2] G,2 A,2 |\n";
let x1 = export(abc);
assert!(
x1.contains("<part-name>Flute</part-name>") && x1.contains("<part-name>Cello</part-name>"),
"precondition: both part-names are emitted"
);
let score = assert_idempotent(abc);
assert_eq!(score.parts.len(), 2, "two %%score voices -> two parts");
let names: Vec<_> = score
.parts
.iter()
.filter_map(|part| part.name.as_ref().map(|line| line.text.clone()))
.collect();
assert_eq!(names, vec!["Flute".to_owned(), "Cello".to_owned()]);
}
#[test]
fn foreign_part_names_project_to_voice_headers() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <score-part id=\"P1\"><part-name>Flute</part-name></score-part>\n",
" <score-part id=\"P2\"><part-name>Cello</part-name></score-part>\n",
" </part-list>\n",
" <part id=\"P1\"><measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure></part>\n",
" <part id=\"P2\"><measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>3</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure></part>\n",
"</score-partwise>\n",
);
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("V:P1 name=\"Flute\"\n"),
"MusicXML part-name must survive into ABC voice metadata; got:\n{abc}"
);
assert!(
abc.contains("V:P2 name=\"Cello\"\n"),
"MusicXML part-name must survive into ABC voice metadata; got:\n{abc}"
);
}
#[test]
fn foreign_part_name_newline_does_not_break_voice_header() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <score-part id=\"P1\"><part-name>S\nA</part-name></score-part>\n",
" </part-list>\n",
" <part id=\"P1\"><measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure></part>\n",
"</score-partwise>\n",
);
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("V:P1 name=\"S A\"\n"),
"a part-name newline must become a space, keeping the V: header on one line:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("name-normalised ABC should export");
assert!(
roundtrip.musicxml.contains("<step>C</step>"),
"the following note must survive the part-name normalisation:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_multi_voice_part_synthesizes_parenthesized_score_group() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <score-part id=\"P1\"><part-name>Piano</part-name></score-part>\n",
" </part-list>\n",
" <part id=\"P1\"><measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" <backup><duration>4</duration></backup>\n",
" <note><pitch><step>E</step><octave>4</octave></pitch><duration>4</duration><voice>2</voice><type>quarter</type></note>\n",
" </measure></part>\n",
"</score-partwise>\n",
);
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%score (P1 P1#2)\n"),
"multiple MusicXML voices in one part must stay grouped in ABC; got:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("grouped ABC should export");
assert_eq!(
roundtrip.musicxml.matches("<score-part id=").count(),
1,
"ABC->MusicXML must keep the two voices in one score-part:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_voice_numbers_survive_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <score-part id=\"P1\"><part-name>Piano</part-name></score-part>\n",
" </part-list>\n",
" <part id=\"P1\"><measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" <backup><duration>4</duration></backup>\n",
" <note><pitch><step>G</step><octave>4</octave></pitch><duration>4</duration><voice>5</voice><type>quarter</type></note>\n",
" </measure></part>\n",
"</score-partwise>\n",
);
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%score (P1 P1#5)\n"),
"imported sparse MusicXML voice numbers must be visible in the ABC ownership group; got:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("sparse voice ABC should export");
assert!(
roundtrip.musicxml.contains("<voice>5</voice>"),
"ABC->MusicXML must preserve imported voice 5, not renumber it to voice 2:\n{}",
roundtrip.musicxml
);
assert!(
!roundtrip.musicxml.contains("<voice>2</voice>"),
"imported sparse voice 5 must not be normalized to sequential voice 2:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_single_unpitched_instrument_round_trips() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <score-part id=\"P1\">\n",
" <part-name>Percussion</part-name>\n",
" <score-instrument id=\"P1-I1\"><instrument-name>Wood Block</instrument-name></score-instrument>\n",
" <midi-instrument id=\"P1-I1\">\n",
" <midi-channel>10</midi-channel>\n",
" <midi-unpitched>76</midi-unpitched>\n",
" </midi-instrument>\n",
" </score-part>\n",
" </part-list>\n",
" <part id=\"P1\"><measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note>\n",
" <unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched>\n",
" <duration>4</duration><voice>1</voice><type>quarter</type>\n",
" <instrument id=\"P1-I1\"/>\n",
" </note>\n",
" </measure></part>\n",
"</score-partwise>\n",
);
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%MIDI midi-unpitched 76"),
"MusicXML <midi-unpitched> must survive the ABC leg; got:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("unpitched ABC carrier should export");
assert!(
roundtrip
.musicxml
.contains("<midi-unpitched>76</midi-unpitched>"),
"ABC->MusicXML must preserve the unpitched MIDI map:\n{}",
roundtrip.musicxml
);
assert!(
roundtrip.musicxml.contains("<unpitched>")
&& roundtrip
.musicxml
.contains("<display-step>C</display-step>")
&& roundtrip
.musicxml
.contains("<display-octave>5</display-octave>"),
"the note must remain an unpitched display note, not disappear or become a pitched note:\n{}",
roundtrip.musicxml
);
assert!(
!roundtrip.musicxml.contains("<pitch>"),
"single unpitched percussion note must not round-trip as a pitched note:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_single_voice_multi_instrument_map_round_trips() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <score-part id=\"P1\">\n",
" <part-name>Percussion</part-name>\n",
" <score-instrument id=\"P1-I1\"><instrument-name>Snare Drum</instrument-name></score-instrument>\n",
" <score-instrument id=\"P1-I2\"><instrument-name>Wood Block</instrument-name></score-instrument>\n",
" <midi-instrument id=\"P1-I1\"><midi-channel>10</midi-channel><midi-unpitched>39</midi-unpitched></midi-instrument>\n",
" <midi-instrument id=\"P1-I2\"><midi-channel>10</midi-channel><midi-unpitched>76</midi-unpitched></midi-instrument>\n",
" </score-part>\n",
" </part-list>\n",
" <part id=\"P1\"><measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note>\n",
" <unpitched><display-step>D</display-step><display-octave>5</display-octave></unpitched>\n",
" <duration>4</duration><instrument id=\"P1-I1\"/><voice>1</voice><type>quarter</type>\n",
" </note>\n",
" <note>\n",
" <unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched>\n",
" <duration>4</duration><instrument id=\"P1-I2\"/><voice>1</voice><type>quarter</type>\n",
" </note>\n",
" </measure></part>\n",
"</score-partwise>\n",
);
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%croma-musicxml-instrument id=\"P1-I1\" name=\"Snare Drum\"")
&& abc.contains("midi-unpitched=39")
&& abc.contains("%%croma-musicxml-instrument id=\"P1-I2\" name=\"Wood Block\"")
&& abc.contains("midi-unpitched=76"),
"MusicXML part-list instruments must survive into the ABC carrier; got:\n{abc}"
);
assert!(
abc.contains("[I:croma-note-instrument id=\"P1-I1\"]")
&& abc.contains("[I:croma-note-instrument id=\"P1-I2\"]"),
"per-note MusicXML instrument refs must survive into the ABC carrier; got:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("multi-instrument ABC carrier should export");
assert!(
roundtrip
.musicxml
.contains("<score-instrument id=\"P1-I1\">")
&& roundtrip
.musicxml
.contains("<instrument-name>Snare Drum</instrument-name>")
&& roundtrip
.musicxml
.contains("<midi-unpitched>39</midi-unpitched>")
&& roundtrip
.musicxml
.contains("<score-instrument id=\"P1-I2\">")
&& roundtrip
.musicxml
.contains("<instrument-name>Wood Block</instrument-name>")
&& roundtrip
.musicxml
.contains("<midi-unpitched>76</midi-unpitched>"),
"ABC->MusicXML must preserve all part-list instruments:\n{}",
roundtrip.musicxml
);
assert!(
roundtrip.musicxml.contains("<instrument id=\"P1-I1\"/>")
&& roundtrip.musicxml.contains("<instrument id=\"P1-I2\"/>"),
"ABC->MusicXML must preserve per-note instrument ids:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_empty_instrument_name_does_not_gain_gm_fallback() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <score-part id=\"P1\">\n",
" <part-name></part-name>\n",
" <score-instrument id=\"P1-I1\"><instrument-name></instrument-name></score-instrument>\n",
" <midi-instrument id=\"P1-I1\">\n",
" <midi-channel>1</midi-channel>\n",
" <midi-program>1</midi-program>\n",
" <volume>78.74</volume>\n",
" <pan>0.00</pan>\n",
" </midi-instrument>\n",
" </score-part>\n",
" </part-list>\n",
" <part id=\"P1\"><measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration><instrument id=\"P1-I1\"/><voice>1</voice><type>quarter</type></note>\n",
" </measure></part>\n",
"</score-partwise>\n",
);
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%croma-musicxml-instrument id=\"P1-I1\" name=\"\""),
"an explicit empty MusicXML instrument name must survive into ABC as name=\"\"; got:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("empty-name instrument carrier should export");
assert!(
roundtrip
.musicxml
.contains("<instrument-name></instrument-name>"),
"ABC->MusicXML must preserve the explicit empty instrument name:\n{}",
roundtrip.musicxml
);
assert!(
roundtrip
.musicxml
.contains("<midi-channel>1</midi-channel>")
&& roundtrip
.musicxml
.contains("<midi-program>1</midi-program>")
&& roundtrip.musicxml.contains("<volume>78.74</volume>")
&& roundtrip.musicxml.contains("<pan>0.00</pan>"),
"empty-name round-trip must preserve the sibling MIDI fields:\n{}",
roundtrip.musicxml
);
assert!(
!roundtrip.musicxml.contains("acoustic_grand_piano"),
"explicit empty instrument names must not be replaced by a GM fallback:\n{}",
roundtrip.musicxml
);
}
#[test]
fn backup_forward_durations_are_read() {
let abc = "X:1\nT:Backup\nM:4/4\nL:1/4\n%%score (V1 V2)\nV:1\nV:2\nK:C\n[V:1] C2 D2 |\n[V:2] E2 F2 |\n";
let x1 = export(abc);
if x1.contains("<backup>") {
let report = read_musicxml(&x1);
assert!(
!report.value.parts.is_empty(),
"backup/forward parsing must still yield parts"
);
}
assert_idempotent("X:1\nT:Lead\nM:4/4\nL:1/4\nK:C\nz C2 z |\n");
}
#[test]
fn divisions_are_recovered() {
let score = assert_idempotent("X:1\nT:Div\nM:4/4\nL:1/8\nK:C\nC2 D/2 E/2 F G A |\n");
assert!(
score.divisions >= 1,
"divisions must be a positive integer, got {}",
score.divisions
);
let first = &score.parts[0].voices[0].events[0];
assert_eq!(first.duration, Fraction::new(1, 4), "C2 under L:1/8 is 1/4");
}
fn header_key(score: &Score) -> &crate::model::KeySignatureModel {
score
.metadata
.key
.as_ref()
.expect("S2 must reconstruct the header <key> into score.metadata.key")
}
#[test]
fn header_key_fifths_sharp_round_trips() {
let score = assert_idempotent_s2("X:1\nT:Sharps\nL:1/4\nK:D\nD\n");
assert_eq!(header_key(&score).fifths, 2, "K:D is 2 sharps");
assert!(
header_key(&score).explicit_accidentals.is_empty(),
"a traditional key emits no explicit key accidentals"
);
}
#[test]
fn header_key_fifths_flat_round_trips() {
let score = assert_idempotent_s2("X:1\nT:Flats\nL:1/4\nK:F\nF\n");
assert_eq!(header_key(&score).fifths, -1, "K:F is 1 flat");
}
#[test]
fn header_key_fifths_zero_round_trips() {
let score = assert_idempotent_s2("X:1\nT:Natural\nL:1/4\nK:C\nC\n");
assert_eq!(header_key(&score).fifths, 0, "K:C is 0 fifths");
}
#[test]
fn header_key_minor_negative_fifths_round_trips() {
let score = assert_idempotent_s2("X:1\nT:Minor\nL:1/4\nK:Cm\nC\n");
assert_eq!(header_key(&score).fifths, -3, "K:Cm is 3 flats");
}
#[test]
fn explicit_key_accidentals_round_trip() {
let abc = "X:1\nT:Exp\nL:1/4\nK:C exp ^f _b\nC\n";
let x1 = export(abc);
assert!(
x1.contains("<key-step>F</key-step>")
&& x1.contains("<key-accidental>flat</key-accidental>"),
"precondition: explicit key accidentals are emitted"
);
let score = assert_idempotent_s2(abc);
let accidentals = &header_key(&score).explicit_accidentals;
assert_eq!(accidentals.len(), 2, "two explicit key accidentals");
assert_eq!(accidentals[0].step, 'F');
assert_eq!(accidentals[0].accidental, crate::model::Accidental::Sharp);
assert_eq!(accidentals[1].step, 'B');
assert_eq!(accidentals[1].accidental, crate::model::Accidental::Flat);
}
#[test]
fn header_meter_round_trips_and_reconstructs() {
let score = assert_idempotent_s2("X:1\nT:Compound\nM:6/8\nL:1/8\nK:C\nC2C2C2\n");
let meter = score
.metadata
.meter
.as_ref()
.expect("S2 must reconstruct the header <time> into score.metadata.meter");
assert_eq!(
meter.display, "6/8",
"reconstructed meter display drives re-emission"
);
assert!(!meter.free_meter);
}
#[test]
fn common_time_symbol_round_trips() {
let abc = "X:1\nT:Common\nM:C\nL:1/4\nK:C\nCCCC\n";
let x1 = export(abc);
assert!(
x1.contains("symbol=\"common\""),
"precondition: M:C emits symbol=\"common\""
);
let score = assert_idempotent_s2(abc);
let meter = score.metadata.meter.as_ref().expect("meter present");
assert_eq!(meter.display, "C");
}
#[test]
fn cut_time_symbol_round_trips() {
let abc = "X:1\nT:Cut\nM:C|\nL:1/4\nK:C\nCCCC\n";
let x1 = export(abc);
assert!(
x1.contains("symbol=\"cut\""),
"precondition: M:C| emits symbol=\"cut\""
);
let score = assert_idempotent_s2(abc);
assert_eq!(score.metadata.meter.as_ref().expect("meter").display, "C|");
}
#[test]
fn free_meter_round_trips() {
let abc = "X:1\nT:Free\nM:none\nL:1/4\nK:C\nCCCC\n";
let x1 = export(abc);
assert!(
!x1.contains("<time"),
"precondition: M:none emits no <time> element"
);
assert_idempotent_s2(abc);
}
#[test]
fn bass_clef_round_trips() {
let abc = "X:1\nT:Bass\nL:1/4\nK:C clef=bass\nC,\n";
let x1 = export(abc);
assert!(
x1.contains("<sign>F</sign>"),
"precondition: clef=bass emits sign F"
);
assert_idempotent_s2(abc);
}
#[test]
fn alto_clef_round_trips() {
let abc = "X:1\nT:Alto\nL:1/4\nK:C clef=alto\nC\n";
let x1 = export(abc);
assert!(
x1.contains("<sign>C</sign>"),
"precondition: clef=alto emits sign C"
);
assert_idempotent_s2(abc);
}
#[test]
fn octave_clef_round_trips() {
let abc = "X:1\nT:Octave\nL:1/4\nK:C clef=treble-8\nC\n";
let x1 = export(abc);
assert!(
x1.contains("<clef-octave-change>-1</clef-octave-change>"),
"precondition: clef=treble-8 emits octave-change -1"
);
assert_idempotent_s2(abc);
}
#[test]
fn default_treble_clef_round_trips() {
let score = assert_idempotent_s2("X:1\nT:Treble\nL:1/4\nK:C\nC\n");
assert!(
!score.parts.is_empty(),
"default clef file must still reconstruct a part"
);
}
#[test]
fn midi_transpose_reconstructs_chromatic() {
let abc = "X:1\nT:Trans\nL:1/4\nK:C\n%%MIDI transpose -12\nC\n";
let x1 = export(abc);
assert!(
x1.contains("<chromatic>-12</chromatic>"),
"precondition: %%MIDI transpose -12 emits chromatic -12"
);
let score = assert_idempotent_s2(abc);
assert_eq!(
score.parts[0].voices[0].midi_transpose,
Some(-12),
"midi_transpose must reconstruct the chromatic value"
);
}
#[test]
fn positive_midi_transpose_reconstructs() {
let abc = "X:1\nT:Up\nL:1/4\nK:C\n%%MIDI transpose 7\nC\n";
let score = assert_idempotent_s2(abc);
assert_eq!(score.parts[0].voices[0].midi_transpose, Some(7));
}
fn assert_idempotent_s3(abc: &str) -> Score {
let (x1, x2, score) = round_trip(abc);
assert_eq!(
x1, x2,
"write(read(write(score))) must equal write(score) byte-for-byte (S3 full loop)"
);
score
}
fn first_midi(score: &Score) -> crate::model::MidiInstrumentModel {
score.parts[0].voices[0]
.midi_instrument
.expect("S3 must reconstruct voice.midi_instrument from the part-list")
}
#[test]
fn midi_program_reconstructs_zero_based() {
let abc = "X:1\nT:P\nL:1/4\nK:C\n%%MIDI program 73\nC\n";
let x1 = export(abc);
assert!(
x1.contains("<midi-program>74</midi-program>")
&& x1.contains("<instrument-name>flute</instrument-name>"),
"precondition: program 73 emits 1-based 74 + GM name flute"
);
let midi = first_midi(&assert_idempotent_s3(abc));
assert_eq!(
midi.program,
Some(73),
"<midi-program>74 inverts to 0-based 73"
);
assert_eq!(midi.channel, None);
assert_eq!(midi.volume_cc, None);
assert_eq!(midi.pan_cc, None);
}
#[test]
fn midi_program_with_channel_reconstructs_both() {
let abc = "X:1\nT:PC\nL:1/4\nK:C\n%%MIDI program 1 56\nC\n";
let x1 = export(abc);
assert!(
x1.contains("<midi-channel>1</midi-channel>")
&& x1.contains("<midi-program>57</midi-program>"),
"precondition: program 1 56 emits channel 1 + 1-based program 57"
);
let midi = first_midi(&assert_idempotent_s3(abc));
assert_eq!(midi.program, Some(56));
assert_eq!(midi.channel, Some(1));
}
#[test]
fn standalone_channel_reconstructs_with_no_program() {
let abc = "X:1\nT:Ch\nL:1/4\nK:C\n%%MIDI channel 10\nC\n";
let x1 = export(abc);
assert!(
x1.contains("<midi-channel>10</midi-channel>")
&& !x1.contains("<midi-program>")
&& x1.contains("<instrument-name>Ch</instrument-name>"),
"precondition: standalone channel emits no program + part-name fallback"
);
let midi = first_midi(&assert_idempotent_s3(abc));
assert_eq!(
midi.program, None,
"no program -> re-write must use the part name"
);
assert_eq!(midi.channel, Some(10));
}
#[test]
fn control7_volume_reconstructs_cc() {
let abc = "X:1\nT:Vol\nL:1/4\nK:C\n%%MIDI control 7 64\nC\n";
let x1 = export(abc);
assert!(
x1.contains("<volume>50.39</volume>"),
"precondition: control 7 64 emits volume 50.39"
);
let midi = first_midi(&assert_idempotent_s3(abc));
assert_eq!(midi.volume_cc, Some(64), "<volume>50.39 inverts to CC 64");
assert_eq!(midi.program, None);
}
#[test]
fn control10_pan_reconstructs_cc() {
let abc = "X:1\nT:Pan\nL:1/4\nK:C\n%%MIDI control 10 64\nC\n";
let x1 = export(abc);
assert!(
x1.contains("<pan>0.00</pan>"),
"precondition: control 10 64 emits exact center pan"
);
let midi = first_midi(&assert_idempotent_s3(abc));
assert_eq!(midi.pan_cc, Some(64), "<pan>0.00 inverts to CC 64");
}
#[test]
fn pan_extremes_reconstruct() {
let low = "X:1\nT:PanLo\nL:1/4\nK:C\n%%MIDI control 10 0\nC\n";
assert_eq!(first_midi(&assert_idempotent_s3(low)).pan_cc, Some(0));
let high = "X:1\nT:PanHi\nL:1/4\nK:C\n%%MIDI control 10 127\nC\n";
assert_eq!(first_midi(&assert_idempotent_s3(high)).pan_cc, Some(127));
}
#[test]
fn full_midi_instrument_reconstructs_all_fields() {
let abc = "X:1\nT:All\nL:1/4\nK:C\n%%MIDI program 1 56\n%%MIDI control 7 100\n%%MIDI control 10 30\nC\n";
let midi = first_midi(&assert_idempotent_s3(abc));
assert_eq!(midi.program, Some(56));
assert_eq!(midi.channel, Some(1));
assert_eq!(midi.volume_cc, Some(100));
assert_eq!(midi.pan_cc, Some(30));
}
#[test]
fn midi_unpitched_reconstructs_and_writes_unpitched_notes() {
let abc = "X:1\nT:Block\nL:1/4\nK:C\n%%MIDI channel 10\n%%MIDI midi-unpitched 76\nc\n";
let x1 = export(abc);
assert!(
x1.contains("<midi-unpitched>76</midi-unpitched>"),
"precondition: midi-unpitched emits a MusicXML percussion map"
);
assert!(
x1.contains("<unpitched>") && !x1.contains("<pitch>"),
"precondition: a voice-level unpitched MIDI map emits notes as <unpitched>, not <pitch>:\n{x1}"
);
let midi = first_midi(&assert_idempotent_s3(abc));
assert_eq!(midi.channel, Some(10));
assert_eq!(midi.midi_unpitched, Some(76));
}
#[test]
fn inline_midi_program_reconstructs_like_line_start() {
let abc = "X:1\nT:Inl\nL:1/4\nK:C\n[I:MIDI=program 40]C\n";
let x1 = export(abc);
assert!(
x1.contains("<midi-program>41</midi-program>"),
"precondition: inline program 40 emits 1-based 41"
);
let midi = first_midi(&assert_idempotent_s3(abc));
assert_eq!(
midi.program,
Some(40),
"inline program 40 inverts to 0-based 40"
);
}
#[test]
fn no_midi_directive_leaves_instrument_none() {
let score = assert_idempotent_s3("X:1\nT:Plain\nL:1/4\nK:C\nC\n");
assert_eq!(
score.parts[0].voices[0].midi_instrument, None,
"no %%MIDI -> no reconstructed instrument"
);
}
#[test]
fn float_cc_round_trip_is_stable_for_every_value() {
for cc in 0u8..=127 {
let v: f64 = format!("{:.2}", f64::from(cc) / 1.27)
.parse()
.expect("formatted volume must parse as f64");
let back = (v * 1.27).round();
assert_eq!(
back,
f64::from(cc),
"volume CC {cc}: round({v} * 1.27) = {back}, expected {cc}"
);
let p: f64 = format!("{:.2}", f64::from(cc) / 127.0 * 180.0 - 90.0)
.parse()
.expect("formatted pan must parse as f64");
let back = ((p + 90.0) * 127.0 / 180.0).round();
assert_eq!(
back,
f64::from(cc),
"pan CC {cc}: round(({p} + 90) * 127 / 180) = {back}, expected {cc}"
);
}
}
fn assert_idempotent_s4(abc: &str) -> Score {
let (x1, x2, score) = round_trip(abc);
assert_eq!(
x1, x2,
"write(read(write(score))) must equal write(score) byte-for-byte (S4 notations)"
);
score
}
fn attachments_at(score: &Score, index: usize) -> &crate::model::EventAttachments {
&score.parts[0].voices[0].events[index].attachments
}
#[test]
fn tie_round_trips_and_reconstructs_attachment() {
use crate::model::TieRole;
let abc = "X:1\nT:Tie\nM:4/4\nL:1/4\nK:C\nC2- C2 z4 |\n";
let x1 = export(abc);
assert!(
x1.contains("<tie type=\"start\"/>") && x1.contains("<tied type=\"start\" number=\"1\"/>"),
"precondition: a tie emits both <tie> and <tied>"
);
let score = assert_idempotent_s4(abc);
let start = &attachments_at(&score, 0).ties;
assert_eq!(start.len(), 1, "tie start note has one TieAttachment");
assert_eq!(start[0].role, TieRole::Start);
assert!(!start[0].dotted);
let stop = &attachments_at(&score, 1).ties;
assert_eq!(stop.len(), 1);
assert_eq!(stop[0].role, TieRole::Stop);
}
#[test]
fn dotted_tie_reconstructs_line_type() {
let abc = "X:1\nT:DotTie\nM:4/4\nL:1/4\nK:C\nC.-C z2 |\n";
let x1 = export(abc);
assert!(
x1.contains("line-type=\"dotted\""),
"precondition: dotted tie emits line-type=\"dotted\""
);
let score = assert_idempotent_s4(abc);
assert!(
attachments_at(&score, 0).ties[0].dotted,
"a dotted tie reconstructs dotted = true"
);
}
#[test]
fn slur_round_trips_and_reconstructs_attachment() {
use crate::model::SlurRole;
let abc = "X:1\nT:Slur\nM:4/4\nL:1/4\nK:C\n(C D) z2 |\n";
let x1 = export(abc);
assert!(
x1.contains("<slur type=\"start\" number=\"1\"/>"),
"precondition: a slur emits number=1"
);
let score = assert_idempotent_s4(abc);
let start = &attachments_at(&score, 0).slurs;
assert_eq!(start.len(), 1);
assert_eq!(start[0].role, SlurRole::Start);
let stop = &attachments_at(&score, 1).slurs;
assert_eq!(stop[0].role, SlurRole::Stop);
assert_eq!(
start[0].pair_id, stop[0].pair_id,
"a slur pair shares pair_id"
);
}
#[test]
fn nested_slurs_reconstruct_distinct_numbers() {
let abc = "X:1\nT:Nest\nM:4/4\nL:1/4\nK:C\n(C (D E) F) |\n";
let x1 = export(abc);
assert!(
x1.contains("<slur type=\"start\" number=\"1\"/>")
&& x1.contains("<slur type=\"start\" number=\"2\"/>"),
"precondition: nested slurs emit numbers 1 and 2"
);
let score = assert_idempotent_s4(abc);
let outer = attachments_at(&score, 0).slurs[0].pair_id;
let inner = attachments_at(&score, 1).slurs[0].pair_id;
assert_ne!(
outer, inner,
"overlapping slurs must reconstruct distinct pair_ids"
);
}
#[test]
fn dotted_slur_reconstructs_line_type() {
let abc = "X:1\nT:DotSlur\nM:4/4\nL:1/4\nK:C\n.(C D.) z2 |\n";
let x1 = export(abc);
assert!(
x1.contains("<slur type=\"start\" number=\"1\" line-type=\"dotted\"/>"),
"precondition: dotted slur emits line-type=\"dotted\""
);
let score = assert_idempotent_s4(abc);
assert!(attachments_at(&score, 0).slurs[0].dotted);
}
#[test]
fn triplet_round_trips_and_reconstructs_tuplet() {
use crate::model::TupletRole;
let abc = "X:1\nT:Trip\nM:4/4\nL:1/8\nK:C\n(3CDE A2 z2 |\n";
let x1 = export(abc);
assert!(
x1.contains("<tuplet type=\"start\" number=\"1\"/>")
&& x1.contains("<actual-notes>3</actual-notes>")
&& x1.contains("<normal-notes>2</normal-notes>"),
"precondition: triplet emits start tuplet + 3:2 time-modification"
);
let score = assert_idempotent_s4(abc);
let start = &attachments_at(&score, 0).tuplets;
assert_eq!(start.len(), 1, "the first triplet note has one tuplet");
assert_eq!(start[0].role, TupletRole::Start);
assert_eq!(start[0].actual_notes, 3);
assert_eq!(start[0].normal_notes, 2);
assert_eq!(
attachments_at(&score, 1).tuplets[0].role,
TupletRole::Continue
);
assert_eq!(attachments_at(&score, 2).tuplets[0].role, TupletRole::Stop);
}
#[test]
fn quintuplet_reconstructs_ratio() {
let abc = "X:1\nT:Quint\nM:4/4\nL:1/8\nK:C\n(5CDEFG z3 |\n";
let x1 = export(abc);
let score = assert_idempotent_s4(abc);
let start = &attachments_at(&score, 0).tuplets[0];
let actual = start.actual_notes;
let normal = start.normal_notes;
assert!(
x1.contains(&format!("<actual-notes>{actual}</actual-notes>"))
&& x1.contains(&format!("<normal-notes>{normal}</normal-notes>")),
"reconstructed tuplet ratio {actual}:{normal} must match the emitted time-modification"
);
}
#[test]
fn two_separate_tuplets_in_a_measure_round_trip() {
let abc = "X:1\nT:TwoTrip\nM:4/4\nL:1/8\nK:C\n(3CDE (3FGA z2 |\n";
let score = assert_idempotent_s4(abc);
let first = attachments_at(&score, 0).tuplets[0].pair_id;
let second = attachments_at(&score, 3).tuplets[0].pair_id;
assert_ne!(first, second, "separate tuplets get distinct pair_ids");
}
#[test]
fn staccato_articulation_round_trips() {
use crate::model::DecorationSourceKind;
let abc = "X:1\nT:Stac\nM:4/4\nL:1/4\nK:C\n.C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<articulations>") && x1.contains("<staccato/>"),
"precondition: . emits <staccato/>"
);
let score = assert_idempotent_s4(abc);
let decos = &attachments_at(&score, 0).decorations;
assert_eq!(decos.len(), 1, "one decoration on the first note");
assert_eq!(decos[0].name, "staccato");
assert_eq!(decos[0].source_kind, DecorationSourceKind::Named);
}
#[test]
fn accent_articulation_round_trips() {
let abc = "X:1\nT:Acc\nM:4/4\nL:1/4\nK:C\n!>!C D E F |\n";
let score = assert_idempotent_s4(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "accent");
}
#[test]
fn foreign_extended_articulations_survive_abc_projection() {
let xml = r#"<?xml version="1.0"?>
<score-partwise version="3.1">
<part-list>
<score-part id="P1">
<part-name>Part</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key><fifths>0</fifths></key>
<time><beats>4</beats><beat-type>4</beat-type></time>
<clef><sign>G</sign><line>2</line></clef>
</attributes>
<note>
<rest/>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><articulations><caesura/></articulations></notations>
</note>
<note>
<pitch><step>D</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><articulations><detached-legato/></articulations></notations>
</note>
<note>
<pitch><step>E</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><articulations><falloff/></articulations></notations>
</note>
<note>
<pitch><step>F</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><articulations><doit/></articulations></notations>
</note>
</measure>
</part>
</score-partwise>"#;
let report = read_musicxml(xml);
assert!(
!report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "musicxml.read.unsupported_notation"),
"extended articulations must read without unsupported-notation diagnostics: {:?}",
report.diagnostics
);
let score = report.value;
let expected = ["caesura", "detached-legato", "falloff", "doit"];
for (index, name) in expected.iter().enumerate() {
assert!(
attachments_at(&score, index)
.decorations
.iter()
.any(|decoration| decoration.name == *name),
"{name} should survive in the reconstructed model"
);
}
let direct_xml = write_score_partwise(&score).value;
for tag in expected {
assert!(
direct_xml.contains(&format!("<{tag}/>")),
"{tag} should re-emit directly after MusicXML read:\n{direct_xml}"
);
}
let abc = write_abc(&score, AbcWriteOptions::default());
for name in expected {
assert!(
abc.contains(&format!("!{name}!")),
"{name} should survive in the ABC projection:\n{abc}"
);
}
let roundtrip = export_musicxml(&abc)
.expect("ABC projection of extended articulations should export")
.musicxml;
for tag in expected {
assert!(
roundtrip.contains(&format!("<{tag}/>")),
"{tag} should survive MusicXML -> ABC -> MusicXML:\n{roundtrip}"
);
}
}
#[test]
fn foreign_cross_voice_slur_round_trips_through_xvoice_carrier() {
let xml = r#"<?xml version="1.0"?>
<score-partwise version="3.1">
<part-list>
<score-part id="P1"><part-name>Part</part-name></score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>1</divisions>
<key><fifths>0</fifths></key>
<time><beats>2</beats><beat-type>4</beat-type></time>
</attributes>
<note>
<pitch><step>C</step><octave>5</octave></pitch>
<duration>1</duration><voice>1</voice><type>quarter</type>
<notations><slur type="start" number="1"/></notations>
</note>
<note>
<pitch><step>D</step><octave>5</octave></pitch>
<duration>1</duration><voice>1</voice><type>quarter</type>
</note>
<backup><duration>2</duration></backup>
<note>
<pitch><step>E</step><octave>4</octave></pitch>
<duration>1</duration><voice>2</voice><type>quarter</type>
</note>
<note>
<pitch><step>F</step><octave>4</octave></pitch>
<duration>1</duration><voice>2</voice><type>quarter</type>
<notations><slur type="stop" number="1"/></notations>
</note>
</measure>
</part>
</score-partwise>
"#;
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
assert_eq!(
abc.matches("[I:croma-xvoice-slur").count(),
2,
"both cross-voice slur ends must ride a carrier:\n{abc}"
);
assert!(abc.contains("role=start"), "start carrier missing:\n{abc}");
assert!(abc.contains("role=stop"), "stop carrier missing:\n{abc}");
let roundtrip = export_musicxml(&abc)
.expect("cross-voice slur ABC should export")
.musicxml;
assert_eq!(count(&roundtrip, "<slur type=\"start\""), 1);
assert_eq!(count(&roundtrip, "<slur type=\"stop\""), 1);
let c = note_with_step(&roundtrip, "C");
let f = note_with_step(&roundtrip, "F");
assert!(
c.contains("<slur type=\"start\"") && c.contains("<voice>1</voice>"),
"slur start must re-emit on C in voice 1:\n{c}"
);
assert!(
f.contains("<slur type=\"stop\"") && f.contains("<voice>2</voice>"),
"slur stop must re-emit on F in voice 2:\n{f}"
);
}
fn count(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
fn note_with_step<'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 closes") + at + "</note>".len();
&xml[start..end]
}
#[test]
fn foreign_tremolos_survive_abc_projection() {
let xml = r#"<?xml version="1.0"?>
<score-partwise version="3.1">
<part-list>
<score-part id="P1">
<part-name>Part</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key><fifths>0</fifths></key>
<time><beats>4</beats><beat-type>4</beat-type></time>
<clef><sign>G</sign><line>2</line></clef>
</attributes>
<note>
<pitch><step>C</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><ornaments><tremolo type="single">2</tremolo></ornaments></notations>
</note>
<note>
<pitch><step>D</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><ornaments><tremolo type="start">3</tremolo></ornaments></notations>
</note>
<note>
<pitch><step>E</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><ornaments><tremolo type="stop">3</tremolo></ornaments></notations>
</note>
<note>
<pitch><step>F</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><ornaments><tremolo type="single">1</tremolo></ornaments></notations>
</note>
</measure>
</part>
</score-partwise>"#;
let report = read_musicxml(xml);
assert!(
!report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "musicxml.read.unsupported_notation"),
"tremolos must read without unsupported-notation diagnostics: {:?}",
report.diagnostics
);
let score = report.value;
let expected = [
("musicxml-tremolo-single-2", "single", "2"),
("musicxml-tremolo-start-3", "start", "3"),
("musicxml-tremolo-stop-3", "stop", "3"),
("musicxml-tremolo-single-1", "single", "1"),
];
for (index, (name, _, _)) in expected.iter().enumerate() {
assert!(
attachments_at(&score, index)
.decorations
.iter()
.any(|decoration| decoration.name == *name),
"{name} should survive in the reconstructed model"
);
}
let direct_xml = write_score_partwise(&score).value;
for (_, tremolo_type, marks) in expected.iter() {
assert!(
direct_xml.contains(&format!(
"<tremolo type=\"{tremolo_type}\">{marks}</tremolo>"
)),
"{tremolo_type} tremolo with {marks} marks should re-emit directly after MusicXML read:\n{direct_xml}"
);
}
let abc = write_abc(&score, AbcWriteOptions::default());
for (name, _, _) in expected.iter() {
assert!(
abc.contains(&format!("!{name}!")),
"{name} should survive in the ABC projection:\n{abc}"
);
}
let roundtrip = export_musicxml(&abc)
.expect("ABC projection of tremolo carriers should export")
.musicxml;
for (_, tremolo_type, marks) in expected.iter() {
assert!(
roundtrip.contains(&format!(
"<tremolo type=\"{tremolo_type}\">{marks}</tremolo>"
)),
"{tremolo_type} tremolo with {marks} marks should survive MusicXML -> ABC -> MusicXML:\n{roundtrip}"
);
}
}
#[test]
fn foreign_measured_tremolo_time_modification_survives_abc_projection() {
let xml = r#"<?xml version="1.0"?>
<score-partwise version="3.1">
<part-list><score-part id="P1"><part-name>P</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>4</divisions><time><beats>2</beats><beat-type>4</beat-type></time></attributes>
<note>
<pitch><step>C</step><octave>4</octave></pitch>
<duration>4</duration><voice>1</voice><type>half</type>
<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>
<notations><ornaments><tremolo type="start">2</tremolo></ornaments></notations>
</note>
<note>
<pitch><step>E</step><octave>4</octave></pitch>
<duration>4</duration><voice>1</voice><type>half</type>
<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>
<notations><ornaments><tremolo type="stop">2</tremolo></ornaments></notations>
</note>
</measure>
</part>
</score-partwise>"#;
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip = export_musicxml(&abc)
.expect("measured-tremolo ABC should export")
.musicxml;
assert_eq!(
roundtrip
.matches("<tremolo type=\"start\">2</tremolo>")
.count()
+ roundtrip
.matches("<tremolo type=\"stop\">2</tremolo>")
.count(),
2,
"both tremolo brackets must survive:\n{roundtrip}"
);
assert_eq!(
roundtrip.matches("<actual-notes>2</actual-notes>").count(),
2,
"both tremolo notes must keep their 2:1 time-modification:\n{roundtrip}"
);
assert_eq!(
roundtrip.matches("<normal-notes>1</normal-notes>").count(),
2,
"both tremolo notes must keep their 2:1 time-modification:\n{roundtrip}"
);
}
#[test]
fn foreign_technical_values_survive_abc_projection() {
let xml = r#"<?xml version="1.0"?>
<score-partwise version="3.1">
<part-list>
<score-part id="P1">
<part-name>Part</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key><fifths>0</fifths></key>
<time><beats>4</beats><beat-type>4</beat-type></time>
<clef><sign>G</sign><line>2</line></clef>
</attributes>
<note>
<pitch><step>C</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><technical><string>4</string><fret>11</fret></technical></notations>
</note>
<note>
<pitch><step>D</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><technical><fingering>E</fingering></technical></notations>
</note>
<note>
<pitch><step>E</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><technical><fingering>1
2
-</fingering></technical></notations>
</note>
<note>
<pitch><step>F</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><technical><string>1</string></technical></notations>
</note>
</measure>
</part>
</score-partwise>"#;
let report = read_musicxml(xml);
assert!(
!report.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "musicxml.read.unsupported_notation"
|| diagnostic.code == "musicxml.read.unsupported_fingering"
}),
"technical values must read without unsupported-notation diagnostics: {:?}",
report.diagnostics
);
let score = report.value;
let expected = [
(0, "musicxml-tech-string-4"),
(0, "musicxml-tech-fret-11"),
(1, "musicxml-tech-fingering-hex-45"),
(2, "musicxml-tech-fingering-hex-310a320a2d"),
(3, "musicxml-tech-string-1"),
];
for (index, name) in expected {
assert!(
attachments_at(&score, index)
.decorations
.iter()
.any(|decoration| decoration.name == name),
"{name} should survive in the reconstructed model"
);
}
let direct_xml = write_score_partwise(&score).value;
for tag in [
"<string>4</string>",
"<fret>11</fret>",
"<fingering>E</fingering>",
"<fingering>1\n2\n-</fingering>",
"<string>1</string>",
] {
assert!(
direct_xml.contains(tag),
"{tag} should re-emit directly after MusicXML read:\n{direct_xml}"
);
}
let abc = write_abc(&score, AbcWriteOptions::default());
for (_, name) in expected {
assert!(
abc.contains(&format!("!{name}!")),
"{name} should survive in the ABC projection:\n{abc}"
);
}
let roundtrip = export_musicxml(&abc)
.expect("ABC projection of technical carriers should export")
.musicxml;
for tag in [
"<string>4</string>",
"<fret>11</fret>",
"<fingering>E</fingering>",
"<fingering>1\n2\n-</fingering>",
"<string>1</string>",
] {
assert!(
roundtrip.contains(tag),
"{tag} should survive MusicXML -> ABC -> MusicXML:\n{roundtrip}"
);
}
}
#[test]
fn foreign_spanner_notations_survive_abc_projection() {
let xml = r#"<?xml version="1.0"?>
<score-partwise version="3.1">
<part-list>
<score-part id="P1">
<part-name>Part</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key><fifths>0</fifths></key>
<time><beats>6</beats><beat-type>4</beat-type></time>
<clef><sign>G</sign><line>2</line></clef>
</attributes>
<note>
<pitch><step>C</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><glissando line-type="wavy" number="1" type="start">gliss.</glissando></notations>
</note>
<note>
<pitch><step>D</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><glissando line-type="wavy" number="1" type="stop"/></notations>
</note>
<note>
<pitch><step>E</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><slide line-type="solid" number="1" type="start">gliss.</slide></notations>
</note>
<note>
<pitch><step>F</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><slide line-type="solid" number="1" type="stop"/></notations>
</note>
<note>
<pitch><step>G</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<notations><ornaments><wavy-line type="start" number="1"/><wavy-line type="stop" number="1"/></ornaments></notations>
</note>
<note>
<pitch><step>A</step><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>"#;
let report = read_musicxml(xml);
assert!(
!report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "musicxml.read.unsupported_notation"),
"spanner notations must read without unsupported-notation diagnostics: {:?}",
report.diagnostics
);
let score = report.value;
let expected = [
(
0,
"musicxml-glissando-start-wavy-1-hex-676c6973732e",
"<glissando type=\"start\" number=\"1\" line-type=\"wavy\">gliss.</glissando>",
),
(
1,
"musicxml-glissando-stop-wavy-1",
"<glissando type=\"stop\" number=\"1\" line-type=\"wavy\"/>",
),
(
2,
"musicxml-slide-start-solid-1-hex-676c6973732e",
"<slide type=\"start\" number=\"1\" line-type=\"solid\">gliss.</slide>",
),
(
3,
"musicxml-slide-stop-solid-1",
"<slide type=\"stop\" number=\"1\" line-type=\"solid\"/>",
),
(
4,
"musicxml-wavy-line-start-1",
"<wavy-line type=\"start\" number=\"1\"/>",
),
(
4,
"musicxml-wavy-line-stop-1",
"<wavy-line type=\"stop\" number=\"1\"/>",
),
];
for (index, name, _) in expected {
assert!(
attachments_at(&score, index)
.decorations
.iter()
.any(|decoration| decoration.name == name),
"{name} should survive in the reconstructed model"
);
}
let direct_xml = write_score_partwise(&score).value;
for (_, _, tag) in expected {
assert!(
direct_xml.contains(tag),
"{tag} should re-emit directly after MusicXML read:\n{direct_xml}"
);
}
let abc = write_abc(&score, AbcWriteOptions::default());
for (_, name, _) in expected {
assert!(
abc.contains(&format!("!{name}!")),
"{name} should survive in the ABC projection:\n{abc}"
);
}
let roundtrip = export_musicxml(&abc)
.expect("ABC projection of spanner carriers should export")
.musicxml;
for (_, _, tag) in expected {
assert!(
roundtrip.contains(tag),
"{tag} should survive MusicXML -> ABC -> MusicXML:\n{roundtrip}"
);
}
}
#[test]
fn foreign_tuplet_display_survives_abc_projection() {
let xml = r#"<?xml version="1.0"?>
<score-partwise version="3.1">
<part-list>
<score-part id="P1">
<part-name>Part</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key><fifths>0</fifths></key>
<time><beats>4</beats><beat-type>4</beat-type></time>
<clef><sign>G</sign><line>2</line></clef>
</attributes>
<note>
<pitch><step>D</step><alter>-1</alter><octave>4</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>half</type>
<time-modification>
<actual-notes>4</actual-notes>
<normal-notes>2</normal-notes>
</time-modification>
<notations>
<tuplet type="start" bracket="yes">
<tuplet-actual>
<tuplet-number>2</tuplet-number>
<tuplet-type>quarter</tuplet-type>
</tuplet-actual>
<tuplet-normal>
<tuplet-number>2</tuplet-number>
<tuplet-type>quarter</tuplet-type>
</tuplet-normal>
</tuplet>
</notations>
</note>
<note>
<pitch><step>A</step><alter>-1</alter><octave>3</octave></pitch>
<duration>4</duration>
<voice>1</voice>
<type>half</type>
<time-modification>
<actual-notes>4</actual-notes>
<normal-notes>2</normal-notes>
</time-modification>
<notations><tuplet type="stop"/></notations>
</note>
</measure>
</part>
</score-partwise>"#;
let report = read_musicxml(xml);
let score = report.value;
let carrier = "musicxml-tuplet-detail-v1-byes-a2-quarter-0-n2-quarter-0";
assert!(
attachments_at(&score, 0)
.decorations
.iter()
.any(|decoration| decoration.name == carrier),
"tuplet display carrier should survive in the reconstructed model"
);
let expected = [
"<tuplet type=\"start\" bracket=\"yes\">",
"<tuplet-actual>",
"<tuplet-number>2</tuplet-number>",
"<tuplet-type>quarter</tuplet-type>",
"<tuplet-normal>",
];
let direct_xml = write_score_partwise(&score).value;
for tag in expected {
assert!(
direct_xml.contains(tag),
"{tag} should re-emit directly after MusicXML read:\n{direct_xml}"
);
}
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains(&format!("!{carrier}!")),
"carrier should survive in the ABC projection:\n{abc}"
);
let roundtrip = export_musicxml(&abc)
.expect("ABC projection of tuplet display carrier should export")
.musicxml;
for tag in expected {
assert!(
roundtrip.contains(tag),
"{tag} should survive MusicXML -> ABC -> MusicXML:\n{roundtrip}"
);
}
}
#[test]
fn foreign_display_tuplet_without_time_modification_emits_no_ratio() {
let xml = r#"<?xml version="1.0"?>
<score-partwise>
<part-list><score-part id="P1"><part-name>P</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>4</divisions><time><beats>1</beats><beat-type>4</beat-type></time></attributes>
<note><pitch><step>C</step><octave>5</octave></pitch><duration>1</duration><voice>1</voice><type>16th</type>
<notations><tuplet type="start" bracket="no"/></notations></note>
<note><pitch><step>D</step><octave>5</octave></pitch><duration>1</duration><voice>1</voice><type>16th</type></note>
<note><pitch><step>E</step><octave>5</octave></pitch><duration>1</duration><voice>1</voice><type>16th</type></note>
<note><pitch><step>F</step><octave>5</octave></pitch><duration>1</duration><voice>1</voice><type>16th</type>
<notations><tuplet type="stop"/></notations></note>
</measure>
</part>
</score-partwise>"#;
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip = export_musicxml(&abc)
.expect("display-tuplet ABC should export")
.musicxml;
assert!(
!roundtrip.contains("<time-modification>"),
"a display-only tuplet must not fabricate a <time-modification>:\n{roundtrip}"
);
assert!(
roundtrip.contains("<tuplet type=\"start\"") && roundtrip.contains("<tuplet type=\"stop\""),
"the display tuplet bracket must survive:\n{roundtrip}"
);
assert_eq!(
roundtrip.matches("<type>16th</type>").count(),
4,
"all four 16th notes must keep their literal value:\n{roundtrip}"
);
}
#[test]
fn trill_ornament_round_trips() {
let abc = "X:1\nT:Tr\nM:4/4\nL:1/4\nK:C\n!trill!C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<ornaments>") && x1.contains("<trill-mark/>"),
"precondition: !trill! emits <trill-mark/>"
);
let score = assert_idempotent_s4(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "trill");
}
#[test]
fn mordent_ornament_round_trips() {
let abc = "X:1\nT:Mord\nM:4/4\nL:1/4\nK:C\n!mordent!C D E F |\n";
let score = assert_idempotent_s4(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "mordent");
}
#[test]
fn fermata_round_trips() {
let abc = "X:1\nT:Ferm\nM:4/4\nL:1/4\nK:C\n!fermata!C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<fermata type=\"upright\"/>"),
"precondition: !fermata! emits <fermata type=\"upright\"/>"
);
let score = assert_idempotent_s4(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "fermata");
}
#[test]
fn inverted_fermata_round_trips() {
let abc = "X:1\nT:IFerm\nM:4/4\nL:1/4\nK:C\n!invertedfermata!C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<fermata type=\"inverted\"/>"),
"precondition: inverted fermata emits type=\"inverted\""
);
let score = assert_idempotent_s4(abc);
assert_eq!(
attachments_at(&score, 0).decorations[0].name,
"invertedfermata"
);
}
#[test]
fn upbow_technical_round_trips() {
let abc = "X:1\nT:Up\nM:4/4\nL:1/4\nK:C\n!upbow!C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<technical>") && x1.contains("<up-bow/>"),
"precondition: !upbow! emits <up-bow/>"
);
let score = assert_idempotent_s4(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "upbow");
}
#[test]
fn fingering_technical_text_round_trips() {
let abc = "X:1\nT:Fing\nM:4/4\nL:1/4\nK:C\n!1!C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<fingering>1</fingering>"),
"precondition: !1! emits <fingering>1</fingering>"
);
let score = assert_idempotent_s4(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "1");
}
#[test]
fn arpeggio_round_trips() {
let abc = "X:1\nT:Arp\nM:4/4\nL:1/4\nK:C\n!arpeggio![CEG] z2 z |\n";
let x1 = export(abc);
assert!(
x1.contains("<arpeggiate/>"),
"precondition: !arpeggio! emits <arpeggiate/>"
);
let score = assert_idempotent_s4(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "arpeggio");
}
#[test]
fn multiple_decorations_on_one_note_round_trip() {
let abc = "X:1\nT:Multi\nM:4/4\nL:1/4\nK:C\n!trill!.!fermata!C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<trill-mark/>")
&& x1.contains("<staccato/>")
&& x1.contains("<fermata type=\"upright\"/>"),
"precondition: all three notations are emitted"
);
let score = assert_idempotent_s4(abc);
let names: Vec<&str> = attachments_at(&score, 0)
.decorations
.iter()
.map(|d| d.name.as_str())
.collect();
assert!(names.contains(&"trill"));
assert!(names.contains(&"staccato"));
assert!(names.contains(&"fermata"));
}
#[test]
fn beams_are_derived_not_stored_and_round_trip() {
let abc = "X:1\nT:Beam\nM:4/4\nL:1/8\nK:C\nCDEF GABc |\n";
let x1 = export(abc);
assert!(
!x1.contains("<beam"),
"precondition: croma's writer derives beams and emits no <beam> element"
);
assert_idempotent_s4(abc);
}
#[test]
fn derived_time_modification_creates_no_tuplet_attachment() {
let abc = "X:1\nT:Odd\nM:4/4\nL:1/4\nK:C\nC2/3 D2/3 E2/3 z |\n";
let x1 = export(abc);
assert!(
x1.contains("<time-modification>") && !x1.contains("<tuplet"),
"precondition: an odd duration emits a derived time-modification with no <tuplet>"
);
let score = assert_idempotent_s4(abc);
assert!(
attachments_at(&score, 0).tuplets.is_empty(),
"a derived time-modification must NOT reconstruct a tuplet attachment"
);
}
#[test]
fn notation_and_tuplet_combine_round_trip() {
let abc = "X:1\nT:Combo\nM:4/4\nL:1/8\nK:C\n(3.CDE (FG) z2 |\n";
let score = assert_idempotent_s4(abc);
assert_eq!(
attachments_at(&score, 0).tuplets[0].role,
crate::model::TupletRole::Start
);
assert!(
attachments_at(&score, 0)
.decorations
.iter()
.any(|d| d.name == "staccato"),
"the first triplet note keeps its staccato"
);
}
#[test]
fn nested_tuplet_7_8_inner_3_2_round_trips_and_reconstructs_ratios() {
use crate::model::TupletRole;
let abc = "X:1\nT:NestedTup\nM:4/4\nL:1/16\nK:C\n[I:tuplets 2 0 2](7:8:8(3AAA AAAAA|\n";
let x1 = export(abc);
assert!(
x1.contains("<actual-notes>21</actual-notes>")
&& x1.contains("<normal-notes>16</normal-notes>"),
"precondition: inner 3 notes carry composite 21:16 time-modification\ngot:\n{x1}"
);
assert!(
x1.contains("<actual-notes>7</actual-notes>")
&& x1.contains("<normal-notes>8</normal-notes>"),
"precondition: outer 5 tail notes carry explicit 7:8 time-modification\ngot:\n{x1}"
);
let score = assert_idempotent_s4(abc);
let ev0 = &attachments_at(&score, 0).tuplets;
assert_eq!(ev0.len(), 2, "first inner note carries two tuplet starts");
let outer_start = ev0
.iter()
.find(|t| t.role == TupletRole::Start && t.actual_notes == 7);
let inner_start = ev0
.iter()
.find(|t| t.role == TupletRole::Start && t.actual_notes == 3);
assert!(
outer_start.is_some(),
"outer tuplet start must have actual_notes=7; got: {ev0:?}"
);
assert!(
inner_start.is_some(),
"inner tuplet start must have actual_notes=3; got: {ev0:?}"
);
let outer_start = outer_start.expect("outer tuplet start present");
let inner_start = inner_start.expect("inner tuplet start present");
assert_eq!(outer_start.actual_notes, 7, "outer actual");
assert_eq!(outer_start.normal_notes, 8, "outer normal");
assert_eq!(inner_start.actual_notes, 3, "inner actual");
assert_eq!(inner_start.normal_notes, 2, "inner normal");
let ev3 = &attachments_at(&score, 3).tuplets;
assert_eq!(ev3.len(), 1, "outer tail note has one tuplet continue");
assert_eq!(ev3[0].role, TupletRole::Continue);
assert_eq!(ev3[0].actual_notes, 7, "outer continue actual");
assert_eq!(ev3[0].normal_notes, 8, "outer continue normal");
}
#[test]
fn single_tuplet_continue_does_not_panic() {
let abc = "X:1\nT:SingleTup\nM:4/4\nL:1/8\nK:C\n(3ccc cccc z|\n";
let _score = assert_idempotent_s4(abc);
}
#[test]
fn nested_tuplet_zero_tail_notes_does_not_panic() {
use crate::model::TupletRole;
let abc = "X:1\nT:ZeroTail\nM:4/4\nL:1/8\nK:C\n[I:tuplets 2 0 2](3:2:3(3:2:3CCC cccc z|\n";
let (_, _, score) = round_trip(abc);
let ev0 = &attachments_at(&score, 0).tuplets;
let outer = ev0
.iter()
.find(|t| t.role == TupletRole::Start && t.actual_notes == 9);
assert!(
outer.is_some(),
"outer start should carry composite 9:4 (no recovery); got: {ev0:?}"
);
let inner = ev0
.iter()
.find(|t| t.role == TupletRole::Start && t.normal_notes == 4);
assert!(
inner.is_some(),
"inner start should carry composite 9:4 (no recovery); got: {ev0:?}"
);
}
fn assert_idempotent_s5(abc: &str) -> Score {
let (x1, x2, score) = round_trip(abc);
assert_eq!(
x1, x2,
"write(read(write(score))) must equal write(score) byte-for-byte (S5a directions)"
);
score
}
#[test]
fn header_tempo_numeric_reconstructs_tempo_model() {
use crate::model::TempoBeat;
let abc = "X:1\nT:Tempo\nQ:1/4=90\nM:4/4\nL:1/4\nK:C\nC D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<beat-unit>quarter</beat-unit>") && x1.contains("<per-minute>90</per-minute>"),
"precondition: Q:1/4=90 emits a quarter metronome at 90"
);
let score = assert_idempotent_s5(abc);
let tempo = score
.metadata
.tempo_model
.as_ref()
.expect("S5a must reconstruct the header <metronome> into metadata.tempo_model");
assert_eq!(tempo.text, None, "a bare numeric tempo carries no words");
assert_eq!(tempo.beat_role, TempoBeatRole::PrintedMetronome);
assert_eq!(
tempo.beat,
Some(TempoBeat {
beat_numerator: 1,
beat_denominator: 4,
bpm: 90,
}),
"the reconstructed beat must drive the same <beat-unit>/<per-minute>"
);
}
#[test]
fn header_tempo_dotted_beat_unit_reconstructs() {
use crate::model::TempoBeat;
let abc = "X:1\nT:Dotted\nQ:3/8=60\nM:6/8\nL:1/8\nK:C\nC2C2C2 |\n";
let x1 = export(abc);
assert!(
x1.contains("<beat-unit-dot/>"),
"precondition: Q:3/8=60 emits a dotted beat unit"
);
let score = assert_idempotent_s5(abc);
assert_eq!(
score.metadata.tempo_model.as_ref().and_then(|t| t.beat),
Some(TempoBeat {
beat_numerator: 3,
beat_denominator: 8,
bpm: 60,
})
);
assert_eq!(
score.metadata.tempo_model.as_ref().map(|t| t.beat_role),
Some(TempoBeatRole::PrintedMetronome)
);
}
#[test]
fn header_tempo_with_text_reconstructs_words_and_beat() {
let abc = "X:1\nT:WithText\nQ:\"Allegro\" 1/4=120\nM:4/4\nL:1/4\nK:C\nC D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<words>Allegro</words>") && x1.contains("<per-minute>120</per-minute>"),
"precondition: text+numeric tempo emits words AND metronome"
);
let score = assert_idempotent_s5(abc);
let tempo = score.metadata.tempo_model.as_ref().expect("tempo_model");
assert_eq!(tempo.text.as_deref(), Some("Allegro"));
assert_eq!(tempo.beat.map(|b| b.bpm), Some(120));
assert_eq!(tempo.beat_role, TempoBeatRole::PrintedMetronome);
}
#[test]
fn header_text_only_tempo_reconstructs_words_no_beat() {
let abc = "X:1\nT:TextTempo\nQ:\"Andante\"\nM:4/4\nL:1/4\nK:C\nC D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<words>Andante</words>")
&& !x1.contains("<sound tempo=\"120.00\"/>")
&& !x1.contains("<metronome>"),
"precondition: a text-only tempo emits words only, no sound/metronome"
);
let score = assert_idempotent_s5(abc);
let tempo = score
.metadata
.tempo_model
.as_ref()
.expect("a text-only tempo must reconstruct metadata.tempo_model");
assert_eq!(tempo.text.as_deref(), Some("Andante"));
assert_eq!(
tempo.beat, None,
"a text-only tempo carries no numeric beat"
);
}
#[test]
fn mid_tune_tempo_change_reconstructs_event() {
use crate::model::TimedEventKind;
let abc = "X:1\nT:MidTempo\nM:4/4\nL:1/4\nK:C\nC D [Q:1/4=160] E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<per-minute>160</per-minute>"),
"precondition: the inline [Q:] emits a 160 metronome"
);
let score = assert_idempotent_s5(abc);
assert!(
score.metadata.tempo_model.is_none(),
"no header Q: -> tempo_model stays None; the inline tempo is an event"
);
let tempo_changes: Vec<_> = score.parts[0].voices[0]
.events
.iter()
.filter(|e| matches!(e.kind, TimedEventKind::TempoChange(_)))
.collect();
assert_eq!(
tempo_changes.len(),
1,
"the inline [Q:] reconstructs exactly one TempoChange event"
);
let bpm = match &tempo_changes[0].kind {
TimedEventKind::TempoChange(model) => model.beat.map(|b| b.bpm),
_ => None,
};
assert_eq!(bpm, Some(160), "the TempoChange carries the 160 bpm");
}
#[test]
fn header_and_mid_tune_tempo_both_round_trip() {
use crate::model::TimedEventKind;
let abc = "X:1\nT:Both\nQ:1/4=90\nM:4/4\nL:1/4\nK:C\nC D [Q:1/4=160] E F |\n";
let score = assert_idempotent_s5(abc);
assert_eq!(
score
.metadata
.tempo_model
.as_ref()
.and_then(|t| t.beat)
.map(|b| b.bpm),
Some(90),
"the leading tempo is the header tempo_model (90)"
);
let mid: Vec<_> = score.parts[0].voices[0]
.events
.iter()
.filter(|e| matches!(e.kind, TimedEventKind::TempoChange(_)))
.collect();
assert_eq!(mid.len(), 1, "exactly one inline TempoChange (160)");
}
#[test]
fn dynamic_forte_reconstructs_decoration() {
use crate::model::DecorationSourceKind;
let abc = "X:1\nT:Forte\nM:4/4\nL:1/4\nK:C\n!f!C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<dynamics>") && x1.contains("<f/>"),
"precondition: !f! emits a <dynamics><f/> direction"
);
let score = assert_idempotent_s5(abc);
let decos = &attachments_at(&score, 0).decorations;
assert_eq!(decos.len(), 1, "one dynamic decoration on the first note");
assert_eq!(decos[0].name, "f", "the reconstructed name re-emits <f/>");
assert_eq!(decos[0].source_kind, DecorationSourceKind::Named);
}
#[test]
fn dynamic_pianissimo_reconstructs() {
let abc = "X:1\nT:PP\nM:4/4\nL:1/4\nK:C\n!pp!C D E F |\n";
let score = assert_idempotent_s5(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "pp");
}
#[test]
fn dynamic_sforzando_reconstructs() {
let abc = "X:1\nT:SF\nM:4/4\nL:1/4\nK:C\n!sfz!C D E F |\n";
let x1 = export(abc);
assert!(x1.contains("<sfz/>"), "precondition: !sfz! emits <sfz/>");
let score = assert_idempotent_s5(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "sfz");
}
#[test]
fn extended_dynamics_reconstruct() {
let abc = "X:1\nT:ExtendedDyn\nM:4/4\nL:1/4\nK:C\n!pppp!C !ffffff!D !fp!E !rf!F |\n";
let x1 = export(abc);
assert!(
x1.contains("<pppp/>")
&& x1.contains("<ffffff/>")
&& x1.contains("<fp/>")
&& x1.contains("<rf/>"),
"precondition: extended ABC dynamics emit matching MusicXML dynamics"
);
let score = assert_idempotent_s5(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "pppp");
assert_eq!(attachments_at(&score, 1).decorations[0].name, "ffffff");
assert_eq!(attachments_at(&score, 2).decorations[0].name, "fp");
assert_eq!(attachments_at(&score, 3).decorations[0].name, "rf");
}
#[test]
fn crescendo_wedge_reconstructs_open_and_close() {
let abc = "X:1\nT:Cresc\nM:4/4\nL:1/4\nK:C\n!<(!C D!<)! E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<wedge type=\"crescendo\"/>") && x1.contains("<wedge type=\"stop\"/>"),
"precondition: a crescendo hairpin emits crescendo + stop wedges"
);
let score = assert_idempotent_s5(abc);
let open = &attachments_at(&score, 0).decorations;
assert!(
open.iter().any(|d| d.name == "crescendo("),
"the open note carries a crescendo( decoration, got {:?}",
open.iter().map(|d| &d.name).collect::<Vec<_>>()
);
let close = &attachments_at(&score, 2).decorations;
assert!(
close.iter().any(|d| d.name == "crescendo)"),
"the note after the close marker carries a crescendo) decoration"
);
}
#[test]
fn diminuendo_wedge_reconstructs() {
let abc = "X:1\nT:Dim\nM:4/4\nL:1/4\nK:C\n!>(!C D!>)! E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<wedge type=\"diminuendo\"/>"),
"precondition: !>(! emits a diminuendo wedge"
);
let score = assert_idempotent_s5(abc);
assert!(
attachments_at(&score, 0)
.decorations
.iter()
.any(|d| d.name == "diminuendo(")
);
}
#[test]
fn coda_reconstructs_decoration() {
let abc = "X:1\nT:Coda\nM:4/4\nL:1/4\nK:C\n!coda!C D E F |\n";
let x1 = export(abc);
assert!(x1.contains("<coda/>"), "precondition: !coda! emits <coda/>");
let score = assert_idempotent_s5(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "coda");
}
#[test]
fn segno_reconstructs_decoration() {
let abc = "X:1\nT:Segno\nM:4/4\nL:1/4\nK:C\n!segno!C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<segno/>"),
"precondition: !segno! emits <segno/>"
);
let score = assert_idempotent_s5(abc);
assert_eq!(attachments_at(&score, 0).decorations[0].name, "segno");
}
#[test]
fn pre_barline_segno_reconstructs_on_trailing_spacer() {
use crate::model::TimedEventKind;
let abc = "X:1\nT:Trail\nM:4/4\nL:1/4\nK:C\nC D E F !segno!|\n";
let x1 = export(abc);
assert!(
x1.contains("<segno/>"),
"precondition: a pre-barline !segno! still emits <segno/>"
);
let score = assert_idempotent_s5(abc);
let events = &score.parts[0].voices[0].events;
let last = events.last().expect("at least one event");
assert!(
matches!(last.kind, TimedEventKind::Spacer),
"the trailing segno reconstructs on a Spacer event, got {:?}",
last.kind
);
assert_eq!(
last.duration,
Fraction::zero(),
"the Spacer is zero-duration"
);
assert!(
last.attachments
.decorations
.iter()
.any(|d| d.name == "segno"),
"the trailing Spacer carries the segno decoration"
);
}
#[test]
fn annotation_above_reconstructs_text_and_placement() {
use crate::model::AnnotationPlacementModel;
let abc = "X:1\nT:Anno\nM:4/4\nL:1/4\nK:C\n\"^Andante\"C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("placement=\"above\"") && x1.contains("<words>Andante</words>"),
"precondition: ^Andante emits an above words direction"
);
let score = assert_idempotent_s5(abc);
let annotations = &attachments_at(&score, 0).annotations;
assert_eq!(annotations.len(), 1, "one annotation on the first note");
assert_eq!(
annotations[0].placement,
Some(AnnotationPlacementModel::Above),
"the above placement must reconstruct"
);
assert!(
annotations[0].text.ends_with("Andante"),
"reconstructed annotation text re-emits Andante, got {:?}",
annotations[0].text
);
}
#[test]
fn annotation_below_reconstructs_placement() {
use crate::model::AnnotationPlacementModel;
let abc = "X:1\nT:Below\nM:4/4\nL:1/4\nK:C\n\"_sotto\"C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("placement=\"below\"") && x1.contains("<words>sotto</words>"),
"precondition: _sotto emits a below words direction"
);
let score = assert_idempotent_s5(abc);
assert_eq!(
attachments_at(&score, 0).annotations[0].placement,
Some(AnnotationPlacementModel::Below)
);
}
#[test]
fn dynamic_and_annotation_on_same_note_round_trip() {
let abc = "X:1\nT:Mix\nM:4/4\nL:1/4\nK:C\n\"^cresc.\"!f!C D E F |\n";
let score = assert_idempotent_s5(abc);
assert_eq!(
attachments_at(&score, 0).annotations.len(),
1,
"the annotation reconstructs"
);
assert!(
attachments_at(&score, 0)
.decorations
.iter()
.any(|d| d.name == "f"),
"the dynamic reconstructs"
);
}
#[test]
fn no_direction_leaves_attachments_empty() {
let score = assert_idempotent_s5("X:1\nT:Plain\nM:4/4\nL:1/4\nK:C\nC D E F |\n");
assert!(score.metadata.tempo_model.is_none());
assert!(attachments_at(&score, 0).annotations.is_empty());
assert!(attachments_at(&score, 0).decorations.is_empty());
}
fn assert_idempotent_s5b(abc: &str) -> Score {
let (x1, x2, score) = round_trip(abc);
assert_eq!(
x1, x2,
"write(read(write(score))) must equal write(score) byte-for-byte (S5b harmony + lyrics)"
);
score
}
#[test]
fn harmony_major_reconstructs_chord_symbol_text() {
let abc = "X:1\nT:H\nM:4/4\nL:1/4\nK:C\n\"C\"C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<kind text=\"C\">major</kind>"),
"precondition: a bare C chord emits a major <harmony>"
);
let score = assert_idempotent_s5b(abc);
let symbols = &attachments_at(&score, 0).chord_symbols;
assert_eq!(symbols.len(), 1, "one chord symbol on the first note");
assert_eq!(
symbols[0].text, "C",
"the reconstructed chord text is the <kind text=...> attribute"
);
}
#[test]
fn harmony_minor_reconstructs() {
let abc = "X:1\nT:H\nM:4/4\nL:1/4\nK:C\n\"Dm\"C D E F |\n";
let x1 = export(abc);
assert!(x1.contains("<kind text=\"Dm\">minor</kind>"));
let score = assert_idempotent_s5b(abc);
assert_eq!(attachments_at(&score, 0).chord_symbols[0].text, "Dm");
}
#[test]
fn harmony_seventh_reconstructs() {
let abc = "X:1\nT:H\nM:4/4\nL:1/4\nK:C\n\"Cmaj7\"C D E F |\n";
let x1 = export(abc);
assert!(x1.contains("<kind text=\"Cmaj7\">major-seventh</kind>"));
let score = assert_idempotent_s5b(abc);
assert_eq!(attachments_at(&score, 0).chord_symbols[0].text, "Cmaj7");
}
#[test]
fn harmony_slash_bass_reconstructs() {
let abc = "X:1\nT:H\nM:4/4\nL:1/4\nK:C\n\"G7/B\"C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<kind text=\"G7/B\">dominant</kind>")
&& x1.contains("<bass-step>B</bass-step>"),
"precondition: a slash chord emits a <bass> and preserves the text"
);
let score = assert_idempotent_s5b(abc);
assert_eq!(attachments_at(&score, 0).chord_symbols[0].text, "G7/B");
}
#[test]
fn harmony_altered_root_reconstructs() {
let abc = "X:1\nT:H\nM:4/4\nL:1/4\nK:C\n\"F#m7b5\"C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<root-alter>1</root-alter>")
&& x1.contains("<kind text=\"F#m7b5\">half-diminished</kind>"),
"precondition: a sharp-root altered chord emits root-alter and the text"
);
let score = assert_idempotent_s5b(abc);
assert_eq!(attachments_at(&score, 0).chord_symbols[0].text, "F#m7b5");
}
#[test]
fn harmony_with_added_degree_reconstructs() {
let abc = "X:1\nT:H\nM:4/4\nL:1/4\nK:C\n\"C7b9\"C D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<degree-value>9</degree-value>"),
"precondition: C7b9 emits an added 9th degree"
);
let score = assert_idempotent_s5b(abc);
assert_eq!(attachments_at(&score, 0).chord_symbols[0].text, "C7b9");
}
#[test]
fn harmony_then_annotation_round_trip_in_order() {
let abc = "X:1\nT:H\nM:4/4\nL:1/4\nK:C\n\"C\"\"^Slow\"C D E F |\n";
let score = assert_idempotent_s5b(abc);
assert_eq!(
attachments_at(&score, 0).chord_symbols.len(),
1,
"the chord reconstructs into chord_symbols"
);
assert_eq!(
attachments_at(&score, 0).annotations.len(),
1,
"the annotation reconstructs into annotations"
);
}
#[test]
fn two_chords_before_one_note_round_trip_in_order() {
let abc = "X:1\nT:H\nM:4/4\nL:1/4\nK:C\n\"C\"\"Am\"D E F G |\n";
let x1 = export(abc);
assert_eq!(
x1.matches("<harmony>").count(),
2,
"precondition: two chords emit two <harmony> blocks"
);
let score = assert_idempotent_s5b(abc);
let symbols = &attachments_at(&score, 0).chord_symbols;
assert_eq!(
symbols.len(),
2,
"both chords reconstruct on the first note"
);
assert_eq!(symbols[0].text, "C", "first chord stays first");
assert_eq!(symbols[1].text, "Am", "second chord stays second");
}
#[test]
fn harmony_before_rest_reconstructs_on_rest_event() {
use crate::model::TimedEventKind;
let abc = "X:1\nT:Trail\nM:4/4\nL:1/4\nK:C\nC D E F | \"C\" z4 |\n";
let x1 = export(abc);
assert!(
x1.contains("<harmony>"),
"precondition: a chord before a rest emits <harmony>"
);
let score = assert_idempotent_s5b(abc);
let rest_event = score.parts[0].voices[0]
.events
.iter()
.find(|e| matches!(e.kind, TimedEventKind::Rest(_)))
.expect("a rest event");
assert_eq!(
rest_event.attachments.chord_symbols.len(),
1,
"the chord reconstructs onto the following rest event"
);
assert_eq!(rest_event.attachments.chord_symbols[0].text, "C");
}
fn foreign_harmony_score(harmony_xml: &str) -> Score {
let xml = format!(
r#"<?xml version="1.0"?>
<score-partwise>
<part-list><score-part id="P1"><part-name>P</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>8</divisions></attributes>
{harmony_xml}
<note>
<pitch><step>C</step><octave>5</octave></pitch>
<duration>8</duration>
<voice>1</voice>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>"#
);
read_musicxml(&xml).value
}
fn foreign_chord_text(harmony_xml: &str) -> String {
let score = foreign_harmony_score(harmony_xml);
let symbols = &attachments_at(&score, 0).chord_symbols;
assert_eq!(
symbols.len(),
1,
"exactly one chord symbol synthesised from the textless <harmony>"
);
symbols[0].text.clone()
}
#[test]
fn textless_harmony_major_synthesises_root() {
let text = foreign_chord_text(
"<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>",
);
assert_eq!(text, "C", "C major synthesises to a bare root");
}
#[test]
fn textless_harmony_minor_synthesises_m_suffix() {
let text = foreign_chord_text(
"<harmony><root><root-step>D</root-step></root><kind>minor</kind></harmony>",
);
assert_eq!(text, "Dm", "D minor synthesises to Dm");
}
#[test]
fn textless_harmony_dominant_synthesises_7() {
let text = foreign_chord_text(
"<harmony><root><root-step>G</root-step></root><kind>dominant</kind></harmony>",
);
assert_eq!(text, "G7", "G dominant synthesises to G7");
}
#[test]
fn foreign_harmony_suffix_text_recombines_with_root() {
for (harmony_xml, expected) in [
(
"<harmony><root><root-step>B</root-step></root><kind text=\"dim\">diminished</kind></harmony>",
"Bdim",
),
(
"<harmony><root><root-step>D</root-step><root-alter>-1</root-alter></root><kind text=\"7\">dominant</kind></harmony>",
"Db7",
),
(
"<harmony><root><root-step>F</root-step></root><kind text=\"m\">minor</kind></harmony>",
"Fm",
),
(
"<harmony><root><root-step>C</root-step></root><kind text=\"m7\">minor-seventh</kind><bass><bass-step>E</bass-step><bass-alter>-1</bass-alter></bass></harmony>",
"Cm7/Eb",
),
] {
let text = foreign_chord_text(harmony_xml);
assert_eq!(
text, expected,
"foreign kind@text can be a quality suffix, not a complete chord symbol"
);
}
}
#[test]
fn foreign_harmony_unknown_kind_emits_bare_root_not_enum() {
for (harmony_xml, expected) in [
(
"<harmony><root><root-step>C</root-step></root><kind text=\"1\">none</kind></harmony>",
"C",
),
(
"<harmony><root><root-step>G</root-step><root-alter>-1</root-alter></root><kind text=\"ยฐ\">other</kind></harmony>",
"Gb",
),
] {
let text = foreign_chord_text(harmony_xml);
assert_eq!(
text, expected,
"an unmodellable kind enum must not be appended verbatim"
);
}
}
#[test]
fn foreign_harmony_unknown_kind_round_trips_via_carrier() {
let xml =
"<harmony><root><root-step>C</root-step></root><kind text=\"1\">none</kind></harmony>";
let mut score = foreign_harmony_score(xml);
crate::musicxml::read::complete_score_for_abc(&mut score);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:cr ht text=\"1\"]\"C\""),
"ABC projection must carry text=\"1\" beside a bare-root chord:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("projected harmony ABC should export");
assert!(
roundtrip.musicxml.contains("<kind text=\"1\">"),
"round-trip MusicXML must restore the original kind@text:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_harmony_suffix_text_survives_abc_projection() {
let xml = "<harmony><root><root-step>B</root-step></root><kind text=\"dim\">diminished</kind></harmony>";
let mut score = foreign_harmony_score(xml);
crate::musicxml::read::complete_score_for_abc(&mut score);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:cr ht text=\"dim\"]\"Bdim\""),
"ABC projection must carry the source MusicXML kind@text suffix:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("projected harmony ABC should export");
assert!(
roundtrip
.musicxml
.contains("<kind text=\"dim\">diminished</kind>"),
"round-trip MusicXML must keep suffix-only kind@text:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_harmony_hex_text_survives_abc_projection() {
let xml =
"<harmony><root><root-step>C</root-step></root><kind text=\"]bad\">other</kind></harmony>";
let mut score = foreign_harmony_score(xml);
crate::musicxml::read::complete_score_for_abc(&mut score);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:cr ht text-hex=5d626164]"),
"ABC projection must carry hostile kind@text through the hex carrier:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("hex-carried harmony ABC should export");
assert!(
roundtrip.musicxml.contains("<kind text=\"]bad\">"),
"round-trip MusicXML must restore the original kind@text containing ']':\n{}",
roundtrip.musicxml
);
}
#[test]
fn textless_harmony_stays_textless_through_abc_projection() {
let xml = "<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>";
let mut score = foreign_harmony_score(xml);
crate::musicxml::read::complete_score_for_abc(&mut score);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:cr htx]\"C\""),
"ABC projection must carry the absent source kind@text:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("projected textless harmony ABC should export");
assert!(
roundtrip.musicxml.contains("<kind>major</kind>")
&& !roundtrip.musicxml.contains("<kind text=\"C\">major</kind>"),
"round-trip MusicXML must not invent kind@text for textless harmony:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_harmony_empty_kind_text_survives_abc_projection() {
let xml = "<harmony><root><root-step>C</root-step></root><kind text=\"\">none</kind></harmony>";
let mut score = foreign_harmony_score(xml);
crate::musicxml::read::complete_score_for_abc(&mut score);
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip = export_musicxml(&abc).expect("projected harmony ABC should export");
assert!(
roundtrip.musicxml.contains("<kind text=\"\">"),
"round-trip MusicXML must restore the explicit empty kind@text:\n{}",
roundtrip.musicxml
);
assert!(
!roundtrip.musicxml.contains("<kind>"),
"an explicit empty `text=` must not degrade to a textless bare <kind>:\n{}",
roundtrip.musicxml
);
}
#[test]
fn textless_harmony_before_zero_duration_directions_survives_abc_projection() {
let xml = r#"<?xml version="1.0"?>
<score-partwise>
<part-list><score-part id="P1"><part-name>P</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>8</divisions></attributes>
<harmony>
<root><root-step>B</root-step><root-alter>-1</root-alter></root>
<kind>major</kind>
</harmony>
<direction placement="above">
<direction-type><rehearsal font-weight="bold">A</rehearsal></direction-type>
</direction>
<direction placement="above">
<direction-type><words>Verse</words></direction-type>
</direction>
<note>
<pitch><step>D</step><alter>-1</alter><octave>5</octave></pitch>
<duration>8</duration>
<voice>1</voice>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>"#;
let mut score = read_musicxml(xml).value;
let direct_xml = write_score_partwise(&score).value;
let direct_harmony = direct_xml
.find("<harmony>")
.expect("direct XML inverse must keep the harmony");
let direct_rehearsal = direct_xml
.find("<rehearsal")
.expect("direct XML inverse must keep the rehearsal");
assert!(
direct_harmony < direct_rehearsal,
"direct MusicXML inverse must keep harmony before the rehearsal:\n{direct_xml}"
);
crate::musicxml::read::complete_score_for_abc(&mut score);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[P:A]"),
"ABC projection must keep the intervening rehearsal direction:\n{abc}"
);
assert!(
abc.contains("[Q:"),
"ABC projection must keep the intervening tempo-like words direction:\n{abc}"
);
assert!(
abc.contains("[I:cr htx]\"Bb\""),
"ABC projection must not drop harmony buffered before zero-duration directions:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("projected harmony with intervening directions should export");
assert!(
roundtrip.musicxml.contains("<harmony>")
&& roundtrip.musicxml.contains("<kind>major</kind>"),
"round-trip MusicXML must keep the textless harmony:\n{}",
roundtrip.musicxml
);
}
#[test]
fn textless_harmony_before_mid_tune_tempo_survives_abc_projection() {
let xml = r#"<?xml version="1.0"?>
<score-partwise>
<part-list><score-part id="P1"><part-name>P</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>8</divisions></attributes>
<note>
<pitch><step>C</step><octave>5</octave></pitch>
<duration>8</duration>
<voice>1</voice>
<type>quarter</type>
</note>
<harmony>
<root><root-step>B</root-step><root-alter>-1</root-alter></root>
<kind>major</kind>
</harmony>
<direction placement="above">
<direction-type><words>Verse</words></direction-type>
</direction>
<note>
<pitch><step>D</step><alter>-1</alter><octave>5</octave></pitch>
<duration>8</duration>
<voice>1</voice>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>"#;
let mut score = read_musicxml(xml).value;
let direct_xml = write_score_partwise(&score).value;
let direct_harmony = direct_xml
.find("<harmony>")
.expect("direct XML inverse must keep the harmony");
let direct_words = direct_xml
.find("<words>Verse</words>")
.expect("direct XML inverse must keep the words tempo");
assert!(
direct_harmony < direct_words,
"direct MusicXML inverse must keep harmony before the tempo words:\n{direct_xml}"
);
crate::musicxml::read::complete_score_for_abc(&mut score);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[Q:\"Verse\"] [I:cr htx]\"Bb\""),
"ABC projection must not drop harmony buffered before a mid-tune tempo:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("projected harmony before mid-tune tempo should export");
assert!(
roundtrip.musicxml.contains("<harmony>")
&& roundtrip.musicxml.contains("<kind>major</kind>")
&& roundtrip.musicxml.contains("<words>Verse</words>"),
"round-trip MusicXML must keep both the textless harmony and tempo words:\n{}",
roundtrip.musicxml
);
}
#[test]
fn textless_harmony_major_seventh_synthesises_maj7() {
let text = foreign_chord_text(
"<harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>",
);
assert_eq!(text, "Cmaj7", "C major-seventh synthesises to Cmaj7");
}
#[test]
fn textless_harmony_sharp_root_minor_seventh_synthesises() {
let text = foreign_chord_text(
"<harmony><root><root-step>F</root-step><root-alter>1</root-alter></root><kind>minor-seventh</kind></harmony>",
);
assert_eq!(text, "F#m7", "F#-rooted minor-seventh synthesises to F#m7");
}
#[test]
fn textless_harmony_float_root_alter_synthesises_sharp_root() {
let text = foreign_chord_text(
"<harmony><root><root-step>F</root-step><root-alter>1.0</root-alter></root><kind>minor-seventh</kind></harmony>",
);
assert_eq!(
text, "F#m7",
"a float <root-alter>1.0</root-alter> must synthesise a sharp root"
);
}
#[test]
fn textless_harmony_float_bass_alter_synthesises_sharp_bass() {
let text = foreign_chord_text(
"<harmony><root><root-step>G</root-step></root><kind>dominant</kind><bass><bass-step>F</bass-step><bass-alter>1.0</bass-alter></bass></harmony>",
);
assert_eq!(
text, "G7/F#",
"a float <bass-alter>1.0</bass-alter> must synthesise a sharp slash bass"
);
}
#[test]
fn textless_harmony_with_bass_synthesises_slash() {
let text = foreign_chord_text(
"<harmony><root><root-step>G</root-step></root><kind>dominant</kind><bass><bass-step>B</bass-step></bass></harmony>",
);
assert_eq!(
text, "G7/B",
"a dominant chord with a B bass synthesises to G7/B"
);
}
#[test]
fn textless_harmony_unmodellable_kind_emits_bare_root() {
let text = foreign_chord_text(
"<harmony><root><root-step>C</root-step></root><kind>other</kind></harmony>",
);
assert_eq!(
text, "C",
"an unmodellable kind enum is dropped, leaving the bare playable root"
);
}
#[test]
fn textless_harmony_empty_kind_emits_bare_root() {
let xml = r#"<?xml version="1.0"?>
<score-partwise>
<part-list><score-part id="P1"><part-name>P</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>8</divisions></attributes>
<harmony><root><root-step>C</root-step></root><kind/></harmony>
<note>
<pitch><step>C</step><octave>5</octave></pitch>
<duration>8</duration>
<voice>1</voice>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>"#;
let report = read_musicxml(xml);
let symbols = &attachments_at(&report.value, 0).chord_symbols;
assert_eq!(symbols.len(), 1, "the bare-root chord is reconstructed");
assert_eq!(
symbols[0].text, "C",
"empty kind yields the bare playable root"
);
}
#[test]
fn textless_harmony_synthesised_string_round_trips_stable() {
for harmony_xml in [
"<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>",
"<harmony><root><root-step>D</root-step></root><kind>minor</kind></harmony>",
"<harmony><root><root-step>G</root-step></root><kind>dominant</kind></harmony>",
"<harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>",
"<harmony><root><root-step>F</root-step><root-alter>1</root-alter></root><kind>minor-seventh</kind></harmony>",
"<harmony><root><root-step>G</root-step></root><kind>dominant</kind><bass><bass-step>B</bass-step></bass></harmony>",
"<harmony><root><root-step>B</root-step><root-alter>-1</root-alter></root><kind>diminished-seventh</kind></harmony>",
] {
let score = foreign_harmony_score(harmony_xml);
let synthesised = attachments_at(&score, 0).chord_symbols[0].text.clone();
let x2 = write_score_partwise(&score).value;
assert!(
x2.contains("<harmony>") && !x2.contains("<kind text="),
"the writer re-emits the synthesised string `{synthesised}` as textless <harmony>"
);
let reread = read_musicxml(&x2).value;
assert_eq!(
attachments_at(&reread, 0).chord_symbols[0].text,
synthesised,
"re-reading croma's own output reproduces the synthesised chord `{synthesised}`"
);
}
}
#[test]
fn every_synthesised_kind_suffix_round_trips_stable() {
let kinds = [
"major",
"minor",
"augmented",
"diminished",
"dominant",
"dominant-seventh",
"major-seventh",
"minor-seventh",
"diminished-seventh",
"augmented-seventh",
"half-diminished",
"half-diminished-seventh",
"major-sixth",
"minor-sixth",
"dominant-ninth",
"major-ninth",
"minor-ninth",
"dominant-11th",
"major-11th",
"minor-11th",
"dominant-13th",
"major-13th",
"minor-13th",
"suspended-fourth",
"suspended-second",
"power",
];
for kind in kinds {
let harmony_xml =
format!("<harmony><root><root-step>C</root-step></root><kind>{kind}</kind></harmony>");
let score = foreign_harmony_score(&harmony_xml);
let symbols = &attachments_at(&score, 0).chord_symbols;
assert_eq!(
symbols.len(),
1,
"kind `{kind}` must synthesise exactly one chord symbol"
);
let synthesised = symbols[0].text.clone();
let x2 = write_score_partwise(&score).value;
assert!(
x2.contains("<harmony>") && !x2.contains("<kind text="),
"kind `{kind}` -> `{synthesised}` must re-parse as a chord on re-export, \
not demote to <words>"
);
let reread = read_musicxml(&x2).value;
assert_eq!(
attachments_at(&reread, 0).chord_symbols[0].text,
synthesised,
"kind `{kind}` -> `{synthesised}` must reproduce itself on re-read"
);
}
}
#[test]
fn lyric_single_syllable_reconstructs() {
use crate::model::LyricControl;
let abc = "X:1\nT:L\nM:4/4\nL:1/4\nK:C\nC D E F |\nw: la la la la\n";
let x1 = export(abc);
assert!(
x1.contains("<syllabic>single</syllabic>") && x1.contains("<text>la</text>"),
"precondition: a stand-alone syllable emits single"
);
let score = assert_idempotent_s5b(abc);
let lyrics = &attachments_at(&score, 0).lyrics;
assert_eq!(lyrics.len(), 1, "one lyric on the first note");
assert_eq!(lyrics[0].verse, 1);
assert_eq!(lyrics[0].text, "la");
assert_eq!(lyrics[0].control, LyricControl::Syllable);
}
#[test]
fn lyric_hyphenated_word_reconstructs_begin_end() {
use crate::model::LyricControl;
let abc = "X:1\nT:L\nM:4/4\nL:1/4\nK:C\nC D E F |\nw: Twin-kle star light\n";
let x1 = export(abc);
assert!(
x1.contains("<syllabic>begin</syllabic>") && x1.contains("<syllabic>end</syllabic>"),
"precondition: a two-syllable word emits begin then end"
);
let score = assert_idempotent_s5b(abc);
let first = &attachments_at(&score, 0).lyrics;
assert_eq!(
first.len(),
2,
"the begin syllable note carries a Syllable AND a trailing Hyphen"
);
assert_eq!(first[0].control, LyricControl::Syllable);
assert_eq!(first[0].text, "Twin");
assert_eq!(
first[1].control,
LyricControl::Hyphen,
"a begin syllabic reconstructs a following Hyphen on the same note"
);
let second = &attachments_at(&score, 1).lyrics;
assert_eq!(
second.len(),
1,
"the end syllable note carries only a Syllable"
);
assert_eq!(second[0].control, LyricControl::Syllable);
assert_eq!(second[0].text, "kle");
}
#[test]
fn lyric_three_syllable_word_reconstructs_middle() {
use crate::model::LyricControl;
let abc = "X:1\nT:L\nM:4/4\nL:1/4\nK:C\nC D E F |\nw: ho-li-day now\n";
let x1 = export(abc);
assert!(
x1.contains("<syllabic>middle</syllabic>"),
"precondition: a three-syllable word emits a middle syllabic"
);
let score = assert_idempotent_s5b(abc);
let middle = &attachments_at(&score, 1).lyrics;
assert_eq!(
middle.len(),
2,
"the middle syllable carries Syllable + Hyphen"
);
assert_eq!(middle[0].text, "li");
assert_eq!(middle[1].control, LyricControl::Hyphen);
}
#[test]
fn lyric_melisma_extender_reconstructs() {
use crate::model::LyricControl;
let abc = "X:1\nT:L\nM:4/4\nL:1/4\nK:C\nC D E F |\nw: yes_ no end\n";
let x1 = export(abc);
assert!(
x1.contains("<extend/>"),
"precondition: a melisma emits <extend/>"
);
let score = assert_idempotent_s5b(abc);
assert_eq!(attachments_at(&score, 0).lyrics[0].text, "yes");
let extend = &attachments_at(&score, 1).lyrics;
assert_eq!(
extend.len(),
1,
"the extended note carries one Extender lyric"
);
assert_eq!(extend[0].control, LyricControl::Extender);
assert!(extend[0].text.is_empty(), "an extender carries no text");
}
#[test]
fn lyric_multiple_verses_reconstruct_numbers() {
use crate::model::LyricControl;
let abc = "X:1\nT:L\nM:4/4\nL:1/4\nK:C\nC D E F |\nw: one two three four\nw: ay bee cee dee\n";
let x1 = export(abc);
assert!(
x1.contains("<lyric number=\"1\">") && x1.contains("<lyric number=\"2\">"),
"precondition: two w: lines emit two numbered verses"
);
let score = assert_idempotent_s5b(abc);
let lyrics = &attachments_at(&score, 0).lyrics;
assert_eq!(lyrics.len(), 2, "the first note carries both verses");
assert_eq!(lyrics[0].verse, 1);
assert_eq!(lyrics[0].text, "one");
assert_eq!(lyrics[0].control, LyricControl::Syllable);
assert_eq!(lyrics[1].verse, 2);
assert_eq!(lyrics[1].text, "ay");
assert_eq!(lyrics[1].control, LyricControl::Syllable);
}
#[test]
fn lyric_text_with_trailing_space_round_trips_verbatim() {
let abc = "X:1\nT:L\nM:4/4\nL:1/4\nK:C\nC D E F |\nw: la~ lo me fa\n";
let x1 = export(abc);
assert!(
x1.contains("<text>la </text>"),
"precondition: a `la~` syllable keeps its trailing space in the <text>, got:\n{x1}"
);
let score = assert_idempotent_s5b(abc);
assert_eq!(
attachments_at(&score, 0).lyrics[0].text,
"la ",
"the reconstructed lyric text preserves the trailing space verbatim"
);
}
#[test]
fn harmony_and_lyric_on_same_note_round_trip() {
let abc = "X:1\nT:Both\nM:4/4\nL:1/4\nK:C\n\"C\"C D E F |\nw: la la la la\n";
let score = assert_idempotent_s5b(abc);
assert_eq!(attachments_at(&score, 0).chord_symbols.len(), 1);
assert_eq!(attachments_at(&score, 0).lyrics.len(), 1);
}
#[test]
fn demoted_chord_symbol_before_harmony_preserves_order() {
let abc = "X:1\nT:R3\nM:4/4\nL:1/4\nK:C\n\"tr\"\"G7\"C D E F |\n";
let x1 = export(abc);
let dir = x1
.find("<words>tr</words>")
.expect("a demoted tr direction");
let harm = x1.find("<kind text=\"G7\">").expect("a G7 harmony");
assert!(
dir < harm,
"precondition: the writer emits <direction>tr BEFORE <harmony>G7"
);
let score = assert_idempotent_s5b(abc);
let symbols = &attachments_at(&score, 0).chord_symbols;
assert_eq!(
symbols.iter().map(|s| s.text.as_str()).collect::<Vec<_>>(),
vec!["tr", "G7"],
"the placement-less direction reconstructs as a chord_symbols entry, in order"
);
assert!(
attachments_at(&score, 0).annotations.is_empty(),
"a placement-less demoted symbol is NOT a (placement-bearing) annotation"
);
}
#[test]
fn harmony_before_demoted_chord_symbol_preserves_order() {
let abc = "X:1\nT:R3\nM:4/4\nL:1/4\nK:C\n\"G7\"\"tr\"C D E F |\n";
let x1 = export(abc);
let harm = x1.find("<kind text=\"G7\">").expect("a G7 harmony");
let dir = x1
.find("<words>tr</words>")
.expect("a demoted tr direction");
assert!(harm < dir, "precondition: <harmony>G7 BEFORE <direction>tr");
let score = assert_idempotent_s5b(abc);
assert_eq!(
attachments_at(&score, 0)
.chord_symbols
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>(),
vec!["G7", "tr"],
"order is preserved with the real chord first"
);
}
#[test]
fn placement_annotation_before_harmony_stays_annotation() {
let abc = "X:1\nT:R3\nM:4/4\nL:1/4\nK:C\n\"^hi\"\"G7\"C D E F |\n";
let x1 = export(abc);
let harm = x1.find("<kind text=\"G7\">").expect("a G7 harmony");
let dir = x1.find("<words>hi</words>").expect("an above hi direction");
assert!(
harm < dir && x1.contains("placement=\"above\""),
"precondition: the writer emits <harmony>G7 before the placed <direction>hi"
);
let score = assert_idempotent_s5b(abc);
assert_eq!(
attachments_at(&score, 0).chord_symbols.len(),
1,
"only the real chord is on the chord_symbols channel"
);
assert_eq!(
attachments_at(&score, 0).annotations.len(),
1,
"the placed annotation stays on the annotations channel"
);
}
#[test]
fn demoted_chord_symbol_before_harmony_in_note_less_measure_round_trips() {
let abc = "X:1\nT:R3\nM:4/4\nL:1/4\nK:C\nC D E F |\n\"tr\"\"G7\"|\n";
let x1 = export(abc);
let dir = x1
.find("<words>tr</words>")
.expect("a demoted tr direction");
let harm = x1.find("<kind text=\"G7\">").expect("a G7 harmony");
assert!(
dir < harm,
"precondition: <direction>tr BEFORE <harmony>G7 on the note-less measure"
);
assert_idempotent_s5b(abc);
}
fn assert_idempotent_s6a(abc: &str) -> Score {
let (x1, x2, score) = round_trip(abc);
assert_eq!(
x1, x2,
"write(read(write(score))) must equal write(score) byte-for-byte (S6a barlines)"
);
score
}
fn measure_at(score: &Score, index: usize) -> &crate::model::Measure {
&score.parts[0].voices[0].measures[index]
}
#[test]
fn final_barline_reconstructs_kind() {
use crate::model::BarlineKind;
let abc = "X:1\nT:F\nM:4/4\nL:1/4\nK:C\nC D E F |]\n";
let x1 = export(abc);
assert!(
x1.contains("<bar-style>light-heavy</bar-style>") && !x1.contains("<repeat"),
"precondition: |] emits light-heavy with no repeat"
);
let score = assert_idempotent_s6a(abc);
let barlines = &measure_at(&score, 0).barlines;
assert_eq!(barlines.len(), 1, "one reconstructed right barline");
assert_eq!(barlines[0].kind, BarlineKind::Final);
}
#[test]
fn double_barline_reconstructs_kind() {
use crate::model::BarlineKind;
let abc = "X:1\nT:D\nM:4/4\nL:1/4\nK:C\nC D E F || G A B c |\n";
let x1 = export(abc);
assert!(x1.contains("<bar-style>light-light</bar-style>"));
let score = assert_idempotent_s6a(abc);
assert_eq!(measure_at(&score, 0).barlines[0].kind, BarlineKind::Double);
}
#[test]
fn dotted_barline_reconstructs_kind() {
use crate::model::BarlineKind;
let abc = "X:1\nT:Dt\nM:4/4\nL:1/4\nK:C\nC D E F .| G A B c |\n";
let x1 = export(abc);
assert!(x1.contains("<bar-style>dotted</bar-style>"));
let score = assert_idempotent_s6a(abc);
assert_eq!(measure_at(&score, 0).barlines[0].kind, BarlineKind::Dotted);
}
#[test]
fn invisible_barline_reconstructs_kind() {
use crate::model::BarlineKind;
let abc = "X:1\nT:Inv\nM:4/4\nL:1/4\nK:C\nC D E F [|] G A B c |\n";
let x1 = export(abc);
assert!(x1.contains("<bar-style>none</bar-style>"));
let score = assert_idempotent_s6a(abc);
assert_eq!(
measure_at(&score, 0).barlines[0].kind,
BarlineKind::Invisible
);
}
#[test]
fn initial_thick_thin_barline_reconstructs_kind() {
use crate::model::BarlineKind;
let abc = "X:1\nT:I\nM:4/4\nL:1/4\nK:C\nC D E F [| G A B c |\n";
let x1 = export(abc);
assert!(
x1.contains("<bar-style>heavy-light</bar-style>") && !x1.contains("<repeat"),
"precondition: a mid-tune [| emits heavy-light with no repeat"
);
let score = assert_idempotent_s6a(abc);
assert_eq!(measure_at(&score, 0).barlines[0].kind, BarlineKind::Initial);
}
#[test]
fn repeat_start_reconstructs_leading_barline() {
use crate::model::BarlineKind;
let abc = "X:1\nT:R\nM:4/4\nL:1/4\nK:C\n|: C D E F :|\n";
let x1 = export(abc);
assert!(
x1.contains("<repeat direction=\"forward\"/>")
&& x1.contains("<repeat direction=\"backward\"/>"),
"precondition: |: ... :| emits a forward and a backward repeat"
);
let score = assert_idempotent_s6a(abc);
let barlines = &measure_at(&score, 0).barlines;
assert!(
barlines.iter().any(|b| b.kind == BarlineKind::RepeatStart),
"the first measure carries a leading RepeatStart"
);
assert!(
barlines.iter().any(|b| b.kind == BarlineKind::RepeatEnd),
"the measure also carries the closing RepeatEnd"
);
}
#[test]
fn repeat_start_end_across_measures_reconstructs() {
use crate::model::BarlineKind;
let abc = "X:1\nT:R2\nM:4/4\nL:1/4\nK:C\n|: C D E F | G A B c :|\n";
let score = assert_idempotent_s6a(abc);
assert!(
measure_at(&score, 0)
.barlines
.iter()
.any(|b| b.kind == BarlineKind::RepeatStart),
"measure 1 opens the repeat"
);
assert!(
measure_at(&score, 1)
.barlines
.iter()
.any(|b| b.kind == BarlineKind::RepeatEnd),
"measure 2 closes the repeat"
);
}
#[test]
fn repeat_both_decomposes_and_round_trips() {
use crate::model::BarlineKind;
let abc = "X:1\nT:RB\nM:4/4\nL:1/4\nK:C\n|: C D E F :: G A B c :|\n";
let score = assert_idempotent_s6a(abc);
assert!(
measure_at(&score, 0)
.barlines
.iter()
.any(|b| b.kind == BarlineKind::RepeatEnd),
"the `::` seam's back half is a RepeatEnd on measure 0's right"
);
assert!(
measure_at(&score, 1)
.barlines
.iter()
.any(|b| b.kind == BarlineKind::RepeatStart),
"the `::` seam's forward half is a leading RepeatStart on measure 1"
);
assert!(
score.parts[0].voices[0]
.measures
.iter()
.all(|m| m.barlines.iter().all(|b| b.kind != BarlineKind::RepeatBoth)),
"the reader decomposes `::` rather than materialising RepeatBoth"
);
}
#[test]
fn first_and_second_endings_reconstruct() {
use crate::model::{BarlineKind, RepeatEndingPartModel};
let abc = "X:1\nT:E\nM:4/4\nL:1/4\nK:C\n|: C D E F |1 G A B c :|2 c B A G |]\n";
let x1 = export(abc);
assert!(
x1.contains("<ending number=\"1\" type=\"start\"/>")
&& x1.contains("<ending number=\"2\" type=\"start\"/>"),
"precondition: 1st/2nd endings emit numbered starts"
);
let score = assert_idempotent_s6a(abc);
let first = score.parts[0].voices[0]
.measures
.iter()
.find_map(|m| m.repeat_endings.first())
.expect("a 1st-ending RepeatEndingModel is reconstructed");
assert_eq!(first.endings, vec![RepeatEndingPartModel::Single(1)]);
let second_count: usize = score.parts[0].voices[0]
.measures
.iter()
.map(|m| m.repeat_endings.len())
.sum();
assert_eq!(second_count, 2, "exactly two ending brackets open");
assert!(
score.parts[0].voices[0]
.measures
.iter()
.any(|m| m.barlines.iter().any(|b| b.kind == BarlineKind::Final)),
"the final |] is reconstructed (it closes the 2nd ending via the schedule)"
);
}
#[test]
fn ending_range_reconstructs_parts() {
use crate::model::RepeatEndingPartModel;
let abc = "X:1\nT:Rng\nM:4/4\nL:1/4\nK:C\n|: C D E F |1,2 G A B c :|\n";
let x1 = export(abc);
assert!(
x1.contains("type=\"start\""),
"precondition: the combined ending emits a start, got:\n{x1}"
);
let score = assert_idempotent_s6a(abc);
let ending = score.parts[0].voices[0]
.measures
.iter()
.find_map(|m| m.repeat_endings.first())
.expect("a combined-ending RepeatEndingModel is reconstructed");
assert_eq!(
ending.endings,
vec![
RepeatEndingPartModel::Single(1),
RepeatEndingPartModel::Single(2)
],
"the `1,2` number list reconstructs two Single parts"
);
}
#[test]
fn no_barline_directives_leave_measure_lists_empty() {
let score = assert_idempotent_s6a("X:1\nT:Plain\nM:4/4\nL:1/4\nK:C\nC D E F | G A B c |\n");
assert!(
score.parts[0].voices[0]
.measures
.iter()
.all(|m| m.barlines.is_empty() && m.repeat_endings.is_empty()),
"plain `|` boundaries reconstruct no barline/ending model entries"
);
}
fn assert_idempotent_s6b(abc: &str) -> Score {
let (x1, x2, score) = round_trip(abc);
assert_eq!(
x1, x2,
"write(read(write(score))) must equal write(score) byte-for-byte (S6b mid-measure attrs)"
);
score
}
fn key_changes(score: &Score) -> Vec<&crate::model::KeySignatureModel> {
score.parts[0].voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::KeyChange(key) => Some(key),
_ => None,
})
.collect()
}
fn meter_changes(score: &Score) -> Vec<&crate::model::MeterModel> {
score.parts[0].voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::MeterChange(meter) => Some(meter),
_ => None,
})
.collect()
}
fn clef_changes(score: &Score) -> Vec<&crate::model::ClefChangeModel> {
score.parts[0].voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::ClefChange(clef) => Some(clef),
_ => None,
})
.collect()
}
#[test]
fn inline_key_change_reconstructs_event() {
let abc = "X:1\nT:K\nM:4/4\nL:1/4\nK:C\nC D [K:G] E F |\n";
let x1 = export(abc);
assert!(
x1.matches("<attributes>").count() == 2,
"precondition: an inline [K:] emits a SECOND mid-measure <attributes>"
);
let score = assert_idempotent_s6b(abc);
assert_eq!(
header_key(&score).fifths,
0,
"the inline key change must not overwrite the header key"
);
let changes = key_changes(&score);
assert_eq!(changes.len(), 1, "exactly one KeyChange event");
assert_eq!(
changes[0].fifths, 1,
"the KeyChange carries G major (fifths 1)"
);
let event = score.parts[0].voices[0]
.events
.iter()
.find(|event| matches!(event.kind, TimedEventKind::KeyChange(_)))
.expect("a KeyChange event");
assert_eq!(
event.onset,
Fraction::new(2, 4),
"KeyChange onset is after C D"
);
assert_eq!(
event.duration,
Fraction::zero(),
"KeyChange is zero-duration"
);
}
#[test]
fn body_key_change_at_measure_start_reconstructs_event() {
let abc = "X:1\nT:K\nM:2/4\nL:1/4\nK:C\nC D |\nK:G\nE F |\n";
let x1 = export(abc);
assert!(
x1.contains("<measure number=\"2\">") && x1.matches("<attributes>").count() == 2,
"precondition: the body K: emits a leading <attributes> in measure 2"
);
let score = assert_idempotent_s6b(abc);
let changes = key_changes(&score);
assert_eq!(changes.len(), 1, "one KeyChange for the body K:");
assert_eq!(changes[0].fifths, 1, "G major (fifths 1)");
let event = score.parts[0].voices[0]
.events
.iter()
.find(|event| matches!(event.kind, TimedEventKind::KeyChange(_)))
.expect("a KeyChange event");
assert_eq!(
event.measure.index, 1,
"the KeyChange is in the second measure"
);
assert_eq!(
event.onset,
Fraction::zero(),
"at the measure start (onset 0)"
);
}
#[test]
fn inline_meter_change_reconstructs_event() {
let abc = "X:1\nT:M\nM:4/4\nL:1/4\nK:C\nC D [M:2/4] E F |\n";
let x1 = export(abc);
assert!(
x1.matches("<attributes>").count() == 2 && x1.contains("<beats>2</beats>"),
"precondition: inline [M:] emits a mid-measure <time>"
);
let score = assert_idempotent_s6b(abc);
let changes = meter_changes(&score);
assert_eq!(changes.len(), 1, "exactly one MeterChange event");
assert_eq!(
changes[0].display, "2/4",
"the MeterChange display re-emits the 2/4 <time>"
);
}
#[test]
fn inline_clef_change_reconstructs_event() {
let abc = "X:1\nT:Cl\nM:4/4\nL:1/4\nK:C\nC D [K:clef=bass] E F |\n";
let x1 = export(abc);
assert!(
x1.matches("<attributes>").count() == 2 && x1.contains("<sign>F</sign>"),
"precondition: inline clef change emits a mid-measure <clef> (bass = F/4)"
);
let score = assert_idempotent_s6b(abc);
let changes = clef_changes(&score);
assert_eq!(changes.len(), 1, "exactly one ClefChange event");
assert_eq!(
changes[0].clef.text, "bass",
"the ClefChange reconstructs the canonical bass clef text"
);
}
#[test]
fn inline_key_and_meter_change_in_one_measure_round_trip() {
let abc = "X:1\nT:KM\nM:4/4\nL:1/4\nK:C\nC D [K:G][M:2/4] E F |\n";
let x1 = export(abc);
assert!(
x1.matches("<attributes>").count() == 3,
"precondition: header + key + meter = three <attributes> blocks"
);
let score = assert_idempotent_s6b(abc);
assert_eq!(key_changes(&score).len(), 1, "one KeyChange");
assert_eq!(meter_changes(&score).len(), 1, "one MeterChange");
let kinds: Vec<&str> = score.parts[0].voices[0]
.events
.iter()
.filter_map(|event| match &event.kind {
TimedEventKind::KeyChange(_) => Some("key"),
TimedEventKind::MeterChange(_) => Some("meter"),
TimedEventKind::Note(_) => Some("note"),
_ => None,
})
.collect();
assert_eq!(
kinds,
vec!["note", "note", "key", "meter", "note", "note"],
"key change precedes meter change, both between the note pairs"
);
}
#[test]
fn header_attributes_in_first_measure_are_not_an_event() {
let score = assert_idempotent_s6b("X:1\nT:Plain\nM:4/4\nL:1/4\nK:C\nC D E F |\n");
assert!(
key_changes(&score).is_empty()
&& meter_changes(&score).is_empty()
&& clef_changes(&score).is_empty(),
"the header <attributes> must not be reconstructed as change events"
);
}
#[test]
fn key_change_then_more_notes_in_later_measure_round_trip() {
let abc = "X:1\nT:Multi\nM:4/4\nL:1/4\nK:C\nC D E F |\nK:Am\nG A B c | d e f g |\n";
let score = assert_idempotent_s6b(abc);
let changes = key_changes(&score);
assert_eq!(changes.len(), 1, "one body KeyChange");
assert_eq!(changes[0].fifths, 0, "A minor is fifths 0");
}
#[test]
fn annotation_before_inline_key_change_round_trips() {
let abc = "X:1\nT:E\nM:4/4\nL:1/4\nK:C\nC D \"^hi\"[K:G] E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<words>hi</words>") && x1.matches("<attributes>").count() == 2,
"precondition: annotation + inline key change both present"
);
let score = assert_idempotent_s6b(abc);
assert_eq!(key_changes(&score).len(), 1, "one KeyChange event");
let total_annotations: usize = score.parts[0].voices[0]
.events
.iter()
.map(|event| event.attachments.annotations.len())
.sum();
assert_eq!(total_annotations, 1, "exactly one annotation reconstructed");
let annotated_is_note = score.parts[0].voices[0].events.iter().any(|event| {
!event.attachments.annotations.is_empty() && matches!(event.kind, TimedEventKind::Note(_))
});
assert!(
annotated_is_note,
"the annotation attaches to a note (the one after the key change)"
);
}
#[test]
fn chord_symbol_before_inline_key_change_round_trips() {
let abc = "X:1\nT:E3\nM:4/4\nL:1/4\nK:C\nC D \"Am\"[K:G] E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<harmony>") && x1.matches("<attributes>").count() == 2,
"precondition: chord symbol + inline key change both present"
);
let score = assert_idempotent_s6b(abc);
assert_eq!(key_changes(&score).len(), 1, "one KeyChange event");
let total_chord_symbols: usize = score.parts[0].voices[0]
.events
.iter()
.map(|event| event.attachments.chord_symbols.len())
.sum();
assert_eq!(
total_chord_symbols, 1,
"exactly one chord symbol reconstructed"
);
let on_note = score.parts[0].voices[0].events.iter().any(|event| {
!event.attachments.chord_symbols.is_empty() && matches!(event.kind, TimedEventKind::Note(_))
});
assert!(
on_note,
"the chord symbol attaches to the note after the key change"
);
}
#[test]
fn mid_tune_key_change_with_explicit_accidentals_round_trips() {
let abc = "X:1\nT:Exp\nM:4/4\nL:1/4\nK:C\nC D [K:D exp _b ^f] E F |\n";
let x1 = export(abc);
assert!(
x1.matches("<attributes>").count() == 2 && x1.contains("<key-accidental>"),
"precondition: the inline key change emits explicit <key-accidental>s"
);
let score = assert_idempotent_s6b(abc);
let changes = key_changes(&score);
assert_eq!(changes.len(), 1, "one KeyChange event");
assert!(
!changes[0].explicit_accidentals.is_empty(),
"the KeyChange reconstructs its explicit accidentals"
);
}
#[test]
fn mid_tune_treble_clef_change_round_trips() {
let abc = "X:1\nT:Tr\nM:4/4\nL:1/4\nK:C clef=bass\nC D [K:clef=treble] E F |\n";
let x1 = export(abc);
assert!(
x1.matches("<attributes>").count() == 2 && x1.matches("<sign>G</sign>").count() == 1,
"precondition: header bass + a mid-tune treble clef change"
);
let score = assert_idempotent_s6b(abc);
let changes = clef_changes(&score);
assert_eq!(changes.len(), 1, "one ClefChange event");
assert_eq!(
changes[0].clef.text, "treble",
"a mid-tune treble clef reconstructs the explicit canonical text"
);
}
#[test]
fn mid_measure_meter_change_before_body_tempo_round_trips() {
let abc = "X:1\nT:NoHdrMeter\nK:C\nM:C\nQ:1/8=120\nC D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<time symbol=\"common\">") && x1.contains("<metronome>"),
"precondition: a body meter AND a body tempo are both present"
);
let meter_pos = x1
.find("<time symbol=\"common\">")
.expect("the meter change is present");
let tempo_pos = x1.find("<metronome>").expect("the body tempo is present");
assert!(
meter_pos < tempo_pos,
"precondition: the writer emits the meter change before the body tempo"
);
let score = assert_idempotent_s6b(abc);
assert_eq!(meter_changes(&score).len(), 1, "one MeterChange event");
assert!(
score.metadata.tempo_model.is_none(),
"a tempo after a mid-measure attributes change is a body TempoChange, \
not the header tempo_model"
);
let tempo_changes = score.parts[0].voices[0]
.events
.iter()
.filter(|event| matches!(event.kind, TimedEventKind::TempoChange(_)))
.count();
assert_eq!(
tempo_changes, 1,
"the body tempo reconstructs one TempoChange"
);
}
#[test]
fn annotation_then_inline_change_in_noteless_measure_round_trips() {
let abc = "X:1\nT:Trio test\nM:4/4\nL:1/8\nK:C\nA2 B2 c2 d2 |\"Trio\"[K:F]|: A2 B2 c2 d2 |\n";
let x1 = export(abc);
let dir_pos = x1.find("<words>Trio</words>").expect("the Trio direction");
let key_pos = x1
.find("<fifths>-1</fifths>")
.expect("the inline key change");
assert!(
dir_pos < key_pos,
"precondition: writer emits the <direction> before the key-change <attributes>"
);
let score = assert_idempotent_s6b(abc);
assert_eq!(key_changes(&score).len(), 1, "one KeyChange event");
let measure2: Vec<_> = score.parts[0].voices[0]
.events
.iter()
.filter(|event| event.measure.index == 1)
.collect();
let spacer_idx = measure2
.iter()
.position(|event| matches!(event.kind, TimedEventKind::Spacer));
let key_idx = measure2
.iter()
.position(|event| matches!(event.kind, TimedEventKind::KeyChange(_)));
assert!(
matches!((spacer_idx, key_idx), (Some(s), Some(k)) if s < k),
"the trailing Spacer must precede the same-onset KeyChange (got spacer={spacer_idx:?}, key={key_idx:?})"
);
}
#[test]
fn trailing_decoration_after_notes_still_appends_after_them() {
let abc = "X:1\nT:Seg\nM:4/4\nL:1/8\nK:C\nA2 B2 c2 d2 !segno!|\n";
let score = assert_idempotent_s6b(abc);
let events = &score.parts[0].voices[0].events;
let last = events.last().expect("at least one event");
assert!(
matches!(last.kind, TimedEventKind::Spacer),
"the trailing decoration lands on a Spacer appended after the notes"
);
assert_eq!(
last.attachments.decorations.len(),
1,
"the Spacer carries the segno decoration"
);
}
fn assert_idempotent_s6c(abc: &str) -> Score {
let (x1, x2, score) = round_trip(abc);
assert_eq!(
x1, x2,
"write(read(write(score))) must equal write(score) byte-for-byte (S6c grace+chord)"
);
score
}
fn grace_groups_at(score: &Score, index: usize) -> &[crate::model::GraceGroupAttachment] {
&score.parts[0].voices[0].events[index]
.attachments
.grace_groups
}
fn grace_note_decoration_names(
group: &crate::model::GraceGroupAttachment,
event_index: usize,
) -> Vec<&str> {
use crate::model::GraceEventKind;
match &group.events[event_index].kind {
GraceEventKind::Note(note) => note
.decorations
.iter()
.map(|decoration| decoration.name.as_str())
.collect(),
other => panic!("expected a grace Note, got {other:?}"),
}
}
fn grace_chord_member_decoration_names(
group: &crate::model::GraceGroupAttachment,
event_index: usize,
member_index: usize,
) -> Vec<&str> {
use crate::model::GraceEventKind;
match &group.events[event_index].kind {
GraceEventKind::Chord(members) => members[member_index]
.decorations
.iter()
.map(|decoration| decoration.name.as_str())
.collect(),
other => panic!("expected a grace Chord, got {other:?}"),
}
}
#[test]
fn single_grace_round_trips_and_reconstructs_group() {
use crate::model::GraceEventKind;
let abc = "X:1\nT:G\nL:1/4\nK:C\n{a}G\n";
let x1 = export(abc);
assert!(
x1.contains("<grace/>") && !x1.contains("slash"),
"precondition: a plain {{a}} emits an unslashed <grace/>"
);
let score = assert_idempotent_s6c(abc);
let groups = grace_groups_at(&score, 0);
assert_eq!(groups.len(), 1, "one grace group on the following note");
assert_eq!(groups[0].note_count, 1, "one grace element");
assert!(groups[0].slash.is_none(), "an unslashed grace");
assert_eq!(groups[0].events.len(), 1);
match &groups[0].events[0].kind {
GraceEventKind::Note(note) => {
assert_eq!(note.pitch.step, 'A');
assert_eq!(note.pitch.octave, 5);
assert_eq!(
note.length_multiplier,
Fraction::one(),
"a plain grace note has length multiplier 1"
);
}
other => panic!("expected a grace Note, got {other:?}"),
}
}
#[test]
fn grace_note_decorations_round_trip() {
let abc = "X:1\nT:GDeco\nL:1/4\nK:C\n{!staccato!!4!a}!fermata!G\n";
let x1 = export(abc);
assert!(
x1.contains("<staccato/>") && x1.contains("<fingering>4</fingering>"),
"precondition: grace-note decorations emit MusicXML notations on the grace note:\n{x1}"
);
let score = assert_idempotent_s6c(abc);
let names = grace_note_decoration_names(&grace_groups_at(&score, 0)[0], 0);
assert!(
names.contains(&"staccato") && names.contains(&"4"),
"the grace note keeps its own decorations, got {names:?}"
);
assert!(
attachments_at(&score, 0)
.decorations
.iter()
.any(|decoration| decoration.name == "fermata"),
"the following main note keeps its separate fermata"
);
}
#[test]
fn grace_dynamic_decoration_round_trip() {
let abc = "X:1\nT:GDyn\nL:1/4\nK:C\n{!p!a}!f!G\n";
let score = assert_idempotent_s6c(abc);
let names = grace_note_decoration_names(&grace_groups_at(&score, 0)[0], 0);
assert_eq!(
names,
vec!["p"],
"the grace note keeps its own dynamic decoration"
);
let principal = &attachments_at(&score, 0).decorations;
assert_eq!(principal.len(), 1, "one decoration on the principal note");
assert_eq!(
principal[0].name, "f",
"the principal note keeps the dynamic outside the grace braces"
);
}
#[test]
fn multi_note_grace_group_round_trips() {
let abc = "X:1\nT:G\nL:1/4\nK:C\n{abc}G\n";
let x1 = export(abc);
assert_eq!(
x1.matches("<grace/>").count(),
3,
"precondition: three <grace/> notes"
);
let score = assert_idempotent_s6c(abc);
let groups = grace_groups_at(&score, 0);
assert_eq!(groups.len(), 1, "one grace group");
assert_eq!(groups[0].note_count, 3, "three grace elements");
assert_eq!(groups[0].events.len(), 3, "three grace events");
}
#[test]
fn slashed_grace_round_trips_and_reconstructs_slash() {
let abc = "X:1\nT:G\nL:1/4\nK:C\n{/a}G\n";
let x1 = export(abc);
assert!(
x1.contains("<grace slash=\"yes\"/>"),
"precondition: {{/a}} emits slash=\"yes\""
);
let score = assert_idempotent_s6c(abc);
let groups = grace_groups_at(&score, 0);
assert_eq!(groups.len(), 1);
assert!(
groups[0].slash.is_some(),
"the reconstructed group records the slash"
);
}
#[test]
fn grace_length_multiplier_round_trips() {
use crate::model::GraceEventKind;
let abc = "X:1\nT:G\nL:1/4\nK:C\n{a2}G\n";
let score = assert_idempotent_s6c(abc);
let groups = grace_groups_at(&score, 0);
match &groups[0].events[0].kind {
GraceEventKind::Note(note) => assert_eq!(
note.length_multiplier,
Fraction::new(2, 1),
"{{a2}} reconstructs a length multiplier of 2"
),
other => panic!("expected a grace Note, got {other:?}"),
}
}
#[test]
fn grace_half_length_multiplier_round_trips() {
use crate::model::GraceEventKind;
let abc = "X:1\nT:G\nL:1/4\nK:C\n{a/}G\n";
let score = assert_idempotent_s6c(abc);
let groups = grace_groups_at(&score, 0);
match &groups[0].events[0].kind {
GraceEventKind::Note(note) => assert_eq!(
note.length_multiplier,
Fraction::new(1, 2),
"{{a/}} reconstructs a length multiplier of 1/2"
),
other => panic!("expected a grace Note, got {other:?}"),
}
}
#[test]
fn grace_with_slur_round_trips() {
use crate::model::SlurRole;
let abc = "X:1\nT:G\nL:1/4\nK:C\n{(ab)}G\n";
let x1 = export(abc);
assert!(
x1.contains("<slur type=\"start\" number=\"1\"/>")
&& x1.contains("<slur type=\"stop\" number=\"1\"/>"),
"precondition: a grace slur emits start+stop slurs"
);
let score = assert_idempotent_s6c(abc);
let groups = grace_groups_at(&score, 0);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].events.len(), 2, "two grace notes");
let start = &groups[0].events[0].slurs;
assert_eq!(start.len(), 1, "first grace note opens a slur");
assert_eq!(start[0].role, SlurRole::Start);
let stop = &groups[0].events[1].slurs;
assert_eq!(stop.len(), 1, "second grace note closes the slur");
assert_eq!(stop[0].role, SlurRole::Stop);
}
#[test]
fn slur_opening_before_grace_binds_first_grace_note() {
use crate::model::SlurRole;
let abc = "X:1\nT:G\nL:1/4\nK:C\n({a}G)A\n";
let x1 = export(abc);
assert!(
x1.contains("<slur type=\"start\" number=\"1\"/>"),
"precondition: the slur start lands on the grace note"
);
let score = assert_idempotent_s6c(abc);
let groups = grace_groups_at(&score, 0);
let start = &groups[0].events[0].slurs;
assert_eq!(start.len(), 1, "the grace note carries the slur start");
assert_eq!(start[0].role, SlurRole::Start);
}
#[test]
fn grace_chord_round_trips_and_reconstructs_members() {
use crate::model::GraceEventKind;
let abc = "X:1\nT:G\nL:1/4\nK:C\n{[ac]}G\n";
let x1 = export(abc);
assert!(
x1.contains("<chord/>") && x1.matches("<grace/>").count() == 2,
"precondition: a grace chord emits two <grace/> with one <chord/>"
);
let score = assert_idempotent_s6c(abc);
let groups = grace_groups_at(&score, 0);
assert_eq!(groups.len(), 1);
assert_eq!(
groups[0].note_count, 1,
"a grace chord is ONE grace element (base unit 1/8)"
);
assert_eq!(groups[0].events.len(), 1, "one grace event (the chord)");
match &groups[0].events[0].kind {
GraceEventKind::Chord(members) => {
assert_eq!(members.len(), 2, "the grace chord has two members");
assert_eq!(members[0].pitch.step, 'A');
assert_eq!(members[1].pitch.step, 'C');
}
other => panic!("expected a grace Chord, got {other:?}"),
}
}
#[test]
fn grace_chord_member_decorations_round_trip() {
let abc = "X:1\nT:GChordDeco\nM:4/4\nL:1/4\nK:C\n{[C!trill!EG]}A B C D|\n";
let score = assert_idempotent_s6c(abc);
let group = &grace_groups_at(&score, 0)[0];
assert_eq!(
grace_chord_member_decoration_names(group, 0, 0),
Vec::<&str>::new(),
"the first grace chord member has no decoration"
);
assert_eq!(
grace_chord_member_decoration_names(group, 0, 1),
vec!["trill"],
"the second grace chord member keeps its trill"
);
assert_eq!(
grace_chord_member_decoration_names(group, 0, 2),
Vec::<&str>::new(),
"the third grace chord member has no decoration"
);
}
#[test]
fn grace_rest_round_trips() {
use crate::model::{GraceEventKind, RestVisibility};
let abc = "X:1\nT:G\nL:1/4\nK:C\n{x}G\n";
let x1 = export(abc);
assert!(
x1.contains("<grace/>") && x1.contains("<rest/>"),
"precondition: a grace rest emits <grace/> with <rest/>"
);
let score = assert_idempotent_s6c(abc);
let groups = grace_groups_at(&score, 0);
assert_eq!(groups[0].events.len(), 1);
match &groups[0].events[0].kind {
GraceEventKind::Rest(rest) => {
assert_eq!(
rest.visibility,
RestVisibility::Invisible,
"an x grace rest is invisible (print-object=no)"
);
}
other => panic!("expected a grace Rest, got {other:?}"),
}
}
#[test]
fn after_grace_at_measure_end_binds_to_preceding_note() {
let abc = "X:1\nT:Trailing\nM:4/4\nL:1/8\nK:C\nTe6{de}|d2f f2f|\n";
let score = assert_idempotent_s6c(abc);
let after = &score.parts[0].voices[0].events[0]
.attachments
.after_grace_groups;
assert_eq!(
after.len(),
1,
"the preceding note carries one after-grace group"
);
assert_eq!(
after[0].note_count, 2,
"the after-grace group has two notes"
);
assert!(
score.parts[0].voices[0].events[0]
.attachments
.grace_groups
.is_empty(),
"the trailing grace is an after-grace, not a before-grace"
);
}
#[test]
fn after_grace_note_decorations_round_trip() {
let abc = "X:1\nT:TrailingDeco\nM:4/4\nL:1/8\nK:C\nTe6{!staccato!d!4!e}|d2f f2f|\n";
let score = assert_idempotent_s6c(abc);
let after = &score.parts[0].voices[0].events[0]
.attachments
.after_grace_groups;
assert_eq!(
after.len(),
1,
"one after-grace group on the principal note"
);
assert_eq!(
grace_note_decoration_names(&after[0], 0),
vec!["staccato"],
"first after-grace note keeps staccato"
);
assert_eq!(
grace_note_decoration_names(&after[0], 1),
vec!["4"],
"second after-grace note keeps fingering"
);
}
#[test]
fn grace_before_chord_member_degrades_without_panic() {
use crate::model::TimedEventKind;
let xml = r#"<?xml version="1.0"?>
<score-partwise>
<part-list><score-part id="P1"><part-name>P</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>8</divisions></attributes>
<note>
<pitch><step>G</step><octave>4</octave></pitch>
<duration>8</duration>
<voice>1</voice>
<type>quarter</type>
</note>
<note>
<grace/>
<pitch><step>A</step><octave>5</octave></pitch>
<voice>1</voice>
<type>eighth</type>
</note>
<note>
<chord/>
<pitch><step>C</step><octave>5</octave></pitch>
<duration>8</duration>
<voice>1</voice>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>"#;
let report = read_musicxml(xml);
let score = report.value;
let event = &score.parts[0].voices[0].events[0];
assert!(
matches!(event.kind, TimedEventKind::Chord(_)),
"the <chord/> member folds the leading G note into a chord"
);
assert_eq!(
event.attachments.grace_groups.len(),
1,
"the orphaned before-grace group is re-bound to the hosting event, not lost"
);
}
#[test]
fn orphan_before_grace_with_no_host_is_dropped_with_diagnostic() {
let xml = r#"<?xml version="1.0"?>
<score-partwise>
<part-list><score-part id="P1"><part-name>P</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>8</divisions></attributes>
<note>
<grace/>
<pitch><step>A</step><octave>5</octave></pitch>
<voice>1</voice>
<type>eighth</type>
</note>
<note>
<chord/>
<pitch><step>C</step><octave>5</octave></pitch>
<duration>8</duration>
<voice>1</voice>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>"#;
let report = read_musicxml(xml);
assert!(
report
.diagnostics
.iter()
.any(|d| d.code == "musicxml.read.orphan_before_grace"),
"the host-less before-grace run must be dropped with a diagnostic, not panic"
);
}
fn first_chord(score: &Score) -> &crate::model::ChordEvent {
score.parts[0].voices[0]
.events
.iter()
.find_map(|event| match &event.kind {
TimedEventKind::Chord(chord) => Some(chord),
_ => None,
})
.expect("expected a ChordEvent")
}
#[test]
fn plain_chord_round_trips_and_reconstructs_members() {
let abc = "X:1\nT:C\nL:1/4\nK:C\n[CEG]\n";
let x1 = export(abc);
assert_eq!(
x1.matches("<chord/>").count(),
2,
"precondition: a 3-note chord emits two <chord/> marks"
);
let score = assert_idempotent_s6c(abc);
assert_eq!(
score.parts[0].voices[0].events.len(),
1,
"a chord is one TimedEvent::Chord, not three notes"
);
let chord = first_chord(&score);
assert_eq!(chord.members.len(), 3, "three chord members");
assert_eq!(chord.members[0].pitch.step, 'C');
assert_eq!(chord.members[1].pitch.step, 'E');
assert_eq!(chord.members[2].pitch.step, 'G');
assert_eq!(
chord.members[0].duration,
Fraction::new(1, 4),
"each member is a quarter"
);
}
#[test]
fn chord_then_note_round_trips() {
let abc = "X:1\nT:C\nM:4/4\nL:1/4\nK:C\n[CEG] D E F|\n";
let score = assert_idempotent_s6c(abc);
let events = &score.parts[0].voices[0].events;
assert!(
matches!(events[0].kind, TimedEventKind::Chord(_)),
"the first event is the chord"
);
assert_eq!(events.len(), 4, "chord + three notes = four events");
assert_eq!(
events[1].onset,
Fraction::new(1, 4),
"the note after the chord is at onset 1/4"
);
}
#[test]
fn chord_with_ties_round_trips_per_member() {
use crate::model::TieRole;
let abc = "X:1\nT:C\nM:4/4\nL:1/4\nK:C\n[CEG]2-[CEG]2 z4|\n";
let x1 = export(abc);
assert!(
x1.contains("<tied type=\"start\" number=\"1\"/>")
&& x1.contains("<tied type=\"start\" number=\"3\"/>"),
"precondition: tied chord members get distinct tie numbers 1..3"
);
let score = assert_idempotent_s6c(abc);
let chord = first_chord(&score);
assert_eq!(chord.members.len(), 3);
for (member_index, member) in chord.members.iter().enumerate() {
assert_eq!(
member.attachments.ties.len(),
1,
"member {member_index} carries one tie"
);
assert_eq!(member.attachments.ties[0].role, TieRole::Start);
}
}
#[test]
fn chord_member_decoration_round_trips() {
let abc = "X:1\nT:C\nM:4/4\nL:1/4\nK:C\n[C!trill!EG] D E F|\n";
let x1 = export(abc);
assert!(
x1.contains("<trill-mark/>"),
"precondition: the chord member decoration emits a trill"
);
let score = assert_idempotent_s6c(abc);
let chord = first_chord(&score);
let decorated = chord
.members
.iter()
.filter(|member| !member.attachments.decorations.is_empty())
.count();
assert_eq!(decorated, 1, "exactly one member carries a decoration");
}
#[test]
fn two_chords_in_a_measure_round_trip() {
let abc = "X:1\nT:C\nM:4/4\nL:1/4\nK:C\n[CE]2 [GB]2 z4|\n";
let score = assert_idempotent_s6c(abc);
let chords = score.parts[0].voices[0]
.events
.iter()
.filter(|event| matches!(event.kind, TimedEventKind::Chord(_)))
.count();
assert_eq!(chords, 2, "two distinct chord events");
}
fn assert_idempotent_full(abc: &str) -> Score {
let (x1, x2, score) = round_trip(abc);
assert_eq!(
x1, x2,
"write(read(write(score))) must equal write(score) byte-for-byte (S6d)"
);
score
}
#[test]
fn multiple_rest_reconstructs_measure_field() {
let abc = "X:1\nT:Multi Rest\nM:4/4\nL:1/4\nK:C\nC D E F | Z2 | G A B c |\n";
let x1 = export(abc);
assert!(
x1.contains("<multiple-rest>2</multiple-rest>"),
"precondition: Z2 emits a multiple-rest of 2"
);
let score = assert_idempotent_full(abc);
let with_mr = score.parts[0].voices[0]
.measures
.iter()
.filter(|m| m.multiple_rest == Some(2))
.count();
assert_eq!(
with_mr, 1,
"exactly one measure carries multiple_rest = Some(2)"
);
}
#[test]
fn two_voice_overlay_reconstructs_second_voice() {
let abc = "X:1\nT:Overlay\nM:4/4\nL:1/4\nK:C\nC2 D2 & E2 F2 |\n";
let x1 = export(abc);
assert!(
x1.contains("<backup>") && x1.contains("<voice>2</voice>"),
"precondition: the overlay emits a <backup> and a <voice>2 block"
);
let score = assert_idempotent_full(abc);
assert_eq!(
score.parts[0].voices.len(),
2,
"the overlay reconstructs a second voice in the part"
);
let v2 = &score.parts[0].voices[1];
assert_eq!(v2.events.len(), 2, "voice 2 has two notes");
assert_eq!(
v2.events[0].onset,
Fraction::zero(),
"voice 2 restarts at onset 0"
);
assert_eq!(
v2.events[1].onset,
Fraction::new(1, 2),
"second voice-2 note at 1/2"
);
}
#[test]
fn three_voice_overlay_round_trips() {
let abc = "X:1\nT:Triple\nM:4/4\nL:1/4\nK:C\nC2 D2 & E2 F2 & G2 A2 |\n";
let x1 = export(abc);
assert_eq!(
x1.matches("<backup>").count(),
2,
"precondition: two overlays emit two backups"
);
assert!(
x1.contains("<voice>3</voice>"),
"precondition: a third voice"
);
let score = assert_idempotent_full(abc);
assert_eq!(score.parts[0].voices.len(), 3, "three reconstructed voices");
}
#[test]
fn unequal_voice_lengths_round_trip() {
let abc = "X:1\nT:Unequal\nM:4/4\nL:1/4\nK:C\nC2 D2 E2 F2 & G2 A2 |\n";
let score = assert_idempotent_full(abc);
assert_eq!(score.parts[0].voices.len(), 2);
assert_eq!(
score.parts[0].voices[0].events.len(),
4,
"voice 1: four notes"
);
assert_eq!(
score.parts[0].voices[1].events.len(),
2,
"voice 2: two notes"
);
}
#[test]
fn two_voice_with_direction_round_trips() {
let abc = "X:1\nT:Dyn Overlay\nM:4/4\nL:1/4\nK:C\n!f!C2 D2 & E2 F2 |\n";
let x1 = export(abc);
assert!(
x1.contains("<dynamics>") && x1.contains("<backup>"),
"precondition: a dynamic and an overlay backup are both emitted"
);
assert_idempotent_full(abc);
}
#[test]
fn two_voice_overlay_with_measure_rest_round_trips() {
let abc = "X:1\nT:Rest Overlay\nM:4/4\nL:1/4\nK:C\nC2 D2 E2 F2 & z4 |\n";
let x1 = export(abc);
if x1.contains("<backup>") {
assert_idempotent_full(abc);
}
}
#[test]
fn grace_in_overlay_voice_round_trips() {
let abc = "X:1\nT:Grace Overlay\nM:4/4\nL:1/4\nK:C\n{ag}C2 D2 & E2 F2 |\n";
let x1 = export(abc);
if x1.contains("<backup>") && x1.contains("<grace") {
assert_idempotent_full(abc);
}
}
#[test]
fn chord_in_overlay_round_trips() {
let abc = "X:1\nT:Chord Overlay\nM:4/4\nL:1/4\nK:C\n[CEG]2 D2 & E2 F2 |\n";
let x1 = export(abc);
if x1.contains("<backup>") && x1.contains("<chord/>") {
assert_idempotent_full(abc);
}
}
#[test]
fn multiple_rest_second_bar_is_plain_measure_rest() {
let abc = "X:1\nT:Multi Rest 2\nM:4/4\nL:1/4\nK:C\nC D E F | Z3 | G A B c |\n";
let x1 = export(abc);
assert!(
x1.contains("<multiple-rest>3</multiple-rest>"),
"precondition: Z3 emits a multiple-rest of 3"
);
let score = assert_idempotent_full(abc);
let counts: Vec<_> = score.parts[0].voices[0]
.measures
.iter()
.filter_map(|m| m.multiple_rest)
.collect();
assert_eq!(
counts,
vec![3],
"exactly one measure carries multiple_rest = 3"
);
}
fn abc_files(dir: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = match fs::read_dir(dir) {
Ok(entries) => entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "abc"))
.collect(),
Err(_) => Vec::new(),
};
files.sort();
files
}
fn first_diverging_tag(x1: &str, x2: &str) -> Option<String> {
for (line1, line2) in x1.lines().zip(x2.lines()) {
if line1 != line2 {
return Some(tag_of(line1).unwrap_or_else(|| tag_of(line2).unwrap_or("?".to_owned())));
}
}
let (longer, shorter) = if x1.lines().count() >= x2.lines().count() {
(x1, x2)
} else {
(x2, x1)
};
longer.lines().nth(shorter.lines().count()).and_then(tag_of)
}
fn tag_of(line: &str) -> Option<String> {
let trimmed = line.trim();
let rest = trimmed.strip_prefix('<')?;
let rest = rest.strip_prefix('/').unwrap_or(rest);
let name: String = rest
.chars()
.take_while(|c| !c.is_whitespace() && *c != '>' && *c != '/')
.collect();
(!name.is_empty()).then_some(name)
}
fn strip_deferred_blocks(xml: &str) -> String {
let mut out = String::with_capacity(xml.len());
let mut skip_until: Option<&'static str> = None;
for line in xml.lines() {
if let Some(close) = skip_until {
if line.trim() == close {
skip_until = None;
}
continue;
}
let trimmed = line.trim();
if trimmed == "<key>" {
skip_until = Some("</key>");
} else if trimmed == "<time>" || trimmed.starts_with("<time ") {
skip_until = Some("</time>");
} else if trimmed.starts_with("<score-instrument") {
skip_until = Some("</score-instrument>");
} else if trimmed.starts_with("<midi-instrument") {
skip_until = Some("</midi-instrument>");
} else {
out.push_str(line);
out.push('\n');
}
}
out
}
#[test]
fn corpus_idempotence_measurement() {
let Ok(root) = std::env::var("ABC_ROOT") else {
eprintln!("ABC_ROOT unset โ skipping S1 corpus idempotence measurement");
return;
};
let files = abc_files(&PathBuf::from(&root));
if files.is_empty() {
eprintln!("no .abc files under {root} โ skipping");
return;
}
let mut total = 0usize;
let mut exported = 0usize;
let mut idempotent = 0usize;
let mut idempotent_supported = 0usize;
let mut divergences: BTreeMap<String, usize> = BTreeMap::new();
let mut idempotent_names: Vec<String> = Vec::new();
for path in &files {
let Ok(bytes) = fs::read(path) else { continue };
let source = String::from_utf8_lossy(&bytes);
total += 1;
let Ok(export) = export_musicxml(&source) else {
continue;
};
exported += 1;
let x1 = export.musicxml;
let report = read_musicxml(&x1);
let x2 = write_musicxml(&report.value).musicxml;
if x1 == x2 {
idempotent += 1;
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
idempotent_names.push(name.to_owned());
}
} else if let Some(tag) = first_diverging_tag(&x1, &x2) {
*divergences.entry(tag).or_default() += 1;
} else {
*divergences
.entry("<equal-lines-unequal-bytes>".to_owned())
.or_default() += 1;
}
if strip_deferred_blocks(&x1) == strip_deferred_blocks(&x2) {
idempotent_supported += 1;
}
}
if let Ok(list_path) = std::env::var("READER_IDEMPOTENT_LIST") {
idempotent_names.sort();
let _ = fs::write(&list_path, idempotent_names.join("\n"));
eprintln!(
"wrote {} idempotent file names to {list_path}",
idempotent_names.len()
);
}
let mut top: Vec<(String, usize)> = divergences.into_iter().collect();
top.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
eprintln!(
"reader corpus idempotence (strict, full bytes): {idempotent}/{exported} exported files round-trip ({total} total .abc)"
);
eprintln!(
"reader corpus idempotence (supported subset, deferred S2/S3 blocks stripped): {idempotent_supported}/{exported}"
);
eprintln!("reader top first-diverging tags:");
for (tag, count) in top.iter().take(10) {
eprintln!(" {tag}: {count}");
}
assert!(exported > 0, "expected at least one corpus file to export");
if let Some(min) = std::env::var("READER_SELFLOOP_MIN")
.ok()
.and_then(|value| value.trim().parse::<usize>().ok())
{
assert!(
idempotent >= min,
"reader self-loop regressed: {idempotent}/{exported} round-trip, baseline {min}"
);
}
}
fn files_with_ext(dir: &Path, exts: &[&str]) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = match fs::read_dir(dir) {
Ok(entries) => entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| exts.contains(&ext))
})
.collect(),
Err(_) => Vec::new(),
};
files.sort();
files
}
fn reads_without_panic(input: &str) -> bool {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = read_musicxml(input);
}))
.is_ok()
}
#[test]
fn totality_fuzz_read_musicxml_never_panics() {
let previous_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let mut own_exercised = 0usize;
let mut own_panics: Vec<String> = Vec::new();
match std::env::var("ABC_ROOT") {
Ok(root) => {
for path in abc_files(&PathBuf::from(&root)) {
let Ok(bytes) = fs::read(&path) else { continue };
let source = String::from_utf8_lossy(&bytes);
let Ok(export) = export_musicxml(&source) else {
continue;
};
own_exercised += 1;
if !reads_without_panic(&export.musicxml) {
own_panics.push(path.display().to_string());
}
}
}
Err(_) => {
eprintln!("totality fuzz: ABC_ROOT unset โ own-exports arm skipped");
}
}
let mut ref_exercised = 0usize;
let mut ref_panics: Vec<String> = Vec::new();
let mut ref_available = false;
if let Ok(ref_root) = std::env::var("REF_ROOT") {
let dir = PathBuf::from(&ref_root);
let files = files_with_ext(&dir, &["musicxml", "xml"]);
if files.is_empty() {
eprintln!(
"totality fuzz: REF_ROOT={ref_root} has no .musicxml/.xml files โ reference arm skipped"
);
} else {
ref_available = true;
for path in files {
let Ok(bytes) = fs::read(&path) else { continue };
let source = String::from_utf8_lossy(&bytes);
ref_exercised += 1;
if !reads_without_panic(&source) {
ref_panics.push(path.display().to_string());
}
}
}
} else {
eprintln!("totality fuzz: REF_ROOT unset โ abc2xml reference arm UNAVAILABLE (skipped)");
}
let must_diagnose: &[&str] = &[
"<score-partwise><part></broken>",
"not xml at all <<< >>>",
"<?xml version=\"1.0\"?>",
"<?xml version=\"1.0\"?><html><body>nope</body></html>",
"<?xml version=\"1.0\"?><score-timewise></score-timewise>",
"<score-partwise><part-list><score-part id=\"P1\"><midi-instrument id=\"x\">\
<midi-channel>NaN</midi-channel><midi-program>-7</midi-program>\
<volume>lots</volume><pan>left</pan></midi-instrument></score-part></part-list>\
<part id=\"P1\"><measure number=\"one\">\
<attributes><divisions>zero</divisions><key><fifths>sharp</fifths></key>\
<time><beats>x</beats><beat-type>y</beat-type></time>\
<transpose><chromatic>lots</chromatic></transpose></attributes>\
<note><pitch><step>H</step><octave>nine</octave><alter>up</alter></pitch>\
<duration>long</duration><voice>v</voice></note>\
<forward><duration>bad</duration></forward>\
<backup><duration>bad</duration></backup>\
</measure></part></score-partwise>",
"<score-partwise><part id=\"P1\"><measure number=\"1\">\
<note><grace/><pitch><step>A</step><octave>5</octave></pitch><voice>1</voice>\
<notations><tuplet type=\"stop\" number=\"9\"/></notations></note>\
<note><chord/><pitch><step>C</step><octave>5</octave></pitch>\
<duration>8</duration><voice>1</voice></note>\
</measure></part></score-partwise>",
];
let no_panic_only: &[&str] = &[
"",
" \n\t ",
"<score-partwise></score-partwise>",
"<score-partwise><part id=\"P1\"><measure number=\"1\">\
<quux><frob/></quux><note><blarg/><pitch><step>C</step><octave>4</octave></pitch>\
<duration>8</duration><voice>1</voice><notations><wibble/></notations></note>\
</measure></part></score-partwise>",
];
let mut malformed_panics: Vec<String> = Vec::new();
let mut malformed_without_diagnostic: Vec<String> = Vec::new();
for (i, input) in must_diagnose.iter().enumerate() {
let caught =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| read_musicxml(input)));
match caught {
Ok(report) => {
if report.diagnostics.is_empty() {
malformed_without_diagnostic.push(format!("must_diagnose #{i}: {input:.60?}"));
}
}
Err(_) => malformed_panics.push(format!("must_diagnose #{i}: {input:.60?}")),
}
}
for (i, input) in no_panic_only.iter().enumerate() {
if !reads_without_panic(input) {
malformed_panics.push(format!("no_panic_only #{i}: {input:.60?}"));
}
}
let malformed_total = must_diagnose.len() + no_panic_only.len();
std::panic::set_hook(previous_hook);
eprintln!(
"totality fuzz: own-exports exercised {own_exercised} files ({} panicked); \
abc2xml-reference {} ({ref_exercised} files, {} panicked); \
malformed cases {} ({} panicked, {} missing diagnostic)",
own_panics.len(),
if ref_available {
"available"
} else {
"UNAVAILABLE"
},
ref_panics.len(),
malformed_total,
malformed_panics.len(),
malformed_without_diagnostic.len(),
);
assert!(
own_panics.is_empty(),
"read_musicxml panicked on {} own-export file(s); first few: {:?}",
own_panics.len(),
own_panics.iter().take(5).collect::<Vec<_>>()
);
assert!(
ref_panics.is_empty(),
"read_musicxml panicked on {} abc2xml-reference file(s); first few: {:?}",
ref_panics.len(),
ref_panics.iter().take(5).collect::<Vec<_>>()
);
assert!(
malformed_panics.is_empty(),
"read_musicxml panicked on malformed input(s): {malformed_panics:?}"
);
assert!(
malformed_without_diagnostic.is_empty(),
"malformed input(s) produced NO diagnostic (must degrade with a warning): {malformed_without_diagnostic:?}"
);
}
#[cfg(feature = "musicxml-reader")]
mod abc_completion {
use super::*;
use crate::model::BarlineKind;
use crate::musicxml::read::complete_score_for_abc;
use crate::to_abc::{AbcWriteOptions, write_abc};
use crate::{LowerOptions, ParseOptions, lower_score, parse_document};
fn completed_from_abc(abc: &str) -> Score {
let x1 = export(abc);
let mut score = read_musicxml(&x1).value;
complete_score_for_abc(&mut score);
score
}
fn completed_from_xml(xml: &str) -> Score {
let mut score = read_musicxml(xml).value;
complete_score_for_abc(&mut score);
score
}
fn lyric_block<'a>(xml: &'a str, text: &str) -> &'a str {
let text_pos = xml
.find(&format!("<text>{text}</text>"))
.unwrap_or_else(|| panic!("lyric text {text:?} not found in:\n{xml}"));
let start = xml[..text_pos]
.rfind("<lyric")
.expect("lyric text should be inside a lyric block");
let end = xml[text_pos..]
.find("</lyric>")
.map(|offset| text_pos + offset + "</lyric>".len())
.expect("lyric block should close");
&xml[start..end]
}
fn measure_block<'a>(xml: &'a str, number: &str) -> &'a str {
let marker = format!("<measure number=\"{number}\"");
let start = xml
.find(&marker)
.unwrap_or_else(|| panic!("measure {number:?} not found in:\n{xml}"));
let end = xml[start..]
.find("</measure>")
.map(|offset| start + offset + "</measure>".len())
.expect("measure block should close");
&xml[start..end]
}
fn first_time_block(xml: &str) -> &str {
let start = xml
.find("<time")
.unwrap_or_else(|| panic!("time block not found in:\n{xml}"));
let end = xml[start..]
.find("</time>")
.map(|offset| start + offset + "</time>".len())
.expect("time block should close");
&xml[start..end]
}
fn foreign_time_score(time_xml: &str) -> String {
format!(
r#"<?xml version="1.0"?>
<score-partwise>
<part-list><score-part id="P1"><part-name>T</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>4</divisions>{time_xml}</attributes>
<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type></note>
</measure>
</part>
</score-partwise>
"#
)
}
fn note_block(xml: &str, index: usize) -> &str {
let mut cursor = 0usize;
let mut start = None;
for _ in 0..=index {
let found = xml[cursor..]
.find("<note>")
.map(|offset| cursor + offset)
.unwrap_or_else(|| panic!("note {index} not found in:\n{xml}"));
start = Some(found);
cursor = found + "<note>".len();
}
let start = start.expect("loop should set note start");
let end = xml[start..]
.find("</note>")
.map(|offset| start + offset + "</note>".len())
.expect("note block should close");
&xml[start..end]
}
fn assert_same_note_verse_lyrics(xml: &str, texts: &[&str]) {
let first_note = note_block(xml, 0);
let second_note = note_block(xml, 1);
assert_eq!(
first_note.matches("<lyric number=\"1\">").count(),
texts.len(),
"first note should keep duplicate same-verse lyric siblings:\n{xml}"
);
for text in texts {
assert!(
first_note.contains(&format!("<text>{text}</text>")),
"first note should contain lyric text {text:?}:\n{xml}"
);
assert!(
!second_note.contains(&format!("<text>{text}</text>")),
"duplicate lyric text {text:?} moved to the second note:\n{xml}"
);
}
}
fn lower(abc: &str) -> Score {
let document = parse_document(abc, ParseOptions::default()).value;
lower_score(&document, LowerOptions)
.value
.expect("fixture ABC should lower cleanly")
}
fn event_barline_kinds(score: &Score) -> Vec<BarlineKind> {
let mut kinds = Vec::new();
for part in &score.parts {
for voice in &part.voices {
for event in &voice.events {
if let TimedEventKind::Barline(b) = &event.kind {
kinds.push(b.kind);
}
}
}
}
kinds
}
fn pitch_sequence(score: &Score) -> Vec<(char, i32, i32)> {
let mut pitches = Vec::new();
for part in &score.parts {
for voice in &part.voices {
for event in &voice.events {
if let TimedEventKind::Note(note) = &event.kind {
pitches.push((
note.pitch.step,
i32::from(note.pitch.alter),
i32::from(note.pitch.octave),
));
}
}
}
}
pitches
}
fn measure_count(score: &Score) -> usize {
score.parts[0].voices[0].measures.len()
}
fn played_duration_sequence(score: &Score) -> Vec<crate::model::Rational> {
let mut durations = Vec::new();
for part in &score.parts {
for voice in &part.voices {
for event in &voice.events {
if matches!(
event.kind,
TimedEventKind::Note(_)
| TimedEventKind::Rest(_)
| TimedEventKind::Chord(_)
) {
durations.push(event.duration);
}
}
}
}
durations
}
#[test]
fn foreign_sound_tempo_without_metronome_projects_to_sound_tempo_carrier() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction placement=\"above\">\n",
" <direction-type><words>Fast</words></direction-type>\n",
" <sound tempo=\"132\"/>\n",
" </direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let tempo = score.parts[0].voices[0]
.events
.iter()
.find_map(|event| match &event.kind {
TimedEventKind::TempoChange(tempo) => Some(tempo),
_ => None,
})
.expect("sound tempo carrier should become a timed tempo event");
assert_eq!(tempo.beat_role, TempoBeatRole::PlaybackSoundOnly);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:croma-sound-tempo bpm=132 beat-n=1 beat-d=4 text=\"Fast\"]"),
"foreign playback-only <sound tempo> must use the croma carrier, not numeric Q:; got:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("sound-tempo carrier ABC projection should export");
assert!(
roundtrip.musicxml.contains("<words>Fast</words>")
&& roundtrip.musicxml.contains("<sound tempo=\"132.00\"")
&& !roundtrip.musicxml.contains("<metronome>"),
"playback-only sound tempo must not gain a printed metronome:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_direction_with_tempo_and_dynamic_preserves_both() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction placement=\"above\">\n",
" <direction-type><words>rall</words></direction-type>\n",
" <direction-type><dynamics><f/></dynamics></direction-type>\n",
" <sound tempo=\"120\"/>\n",
" </direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:croma-sound-tempo bpm=120 beat-n=1 beat-d=4 text=\"rall\"]"),
"mixed direction must keep playback tempo carrier:\n{abc}"
);
assert!(
abc.contains("!f!C"),
"mixed direction must keep co-located dynamic on the next note:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("mixed tempo+dynamic ABC projection should export");
assert!(
roundtrip.musicxml.contains("<words>rall</words>")
&& roundtrip.musicxml.contains("<sound tempo=\"120.00\"/>")
&& roundtrip.musicxml.contains("<dynamics>")
&& roundtrip.musicxml.contains("<f/>"),
"MusicXML round trip must preserve tempo and dynamic:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_dynamic_and_wedge_placement_survive_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>Voice</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction placement=\"above\">\n",
" <direction-type><dynamics><p/></dynamics></direction-type>\n",
" </direction>\n",
" <direction placement=\"above\">\n",
" <direction-type><wedge type=\"crescendo\"/></direction-type>\n",
" </direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip =
export_musicxml(&abc).expect("placed dynamic/wedge ABC projection should export");
assert!(
roundtrip.musicxml.contains("<dynamics>")
&& roundtrip.musicxml.contains("<p/>")
&& roundtrip.musicxml.contains("<wedge type=\"crescendo\"/>"),
"dynamic and wedge must survive:\n{}",
roundtrip.musicxml
);
assert_eq!(
roundtrip
.musicxml
.matches("<direction placement=\"above\">")
.count(),
2,
"both dynamic and wedge directions must stay above:\n{}",
roundtrip.musicxml
);
assert_eq!(
roundtrip
.musicxml
.matches("<direction placement=\"below\">")
.count(),
0,
"placed imported directions must not fall back below:\n{}",
roundtrip.musicxml
);
}
#[test]
fn trailing_wedge_stop_on_spacer_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>Voice</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction placement=\"above\">\n",
" <direction-type><wedge type=\"crescendo\"/></direction-type>\n",
" </direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" <direction placement=\"above\">\n",
" <direction-type><wedge type=\"stop\"/></direction-type>\n",
" </direction>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip =
export_musicxml(&abc).expect("trailing wedge-stop ABC projection should export");
assert_eq!(
roundtrip
.musicxml
.matches("<wedge type=\"crescendo\"")
.count(),
1,
"the wedge start must survive once:\n{}",
roundtrip.musicxml
);
assert_eq!(
roundtrip.musicxml.matches("<wedge type=\"stop\"").count(),
1,
"the trailing wedge stop must survive once:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_lyric_text_with_same_note_extend_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type>",
"<lyric number=\"1\"><syllabic>single</syllabic><text>non</text><extend/></lyric>",
"</note>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let direct_xml = write_score_partwise(&read_musicxml(xml).value).value;
let direct_lyric = lyric_block(&direct_xml, "non");
assert!(
direct_lyric.contains("<text>non</text>") && direct_lyric.contains("<extend/>"),
"direct MusicXML read/write must keep text+extend in one lyric block:\n{direct_xml}"
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:cr le=1]C"),
"ABC projection needs a croma carrier because w: cannot spell same-note lyric extend:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("lyric same-note extend carrier should export");
let roundtrip_lyric = lyric_block(&roundtrip.musicxml, "non");
assert!(
roundtrip_lyric.contains("<text>non</text>") && roundtrip_lyric.contains("<extend/>"),
"MusicXML round trip must keep text+extend in one lyric block:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_multi_verse_same_note_lyric_extends_survive_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type>",
"<lyric number=\"1\"><syllabic>single</syllabic><text>one</text><extend/></lyric>",
"<lyric number=\"2\"><syllabic>single</syllabic><text>two</text><extend/></lyric>",
"</note>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:cr le=1]") && abc.contains("[I:cr le=2]"),
"ABC projection should preserve each verse's same-note extend carrier:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("multi-verse lyric same-note extends should export");
for text in ["one", "two"] {
let lyric = lyric_block(&roundtrip.musicxml, text);
assert!(
lyric.contains(&format!("<text>{text}</text>")) && lyric.contains("<extend/>"),
"MusicXML round trip must keep text+extend for lyric {text:?}:\n{}",
roundtrip.musicxml
);
}
}
#[test]
fn foreign_duplicate_same_verse_lyrics_survive_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type>",
"<lyric number=\"1\"><syllabic>single</syllabic><text>By</text></lyric>",
"<lyric number=\"1\"><syllabic>single</syllabic><text>John</text></lyric>",
"</note>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let direct_xml = write_score_partwise(&read_musicxml(xml).value).value;
assert_same_note_verse_lyrics(&direct_xml, &["By", "John"]);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("croma-lyric-duplicate verse=1 text=\"John\""),
"ABC projection needs a croma carrier because w: cannot spell duplicate same-verse lyrics:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("duplicate same-verse lyric ABC projection should export");
assert_same_note_verse_lyrics(&roundtrip.musicxml, &["By", "John"]);
}
#[test]
fn foreign_chord_head_lyric_keeps_alignment_in_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type>",
"<lyric number=\"1\"><syllabic>single</syllabic><text>lead</text></lyric></note>\n",
" <note><chord/><pitch><step>E</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" <note><pitch><step>G</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("w:lead *"),
"the chord-head lyric must align to the first ABC lyric slot:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("chord-head lyric ABC should export");
let measure = measure_block(&roundtrip.musicxml, "1");
let lyric = measure
.find("<text>lead</text>")
.unwrap_or_else(|| panic!("roundtrip must keep the lyric:\n{}", roundtrip.musicxml));
let chord_member = measure
.find("<chord/>")
.unwrap_or_else(|| panic!("roundtrip must keep the chord:\n{}", roundtrip.musicxml));
assert!(
lyric < chord_member,
"lyric must stay on the chord head, before the chord member note:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_chord_member_arpeggiate_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type>",
"<notations><arpeggiate/></notations></note>\n",
" <note><chord/><pitch><step>E</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type>",
"<notations><arpeggiate/></notations></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let direct_xml = write_score_partwise(&read_musicxml(xml).value).value;
assert_eq!(
direct_xml.matches("<arpeggiate/>").count(),
2,
"precondition: the reader model preserves head and member arpeggiate"
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[C!arpeggio!E]"),
"member arpeggiate must be projected inside the ABC chord:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("chord-member arpeggiate ABC projection should export");
let measure = measure_block(&roundtrip.musicxml, "1");
assert_eq!(
measure.matches("<arpeggiate/>").count(),
2,
"roundtrip must keep arpeggiate on the chord head and member:\nABC:\n{abc}\nXML:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_duplicate_same_verse_lyric_with_inline_delimiter_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type>",
"<lyric number=\"1\"><syllabic>single</syllabic><text>By</text></lyric>",
"<lyric number=\"1\"><syllabic>single</syllabic><text>A]B</text></lyric>",
"</note>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip = export_musicxml(&abc)
.expect("duplicate same-verse lyric delimiter carrier should export");
assert_same_note_verse_lyrics(&roundtrip.musicxml, &["By", "A]B"]);
}
#[test]
fn foreign_duplicate_same_verse_lyric_with_comment_marker_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type>",
"<lyric number=\"1\"><syllabic>single</syllabic><text>By</text></lyric>",
"<lyric number=\"1\"><syllabic>single</syllabic><text>A%B</text></lyric>",
"</note>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip = export_musicxml(&abc)
.expect("duplicate same-verse lyric percent carrier should export");
assert_same_note_verse_lyrics(&roundtrip.musicxml, &["By", "A%B"]);
}
#[test]
fn foreign_sparse_voice_keeps_measure_offset_in_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" <backup><duration>4</duration></backup>\n",
" <note><pitch><step>E</step><octave>3</octave></pitch>",
"<duration>4</duration><voice>5</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip = export_musicxml(&abc).expect("sparse voice ABC should export");
let first_measure = measure_block(&roundtrip.musicxml, "1");
let second_measure = measure_block(&roundtrip.musicxml, "2");
assert!(
!first_measure.contains("<voice>5</voice>")
&& second_measure.contains("<voice>5</voice>"),
"the sparse voice starts in measure 2 and must not be projected into measure 1; abc:\n{abc}\nxml:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_backup_forward_voice_offset_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" <backup><duration>16</duration></backup>\n",
" <forward><duration>8</duration></forward>\n",
" <note><pitch><step>E</step><octave>3</octave></pitch>",
"<duration>8</duration><voice>2</voice><type>half</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:cr mf]"),
"ABC projection needs a croma carrier for a MusicXML <forward> cursor gap:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("backup/forward voice-offset ABC should export");
let measure = measure_block(&roundtrip.musicxml, "1");
let backup = measure
.find("<backup>")
.unwrap_or_else(|| panic!("roundtrip must keep backup:\n{}", roundtrip.musicxml));
let forward = measure
.find("<forward>")
.unwrap_or_else(|| panic!("roundtrip must keep forward:\n{}", roundtrip.musicxml));
let voice2 = measure
.find("<voice>2</voice>")
.unwrap_or_else(|| panic!("roundtrip must keep voice 2 note:\n{}", roundtrip.musicxml));
let forward_block = &measure[forward..voice2];
assert!(
backup < forward && forward < voice2,
"forward must stay between backup and the offset voice-2 note:\n{}",
roundtrip.musicxml
);
assert!(
forward_block.contains("<duration>16</duration>"),
"forward must keep the half-measure cursor-gap duration in the exported divisions:\n{}",
roundtrip.musicxml
);
}
#[test]
fn backup_positioned_meter_restatement_does_not_synthesize_forward() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" <backup><duration>16</duration></backup>\n",
" <attributes><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>E</step><octave>3</octave></pitch>",
"<duration>16</duration><voice>5</voice><type>whole</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
!abc.contains("[I:cr mf]"),
"a restatement after a backup must not rewind the ABC gap cursor and create a fake forward:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("backup-positioned meter restatement should export");
let measure = measure_block(&roundtrip.musicxml, "1");
assert!(
measure.contains("<backup>") && !measure.contains("<forward>"),
"roundtrip must keep the backup into voice 5 without inventing a forward:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_post_backup_clef_cursor_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" <backup><duration>16</duration></backup>\n",
" <note><pitch><step>E</step><octave>3</octave></pitch>",
"<duration>16</duration><voice>5</voice><type>whole</type></note>\n",
" <backup><duration>4</duration></backup>\n",
" <attributes><clef><sign>F</sign><line>4</line></clef></attributes>\n",
" <forward><duration>4</duration></forward>\n",
" <backup><duration>16</duration></backup>\n",
" <note><pitch><step>G</step><octave>3</octave></pitch>",
"<duration>16</duration><voice>6</voice><type>whole</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:croma-clef-cursor "),
"ABC projection needs a private carrier for a post-backup MusicXML clef cursor:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("post-backup clef cursor ABC should export");
let measure = measure_block(&roundtrip.musicxml, "1");
let bass_sign = measure.find("<sign>F</sign>").unwrap_or_else(|| {
panic!("roundtrip must keep the bass clef:\n{}", roundtrip.musicxml)
});
let clef = measure[..bass_sign]
.rfind("<clef>")
.unwrap_or_else(|| panic!("bass clef sign must be inside a clef block:\n{measure}"));
let first_backup = measure[..clef]
.rfind("<backup>")
.unwrap_or_else(|| panic!("clef must be preceded by its short backup:\n{measure}"));
let forward = measure[clef..]
.find("<forward>")
.map(|offset| clef + offset)
.unwrap_or_else(|| {
panic!("clef must be followed by its cursor-restoring forward:\n{measure}")
});
let voice6 = measure
.find("<voice>6</voice>")
.unwrap_or_else(|| panic!("roundtrip must keep voice 6:\n{}", roundtrip.musicxml));
let second_backup = measure[forward..voice6]
.find("<backup>")
.map(|offset| forward + offset)
.unwrap_or_else(|| {
panic!("voice 6 must still have its own full-measure backup:\n{measure}")
});
assert!(
first_backup < clef
&& clef < forward
&& forward < second_backup
&& second_backup < voice6,
"roundtrip must preserve backup -> clef -> forward -> backup -> voice ordering:\n{}",
roundtrip.musicxml
);
assert!(
measure[first_backup..clef].contains("<duration>8</duration>")
&& measure[forward..second_backup].contains("<duration>8</duration>"),
"clef cursor backup/forward must keep the same one-quarter duration:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_chained_clef_cursor_before_rest_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>E</step><octave>4</octave></pitch>",
"<duration>12</duration><voice>1</voice><type>half</type><dot/></note>\n",
" <backup><duration>10</duration></backup>\n",
" <attributes><clef><sign>F</sign><line>4</line></clef></attributes>\n",
" <forward><duration>8</duration></forward>\n",
" <attributes><clef><sign>G</sign><line>2</line></clef></attributes>\n",
" <forward><duration>2</duration></forward>\n",
" <note><rest/><duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip =
export_musicxml(&abc).expect("chained clef cursor carrier ABC should export");
let measure = measure_block(&roundtrip.musicxml, "1");
let bass_sign = measure.find("<sign>F</sign>").unwrap_or_else(|| {
panic!("roundtrip must keep the bass clef:\n{}", roundtrip.musicxml)
});
let bass_clef = measure[..bass_sign]
.rfind("<clef>")
.unwrap_or_else(|| panic!("bass clef sign must be inside a clef block:\n{measure}"));
let treble_sign = measure[bass_sign..]
.find("<sign>G</sign>")
.map(|offset| bass_sign + offset)
.unwrap_or_else(|| panic!("roundtrip must keep the second treble clef:\n{measure}"));
let treble_clef = measure[..treble_sign]
.rfind("<clef>")
.unwrap_or_else(|| panic!("treble clef sign must be inside a clef block:\n{measure}"));
let first_forward = measure[bass_clef..treble_clef]
.find("<forward>")
.map(|offset| bass_clef + offset)
.unwrap_or_else(|| panic!("bass clef must be followed by a forward:\n{measure}"));
assert!(
!measure[first_forward..treble_clef].contains("<backup>"),
"chained clef cursor restore must not insert a backup between the two clefs:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_overbackup_between_voice_sequences_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>E</step><octave>4</octave></pitch>",
"<duration>12</duration><voice>1</voice><type>half</type><dot/></note>\n",
" <backup><duration>16</duration></backup>\n",
" <note><pitch><step>G</step><octave>3</octave></pitch>",
"<duration>4</duration><voice>5</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:croma-musicxml-sequence-backup "),
"ABC projection needs a private carrier for over-backup before a voice sequence:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("over-backup carrier ABC should export");
let measure = measure_block(&roundtrip.musicxml, "1");
let voice5 = measure
.find("<voice>5</voice>")
.unwrap_or_else(|| panic!("roundtrip must keep voice 5:\n{}", roundtrip.musicxml));
let backup = measure[..voice5]
.rfind("<backup>")
.unwrap_or_else(|| panic!("voice 5 must be preceded by a backup:\n{measure}"));
assert!(
measure[backup..voice5].contains("<duration>32</duration>"),
"roundtrip must keep the original four-quarter backup before voice 5:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_trailing_forward_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" <backup><duration>16</duration></backup>\n",
" <note><pitch><step>E</step><octave>3</octave></pitch>",
"<duration>4</duration><voice>2</voice><type>quarter</type></note>\n",
" <forward><duration>12</duration></forward>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:cr mf]"),
"ABC projection needs a croma carrier for a trailing MusicXML <forward>:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("trailing forward ABC should export");
let measure = measure_block(&roundtrip.musicxml, "1");
let voice2 = measure
.find("<voice>2</voice>")
.unwrap_or_else(|| panic!("roundtrip must keep voice 2 note:\n{}", roundtrip.musicxml));
let forward = measure.find("<forward>").unwrap_or_else(|| {
panic!(
"roundtrip must keep trailing forward before the measure boundary:\n{}",
roundtrip.musicxml
)
});
let forward_block = &measure[forward..];
assert!(
voice2 < forward,
"trailing forward must remain after the short voice-2 note:\n{}",
roundtrip.musicxml
);
assert!(
forward_block.contains("<duration>24</duration>"),
"trailing forward must keep the remaining three-quarter cursor gap:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_forward_only_measure_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <forward><duration>16</duration></forward>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:cr mf]"),
"ABC projection needs a croma carrier for a forward-only measure:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("forward-only measure ABC should export");
let first_measure = measure_block(&roundtrip.musicxml, "1");
assert!(
first_measure.contains("<forward>")
&& first_measure.contains("<duration>32</duration>"),
"roundtrip must keep the forward-only measure as a MusicXML <forward>:\n{}",
roundtrip.musicxml
);
assert!(
measure_block(&roundtrip.musicxml, "2").contains("<voice>1</voice>"),
"the following note must stay in measure 2:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_ending_stop_on_regular_barline_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <barline location=\"left\"><ending number=\"2\" type=\"start\"/></barline>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" <barline location=\"right\"><ending number=\"2\" type=\"stop\"/></barline>\n",
" </measure>\n",
" <measure number=\"3\">\n",
" <note><pitch><step>E</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip =
export_musicxml(&abc).expect("ending-stop-on-regular-barline ABC should export");
let second_measure = measure_block(&roundtrip.musicxml, "2");
assert!(
second_measure.contains("<ending number=\"2\" type=\"stop\"/>"),
"roundtrip must keep the explicit regular-barline ending stop in measure 2; abc:\n{abc}\nxml:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_ending_stop_on_left_barline_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <barline location=\"left\"><ending number=\"2\" type=\"start\"/></barline>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <barline location=\"left\"><ending number=\"2\" type=\"stop\"/></barline>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip = export_musicxml(&abc).expect("left ending-stop carrier ABC should export");
let second_measure = measure_block(&roundtrip.musicxml, "2");
assert!(
second_measure.contains("<barline location=\"left\">")
&& second_measure.contains("<ending number=\"2\" type=\"stop\"/>")
&& !second_measure.contains("<barline location=\"right\">"),
"roundtrip must keep the explicit ending stop on measure 2's left barline; abc:\n{abc}\nxml:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_ending_discontinue_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <barline location=\"left\"><ending number=\"2\" type=\"start\"/></barline>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" <barline location=\"right\"><ending number=\"2\" type=\"discontinue\"/></barline>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip = export_musicxml(&abc).expect("ending-discontinue ABC should export");
let first_measure = measure_block(&roundtrip.musicxml, "1");
assert!(
first_measure.contains("<ending number=\"2\" type=\"discontinue\"/>"),
"roundtrip must keep explicit MusicXML ending discontinue, not normalize to stop; abc:\n{abc}\nxml:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_dashed_barline_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" <barline location=\"right\"><bar-style>dashed</bar-style></barline>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:croma-barline-style style=dashed]"),
"ABC projection needs a croma carrier for a MusicXML dashed barline:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("dashed barline ABC should export");
let first_measure = measure_block(&roundtrip.musicxml, "1");
assert!(
first_measure.contains("<bar-style>dashed</bar-style>"),
"roundtrip must keep MusicXML dashed barline style; abc:\n{abc}\nxml:\n{}",
roundtrip.musicxml
);
}
#[test]
fn dashed_barline_carrier_does_not_replace_repeat_end() {
let abc = "X:1\nM:4/4\nL:1/8\nK:C\nC8 [I:croma-barline-style style=dashed]:|\n";
let roundtrip = export_musicxml(abc).expect("dashed repeat-end ABC should export");
let first_measure = measure_block(&roundtrip.musicxml, "1");
assert!(
first_measure.contains("<repeat direction=\"backward\"/>"),
"barline style carrier must not drop repeat-end semantics:\n{}",
roundtrip.musicxml
);
assert!(
!first_measure.contains("<bar-style>dashed</bar-style>"),
"dashed style carrier is only preserved for regular bars until mixed style+repeat is modeled:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_redundant_meter_restatement_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <attributes><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:cr mr] [M:4/4]"),
"ABC projection must carry a private marker so the no-op [M:] survives re-lowering:\n{abc}"
);
assert_eq!(
write_abc(&lower(&abc), AbcWriteOptions::default()),
abc,
"the croma meter restatement carrier should be an ABC fixed point"
);
let roundtrip = export_musicxml(&abc).expect("redundant meter ABC should export");
assert_eq!(
roundtrip.musicxml.matches("<time>").count(),
2,
"MusicXML-origin repeated <time> must survive XML->ABC->XML; abc:\n{abc}\nxml:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_redundant_key_restatement_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><key><fifths>-1</fifths></key></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <attributes><key><fifths>-1</fifths></key></attributes>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:cr kr] [K:"),
"ABC projection must carry a private marker so the no-op [K:] survives re-lowering:\n{abc}"
);
assert_eq!(
write_abc(&lower(&abc), AbcWriteOptions::default()),
abc,
"the croma key restatement carrier should be an ABC fixed point"
);
let roundtrip = export_musicxml(&abc).expect("redundant key ABC should export");
assert_eq!(
roundtrip.musicxml.matches("<fifths>-1</fifths>").count(),
2,
"MusicXML-origin repeated <key> must survive XML->ABC->XML; abc:\n{abc}\nxml:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_grace_accidental_survives_clef_octave_shift() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>G</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions>\n",
" <clef><sign>G</sign><line>2</line><clef-octave-change>-1</clef-octave-change></clef>\n",
" </attributes>\n",
" <note><pitch><step>C</step><alter>1</alter><octave>5</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type><accidental>sharp</accidental></note>\n",
" <note><grace slash=\"yes\"/><pitch><step>C</step><alter>1</alter><octave>5</octave></pitch>",
"<voice>1</voice><type>eighth</type></note>\n",
" <note><pitch><step>D</step><octave>5</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
let roundtrip = export_musicxml(&abc).expect("clef-octave grace ABC should export");
assert_eq!(
roundtrip.musicxml.matches("<alter>1</alter>").count(),
2,
"both the anchor and grace C# must keep their sharp through XML->ABC->XML; abc:\n{abc}\nxml:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_cut_time_symbol_keeps_source_beats_through_abc_projection() {
let xml = foreign_time_score(
"<time symbol=\"cut\"><beats>4</beats><beat-type>2</beat-type></time>",
);
let score = completed_from_xml(&xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("M:4/2"),
"ABC projection must keep the real beats instead of collapsing to M:C|:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("foreign cut-time ABC should export");
let time = first_time_block(&roundtrip.musicxml);
assert!(
time.contains("<time symbol=\"cut\">")
&& time.contains("<beats>4</beats>")
&& time.contains("<beat-type>2</beat-type>"),
"MusicXML symbol and beats must both survive XML->ABC->XML:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_common_time_symbol_keeps_source_beats_through_abc_projection() {
let xml = foreign_time_score(
"<time symbol=\"common\"><beats>2</beats><beat-type>2</beat-type></time>",
);
let score = completed_from_xml(&xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("M:2/2"),
"ABC projection must keep the real beats instead of collapsing to M:C:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("foreign common-time ABC should export");
let time = first_time_block(&roundtrip.musicxml);
assert!(
time.contains("<time symbol=\"common\">")
&& time.contains("<beats>2</beats>")
&& time.contains("<beat-type>2</beat-type>"),
"MusicXML symbol and beats must both survive XML->ABC->XML:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_numeric_common_meter_does_not_gain_time_symbol() {
let xml = foreign_time_score("<time><beats>4</beats><beat-type>4</beat-type></time>");
let score = completed_from_xml(&xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("M:4/4"),
"numeric MusicXML 4/4 must project as numeric ABC, not M:C:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("foreign numeric-time ABC should export");
let time = first_time_block(&roundtrip.musicxml);
assert!(
!time.contains("symbol="),
"numeric MusicXML 4/4 must not gain a common-time symbol:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_part_initial_meter_symbol_difference_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <score-part id=\"P1\"><part-name>One</part-name></score-part>\n",
" <score-part id=\"P2\"><part-name>Two</part-name></score-part>\n",
" </part-list>\n",
" <part id=\"P1\"><measure number=\"1\">\n",
" <attributes><divisions>4</divisions>",
"<time symbol=\"cut\"><beats>2</beats><beat-type>2</beat-type></time>",
"</attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure></part>\n",
" <part id=\"P2\"><measure number=\"1\">\n",
" <attributes><divisions>4</divisions>",
"<time><beats>2</beats><beat-type>2</beat-type></time>",
"</attributes>\n",
" <note><pitch><step>E</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure></part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("M:C|") && abc.contains("croma-initial-meter display=\"2/2\""),
"ABC projection must preserve P2's numeric 2/2 initial meter separately from P1's cut-time header:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("part-initial meter ABC should export");
let p1 = measure_block(&roundtrip.musicxml, "1");
let p2_start = roundtrip
.musicxml
.find("<part id=\"P2\">")
.expect("P2 should exist");
let p2 = measure_block(&roundtrip.musicxml[p2_start..], "1");
assert!(
first_time_block(p1).contains("<time symbol=\"cut\">"),
"P1 must keep cut-time symbol:\n{}",
roundtrip.musicxml
);
assert!(
!first_time_block(p2).contains("symbol="),
"P2 must keep numeric 2/2 without inheriting cut-time symbol:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_pitch_alter_without_accidental_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>F</step><alter>1</alter><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" <note><grace/><pitch><step>C</step><alter>1</alter><octave>5</octave></pitch>",
"<voice>1</voice><type>eighth</type></note>\n",
" <note><pitch><step>D</step><octave>5</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("^F") && abc.contains("{^c}"),
"ABC projection must spell MusicXML <alter> values that lack <accidental>:\n{abc}"
);
assert_eq!(
pitch_sequence(&lower(&abc)),
pitch_sequence(&score),
"MusicXML <alter> values must survive through ABC re-lowering:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("altered pitch ABC should export");
assert!(
roundtrip.musicxml.matches("<alter>1</alter>").count() >= 2,
"main and grace altered pitches must survive XML->ABC->XML:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_words_sound_120_without_metronome_keeps_playback_tempo() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction placement=\"above\">\n",
" <direction-type><words>rall</words></direction-type>\n",
" <sound tempo=\"120\"/>\n",
" </direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let tempo = score.parts[0].voices[0]
.events
.iter()
.find_map(|event| match &event.kind {
TimedEventKind::TempoChange(tempo) => Some(tempo),
_ => None,
})
.expect("sound tempo carrier should become a timed tempo event");
assert_eq!(tempo.beat_role, TempoBeatRole::PlaybackSoundOnly);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:croma-sound-tempo bpm=120 beat-n=1 beat-d=4 text=\"rall\"]"),
"foreign <sound tempo=\"120\"> is source tempo, not croma's old text-only fallback; got:\n{abc}"
);
}
#[test]
fn foreign_tempo_multiple_words_keeps_nonempty_text_and_beat() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction placement=\"above\">\n",
" <direction-type>\n",
" <metronome><beat-unit>quarter</beat-unit><per-minute>80</per-minute></metronome>\n",
" </direction-type>\n",
" <direction-type><words>\n</words><words>andante</words></direction-type>\n",
" <sound tempo=\"80\"/>\n",
" </direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("Q:\"andante\" 1/4=80\n"),
"tempo text split across MusicXML words must survive as one nonempty ABC Q text; got:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("multi-word tempo ABC projection should export");
assert!(
roundtrip.musicxml.contains("<words>andante</words>")
&& roundtrip.musicxml.contains("<per-minute>80</per-minute>"),
"ABC projection must re-emit both tempo words and the metronome beat:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_tempo_text_is_normalized_to_one_line_q() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction placement=\"above\">\n",
" <direction-type><words>\nSwing\n</words></direction-type>\n",
" <direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>112</per-minute></metronome></direction-type>\n",
" <sound tempo=\"112\"/>\n",
" </direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("Q:\"Swing\" 1/4=112\n"),
"tempo text with XML whitespace must not split the ABC Q field; got:\n{abc}"
);
assert!(
!abc.contains("Q:\"\n"),
"tempo text must be normalized before ABC quoting; got:\n{abc}"
);
}
#[test]
fn foreign_tempo_text_with_quotes_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction><direction-type>",
"<words>(robot and elaborated melody for \"funky town\" on D.C)</words>",
"</direction-type></direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("Q:\"(robot and elaborated melody for "funky town" on D.C)\""),
"ABC projection must escape embedded tempo quotes:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("quoted tempo text ABC projection should export");
assert!(
roundtrip.musicxml.contains(
"<words>(robot and elaborated melody for "funky town" on D.C)</words>"
),
"roundtrip must preserve embedded tempo quotes:\nABC:\n{abc}\nXML:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_mid_tune_tempo_text_with_bracket_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <direction><direction-type>",
"<words>Notes: Air - [Bass] bracket</words>",
"<metronome><beat-unit>quarter</beat-unit><per-minute>84</per-minute></metronome>",
"</direction-type></direction>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains(
"[I:croma-tempo role=printed text-hex=4e6f7465733a20416972202d205b426173735d20627261636b6574 bpm=84 beat-n=1 beat-d=4]"
),
"ABC projection must carry the bracketed tempo text and beat through a safe carrier:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("bracketed tempo text ABC projection should export");
assert!(
roundtrip
.musicxml
.contains("<words>Notes: Air - [Bass] bracket</words>")
&& roundtrip.musicxml.contains("<per-minute>84</per-minute>"),
"roundtrip must preserve tempo text containing ']' and its beat:\nABC:\n{abc}\nXML:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_header_tempo_text_with_percent_uses_safe_carrier() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction><direction-type>",
"<words>50% swing</words>",
"<metronome><beat-unit>quarter</beat-unit><per-minute>100</per-minute></metronome>",
"</direction-type></direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
!abc.contains("Q:\"50% swing\""),
"tempo text containing '%' must not be emitted through raw Q: syntax:\n{abc}"
);
assert!(
abc.contains(
"[I:croma-tempo role=printed text-hex=353025207377696e67 bpm=100 beat-n=1 beat-d=4]"
),
"header tempo text containing '%' must use a safe carrier:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("percent tempo text ABC projection should export");
assert!(
roundtrip.musicxml.contains("<words>50% swing</words>")
&& roundtrip.musicxml.contains("<per-minute>100</per-minute>"),
"roundtrip must preserve header tempo text containing '%' and its beat:\nABC:\n{abc}\nXML:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_mid_tune_tempo_text_with_percent_uses_safe_carrier() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <direction><direction-type>",
"<words>50% swing</words>",
"</direction-type></direction>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
!abc.contains("[Q:\"50% swing\"]"),
"mid-tune tempo text containing '%' must not be emitted through raw inline Q: syntax:\n{abc}"
);
assert!(
abc.contains("[I:croma-tempo role=printed text-hex=353025207377696e67]"),
"mid-tune tempo text containing '%' must use a safe carrier:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("percent tempo text ABC projection should export");
assert!(
roundtrip.musicxml.contains("<words>50% swing</words>"),
"roundtrip must preserve mid-tune tempo text containing '%':\nABC:\n{abc}\nXML:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_bare_sound_tempo_projects_to_sound_tempo_carrier() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction><sound tempo=\"96\"/></direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:croma-sound-tempo bpm=96 beat-n=1 beat-d=4]"),
"a bare foreign <sound tempo> must not be dropped or printed as Q:; got:\n{abc}"
);
}
#[test]
fn foreign_sound_tempo_text_with_bracket_uses_safe_carrier() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" <direction><direction-type>",
"<words>Push [rit.]</words>",
"</direction-type><sound tempo=\"72\"/></direction>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains(
"[I:croma-sound-tempo bpm=72 beat-n=1 beat-d=4 text-hex=50757368205b7269742e5d]"
),
"playback-only tempo text containing ']' must use a safe carrier:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("bracketed sound-tempo carrier should export");
assert!(
roundtrip.musicxml.contains("<words>Push [rit.]</words>")
&& roundtrip.musicxml.contains("<sound tempo=\"72.00\"/>"),
"roundtrip must preserve playback-only tempo text and sound tempo:\nABC:\n{abc}\nXML:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_measure_number_zero_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"0\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction placement=\"above\">\n",
" <direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>60</per-minute></metronome></direction-type>\n",
" </direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:croma-measure-number n=0]"),
"MusicXML measure number 0 needs a private ABC carrier:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("measure-number carrier should export");
let measure_zero = measure_block(&roundtrip.musicxml, "0");
assert!(
measure_zero.contains("<per-minute>60</per-minute>"),
"header tempo must stay in source measure number 0:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_restarted_measure_numbers_preserve_tempo_positions() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" <measure number=\"1\">\n",
" <direction placement=\"above\"><direction-type><words>Allegro</words></direction-type></direction>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("| [I:croma-measure-number n=1]"),
"restarted source measure numbers need a carrier on the later measure:\n{abc}"
);
assert_eq!(
write_abc(&lower(&abc), AbcWriteOptions::default()),
abc,
"the measure-number carrier should be an ABC fixed point"
);
let roundtrip = export_musicxml(&abc).expect("measure-number carrier should export");
assert_eq!(
roundtrip.musicxml.matches("<measure number=\"1\">").count(),
2,
"both source measure numbers should survive:\n{}",
roundtrip.musicxml
);
assert!(
roundtrip
.musicxml
.split("<measure number=\"1\">")
.nth(2)
.is_some_and(|tail| tail.contains("<words>Allegro</words>")),
"the Allegro direction must remain in the repeated measure number 1:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_nonnumeric_measure_numbers_survive_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"X1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction placement=\"above\"><direction-type><words>Scene</words></direction-type></direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" <measure number=\"X2\">\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:croma-measure-number n=X1]")
&& abc.contains("[I:croma-measure-number n=X2]"),
"nonnumeric source measure numbers need private ABC carriers:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("measure-number carrier should export");
let measure_x1 = measure_block(&roundtrip.musicxml, "X1");
assert!(
measure_x1.contains("<words>Scene</words>"),
"Scene direction must remain in source measure X1:\n{}",
roundtrip.musicxml
);
assert!(
roundtrip.musicxml.contains("<measure number=\"X2\">"),
"second nonnumeric source measure number should survive:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_pickup_measure_number_survives_before_leading_repeat() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"0\">\n",
" <attributes><divisions>4</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" <direction placement=\"above\"><direction-type><words>Adagio</words></direction-type><sound tempo=\"92\"/></direction>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" <measure number=\"1\">\n",
" <barline location=\"left\"><bar-style>heavy-light</bar-style><repeat direction=\"forward\"/></barline>\n",
" <note><pitch><step>E</step><octave>4</octave></pitch>",
"<duration>16</duration><voice>1</voice><type>whole</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("|: [I:croma-measure-number n=1]"),
"the next measure's label carrier must follow its leading repeat:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("measure-number carrier should export");
let pickup = measure_block(&roundtrip.musicxml, "0");
assert!(
pickup.contains("<words>Adagio</words>") && pickup.contains("<sound tempo=\"92.00\"/>"),
"pickup tempo must remain in source measure number 0:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_part_initial_key_survives_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>",
"<score-part id=\"P1\"><part-name>One</part-name></score-part>",
"<score-part id=\"P2\"><part-name>Two</part-name></score-part>",
"</part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><key><fifths>2</fifths></key></attributes>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
" <part id=\"P2\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><key><fifths>0</fifths></key></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:croma-initial-key fifths=0]"),
"part-local initial key needs a private ABC carrier:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("part-local key ABC should export");
let p2 = roundtrip
.musicxml
.split("<part id=\"P2\">")
.nth(1)
.expect("P2 should be present");
assert!(
p2.contains("<fifths>0</fifths>"),
"P2 must keep its own initial C major key:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_part_initial_key_does_not_suppress_later_key_change() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>",
"<score-part id=\"P1\"><part-name>One</part-name></score-part>",
"<score-part id=\"P2\"><part-name>Two</part-name></score-part>",
"</part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><key><fifths>2</fifths></key></attributes>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
" <part id=\"P2\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions><key><fifths>0</fifths></key></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <attributes><key><fifths>-1</fifths></key></attributes>\n",
" <note><pitch><step>F</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("[I:croma-initial-key fifths=0]") && abc.contains("[K:F]"),
"part-local initial key and later key change must both survive in ABC:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("part-local key ABC should export");
let p2 = roundtrip
.musicxml
.split("<part id=\"P2\">")
.nth(1)
.expect("P2 should be present");
assert!(
p2.contains("<fifths>0</fifths>") && p2.contains("<fifths>-1</fifths>"),
"P2 must keep its own initial key and later key change:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_midi_instrument_projects_to_midi_directives() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <score-part id=\"P1\">\n",
" <part-name>T</part-name>\n",
" <score-instrument id=\"P1-I1\"><instrument-name>Piano</instrument-name></score-instrument>\n",
" <midi-instrument id=\"P1-I1\">\n",
" <midi-channel>2</midi-channel>\n",
" <midi-program>41</midi-program>\n",
" <volume>78.7402</volume>\n",
" <pan>0</pan>\n",
" </midi-instrument>\n",
" </score-part>\n",
" </part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%MIDI program 2 40\n"),
"MusicXML midi-program 41 must project to 0-based ABC program 40 with channel 2; got:\n{abc}"
);
assert!(
abc.contains("%%MIDI control 7 100\n"),
"MusicXML volume 78.7402 must project to MIDI CC7 100; got:\n{abc}"
);
assert!(
abc.contains("%%MIDI control 10 64\n"),
"MusicXML pan 0 must project to centered MIDI CC10 64; got:\n{abc}"
);
assert!(
abc.contains("V:P1 name=\"T\" nm=\"Piano\"\n"),
"MusicXML part/instrument names must survive into ABC voice metadata; got:\n{abc}"
);
}
#[test]
fn completion_synthesizes_voice_barline_events_from_measure_structure() {
let x1 = export("X:1\nL:1/4\nK:C\nC D E F | G A B c |\n");
let raw = read_musicxml(&x1).value;
assert!(
event_barline_kinds(&raw).is_empty(),
"the raw reader output must carry NO barline events (they live in Measure.barlines)"
);
let mut completed = raw;
complete_score_for_abc(&mut completed);
assert_eq!(
event_barline_kinds(&completed),
vec![BarlineKind::Regular],
"the completion pass must synthesize the internal measure boundary `|`"
);
let projected = write_abc(&completed, AbcWriteOptions::default());
assert_eq!(
measure_count(&lower(&projected)),
2,
"both measures must survive XML -> ABC -> Score; abc {projected:?}"
);
}
#[test]
fn completed_abc_reparses_to_same_measure_count_and_barline_kinds() {
let abc = "X:1\nL:1/4\nK:C\n|: C D E F | G A B c :| c B A G |]\n";
let completed = completed_from_abc(abc);
let projected = write_abc(&completed, AbcWriteOptions::default());
assert!(projected.contains("|:"), "leading repeat: {projected:?}");
assert!(projected.contains(":|"), "backward repeat: {projected:?}");
assert!(projected.contains("|]"), "final barline: {projected:?}");
let reference = lower(abc);
let reparsed = lower(&projected);
assert_eq!(
measure_count(&reparsed),
measure_count(&reference),
"measure count must survive XML -> ABC -> Score; abc {projected:?}"
);
assert_eq!(
event_barline_kinds(&reparsed),
event_barline_kinds(&reference),
"barline kinds must survive XML -> ABC -> Score; abc {projected:?}"
);
}
#[test]
fn completion_sets_canonical_major_key_display_from_fifths() {
let x1 = export("X:1\nL:1/4\nK:Eb\nE F G A | B c d e |\n");
let raw = read_musicxml(&x1).value;
assert_eq!(
raw.metadata.key.as_ref().map(|k| k.display.as_str()),
Some(""),
"the raw reader leaves the key display empty"
);
let mut completed = raw;
complete_score_for_abc(&mut completed);
assert_eq!(
completed.metadata.key.as_ref().map(|k| k.display.as_str()),
Some("Eb"),
"fifths -3 must spell as the canonical major key Eb"
);
let projected = write_abc(&completed, AbcWriteOptions::default());
assert!(
projected.contains("\nK:Eb\n"),
"completed ABC must emit K:Eb, got {projected:?}"
);
}
#[test]
fn non_c_key_pitch_sequence_survives_xml_to_abc_to_score() {
let abc = "X:1\nL:1/8\nK:Eb\nE F G A B c d e |\n";
let reference = lower(abc);
let completed = completed_from_abc(abc);
assert_eq!(
pitch_sequence(&completed),
pitch_sequence(&reference),
"reconstruction must preserve the sounding pitch sequence in a non-C key"
);
let projected = write_abc(&completed, AbcWriteOptions::default());
let reparsed = lower(&projected);
assert_eq!(
pitch_sequence(&reparsed),
pitch_sequence(&reference),
"completed ABC must reparse to the same pitches in a non-C key; abc {projected:?}"
);
}
#[test]
fn mid_tune_key_change_display_is_filled_canonically() {
let abc = "X:1\nL:1/4\nK:C\nC D E F | [K:F] G A B c |\n";
let mut score = read_musicxml(&export(abc)).value;
complete_score_for_abc(&mut score);
let change_displays: Vec<String> = score.parts[0].voices[0]
.events
.iter()
.filter_map(|e| match &e.kind {
TimedEventKind::KeyChange(k) => Some(k.display.clone()),
_ => None,
})
.collect();
assert_eq!(
change_displays,
vec!["F".to_string()],
"mid-tune KeyChange display must be the canonical major spelling for fifths 1"
);
let projected = write_abc(&score, AbcWriteOptions::default());
assert!(
projected.contains("[K:F]"),
"completed ABC must carry the inline [K:F], got {projected:?}"
);
let reference = lower(abc);
let reparsed = lower(&projected);
let key_fifths = |s: &Score| -> Vec<i8> {
let mut v = Vec::new();
for p in &s.parts {
for voice in &p.voices {
for e in &voice.events {
if let TimedEventKind::KeyChange(k) = &e.kind {
v.push(k.fifths);
}
}
}
}
v
};
assert_eq!(
key_fifths(&reparsed),
key_fifths(&reference),
"mid-tune key fifths must survive XML -> ABC -> Score; abc {projected:?}"
);
}
#[test]
fn first_and_second_endings_round_trip_through_completed_abc() {
let abc = "X:1\nL:1/4\n K:C\n|: C D E F |1 G A B c :|2 c B A G |]\n";
let completed = completed_from_abc(abc);
let projected = write_abc(&completed, AbcWriteOptions::default());
assert!(projected.contains("[1"), "first ending: {projected:?}");
assert!(projected.contains("[2"), "second ending: {projected:?}");
let reference = lower(abc);
let reparsed = lower(&projected);
assert_eq!(measure_count(&reparsed), measure_count(&reference));
assert_eq!(
event_barline_kinds(&reparsed),
event_barline_kinds(&reference),
"ending barline kinds must survive; abc {projected:?}"
);
}
#[test]
fn format_xml_inverse_is_byte_identical_with_and_without_completion() {
for abc in [
"X:1\nL:1/4\nK:C\nC D E F | G A B c |\n",
"X:1\nL:1/4\nK:Eb\n|: E F G A :| B c d e |]\n",
"X:1\nL:1/4\nK:D\n|: A B c d |1 e f g a :|2 a g f e |]\n",
] {
let x1 = export(abc);
let mut score = read_musicxml(&x1).value;
let xml_before = write_musicxml(&score).musicxml;
complete_score_for_abc(&mut score);
let xml_after = write_musicxml(&score).musicxml;
assert_eq!(
xml_before, xml_after,
"completion must not change the write_musicxml inverse for {abc:?}"
);
assert_eq!(
xml_after, x1,
"the XML inverse must stay byte-identical to the forward XML for {abc:?}"
);
}
}
#[test]
fn key_display_is_left_untouched_when_metadata_key_is_none() {
let mut score = read_musicxml(&export("X:1\nL:1/4\nK:C\nC D E F |\n")).value;
score.metadata.key = None;
complete_score_for_abc(&mut score);
assert!(
score.metadata.key.is_none(),
"the pass must not synthesize a key when none is present"
);
let projected = write_abc(&score, AbcWriteOptions::default());
assert!(
projected.contains("\nK:C\n"),
"write_abc must default a key-less Score to K:C, got {projected:?}"
);
}
#[test]
fn tuplets_in_separate_measures_do_not_merge_in_abc_projection() {
let abc = "X:1\nT:TwoMeasureTrips\nM:4/4\nL:1/8\nK:C\n(3CDE A2 z2 | (3FGA c2 z2 |\n";
let x1 = export(abc);
assert_eq!(
x1.matches("<tuplet type=\"start\"").count(),
2,
"precondition: two triplets, one per measure"
);
let direct = read_musicxml(&x1).value;
let completed = completed_from_abc(abc);
let projected = write_abc(&completed, AbcWriteOptions::default());
assert_eq!(
projected.matches("(3:2:3").count(),
2,
"each triplet must project as its own (3:2:3 group; abc {projected:?}"
);
assert!(
!projected.contains("(3:2:4")
&& !projected.contains("(3:2:5")
&& !projected.contains("(3:2:6"),
"no merged super-span tuplet marker may appear; abc {projected:?}"
);
let reparsed = lower(&projected);
assert_eq!(
played_duration_sequence(&reparsed),
played_duration_sequence(&direct),
"sounding durations must survive XML -> ABC -> Score; abc {projected:?}"
);
assert_eq!(
pitch_sequence(&reparsed),
pitch_sequence(&direct),
"pitch sequence must survive XML -> ABC -> Score; abc {projected:?}"
);
}
#[test]
fn foreign_wide_tuplet_survives_abc_projection_without_invalid_marker() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>480</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>48</duration><voice>1</voice><type>eighth</type>",
"<time-modification><actual-notes>10</actual-notes><normal-notes>2</normal-notes></time-modification>",
"<notations><tuplet type=\"start\" bracket=\"no\" show-number=\"none\"/></notations></note>\n",
" <note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>48</duration><voice>1</voice><type>eighth</type>",
"<time-modification><actual-notes>10</actual-notes><normal-notes>2</normal-notes></time-modification></note>\n",
" <note><pitch><step>E</step><octave>4</octave></pitch>",
"<duration>48</duration><voice>1</voice><type>eighth</type>",
"<time-modification><actual-notes>10</actual-notes><normal-notes>2</normal-notes></time-modification></note>\n",
" <note><pitch><step>F</step><octave>4</octave></pitch>",
"<duration>48</duration><voice>1</voice><type>eighth</type>",
"<time-modification><actual-notes>10</actual-notes><normal-notes>2</normal-notes></time-modification></note>\n",
" <note><pitch><step>G</step><octave>4</octave></pitch>",
"<duration>48</duration><voice>1</voice><type>eighth</type>",
"<time-modification><actual-notes>10</actual-notes><normal-notes>2</normal-notes></time-modification></note>\n",
" <note><pitch><step>A</step><octave>4</octave></pitch>",
"<duration>48</duration><voice>1</voice><type>eighth</type>",
"<time-modification><actual-notes>10</actual-notes><normal-notes>2</normal-notes></time-modification></note>\n",
" <note><pitch><step>B</step><octave>4</octave></pitch>",
"<duration>48</duration><voice>1</voice><type>eighth</type>",
"<time-modification><actual-notes>10</actual-notes><normal-notes>2</normal-notes></time-modification></note>\n",
" <note><pitch><step>C</step><octave>5</octave></pitch>",
"<duration>48</duration><voice>1</voice><type>eighth</type>",
"<time-modification><actual-notes>10</actual-notes><normal-notes>2</normal-notes></time-modification></note>\n",
" <note><pitch><step>D</step><octave>5</octave></pitch>",
"<duration>48</duration><voice>1</voice><type>eighth</type>",
"<time-modification><actual-notes>10</actual-notes><normal-notes>2</normal-notes></time-modification></note>\n",
" <note><pitch><step>E</step><octave>5</octave></pitch>",
"<duration>48</duration><voice>1</voice><type>eighth</type>",
"<time-modification><actual-notes>10</actual-notes><normal-notes>2</normal-notes></time-modification>",
"<notations><tuplet type=\"stop\" number=\"1\"/></notations></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let direct = read_musicxml(xml).value;
let completed = completed_from_xml(xml);
let projected = write_abc(&completed, AbcWriteOptions::default());
assert!(
!projected.contains("(10:2"),
"ABC projection must not emit parser-invalid wide tuplets:\n{projected}"
);
assert!(
projected.contains("[I:croma-musicxml-tuplet "),
"wide MusicXML tuplets need a private carrier:\n{projected}"
);
let reparsed = lower(&projected);
assert_eq!(
played_duration_sequence(&reparsed),
played_duration_sequence(&direct),
"sounding durations must survive XML -> ABC -> Score; abc {projected:?}"
);
let roundtrip = export_musicxml(&projected).expect("wide-tuplet carrier ABC should export");
assert!(
roundtrip
.musicxml
.contains("<actual-notes>10</actual-notes>")
&& roundtrip
.musicxml
.contains("<normal-notes>2</normal-notes>")
&& roundtrip
.musicxml
.contains("<tuplet type=\"start\" bracket=\"no\"/>")
&& roundtrip
.musicxml
.contains("<tuplet type=\"stop\" number=\"1\"/>"),
"roundtrip must keep the 10:2 MusicXML tuplet:\n{}",
roundtrip.musicxml
);
}
#[test]
fn foreign_wide_tuplet_on_chord_survives_abc_projection() {
let tm = "<time-modification><actual-notes>10</actual-notes><normal-notes>2</normal-notes></time-modification>";
let mut body = String::new();
body.push_str(&format!(
"<note><pitch><step>C</step><octave>4</octave></pitch><duration>48</duration><voice>1</voice><type>eighth</type>{tm}<notations><tuplet type=\"start\" bracket=\"no\" show-number=\"none\"/></notations></note>"
));
body.push_str(&format!(
"<note><chord/><pitch><step>E</step><octave>4</octave></pitch><duration>48</duration><voice>1</voice><type>eighth</type>{tm}</note>"
));
for (step, octave) in [
("D", "4"),
("E", "4"),
("F", "4"),
("G", "4"),
("A", "4"),
("B", "4"),
("C", "5"),
("D", "5"),
] {
body.push_str(&format!(
"<note><pitch><step>{step}</step><octave>{octave}</octave></pitch><duration>48</duration><voice>1</voice><type>eighth</type>{tm}</note>"
));
}
body.push_str(&format!(
"<note><pitch><step>E</step><octave>5</octave></pitch><duration>48</duration><voice>1</voice><type>eighth</type>{tm}<notations><tuplet type=\"stop\" number=\"1\"/></notations></note>"
));
body.push_str(&format!(
"<note><chord/><pitch><step>G</step><octave>5</octave></pitch><duration>48</duration><voice>1</voice><type>eighth</type>{tm}</note>"
));
let xml = format!(
"<?xml version=\"1.0\"?>\n<score-partwise>\n <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n <part id=\"P1\">\n <measure number=\"1\">\n <attributes><divisions>480</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n {body}\n </measure>\n </part>\n</score-partwise>\n"
);
let direct = read_musicxml(&xml).value;
let completed = completed_from_xml(&xml);
let projected = write_abc(&completed, AbcWriteOptions::default());
assert!(
!projected.contains("(10:2"),
"ABC projection must not emit parser-invalid wide tuplets:\n{projected}"
);
assert!(
projected.contains("[I:croma-musicxml-tuplet "),
"wide MusicXML tuplets need a private carrier:\n{projected}"
);
let reparsed = lower(&projected);
assert_eq!(
played_duration_sequence(&reparsed),
played_duration_sequence(&direct),
"sounding durations must survive XML -> ABC -> Score; abc {projected:?}"
);
let roundtrip = export_musicxml(&projected).expect("wide-tuplet carrier ABC should export");
assert!(
roundtrip
.musicxml
.contains("<actual-notes>10</actual-notes>")
&& roundtrip
.musicxml
.contains("<normal-notes>2</normal-notes>")
&& roundtrip
.musicxml
.contains("<tuplet type=\"start\" bracket=\"no\"/>")
&& roundtrip
.musicxml
.contains("<tuplet type=\"stop\" number=\"1\"/>"),
"roundtrip must keep the 10:2 MusicXML tuplet when it lands on a chord:\n{}",
roundtrip.musicxml
);
}
fn total_slur_starts(score: &Score) -> usize {
use crate::model::SlurRole;
let mut starts = 0;
for part in &score.parts {
for voice in &part.voices {
for event in &voice.events {
starts += event
.attachments
.slurs
.iter()
.filter(|s| s.role == SlurRole::Start)
.count();
for group in event
.attachments
.grace_groups
.iter()
.chain(&event.attachments.after_grace_groups)
{
starts += group
.slurs
.iter()
.filter(|s| s.role == SlurRole::Start)
.count();
for grace in &group.events {
starts += grace
.slurs
.iter()
.filter(|s| s.role == SlurRole::Start)
.count();
}
}
}
}
}
starts
}
#[test]
fn foreign_grace_note_decorations_survive_abc_projection() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <note><grace/><pitch><step>B</step><octave>4</octave></pitch>",
"<voice>1</voice><type>eighth</type>",
"<notations><fermata type=\"upright\"/><articulations><staccato/></articulations></notations>",
"</note>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let score = completed_from_xml(xml);
let names = grace_note_decoration_names(
&score.parts[0].voices[0].events[0].attachments.grace_groups[0],
0,
);
assert_eq!(
names,
vec!["fermata", "staccato"],
"foreign grace-note notations reconstruct onto the grace note"
);
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("{!fermata!!staccato!B}C2"),
"foreign grace-note decorations must remain inside the grace braces:\n{abc}"
);
let roundtrip =
export_musicxml(&abc).expect("decorated grace-note ABC projection should export");
assert!(
roundtrip.musicxml.contains("<fermata type=\"upright\"/>")
&& roundtrip.musicxml.contains("<staccato/>"),
"MusicXML round trip must preserve grace-note notations:\n{}",
roundtrip.musicxml
);
}
#[test]
fn empty_trailing_all_spacer_measure_is_preserved_in_abc_projection() {
let abc = "X:1\nL:1/4\nK:C\nC D E F | y4 y4 y4 y4\n";
let reference = lower(abc);
assert_eq!(
measure_count(&reference),
2,
"precondition: the source has two measures (content + all-spacer)"
);
let completed = completed_from_abc(abc);
let projected = write_abc(&completed, AbcWriteOptions::default());
let reparsed = lower(&projected);
assert_eq!(
measure_count(&reparsed),
measure_count(&reference),
"the trailing all-spacer measure must survive XML -> ABC -> Score; abc {projected:?}"
);
}
#[test]
fn empty_trailing_measure_preservation_keeps_xml_inverse_byte_identical() {
let abc = "X:1\nL:1/4\nK:C\nC D E F | y4 y4 y4 y4\n";
let x1 = export(abc);
let mut score = read_musicxml(&x1).value;
let xml_before = write_musicxml(&score).musicxml;
complete_score_for_abc(&mut score);
let xml_after = write_musicxml(&score).musicxml;
assert_eq!(
xml_before, xml_after,
"completion must not change the write_musicxml inverse"
);
assert_eq!(xml_after, x1, "the XML inverse must stay byte-identical");
}
#[test]
fn empty_interior_measure_is_preserved_in_abc_projection() {
let abc = "X:1\nL:1/4\nK:C\nC D E F | || G A B c | d e f g |\n";
let reference = lower(abc);
assert_eq!(
measure_count(&reference),
4,
"precondition: `| ||` yields an empty interior measure (4 total)"
);
let completed = completed_from_abc(abc);
let projected = write_abc(&completed, AbcWriteOptions::default());
let reparsed = lower(&projected);
assert_eq!(
measure_count(&reparsed),
measure_count(&reference),
"the empty interior measure must survive XML -> ABC -> Score; abc {projected:?}"
);
assert_eq!(
pitch_sequence(&reparsed),
pitch_sequence(&reference),
"every note must survive XML -> ABC -> Score; abc {projected:?}"
);
}
#[test]
fn empty_interior_measure_preservation_keeps_xml_inverse_byte_identical() {
let abc = "X:1\nL:1/4\nK:C\nC D E F | || G A B c | d e f g |\n";
let x1 = export(abc);
let mut score = read_musicxml(&x1).value;
let xml_before = write_musicxml(&score).musicxml;
complete_score_for_abc(&mut score);
let xml_after = write_musicxml(&score).musicxml;
assert_eq!(
xml_before, xml_after,
"completion must not change the write_musicxml inverse"
);
assert_eq!(xml_after, x1, "the XML inverse must stay byte-identical");
}
const DIRECTIVE_ONLY_MIDDLE_XML: &str = concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<score-partwise version=\"3.1\">\n",
" <part-list><score-part id=\"P1\"><part-name>P</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>1</divisions><key><fifths>0</fifths></key>\n",
" <time><beats>4</beats><beat-type>4</beat-type></time></attributes>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration><voice>1</voice><type>whole</type></note>\n",
" </measure>\n",
" <measure number=\"2\">\n",
" <attributes><key><fifths>1</fifths></key></attributes>\n",
" </measure>\n",
" <measure number=\"3\">\n",
" <note><pitch><step>G</step><octave>4</octave></pitch><duration>4</duration><voice>1</voice><type>whole</type></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
#[test]
fn directive_only_measure_is_preserved_in_abc_projection() {
let raw = read_musicxml(DIRECTIVE_ONLY_MIDDLE_XML).value;
assert_eq!(
measure_count(&raw),
3,
"precondition: the reader keeps a Measure for the directive-only middle measure"
);
let mut completed = raw;
complete_score_for_abc(&mut completed);
let projected = write_abc(&completed, AbcWriteOptions::default());
let reparsed = lower(&projected);
assert_eq!(
measure_count(&reparsed),
3,
"the directive-only measure must survive XML -> ABC -> Score; abc {projected:?}"
);
assert_eq!(
pitch_sequence(&reparsed),
vec![('C', 0, 4), ('G', 0, 4)],
"both notes must survive, none lost to the folded measure; abc {projected:?}"
);
}
#[test]
fn directive_only_measure_preservation_keeps_xml_inverse_byte_identical() {
let mut score = read_musicxml(DIRECTIVE_ONLY_MIDDLE_XML).value;
let xml_before = write_musicxml(&score).musicxml;
complete_score_for_abc(&mut score);
let xml_after = write_musicxml(&score).musicxml;
assert_eq!(
xml_before, xml_after,
"completion must not change the write_musicxml inverse (the spacer emits no XML)"
);
}
#[test]
fn grace_anchored_slur_start_survives_abc_projection() {
let abc = "X:1\nL:1/8\nK:C\n({Bc}B2) A2 |\n";
let reference = lower(abc);
assert_eq!(
total_slur_starts(&reference),
1,
"precondition: the source has exactly one slur start"
);
let completed = completed_from_abc(abc);
let projected = write_abc(&completed, AbcWriteOptions::default());
assert!(
projected.contains("({"),
"grace-anchored slur must open before the grace group `({{`, got {projected:?}"
);
let reparsed = lower(&projected);
assert_eq!(
total_slur_starts(&reparsed),
total_slur_starts(&reference),
"the grace-anchored slur start must survive XML -> ABC -> Score; abc {projected:?}"
);
}
#[test]
fn internal_grace_slur_survives_abc_projection() {
let abc = "X:1\nL:1/8\nK:C\n{(ef)}g2 |\n";
let reference = lower(abc);
assert_eq!(
total_slur_starts(&reference),
1,
"precondition: one internal grace slur"
);
let completed = completed_from_abc(abc);
let projected = write_abc(&completed, AbcWriteOptions::default());
let reparsed = lower(&projected);
assert_eq!(
total_slur_starts(&reparsed),
total_slur_starts(&reference),
"the internal grace slur must survive XML -> ABC -> Score; abc {projected:?}"
);
}
#[test]
fn after_grace_trailing_slur_survives_abc_projection() {
let abc = "X:1\nL:1/8\nK:C\n(f4{ef}) g2 |\n";
let reference = lower(abc);
assert_eq!(
total_slur_starts(&reference),
1,
"precondition: one slur start on the main note"
);
let completed = completed_from_abc(abc);
let projected = write_abc(&completed, AbcWriteOptions::default());
let reparsed = lower(&projected);
assert_eq!(
total_slur_starts(&reparsed),
total_slur_starts(&reference),
"the after-grace trailing slur must survive XML -> ABC -> Score; abc {projected:?}"
);
assert_eq!(
pitch_sequence(&reparsed),
pitch_sequence(&reference),
"after-grace slur pitch sequence must survive; abc {projected:?}"
);
}
#[test]
fn grace_slur_preservation_keeps_xml_inverse_byte_identical() {
for abc in [
"X:1\nL:1/8\nK:C\n({Bc}B2) A2 |\n",
"X:1\nL:1/8\nK:C\n{(ef)}g2 |\n",
"X:1\nL:1/8\nK:C\n(f4{ef}) g2 |\n",
] {
let x1 = export(abc);
let mut score = read_musicxml(&x1).value;
let xml_before = write_musicxml(&score).musicxml;
complete_score_for_abc(&mut score);
let xml_after = write_musicxml(&score).musicxml;
assert_eq!(
xml_before, xml_after,
"completion must not change the write_musicxml inverse for {abc:?}"
);
assert_eq!(
xml_after, x1,
"the XML inverse must stay byte-identical for {abc:?}"
);
}
}
fn header_key_explicit(score: &Score) -> Vec<(char, i8)> {
score
.metadata
.key
.as_ref()
.map(|key| {
key.explicit_accidentals
.iter()
.map(|acc| (acc.step, acc.accidental.alter()))
.collect()
})
.unwrap_or_default()
}
#[test]
fn explicit_key_accidentals_survive_abc_projection() {
let abc = "X:1\nL:1/8\nK:C _B\nB c d e |\n";
let reference = lower(abc);
assert_eq!(
header_key_explicit(&reference),
vec![('B', -1)],
"precondition: the source key carries one explicit flat on B"
);
let mut completed = read_musicxml(&export(abc)).value;
complete_score_for_abc(&mut completed);
assert_eq!(
header_key_explicit(&completed),
header_key_explicit(&reference),
"reconstruction must carry the explicit key accidental"
);
let projected = write_abc(&completed, AbcWriteOptions::default());
let reparsed = lower(&projected);
assert_eq!(
header_key_explicit(&reparsed),
header_key_explicit(&reference),
"explicit key accidentals must survive XML -> ABC -> Score; abc {projected:?}"
);
assert_eq!(
pitch_sequence(&reparsed),
pitch_sequence(&reference),
"explicit-key sounding pitches must survive; abc {projected:?}"
);
}
#[test]
fn explicit_exp_key_accidentals_survive_abc_projection() {
let abc = "X:1\nL:1/8\nK:D exp _B ^g\nB c d e |\n";
let reference = lower(abc);
assert_eq!(
header_key_explicit(&reference),
vec![('B', -1), ('G', 1)],
"precondition: exp key carries _B and ^g"
);
let mut completed = read_musicxml(&export(abc)).value;
complete_score_for_abc(&mut completed);
let projected = write_abc(&completed, AbcWriteOptions::default());
let reparsed = lower(&projected);
assert_eq!(
header_key_explicit(&reparsed),
header_key_explicit(&reference),
"exp-key explicit accidentals must survive; abc {projected:?}"
);
}
#[test]
fn mid_tune_explicit_key_accidentals_survive_abc_projection() {
let abc = "X:1\nL:1/8\nK:Bb\nB c d e | [K:=e] e f g a |\n";
let reference = lower(abc);
let mid_tune_explicit = |s: &Score| -> Vec<Vec<(char, i8)>> {
let mut out = Vec::new();
for p in &s.parts {
for v in &p.voices {
for e in &v.events {
if let TimedEventKind::KeyChange(k) = &e.kind {
out.push(
k.explicit_accidentals
.iter()
.map(|a| (a.step, a.accidental.alter()))
.collect(),
);
}
}
}
}
out
};
assert_eq!(
mid_tune_explicit(&reference),
vec![vec![('E', 0)]],
"precondition: mid-tune [K:=e] carries one explicit natural on E"
);
let mut completed = read_musicxml(&export(abc)).value;
complete_score_for_abc(&mut completed);
let projected = write_abc(&completed, AbcWriteOptions::default());
let reparsed = lower(&projected);
assert_eq!(
mid_tune_explicit(&reparsed),
mid_tune_explicit(&reference),
"mid-tune explicit key accidentals must survive; abc {projected:?}"
);
}
#[test]
fn explicit_key_preservation_keeps_xml_inverse_byte_identical() {
for abc in [
"X:1\nL:1/8\nK:C _B\nB c d e |\n",
"X:1\nL:1/8\nK:D exp _B ^g\nB c d e |\n",
"X:1\nL:1/8\nK:Bb\nB c d e | [K:=e] e f g a |\n",
] {
let x1 = export(abc);
let mut score = read_musicxml(&x1).value;
let xml_before = write_musicxml(&score).musicxml;
complete_score_for_abc(&mut score);
let xml_after = write_musicxml(&score).musicxml;
assert_eq!(
xml_before, xml_after,
"completion must not change the write_musicxml inverse for {abc:?}"
);
assert_eq!(
xml_after, x1,
"the XML inverse must stay byte-identical for {abc:?}"
);
}
}
}
#[test]
fn corpus_abc_reemission_through_xml() {
use crate::to_abc::{AbcWriteOptions, write_abc};
let Ok(root) = std::env::var("ABC_ROOT") else {
eprintln!("ABC_ROOT unset โ skipping R1 ABC-reemission-through-XML measurement");
return;
};
let files = abc_files(&PathBuf::from(&root));
if files.is_empty() {
eprintln!("no .abc files under {root} โ skipping");
return;
}
let mut total = 0usize;
let mut lowered = 0usize;
let mut idempotent = 0usize;
let mut first_divergences: Vec<String> = Vec::new();
for path in &files {
let Ok(bytes) = fs::read(path) else { continue };
let source = String::from_utf8_lossy(&bytes);
total += 1;
let Ok(export) = export_musicxml(&source) else {
continue;
};
lowered += 1;
let x1 = export.musicxml;
let document = crate::parse_document(&source, crate::ParseOptions::default()).value;
let Some(s1) = crate::lower_score(&document, crate::LowerOptions).value else {
continue;
};
let expected_abc = write_abc(&s1, AbcWriteOptions::default());
let reconstructed = read_musicxml(&x1).value;
let round_trip_abc = write_abc(&reconstructed, AbcWriteOptions::default());
if round_trip_abc == expected_abc {
idempotent += 1;
} else if first_divergences.len() < 5 {
let line = first_diverging_line(&expected_abc, &round_trip_abc);
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("<unknown>");
first_divergences.push(format!("{name}: {line}"));
}
}
eprintln!(
"ABC re-emission through XML (ABC -> XML -> Score -> ABC): {idempotent}/{lowered} \
files are BYTE-identical full documents ({total} total .abc). NOTE: this byte \
count is EXPECTED to be near-zero and is not a metric โ write_abc emits \
ABC-only state (X:/W:/V: ids, empty display K:) that the lossy XML round-trip \
legitimately drops, so the documents differ by construction. This arm asserts \
TOTALITY ONLY (0 panics). The real structural round-trip proof lives in \
tools/prove_reader_abc_roundtrip.py."
);
if !first_divergences.is_empty() {
eprintln!("first ABC divergences (up to 5):");
for note in &first_divergences {
eprintln!(" {note}");
}
}
assert!(
lowered > 0,
"expected at least one corpus file to lower and export"
);
}
fn minimal_part(id: &str) -> String {
format!(
" <part id=\"{id}\">\n <measure number=\"1\">\
\n <attributes><divisions>4</divisions></attributes>\
\n <note><pitch><step>C</step><octave>4</octave></pitch>\
\n <duration>4</duration><voice>1</voice><type>quarter</type></note>\
\n </measure>\n </part>\n"
)
}
fn minimal_two_voice_part(id: &str) -> String {
format!(
" <part id=\"{id}\">\n <measure number=\"1\">\
\n <attributes><divisions>4</divisions></attributes>\
\n <note><pitch><step>C</step><octave>4</octave></pitch>\
\n <duration>4</duration><voice>1</voice><type>quarter</type></note>\
\n <backup><duration>4</duration></backup>\
\n <note><pitch><step>E</step><octave>4</octave></pitch>\
\n <duration>4</duration><voice>2</voice><type>quarter</type></note>\
\n </measure>\n </part>\n"
)
}
#[test]
fn part_group_bracket_synthesizes_score_directive() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <part-group number=\"1\" type=\"start\">\n",
" <group-symbol>bracket</group-symbol>\n",
" </part-group>\n",
" <score-part id=\"P1\"><part-name/></score-part>\n",
" <score-part id=\"P2\"><part-name/></score-part>\n",
" <score-part id=\"P3\"><part-name/></score-part>\n",
" <part-group number=\"1\" type=\"stop\"/>\n",
" </part-list>\n",
);
let parts = format!(
"{}{}{}",
minimal_part("P1"),
minimal_part("P2"),
minimal_part("P3")
);
let xml = format!("{xml}{parts}</score-partwise>\n");
let score = read_musicxml(&xml).value;
assert_eq!(
score.metadata.directives.len(),
1,
"bracket group must synthesize exactly one %%score directive; \
directives = {:?}",
score
.metadata
.directives
.iter()
.map(|d| &d.value.text)
.collect::<Vec<_>>()
);
let directive_text = &score.metadata.directives[0].value.text;
assert_eq!(
directive_text, "[P1 P2 P3]",
"bracket group must produce `[P1 P2 P3]`"
);
use crate::to_abc::{AbcWriteOptions, write_abc};
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%score [P1 P2 P3]\n"),
"write_abc must emit `%%score [P1 P2 P3]` for the bracket group; got:\n{abc}"
);
}
#[test]
fn part_group_with_multi_voice_part_uses_one_combined_score_directive() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <part-group number=\"1\" type=\"start\">\n",
" <group-symbol>bracket</group-symbol>\n",
" </part-group>\n",
" <score-part id=\"P1\"><part-name/></score-part>\n",
" <score-part id=\"P2\"><part-name/></score-part>\n",
" <part-group number=\"1\" type=\"stop\"/>\n",
" </part-list>\n",
);
let parts = format!("{}{}", minimal_two_voice_part("P1"), minimal_part("P2"));
let xml = format!("{xml}{parts}</score-partwise>\n");
let score = read_musicxml(&xml).value;
assert_eq!(
score.metadata.directives.len(),
1,
"mixed visual and ownership grouping must synthesize one %%score directive"
);
let directive_text = &score.metadata.directives[0].value.text;
assert_eq!(
directive_text, "[(P1 P1#2) P2]",
"bracket group must preserve P1 voice ownership inside the visual group"
);
use crate::to_abc::{AbcWriteOptions, write_abc};
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%score [(P1 P1#2) P2]\n"),
"write_abc must emit the combined %%score directive; got:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("combined score directive should export");
assert_eq!(
roundtrip.musicxml.matches("<score-part id=").count(),
2,
"ABC->MusicXML must keep P1 voices in one part and P2 in the other:\n{}",
roundtrip.musicxml
);
}
#[test]
fn part_group_brace_synthesizes_score_directive() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <part-group number=\"1\" type=\"start\">\n",
" <group-symbol>brace</group-symbol>\n",
" </part-group>\n",
" <score-part id=\"P1\"><part-name/></score-part>\n",
" <score-part id=\"P2\"><part-name/></score-part>\n",
" <part-group number=\"1\" type=\"stop\"/>\n",
" </part-list>\n",
);
let parts = format!("{}{}", minimal_part("P1"), minimal_part("P2"));
let xml = format!("{xml}{parts}</score-partwise>\n");
let score = read_musicxml(&xml).value;
assert_eq!(
score.metadata.directives.len(),
1,
"brace group must synthesize exactly one %%score directive"
);
let directive_text = &score.metadata.directives[0].value.text;
assert_eq!(
directive_text, "{P1 P2}",
"brace group must produce `{{P1 P2}}`"
);
use crate::to_abc::{AbcWriteOptions, write_abc};
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%score {P1 P2}\n"),
"write_abc must emit `%%score {{P1 P2}}` for the brace group; got:\n{abc}"
);
}
#[test]
fn part_group_nested_synthesizes_score_directive() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <part-group number=\"1\" type=\"start\">\n",
" <group-symbol>bracket</group-symbol>\n",
" </part-group>\n",
" <part-group number=\"2\" type=\"start\">\n",
" <group-symbol>brace</group-symbol>\n",
" </part-group>\n",
" <score-part id=\"P1\"><part-name/></score-part>\n",
" <score-part id=\"P2\"><part-name/></score-part>\n",
" <part-group number=\"2\" type=\"stop\"/>\n",
" <score-part id=\"P3\"><part-name/></score-part>\n",
" <part-group number=\"1\" type=\"stop\"/>\n",
" </part-list>\n",
);
let parts = format!(
"{}{}{}",
minimal_part("P1"),
minimal_part("P2"),
minimal_part("P3")
);
let xml = format!("{xml}{parts}</score-partwise>\n");
let score = read_musicxml(&xml).value;
assert_eq!(
score.metadata.directives.len(),
1,
"nested groups must produce exactly one %%score directive"
);
let directive_text = &score.metadata.directives[0].value.text;
assert_eq!(
directive_text, "[{P1 P2} P3]",
"nested bracket(brace(P1,P2),P3) must produce `[{{P1 P2}} P3]`"
);
use crate::to_abc::{AbcWriteOptions, write_abc};
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%score [{P1 P2} P3]\n"),
"write_abc must emit `%%score [{{P1 P2}} P3]` for nested groups; got:\n{abc}"
);
}
#[test]
fn no_part_group_synthesizes_no_score_directive() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <score-part id=\"P1\"><part-name/></score-part>\n",
" <score-part id=\"P2\"><part-name/></score-part>\n",
" </part-list>\n",
);
let parts = format!("{}{}", minimal_part("P1"), minimal_part("P2"));
let xml = format!("{xml}{parts}</score-partwise>\n");
let score = read_musicxml(&xml).value;
assert!(
score.metadata.directives.is_empty(),
"no <part-group> must not synthesize any %%score directive; \
got {:?}",
score
.metadata
.directives
.iter()
.map(|d| &d.value.text)
.collect::<Vec<_>>()
);
use crate::to_abc::{AbcWriteOptions, write_abc};
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
!abc.contains("%%score"),
"write_abc must not emit %%score when there is no part-group; got:\n{abc}"
);
}
#[test]
fn two_sibling_bracket_groups_both_emitted() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <part-group number=\"1\" type=\"start\">\n",
" <group-symbol>bracket</group-symbol>\n",
" </part-group>\n",
" <score-part id=\"P1\"><part-name/></score-part>\n",
" <score-part id=\"P2\"><part-name/></score-part>\n",
" <part-group number=\"1\" type=\"stop\"/>\n",
" <part-group number=\"2\" type=\"start\">\n",
" <group-symbol>bracket</group-symbol>\n",
" </part-group>\n",
" <score-part id=\"P3\"><part-name/></score-part>\n",
" <score-part id=\"P4\"><part-name/></score-part>\n",
" <part-group number=\"2\" type=\"stop\"/>\n",
" </part-list>\n",
);
let parts = format!(
"{}{}{}{}",
minimal_part("P1"),
minimal_part("P2"),
minimal_part("P3"),
minimal_part("P4"),
);
let xml = format!("{xml}{parts}</score-partwise>\n");
let score = read_musicxml(&xml).value;
assert_eq!(
score.metadata.directives.len(),
1,
"two sibling bracket groups must produce exactly one %%score directive; \
directives = {:?}",
score
.metadata
.directives
.iter()
.map(|d| &d.value.text)
.collect::<Vec<_>>()
);
let directive_text = &score.metadata.directives[0].value.text;
assert_eq!(
directive_text, "[P1 P2] [P3 P4]",
"two sibling bracket groups must produce `[P1 P2] [P3 P4]`; got `{directive_text}`"
);
use crate::to_abc::{AbcWriteOptions, write_abc};
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%score [P1 P2] [P3 P4]\n"),
"write_abc must emit `%%score [P1 P2] [P3 P4]` for two sibling bracket groups; got:\n{abc}"
);
}
#[test]
fn sibling_bracket_and_brace_groups_both_emitted() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <part-group number=\"1\" type=\"start\">\n",
" <group-symbol>bracket</group-symbol>\n",
" </part-group>\n",
" <score-part id=\"P1\"><part-name/></score-part>\n",
" <score-part id=\"P2\"><part-name/></score-part>\n",
" <part-group number=\"1\" type=\"stop\"/>\n",
" <part-group number=\"2\" type=\"start\">\n",
" <group-symbol>brace</group-symbol>\n",
" </part-group>\n",
" <score-part id=\"P3\"><part-name/></score-part>\n",
" <score-part id=\"P4\"><part-name/></score-part>\n",
" <part-group number=\"2\" type=\"stop\"/>\n",
" </part-list>\n",
);
let parts = format!(
"{}{}{}{}",
minimal_part("P1"),
minimal_part("P2"),
minimal_part("P3"),
minimal_part("P4"),
);
let xml = format!("{xml}{parts}</score-partwise>\n");
let score = read_musicxml(&xml).value;
assert_eq!(
score.metadata.directives.len(),
1,
"sibling bracket+brace groups must produce exactly one %%score directive; \
directives = {:?}",
score
.metadata
.directives
.iter()
.map(|d| &d.value.text)
.collect::<Vec<_>>()
);
let directive_text = &score.metadata.directives[0].value.text;
assert_eq!(
directive_text, "[P1 P2] {P3 P4}",
"sibling bracket+brace groups must produce `[P1 P2] {{P3 P4}}`; got `{directive_text}`"
);
use crate::to_abc::{AbcWriteOptions, write_abc};
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%score [P1 P2] {P3 P4}\n"),
"write_abc must emit `%%score [P1 P2] {{P3 P4}}` for sibling bracket+brace groups; \
got:\n{abc}"
);
}
#[test]
fn unbalanced_part_group_emits_diagnostic() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <part-group number=\"1\" type=\"start\">\n",
" <group-symbol>bracket</group-symbol>\n",
" </part-group>\n",
" <score-part id=\"P1\"><part-name/></score-part>\n",
" <score-part id=\"P2\"><part-name/></score-part>\n",
" </part-list>\n",
);
let parts = format!("{}{}", minimal_part("P1"), minimal_part("P2"));
let xml = format!("{xml}{parts}</score-partwise>\n");
let report = read_musicxml(&xml);
let score = &report.value;
assert_eq!(
score.parts.len(),
2,
"unclosed group must still produce 2 parts"
);
let has_diag = report
.diagnostics
.iter()
.any(|d| d.code == "musicxml.read.unbalanced_part_group");
assert!(
has_diag,
"unclosed <part-group> must emit a `musicxml.read.unbalanced_part_group` diagnostic; \
got diagnostics: {:?}",
report
.diagnostics
.iter()
.map(|d| d.code)
.collect::<Vec<_>>()
);
}
#[test]
fn ungrouped_part_included_in_score_directive() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <part-list>\n",
" <part-group number=\"1\" type=\"start\">\n",
" <group-symbol>bracket</group-symbol>\n",
" </part-group>\n",
" <score-part id=\"P1\"><part-name/></score-part>\n",
" <score-part id=\"P2\"><part-name/></score-part>\n",
" <score-part id=\"P3\"><part-name/></score-part>\n",
" <part-group number=\"1\" type=\"stop\"/>\n",
" <score-part id=\"P4\"><part-name/></score-part>\n",
" </part-list>\n",
);
let parts = format!(
"{}{}{}{}",
minimal_part("P1"),
minimal_part("P2"),
minimal_part("P3"),
minimal_part("P4"),
);
let xml = format!("{xml}{parts}</score-partwise>\n");
let score = read_musicxml(&xml).value;
assert_eq!(
score.metadata.directives.len(),
1,
"bracket group + standalone P4 must produce exactly one %%score directive; \
directives = {:?}",
score
.metadata
.directives
.iter()
.map(|d| &d.value.text)
.collect::<Vec<_>>()
);
let directive_text = &score.metadata.directives[0].value.text;
assert_eq!(
directive_text, "[P1 P2 P3] P4",
"bracket group + standalone P4 must produce `[P1 P2 P3] P4`; got `{directive_text}`"
);
use crate::to_abc::{AbcWriteOptions, write_abc};
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("%%score [P1 P2 P3] P4\n"),
"write_abc must emit `%%score [P1 P2 P3] P4` for bracket group + standalone P4; \
got:\n{abc}"
);
}
fn section_labels(score: &Score) -> Vec<String> {
let mut labels = Vec::new();
for part in &score.parts {
for voice in &part.voices {
for event in &voice.events {
if let TimedEventKind::SectionLabel(label) = &event.kind {
labels.push(label.clone());
}
}
}
}
labels
}
#[test]
fn body_part_label_round_trips_through_rehearsal() {
let abc = "X:1\nT:Sections\nM:4/4\nL:1/4\nK:C\nP:A\nC D E F |\nP:B\nG A B c |\n";
let x1 = export(abc);
assert!(
x1.contains("<rehearsal font-weight=\"bold\">A</rehearsal>"),
"precondition: body `P:A` must emit a <rehearsal>A direction; got:\n{x1}"
);
assert!(
x1.contains("<rehearsal font-weight=\"bold\">B</rehearsal>"),
"precondition: body `P:B` must emit a <rehearsal>B direction"
);
let score = assert_idempotent_full(abc);
assert_eq!(
section_labels(&score),
vec!["A".to_owned(), "B".to_owned()],
"the reader must reconstruct both body section labels in order"
);
}
#[test]
fn inline_part_label_round_trips_through_rehearsal() {
let abc = "X:1\nT:Inline\nM:4/4\nL:1/4\nK:C\nC D [P:B] E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<rehearsal font-weight=\"bold\">B</rehearsal>"),
"precondition: inline `[P:B]` must emit a <rehearsal>B direction; got:\n{x1}"
);
let score = assert_idempotent_full(abc);
assert_eq!(
section_labels(&score),
vec!["B".to_owned()],
"the reader must reconstruct the inline section label"
);
}
#[test]
fn foreign_rehearsal_imports_as_part_label() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<score-partwise version="3.1">
<part-list>
<score-part id="P1"><part-name>V</part-name></score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>1</divisions>
<key><fifths>0</fifths></key>
<time><beats>4</beats><beat-type>4</beat-type></time>
<clef><sign>G</sign><line>2</line></clef>
</attributes>
<direction placement="above">
<direction-type>
<rehearsal font-weight="bold">A</rehearsal>
</direction-type>
</direction>
<note>
<pitch><step>C</step><octave>4</octave></pitch>
<duration>4</duration>
<type>whole</type>
</note>
</measure>
</part>
</score-partwise>
"#;
let report = read_musicxml(xml);
assert_eq!(
section_labels(&report.value),
vec!["A".to_owned()],
"a foreign <rehearsal> must import as a SectionLabel; diags: {:?}",
report.diagnostics
);
use crate::to_abc::{AbcWriteOptions, write_abc};
let abc = write_abc(&report.value, AbcWriteOptions::default());
assert!(
abc.contains("[P:A]"),
"write_abc must emit `[P:A]` for the imported section label; got:\n{abc}"
);
}
#[test]
fn foreign_rehearsal_newline_does_not_break_inline_label() {
let xml = "<?xml version=\"1.0\"?>\n\
<score-partwise>\n\
<part-list><score-part id=\"P1\"><part-name>V</part-name></score-part></part-list>\n\
<part id=\"P1\"><measure number=\"1\">\n\
<attributes><divisions>1</divisions></attributes>\n\
<direction placement=\"above\"><direction-type>\n\
<rehearsal>pre chorus\n</rehearsal>\n\
</direction-type></direction>\n\
<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration><type>whole</type></note>\n\
</measure></part>\n\
</score-partwise>\n";
let report = read_musicxml(xml);
use crate::to_abc::{AbcWriteOptions, write_abc};
let abc = write_abc(&report.value, AbcWriteOptions::default());
assert!(
abc.contains("[P:pre chorus]"),
"the rehearsal newline must normalise so `[P:...]` stays on one line; got:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("sanitised section-label ABC should export");
assert!(
roundtrip.musicxml.contains("<step>C</step>"),
"the following note must survive section-label normalisation:\n{}",
roundtrip.musicxml
);
}
#[test]
fn header_play_order_is_not_a_rehearsal() {
let abc = "X:1\nT:PlayOrder\nM:4/4\nL:1/4\nP:ABAB\nK:C\nC D E F |\n";
let x1 = export(abc);
assert!(
!x1.contains("<rehearsal"),
"a header `P:ABAB` must NOT emit a <rehearsal>; got:\n{x1}"
);
let report = read_musicxml(&x1);
assert!(
section_labels(&report.value).is_empty(),
"a header `P:ABAB` must produce no SectionLabel events"
);
}
#[test]
fn rehearsal_label_xml_escapes_round_trip() {
let abc = "X:1\nT:Escape\nM:4/4\nL:1/4\nK:C\nP:A&<B\nC D E F |\n";
let x1 = export(abc);
assert!(
x1.contains("<rehearsal font-weight=\"bold\">A&<B</rehearsal>"),
"the `&`/`<` in the label must be XML-escaped in <rehearsal>; got:\n{x1}"
);
let score = assert_idempotent_full(abc);
assert_eq!(
section_labels(&score),
vec!["A&<B".to_owned()],
"the reader must XML-unescape the label back to `A&<B`"
);
}
fn first_diverging_line(expected: &str, actual: &str) -> String {
for (line_e, line_a) in expected.lines().zip(actual.lines()) {
if line_e != line_a {
return format!("`{line_e}` != `{line_a}`");
}
}
let expected_lines = expected.lines().count();
let actual_lines = actual.lines().count();
if expected_lines == actual_lines {
"<equal-lines-unequal-bytes>".to_owned()
} else {
format!("<line-count differs: expected {expected_lines}, actual {actual_lines}>")
}
}
#[test]
fn per_part_divisions_are_decoded_independently() {
let xml = concat!(
"<score-partwise version=\"4.0\">",
"<part-list>",
"<score-part id=\"P1\"><part-name>A</part-name></score-part>",
"<score-part id=\"P2\"><part-name>B</part-name></score-part>",
"</part-list>",
"<part id=\"P1\"><measure number=\"1\">",
"<attributes><divisions>4</divisions><key><fifths>0</fifths></key>",
"<time><beats>1</beats><beat-type>4</beat-type></time>",
"<clef><sign>G</sign><line>2</line></clef></attributes>",
"<note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><type>quarter</type></note>",
"</measure></part>",
"<part id=\"P2\"><measure number=\"1\">",
"<attributes><divisions>2</divisions><key><fifths>0</fifths></key>",
"<time><beats>1</beats><beat-type>4</beat-type></time>",
"<clef><sign>F</sign><line>4</line></clef></attributes>",
"<note><pitch><step>C</step><octave>3</octave></pitch>",
"<duration>2</duration><type>quarter</type></note>",
"</measure></part>",
"</score-partwise>",
);
let score = read_musicxml(xml).value;
assert_eq!(score.parts.len(), 2, "two parts");
assert_eq!(
score.parts[0].voices[0].events[0].duration,
Fraction::new(1, 4),
"P1 quarter at its own divisions=4"
);
assert_eq!(
score.parts[1].voices[0].events[0].duration,
Fraction::new(1, 4),
"P2 quarter at its own divisions=2 must be 1/4, not misread against P1's divisions=4"
);
}
#[test]
fn mid_part_divisions_change_is_applied() {
let xml = concat!(
"<score-partwise version=\"4.0\">",
"<part-list><score-part id=\"P1\"><part-name>A</part-name></score-part></part-list>",
"<part id=\"P1\">",
"<measure number=\"1\">",
"<attributes><divisions>2</divisions><key><fifths>0</fifths></key>",
"<time><beats>1</beats><beat-type>4</beat-type></time>",
"<clef><sign>G</sign><line>2</line></clef></attributes>",
"<note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>2</duration><type>quarter</type></note>",
"</measure>",
"<measure number=\"2\">",
"<attributes><divisions>8</divisions></attributes>",
"<note><pitch><step>D</step><octave>4</octave></pitch>",
"<duration>8</duration><type>quarter</type></note>",
"</measure>",
"</part></score-partwise>",
);
let score = read_musicxml(xml).value;
let events = &score.parts[0].voices[0].events;
assert_eq!(
events[0].duration,
Fraction::new(1, 4),
"measure 1 quarter at divisions=2"
);
assert_eq!(
events[1].duration,
Fraction::new(1, 4),
"measure 2 quarter at the redefined divisions=8 must still be 1/4"
);
}
#[test]
fn movement_title_populates_metadata_title_when_work_title_absent() {
let xml = concat!(
"<score-partwise version=\"4.0\">",
"<movement-title>Apres un reve</movement-title>",
"<part-list><score-part id=\"P1\"><part-name>V</part-name></score-part></part-list>",
"<part id=\"P1\"><measure number=\"1\">",
"<attributes><divisions>1</divisions><key><fifths>0</fifths></key>",
"<time><beats>4</beats><beat-type>4</beat-type></time>",
"<clef><sign>G</sign><line>2</line></clef></attributes>",
"<note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><type>whole</type></note>",
"</measure></part></score-partwise>",
);
let score = read_musicxml(xml).value;
assert_eq!(
score.metadata.title.as_ref().map(|t| t.text.as_str()),
Some("Apres un reve"),
"<movement-title> must populate metadata.title when <work-title> is absent"
);
}
#[test]
fn work_title_wins_over_movement_title() {
let xml = concat!(
"<score-partwise version=\"4.0\">",
"<work><work-title>Work Title</work-title></work>",
"<movement-title>Movement Title</movement-title>",
"<part-list><score-part id=\"P1\"><part-name>V</part-name></score-part></part-list>",
"<part id=\"P1\"><measure number=\"1\">",
"<attributes><divisions>1</divisions><key><fifths>0</fifths></key>",
"<time><beats>4</beats><beat-type>4</beat-type></time>",
"<clef><sign>G</sign><line>2</line></clef></attributes>",
"<note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><type>whole</type></note>",
"</measure></part></score-partwise>",
);
let score = read_musicxml(xml).value;
assert_eq!(
score.metadata.title.as_ref().map(|t| t.text.as_str()),
Some("Work Title"),
"<work-title> must win when both are present"
);
}
#[test]
fn composer_projects_to_abc_c_field_and_round_trips() {
let xml = concat!(
"<score-partwise version=\"4.0\">",
"<work><work-title>Song</work-title></work>",
"<identification><creator type=\"composer\">Gabriel Faure</creator></identification>",
"<part-list><score-part id=\"P1\"><part-name>V</part-name></score-part></part-list>",
"<part id=\"P1\"><measure number=\"1\">",
"<attributes><divisions>1</divisions><key><fifths>0</fifths></key>",
"<time><beats>4</beats><beat-type>4</beat-type></time>",
"<clef><sign>G</sign><line>2</line></clef></attributes>",
"<note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><type>whole</type></note>",
"</measure></part></score-partwise>",
);
let score = read_musicxml(xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(
abc.contains("\nC:Gabriel Faure\n"),
"ABC projection must emit a C: composer field, got:\n{abc}"
);
let xml2 = export(&abc);
assert!(
xml2.contains("<creator type=\"composer\">Gabriel Faure</creator>"),
"C: must export back to <creator type=\"composer\">, got:\n{xml2}"
);
}
#[test]
fn two_staff_part_reconstructs_staves_and_per_staff_clefs() {
let xml = concat!(
"<score-partwise version=\"4.0\">",
"<part-list><score-part id=\"P1\"><part-name>Piano</part-name></score-part></part-list>",
"<part id=\"P1\"><measure number=\"1\">",
"<attributes><divisions>1</divisions><key><fifths>0</fifths></key>",
"<time><beats>4</beats><beat-type>4</beat-type></time>",
"<staves>2</staves>",
"<clef number=\"1\"><sign>G</sign><line>2</line></clef>",
"<clef number=\"2\"><sign>F</sign><line>4</line></clef></attributes>",
"<note><pitch><step>C</step><octave>5</octave></pitch><duration>4</duration>",
"<voice>1</voice><type>whole</type><staff>1</staff></note>",
"<backup><duration>4</duration></backup>",
"<note><pitch><step>C</step><octave>3</octave></pitch><duration>4</duration>",
"<voice>2</voice><type>whole</type><staff>2</staff></note>",
"</measure></part></score-partwise>",
);
let score = read_musicxml(xml).value;
let part = &score.parts[0];
assert_eq!(part.staves.len(), 2, "two staves reconstructed");
assert_eq!(part.voices.len(), 2, "two voices");
assert_eq!(part.voices[0].staff.value, 1, "voice 1 routed to staff 1");
assert_eq!(part.voices[1].staff.value, 2, "voice 2 routed to staff 2");
assert_eq!(
part.voices[0]
.initial_properties
.clef
.as_ref()
.map(|c| c.text.as_str()),
None,
"staff 1 is treble (default, no clef property)"
);
assert_eq!(
part.voices[1]
.initial_properties
.clef
.as_ref()
.map(|c| c.text.as_str()),
Some("bass"),
"staff 2 voice must carry the bass clef"
);
let xml2 = write_score_partwise(&score).value;
assert!(
xml2.contains("<staves>2</staves>"),
"re-emit <staves>2, got:\n{xml2}"
);
assert!(
xml2.contains("<clef number=\"2\">") && xml2.contains("<sign>F</sign>"),
"re-emit the bass <clef number=2>, got:\n{xml2}"
);
}
#[test]
fn two_staff_part_synthesizes_brace_score_directive() {
let xml = concat!(
"<score-partwise version=\"4.0\">",
"<part-list><score-part id=\"P1\"><part-name>Piano</part-name></score-part></part-list>",
"<part id=\"P1\"><measure number=\"1\">",
"<attributes><divisions>1</divisions><key><fifths>0</fifths></key>",
"<time><beats>4</beats><beat-type>4</beat-type></time>",
"<staves>2</staves>",
"<clef number=\"1\"><sign>G</sign><line>2</line></clef>",
"<clef number=\"2\"><sign>F</sign><line>4</line></clef></attributes>",
"<note><pitch><step>C</step><octave>5</octave></pitch><duration>4</duration>",
"<voice>1</voice><type>whole</type><staff>1</staff></note>",
"<backup><duration>4</duration></backup>",
"<note><pitch><step>C</step><octave>3</octave></pitch><duration>4</duration>",
"<voice>2</voice><type>whole</type><staff>2</staff></note>",
"</measure></part></score-partwise>",
);
let score = read_musicxml(xml).value;
let directive = score
.metadata
.directives
.iter()
.find(|d| d.value.text.contains("P1"))
.expect("a %%score directive for the grand staff");
assert_eq!(
directive.value.text.trim(),
"{P1 P1#2}",
"grand staff must use a brace group, got {:?}",
directive.value.text
);
}
#[test]
fn brace_score_group_lowers_to_one_part_with_two_staves() {
let abc = concat!(
"X:1\n",
"%%score {P1 P1#2}\n",
"M:4/4\nL:1/4\nK:C\n",
"V:P1 clef=treble\n",
"C D E F |\n",
"V:P1#2 clef=bass\n",
"C,, D,, E,, F,, |\n",
);
let xml = export(abc);
assert_eq!(
xml.matches("<part id=").count(),
1,
"a same-part brace group is ONE part (grand staff), got:\n{xml}"
);
assert!(
xml.contains("<staves>2</staves>"),
"the grand staff must export <staves>2, got:\n{xml}"
);
assert!(
xml.contains("<clef number=\"2\">") && xml.contains("<sign>F</sign>"),
"the bass voice must export a <clef number=\"2\"> F clef, got:\n{xml}"
);
}
#[test]
fn brace_over_distinct_parts_stays_two_parts() {
let abc = concat!(
"X:1\n",
"%%score {P1 P2}\n",
"M:4/4\nL:1/4\nK:C\n",
"V:P1\n",
"C D E F |\n",
"V:P2\n",
"C,, D,, E,, F,, |\n",
);
let xml = export(abc);
assert_eq!(
xml.matches("<part id=").count(),
2,
"a brace over distinct part ids must stay two parts, got:\n{xml}"
);
assert!(
!xml.contains("<staves>"),
"two distinct parts must not collapse into a multi-staff part, got:\n{xml}"
);
}
#[test]
fn mid_tune_clef_change_projects_to_inline_abc_field() {
let xml = concat!(
"<score-partwise version=\"4.0\"><part-list><score-part id=\"P1\">",
"<part-name>Music</part-name></score-part></part-list><part id=\"P1\">",
"<measure number=\"1\">",
"<attributes><divisions>2</divisions><key><fifths>0</fifths></key>",
"<time><beats>4</beats><beat-type>4</beat-type></time>",
"<clef><sign>G</sign><line>2</line></clef></attributes>",
"<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration>",
"<voice>1</voice><type>quarter</type></note>",
"<attributes><clef><sign>F</sign><line>4</line></clef></attributes>",
"<note><pitch><step>C</step><octave>3</octave></pitch><duration>6</duration>",
"<voice>1</voice><type>half</type></note>",
"</measure></part></score-partwise>",
);
let report = read_musicxml(xml);
let abc = write_abc(&report.value, AbcWriteOptions::default());
assert!(
abc.contains("[K:clef=bass]"),
"a mid-tune <clef> must project as an inline `[K:clef=..]` field; got:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("clef-change ABC should export");
assert!(
roundtrip
.musicxml
.contains("<attributes>\n <clef>\n <sign>F</sign>"),
"the clef change must re-export as its own mid-tune <attributes>; got:\n{}",
roundtrip.musicxml
);
}
#[test]
fn numbered_clef_change_routes_to_its_staff_voice() {
let xml = concat!(
"<score-partwise version=\"4.0\"><part-list><score-part id=\"P1\">",
"<part-name>Piano</part-name></score-part></part-list><part id=\"P1\">",
"<measure number=\"1\"><attributes><divisions>8</divisions>",
"<key><fifths>3</fifths></key><time><beats>2</beats><beat-type>4</beat-type></time>",
"<staves>2</staves><clef number=\"1\"><sign>G</sign><line>2</line></clef>",
"<clef number=\"2\"><sign>F</sign><line>4</line></clef></attributes>",
"<forward><duration>2</duration><voice>1</voice><staff>1</staff></forward>",
"<note><pitch><step>A</step><octave>4</octave></pitch><duration>4</duration>",
"<voice>1</voice><type>eighth</type><staff>1</staff></note>",
"<backup><duration>6</duration></backup>",
"<forward><duration>2</duration><voice>3</voice><staff>2</staff></forward>",
"<attributes><clef number=\"2\"><sign>G</sign><line>2</line></clef></attributes>",
"<note><pitch><step>A</step><octave>4</octave></pitch><duration>4</duration>",
"<voice>3</voice><type>eighth</type><staff>2</staff></note></measure>",
"<measure number=\"2\"><attributes>",
"<clef number=\"2\"><sign>F</sign><line>4</line></clef></attributes>",
"<note><pitch><step>E</step><octave>4</octave></pitch><duration>16</duration>",
"<voice>1</voice><type>half</type><staff>1</staff></note>",
"<backup><duration>16</duration></backup>",
"<note><pitch><step>A</step><octave>3</octave></pitch><duration>16</duration>",
"<voice>3</voice><type>half</type><staff>2</staff></note></measure>",
"</part></score-partwise>",
);
let report = read_musicxml(xml);
let abc = write_abc(&report.value, AbcWriteOptions::default());
let lower_staff = abc
.lines()
.skip_while(|line| !line.starts_with("V:P1#3"))
.skip(1)
.collect::<Vec<_>>()
.join("\n");
assert!(
lower_staff.contains("[K:clef=treble]") && lower_staff.contains("[K:clef=bass]"),
"both staff-2 clef changes belong to the lower staff's voice; got:\n{abc}"
);
let upper_staff = abc
.lines()
.skip_while(|line| !line.starts_with("V:P1 "))
.take_while(|line| !line.starts_with("V:P1#3"))
.collect::<Vec<_>>()
.join("\n");
assert!(
!upper_staff.contains("clef="),
"no clef change belongs to the upper staff's voice; got:\n{abc}"
);
let roundtrip = export_musicxml(&abc).expect("grand-staff clef ABC should export");
assert_eq!(
roundtrip.musicxml.matches("<clef number=\"2\">").count(),
3,
"staff 2 keeps its header clef plus both changes; got:\n{}",
roundtrip.musicxml
);
}
#[test]
fn octave_shifting_clef_change_compensates_the_voice_shift() {
let abc = "X:1\nM:4/4\nL:1/4\nK:C\nV:1\nC C [K:clef=treble-8] C C |\n";
let x1 = export(abc);
let report = read_musicxml(&x1);
let projected = write_abc(&report.value, AbcWriteOptions::default());
assert!(
projected.contains("[K:clef=treble-8 octave=1]"),
"the -8 clef change must carry its `octave=` compensation; got:\n{projected}"
);
let x2 = export_musicxml(&projected)
.expect("clef-change projection should export")
.musicxml;
assert_eq!(
x1, x2,
"the octave-shifting clef change must round-trip pitch-exactly"
);
}
#[test]
fn legend_lists_only_the_codes_the_document_uses() {
let abc = "X:1\nM:4/4\nL:1/4\nK:C\n[I:cr mr][M:4/4]C D E F |\n";
let score = crate::lower_score(
&crate::parse_document(abc, crate::ParseOptions::default()).value,
crate::LowerOptions,
)
.value
.expect("score");
let plain = write_abc(&score, AbcWriteOptions::default());
assert!(!plain.contains('%'), "the legend is opt-in; got:\n{plain}");
let options = AbcWriteOptions { legend: true };
let with_legend = write_abc(&score, options);
assert!(
with_legend.starts_with("% croma carriers used in this file:"),
"got:\n{with_legend}"
);
assert!(
with_legend.contains("meter restatement"),
"legend body must describe the `mr` carrier it lists, not just emit \
the header; got:\n{with_legend}"
);
assert!(
!with_legend.contains("dp="),
"unused codes must not be listed; got:\n{with_legend}"
);
assert_eq!(
export_musicxml(&with_legend)
.expect("legend ABC exports")
.musicxml,
export_musicxml(&plain).expect("plain ABC exports").musicxml,
);
}
fn score_text_for_part_list(part_list: &str, part_count: usize) -> String {
let parts: String = (1..=part_count)
.map(|n| minimal_part(&format!("P{n}")))
.collect();
let xml = format!(
"<?xml version=\"1.0\"?>\n<score-partwise>\n <part-list>\n{part_list} </part-list>\n\
{parts}</score-partwise>\n"
);
let score = read_musicxml(&xml).value;
assert_eq!(score.metadata.directives.len(), 1, "one %%score directive");
score.metadata.directives[0].value.text.clone()
}
fn group_start(number: u32, symbol: &str) -> String {
format!(
" <part-group number=\"{number}\" type=\"start\"><group-symbol>{symbol}</group-symbol></part-group>\n"
)
}
fn group_stop(number: u32) -> String {
format!(" <part-group number=\"{number}\" type=\"stop\"/>\n")
}
fn score_part(id: &str) -> String {
format!(" <score-part id=\"{id}\"><part-name/></score-part>\n")
}
#[test]
fn part_groups_over_identical_parts_nest_in_start_order() {
for (first_stop, second_stop) in [(2, 1), (1, 2)] {
let part_list = [
group_start(1, "bracket"),
group_start(2, "brace"),
score_part("P1"),
score_part("P2"),
group_stop(first_stop),
group_stop(second_stop),
]
.concat();
assert_eq!(score_text_for_part_list(&part_list, 2), "[{P1 P2}]");
}
}
#[test]
fn three_level_part_group_nesting_keeps_every_level() {
let part_list = [
group_start(1, "bracket"),
group_start(2, "brace"),
group_start(3, "bracket"),
score_part("P1"),
score_part("P2"),
group_stop(3),
score_part("P3"),
group_stop(2),
score_part("P4"),
group_stop(1),
]
.concat();
assert_eq!(score_text_for_part_list(&part_list, 4), "[{[P1 P2] P3} P4]");
}
#[test]
fn huge_lyric_numbers_are_dropped_instead_of_expanding_into_empty_verses() {
let lyric_part = |number: &str| {
format!(
"<?xml version=\"1.0\"?>\n<score-partwise>\n <part-list>\n\
<score-part id=\"P1\"><part-name/></score-part>\n </part-list>\n\
<part id=\"P1\"><measure number=\"1\">\
<attributes><divisions>4</divisions></attributes>\
<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration>\
<voice>1</voice><type>quarter</type>\
<lyric number=\"{number}\"><syllabic>single</syllabic><text>la</text></lyric>\
</note></measure></part>\n</score-partwise>\n"
)
};
use crate::to_abc::{AbcWriteOptions, write_abc};
let report = read_musicxml(&lyric_part("65536"));
assert!(
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "musicxml.read.lyric_number_out_of_range")
);
let abc = write_abc(&report.value, AbcWriteOptions::default());
assert!(
abc.lines().filter(|line| line.starts_with("w:")).count() <= 1,
"{abc}"
);
let report = read_musicxml(&lyric_part("2"));
let abc = write_abc(&report.value, AbcWriteOptions::default());
assert!(
abc.contains("w:*\nw:la"),
"verse 2 keeps its position:\n{abc}"
);
}
#[test]
fn score_grouping_survives_abc_to_xml_to_abc() {
use crate::to_abc::{AbcWriteOptions, write_abc};
let source = "X:1\n%%score [{V1 V2} V3] V4\nL:1/4\nK:C\n\
V:V1\nC4|\nV:V2\nD4|\nV:V3\nE4|\nV:V4\nF4|\n";
let xml = crate::export_musicxml(source).expect("export").musicxml;
let score = read_musicxml(&xml).value;
let abc = write_abc(&score, AbcWriteOptions::default());
assert!(abc.contains("%%score [{P1 P2} P3] P4\n"), "{abc}");
}
#[test]
fn text_with_abc_special_characters_survives_xml_to_abc_to_xml() {
let xml = concat!(
"<?xml version=\"1.0\"?>\n",
"<score-partwise>\n",
" <work><work-title>50% off: A\\B & C&D \"live\"</work-title></work>\n",
" <part-list><score-part id=\"P1\"><part-name>T</part-name></score-part></part-list>\n",
" <part id=\"P1\">\n",
" <measure number=\"1\">\n",
" <attributes><divisions>4</divisions></attributes>\n",
" <direction placement=\"above\"><direction-type>",
"<words>say \"Ouch\" 100%</words></direction-type></direction>\n",
" <note><pitch><step>C</step><octave>4</octave></pitch>",
"<duration>4</duration><voice>1</voice><type>quarter</type>",
"<lyric number=\"1\"><syllabic>single</syllabic><text>R&B%</text></lyric></note>\n",
" </measure>\n",
" </part>\n",
"</score-partwise>\n",
);
let mut score = read_musicxml(xml).value;
crate::complete_score_for_abc(&mut score);
let abc = crate::to_abc::write_abc(&score, crate::to_abc::AbcWriteOptions::default());
let again = crate::export_musicxml(&abc).expect("re-export").musicxml;
for expected in [
"<work-title>50% off: A\\B & C&D "live"</work-title>",
"say "Ouch" 100%",
"<text>R&B%</text>",
] {
assert!(
again.contains(expected),
"missing {expected:?}\nABC:\n{abc}\nXML:\n{again}"
);
}
}