use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::model::{Fraction, Score, TimedEventKind};
use crate::musicxml::read::read_musicxml;
use crate::musicxml::write_score_partwise;
use crate::{export_musicxml, 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 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 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.71</pan>"),
"precondition: control 10 64 emits pan 0.71"
);
let midi = first_midi(&assert_idempotent_s3(abc));
assert_eq!(midi.pan_cc, Some(64), "<pan>0.71 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 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 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,
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,
})
);
}
#[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));
}
#[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 + sound, no 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 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 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_falls_back_to_kind_text_content() {
let text = foreign_chord_text(
"<harmony><root><root-step>C</root-step></root><kind>Tristan</kind></harmony>",
);
assert_eq!(
text, "CTristan",
"an unknown kind value falls back to its own text content appended to the root"
);
}
#[test]
fn textless_harmony_unknown_empty_kind_skips_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>
<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);
assert!(
attachments_at(&report.value, 0).chord_symbols.is_empty(),
"an unmodellable textless chord is skipped, not fabricated"
);
assert!(
report
.diagnostics
.iter()
.any(|d| d.code == "musicxml.read.harmony_unmodellable_kind"),
"skipping an unmodellable textless chord emits a diagnostic"
);
}
#[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(&format!("text=\"{synthesised}\"")),
"the writer re-emits the synthesised string `{synthesised}` as <kind text=...>"
);
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(&format!("text=\"{synthesised}\"")),
"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
}
#[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 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_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 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");
}
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 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 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:?}"
);
}
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 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"
)
}
#[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_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 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}>")
}
}