mod common;
use acorde_render_svg::{render_svg, SvgRenderOptions};
fn opts() -> SvgRenderOptions {
SvgRenderOptions { width: 700.0, staff_size: 24.0, measures_per_system: 4, interactive: true }
}
fn assert_well_formed_xml(svg: &str) {
use quick_xml::events::Event;
use quick_xml::Reader;
let mut reader = Reader::from_str(svg);
loop {
match reader.read_event() {
Ok(Event::Eof) => break,
Ok(_) => {}
Err(e) => panic!("SVG is not well-formed XML: {e}\n---\n{svg}"),
}
}
}
#[test]
fn svg_root_present_and_well_formed() {
let svg = render_svg(&common::satb_major(), &opts()).unwrap();
assert!(svg.starts_with("<svg"));
assert!(svg.trim_end().ends_with("</svg>"));
assert_well_formed_xml(&svg);
}
#[test]
fn large_score_and_many_staves_render_without_panicking() {
use acorde_core::{Clef, Duration, Note, Pitch, Score, Staff, Step};
let mut score = Score::new("large score", 120, 4, 4, 0, 32);
let mut extra_staves = Vec::new();
for index in 0..15 {
let mut staff = Staff::new(if index % 2 == 0 { Clef::Treble } else { Clef::Bass });
staff.measures = score.parts[0].staves[0].measures.clone();
for measure in &mut staff.measures {
measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
}
extra_staves.push(staff);
}
score.parts[0].staves.extend(extra_staves);
let svg = render_svg(&score, &opts()).expect("large score should render");
assert!(svg.starts_with("<svg"));
assert_eq!(svg.matches(r#"data-acorde-kind="measure""#).count(), 16 * 32);
}
#[test]
fn pathological_voice_lengths_render_as_bounded_svg() {
use acorde_core::{Duration, Note, Pitch, Score, Step};
let mut score = Score::new("long voices", 120, 4, 4, 0, 1);
let measure = &mut score.parts[0].staves[0].measures[0];
for voice in &mut measure.voices {
voice.extend((0..64).map(|index| {
Note::new(
Pitch::new(if index % 2 == 0 { Step::C } else { Step::D }, 5),
Duration::Sixteenth,
)
}));
}
let svg = render_svg(&score, &opts()).expect("long voices should render");
assert!(svg.len() < 2_000_000, "renderer output unexpectedly large");
assert!(svg.matches(r#"data-acorde-kind="note""#).count() >= 256);
}
#[test]
fn staff_line_count_matches_two_staves() {
let svg = render_svg(&common::satb_major(), &opts()).unwrap();
assert_eq!(svg.matches(r#"class="acorde-staff""#).count(), 2);
assert_eq!(svg.matches("acorde-staff-line").count(), 10);
}
#[test]
fn note_count_matches_satb_voices() {
let svg = render_svg(&common::satb_major(), &opts()).unwrap();
assert_eq!(svg.matches(r#"data-acorde-kind="note""#).count(), 16);
assert_eq!(svg.matches(r#"data-acorde-kind="rest""#).count(), 0);
}
#[test]
fn rest_present_in_dotted_and_rest_fixture() {
let svg = render_svg(&common::satb_dotted_and_rest(), &opts()).unwrap();
assert_eq!(svg.matches(r#"data-acorde-kind="rest""#).count(), 1);
assert_eq!(svg.matches(r#"data-acorde-kind="note""#).count(), 4 + 1 + 4 + 4);
}
#[test]
fn measure_group_count_is_one_per_staff() {
let svg = render_svg(&common::satb_major(), &opts()).unwrap();
assert_eq!(svg.matches(r#"data-acorde-kind="measure""#).count(), 2);
}
#[test]
fn barline_present_at_measure_end() {
let svg = render_svg(&common::satb_major(), &opts()).unwrap();
assert!(svg.matches("acorde-barline").count() >= 1);
}
#[test]
fn stable_data_attributes_present_and_addressable() {
let svg = render_svg(&common::satb_major(), &opts()).unwrap();
assert!(svg.contains(r#"data-part="0""#));
assert!(svg.contains(r#"data-staff="0""#));
assert!(svg.contains(r#"data-staff="1""#));
assert!(svg.contains(r#"data-measure="0""#));
assert!(svg.contains(r#"data-voice="0""#));
assert!(svg.contains(r#"data-voice="1""#));
assert!(svg.contains(r#"data-note-addr="0:0:0:0:1""#));
assert!(svg.contains(r#"data-note-addr="0:1:0:1:0""#));
}
#[test]
fn interactive_false_omits_data_attributes() {
let mut o = opts();
o.interactive = false;
let svg = render_svg(&common::satb_major(), &o).unwrap();
assert!(!svg.contains("data-acorde-kind"));
assert!(!svg.contains("data-note-addr"));
}
#[test]
fn common_span_marks_are_rendered() {
use acorde_core::{Duration, HairpinKind, Note, OttavaKind, Pitch, Step};
let mut score = common::single_staff_score(
acorde_core::Clef::Treble,
0,
4,
4,
vec![
Note::new(Pitch::new(Step::C, 5), Duration::Quarter),
Note::new(Pitch::new(Step::D, 5), Duration::Quarter),
Note::new(Pitch::new(Step::E, 5), Duration::Quarter),
Note::new(Pitch::new(Step::F, 5), Duration::Quarter),
],
vec![],
);
let notes = &mut score.parts[0].staves[0].measures[0].voices[0];
notes[0].tie_start = true;
notes[0].slur_start = true;
notes[2].slur_end = true;
notes[0].hairpin_start = Some(HairpinKind::Crescendo);
notes[2].hairpin_end = true;
notes[0].ottava_start = Some(OttavaKind::Va8);
notes[2].ottava_end = true;
notes[0].pedal_start = true;
notes[2].pedal_end = true;
let svg = render_svg(&score, &opts()).unwrap();
assert!(svg.contains("class=\"acorde-tie\""));
assert!(svg.contains("class=\"acorde-slur\""));
assert!(svg.contains("class=\"acorde-hairpin\""));
assert!(svg.contains("class=\"acorde-ottava\""));
assert!(svg.contains("class=\"acorde-pedal\""));
assert!(svg.contains("data-acorde-span=\"hairpin\""));
assert!(svg.contains("data-start-note-addr=\"0:0:0:0:0\""));
assert!(svg.contains("data-end-note-addr=\"0:0:0:0:2\""));
}
#[test]
fn spans_crossing_systems_get_continuation_segments() {
use acorde_core::{Duration, HairpinKind, Note, Pitch, Step};
let mut score = acorde_core::Score::new("Cross-system", 120, 4, 4, 0, 2);
let measures = &mut score.parts[0].staves[0].measures;
measures[0].voices[0] = vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
measures[1].voices[0] = vec![Note::new(Pitch::new(Step::D, 5), Duration::Whole)];
measures[0].voices[0][0].hairpin_start = Some(HairpinKind::Crescendo);
measures[1].voices[0][0].hairpin_end = true;
measures[0].voices[0][0].tie_start = true;
let mut options = opts();
options.measures_per_system = 1;
let svg = render_svg(&score, &options).unwrap();
assert!(svg.contains("class=\"acorde-hairpin\" data-continuation=\"true\""));
assert!(svg.matches("class=\"acorde-tie\"").count() >= 2);
}
#[test]
fn note_annotations_are_rendered_and_xml_escaped() {
use acorde_core::{Articulation, ChordSymbol, Duration, Dynamic, Lyric, Note, Pitch, Step};
let mut score = common::single_staff_score(
acorde_core::Clef::Treble,
0,
4,
4,
vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)],
vec![],
);
let note = &mut score.parts[0].staves[0].measures[0].voices[0][0];
note.dynamic = Some(Dynamic::Mf);
note.chord_symbol = Some(ChordSymbol { root: "C".into(), kind: "major".into(), bass: Some("G".into()) });
note.lyric = Some(Lyric { text: "A&B<".into(), syllabic: "single".into() });
note.articulations = vec![Articulation::Staccato, Articulation::Accent, Articulation::Tenuto];
let svg = render_svg(&score, &opts()).unwrap();
assert!(svg.contains("class=\"acorde-dynamic\""));
assert!(svg.contains("class=\"acorde-chord-symbol\""));
assert!(svg.contains("class=\"acorde-lyric\""));
assert!(svg.contains("A&B<"));
assert!(svg.contains("acorde-staccato"));
assert!(svg.contains("acorde-accent"));
assert!(svg.contains("acorde-tenuto"));
}
#[test]
fn short_rests_custom_noteheads_and_small_notes_are_rendered() {
use acorde_core::{Duration, Note, NoteHead, Pitch, Step};
let mut notes = vec![
Note::rest(Duration::Sixteenth),
Note::rest(Duration::ThirtySecond),
Note::rest(Duration::SixtyFourth),
Note::new(Pitch::new(Step::C, 5), Duration::Quarter),
Note::new(Pitch::new(Step::D, 5), Duration::Quarter),
Note::new(Pitch::new(Step::E, 5), Duration::Quarter),
Note::new(Pitch::new(Step::F, 5), Duration::Quarter),
];
notes[3].note_head = NoteHead::Diamond;
notes[4].note_head = NoteHead::X;
notes[5].note_head = NoteHead::Triangle;
notes[6].is_grace = true;
notes[6].grace_slash = true;
notes[6].is_cue = true;
let score = common::single_staff_score(
acorde_core::Clef::Treble, 0, 4, 4, notes, vec![],
);
let svg = render_svg(&score, &opts()).unwrap();
assert!(svg.contains("acorde-rest-flag"));
assert!(svg.contains("acorde-notehead-diamond"));
assert!(svg.contains("acorde-notehead-x"));
assert!(svg.contains("acorde-notehead-triangle"));
assert!(svg.contains("acorde-grace"));
assert!(svg.contains("acorde-cue"));
assert!(svg.contains("acorde-grace-slash"));
assert_well_formed_xml(&svg);
}
#[test]
fn part_group_connectors_and_first_system_labels_are_rendered() {
use acorde_core::{PartGroup, PartGroupSymbol, ScoreTemplate};
let mut score = acorde_core::Score::template(ScoreTemplate::StringQuartet);
score.part_groups.push(PartGroup {
first_part: 0,
last_part: 3,
symbol: PartGroupSymbol::Bracket,
barlines_connect: true,
});
let svg = render_svg(&score, &opts()).unwrap();
assert!(svg.contains("acorde-part-bracket"));
assert!(svg.contains("acorde-part-label"));
assert_well_formed_xml(&svg);
}
#[test]
fn precomputed_row_and_metadata_contracts_are_stable() {
use acorde_layout::{compute_layout, LayoutConfig};
let score = common::satb_major();
let layout = compute_layout(&score, &LayoutConfig::default());
let row = acorde_render_svg::render_svg_row(&score, &layout, 0, &opts()).unwrap();
let metadata = acorde_render_svg::render_svg_metadata(&score, &layout, &opts()).unwrap();
assert!(row.starts_with("<svg"));
assert_eq!(metadata.width, opts().width);
assert_eq!(metadata.address_bounds.len(), 16);
assert_eq!((metadata.address_bounds[0].part, metadata.address_bounds[0].staff, metadata.address_bounds[0].measure), (0, 0, 0));
assert!(acorde_render_svg::render_svg_row(&score, &layout, 99, &opts()).is_err());
}
#[test]
fn malformed_precomputed_layout_returns_error_instead_of_panicking() {
use acorde_layout::{compute_layout, LayoutConfig};
let score = common::satb_major();
let mut layout = compute_layout(&score, &LayoutConfig::default());
layout.rows[0].measure_indices[0] = 999;
let err = acorde_render_svg::render_svg_with_layout(&score, &layout, &opts()).unwrap_err();
assert!(matches!(err, acorde_render_svg::RenderError::InvalidLayout { .. }));
}
#[test]
fn invalid_render_dimensions_return_errors() {
let score = common::satb_major();
let mut options = opts();
options.width = 0.0;
assert!(matches!(render_svg(&score, &options), Err(acorde_render_svg::RenderError::InvalidOptions { .. })));
options = opts();
options.staff_size = -1.0;
assert!(matches!(render_svg(&score, &options), Err(acorde_render_svg::RenderError::InvalidOptions { .. })));
}
#[test]
fn extreme_ledger_content_expands_vertical_margin() {
use acorde_core::{Duration, Note, Pitch, Step};
let normal = common::single_staff_score(
acorde_core::Clef::Treble, 0, 4, 4,
vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)], vec![],
);
let extreme = common::single_staff_score(
acorde_core::Clef::Treble, 0, 4, 4,
vec![Note::new(Pitch::new(Step::C, 8), Duration::Whole)], vec![],
);
let normal_meta = acorde_render_svg::render_svg_metadata(
&normal, &acorde_layout::compute_layout(&normal, &Default::default()), &opts(),
).unwrap();
let extreme_meta = acorde_render_svg::render_svg_metadata(
&extreme, &acorde_layout::compute_layout(&extreme, &Default::default()), &opts(),
).unwrap();
assert!(extreme_meta.height > normal_meta.height);
}
#[test]
fn key_signature_glyph_present_when_nonzero_fifths() {
let major = render_svg(&common::satb_major(), &opts()).unwrap(); let minor = render_svg(&common::satb_minor(), &opts()).unwrap();
assert!(!major.contains("acorde-sharp") && !major.contains("acorde-flat"));
assert_eq!(minor.matches("acorde-key-sig").count(), 2); assert!(minor.matches("acorde-sharp").count() >= 2); }
#[test]
fn time_signature_drawn_once_per_staff_on_first_row() {
let svg = render_svg(&common::satb_major(), &opts()).unwrap();
assert_eq!(svg.matches("acorde-time-sig").count(), 2);
}
#[test]
fn accidentals_cover_all_required_kinds() {
use acorde_core::{Clef, Duration, KeySignature, Measure, Note, Part, Pitch, Score, Staff, Step, TimeSignature};
let mut score = Score::default();
score.settings.time_signature = TimeSignature { numerator: 4, denominator: 4 };
score.settings.key_signature = KeySignature { fifths: 0, mode: "major".to_string() };
let mut part = Part::new("T", "T");
let mut staff = Staff::new(Clef::Treble);
let mut m = Measure::empty(4, 4);
m.number = 1;
let mut sharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
sharp.pitches[0].alter = 1;
let mut flat = Note::new(Pitch::new(Step::B, 4), Duration::Quarter);
flat.pitches[0].alter = -1;
let mut dsharp = Note::new(Pitch::new(Step::C, 5), Duration::Quarter);
dsharp.pitches[0].alter = 2;
let mut dflat = Note::new(Pitch::new(Step::D, 5), Duration::Quarter);
dflat.pitches[0].alter = -2;
m.voices[0] = vec![sharp, flat, dsharp, dflat];
staff.measures.push(m);
part.staves.push(staff);
score.parts = vec![part];
let svg = render_svg(&score, &opts()).unwrap();
assert!(svg.contains("acorde-sharp"));
assert!(svg.contains("acorde-flat"));
assert!(svg.contains("acorde-double-sharp"));
assert!(svg.contains("acorde-double-flat"));
assert_well_formed_xml(&svg);
}
#[test]
fn triple_sharp_is_rejected_not_silently_dropped() {
use acorde_core::{Clef, Duration, KeySignature, Measure, Note, Part, Pitch, Score, Staff, Step, TimeSignature};
let mut score = Score::default();
score.settings.time_signature = TimeSignature { numerator: 4, denominator: 4 };
score.settings.key_signature = KeySignature { fifths: 0, mode: "major".to_string() };
let mut part = Part::new("T", "T");
let mut staff = Staff::new(Clef::Treble);
let mut m = Measure::empty(4, 4);
m.number = 1;
let mut triple = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
triple.pitches[0].alter = 3; m.voices[0] = vec![triple];
staff.measures.push(m);
part.staves.push(staff);
score.parts = vec![part];
let err = render_svg(&score, &opts()).unwrap_err();
assert_eq!(err, acorde_render_svg::RenderError::UnsupportedAccidental { alter: 3 });
}
#[test]
fn percussion_clef_is_rejected_not_silently_treble() {
use acorde_core::{Clef, Part, Score, Staff};
let mut score = Score::default();
let mut part = Part::new("Drums", "Dr.");
part.staves.push(Staff::new(Clef::Percussion));
score.parts = vec![part];
let err = render_svg(&score, &opts()).unwrap_err();
assert_eq!(err, acorde_render_svg::RenderError::UnsupportedClef);
}
#[test]
fn empty_score_is_rejected() {
use acorde_core::Score;
let score = Score { parts: vec![], ..Score::default() };
let err = render_svg(&score, &opts()).unwrap_err();
assert_eq!(err, acorde_render_svg::RenderError::EmptyScore);
}
#[test]
fn multi_voice_two_voices_get_opposite_stem_directions() {
let svg = render_svg(&common::satb_major(), &opts()).unwrap();
assert_eq!(svg.matches(r#"data-staff="0" data-measure="0" data-voice="0""#).count(), 4);
assert_eq!(svg.matches(r#"data-staff="0" data-measure="0" data-voice="1""#).count(), 4);
}