import copy
import argparse
from fractions import Fraction
import hashlib
import json
from pathlib import Path
import tempfile
import unittest
from xml.etree import ElementTree
from generator.arrange.generate import arrange_document
from generator.__main__ import generate_assets
from generator.engraving import ENGINE_NAME, ENGINE_VERSION, EngravingError, Fonts, Style, check_page, engrave_tree, layout_score
from tests.musicxml_roundtrip import audit_roundtrip
from generator.model import fraction, pitch_number, rational, validate_document
from generator.musicxml import load_musicxml
from generator.musicxml_writer import write_musicxml
from generator.practice.generate import practice_document
from generator.score import load_json
from generator.transposition import transpose_document
from tests.test_musicxml import notes, parse, xml_note, xml_score
class ScoreBundleTests(unittest.TestCase):
def test_real_asset_regeneration_removes_pages_and_rolls_back_font_failure(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
data, output = root / 'data', root / 'output'
(data / 'songs').mkdir(parents=True)
source = data / '001.xml'
source.write_text(xml_score(xml_note()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
generate_assets(args)
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
self.assertTrue(list(output.rglob('*.png')))
generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(xml_score(xml_note(extra='other')))
with self.assertRaisesRegex(EngravingError, 'unavailableGlyph'):
generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(xml_score(xml_note()))
renamed = data / '127.xml'
source.rename(renamed)
source = renamed
generate_assets(args)
self.assertFalse((output / 'practice/001').exists())
self.assertEqual(len(list((output / 'practice/127').glob('*.json'))), 12)
source.unlink()
generate_assets(args)
self.assertFalse(list(output.rglob('*.png')))
self.assertFalse(list(output.rglob('*.json')))
def round_trip(self, document):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'roundtrip.xml'
write_musicxml(path, document)
return load_musicxml(path)
def test_multiple_voices_keep_independent_ties_and_transformations(self):
def half(action):
return xml_note(duration=2, kind='half', extra=f'1')
content = half('start') + half('stop') + '4'
content += xml_note('C', extra='2')
source = parse(xml_score(content))
measure = source['staves'][0]['measures'][0]
self.assertEqual([v['id'] for v in measure['voices']], ['1', '2'])
self.assertNotIn('tie_from', measure['voices'][1]['notes'][0]['pitches'][0])
for document in (source, practice_document(source), transpose_document(source, 'D'), arrange_document(source, 'vocal')):
validate_document(document)
restored = self.round_trip(document)
validate_document(restored)
self.assertTrue(any(len(m['voices']) == 2 for m in restored['staves'][0]['measures']))
def test_microtones_remain_exact_after_transposition_and_export(self):
source = parse(xml_score(xml_note(alter='0.5', extra='quarter-sharp')))
transposed = transpose_document(source, 'D')
self.assertEqual(pitch_number(notes(transposed)[0]['pitches'][0]), Fraction(125, 2))
restored = self.round_trip(transposed)
self.assertEqual(notes(restored)[0]['pitches'], notes(transposed)[0]['pitches'])
unqualified = parse(xml_score(xml_note(alter='0.1')))
self.assertEqual(fraction(notes(unqualified)[0]['pitches'][0]['alter']), Fraction(1, 10))
layout = layout_score(unqualified, Style(), Fonts(Path('package/assets/fonts/petaluma')))
self.assertTrue(any(p.role == 'accidental' and p.value == '(+1/10 st)' for page in layout.pages for p in page.primitives))
def test_octave_shift_stop_at_a_bar_boundary_is_emitted_once(self):
tree = ElementTree.fromstring(xml_score(xml_note()))
ElementTree.SubElement(tree.find('part'), 'measure', number='2').append(ElementTree.fromstring(xml_note()))
source = parse(ElementTree.tostring(tree, encoding='unicode'))
source['staves'][0]['octave_shifts'] = [{'id': 'octave-1', 'start': rational(0), 'end': rational(4), 'octaves': 1}]
restored = self.round_trip(source)
self.assertEqual(source['staves'][0]['octave_shifts'], restored['staves'][0]['octave_shifts'])
def test_unequal_chord_tones_are_retained_but_never_silently_flattened(self):
source = parse(xml_score(xml_note() + xml_note('E', 2, 'half', prefix='')))
restored = self.round_trip(source)
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
heads = [p.value for page in layout.pages for p in page.primitives if p.role == 'head']
self.assertEqual(heads, ['noteheadWhole', 'noteheadHalf'])
def test_overlapping_notes_within_one_voice_are_rejected(self):
with self.assertRaisesRegex(ValueError, 'overlapping'):
parse(xml_score(xml_note() + '4' + xml_note('E')))
def test_multiple_lyric_verses_and_elisions_round_trip(self):
lyrics = 'beginone two'
lyrics += 'singlethree'
source = parse(xml_score(xml_note(extra=lyrics)))
restored = self.round_trip(source)
self.assertEqual(notes(restored)[0]['lyrics'], notes(source)[0]['lyrics'])
def test_percussion_and_tablature_are_semantic_not_pitch_guesses(self):
percussion = 'C54wholex'
source = parse(xml_score(percussion, attributes='percussion'))
restored = self.round_trip(source)
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
tab = parse(xml_score(xml_note(extra='21'), attributes='TAB56'))
self.assertEqual(notes(self.round_trip(tab))[0]['pitches'], notes(tab)[0]['pitches'])
for document in (source, tab):
validate_document(practice_document(document))
for part in ('vocal', 'acoustic', 'electric'):
with self.assertRaisesRegex(ValueError, 'conversion is not qualified'):
arrange_document(document, part)
def test_percussion_instrument_definitions_round_trip_and_are_audited(self):
tree = ElementTree.fromstring(xml_score('C54whole', attributes='percussion'))
definition = tree.find('part-list/score-part')
definition.append(ElementTree.fromstring('Snaredrum.snare-drumKit'))
definition.append(ElementTree.fromstring('1039'))
source = parse(ElementTree.tostring(tree, encoding='unicode'))
self.assertEqual(self.round_trip(source)['staves'][0]['instruments'], source['staves'][0]['instruments'])
changed = copy.deepcopy(source)
changed['staves'][0]['instruments'][0]['midi']['midi-unpitched'] = '40'
with tempfile.TemporaryDirectory() as temporary:
before, after = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
write_musicxml(before, source)
write_musicxml(after, changed)
with self.assertRaisesRegex(ValueError, 'percussion instrument'):
audit_roundtrip(before, after)
source['staves'][0]['instruments'] = []
with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
validate_document(source)
def test_catalogue_roundtrip_preserves_written_pitch_and_rhythm(self):
for path in Path('data').rglob('*.xml'):
with self.subTest(path=path):
source = load_musicxml(path)
restored = self.round_trip(source)
a, b = source['staves'][0]['measures'], restored['staves'][0]['measures']
self.assertEqual(len(a), len(b))
for original, result in zip(a, b):
self.assertEqual(original['duration'], result['duration'])
for v, w in zip(original['voices'], result['voices']):
self.assertEqual(v['notes'], w['notes'])
def test_missing_font_does_not_produce_a_bundle(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = parse(xml_score(xml_note()))
path = root / 'fixture.json'
path.write_text(json.dumps(source))
with self.assertRaisesRegex(EngravingError, 'cannot load bundled'):
engrave_tree(root, root / 'missing-fonts')
self.assertEqual(json.loads(path.read_text()), source)
def test_blank_and_wrong_sized_pages_are_rejected(self):
from PIL import Image
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'page.png'
for size in ((976, 484), (100, 100)):
Image.new('RGB', size, 'white').save(path)
with self.assertRaises(EngravingError):
check_page(path)
def test_audit_rejects_an_engraver_pitch_change(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
source.write_text(xml_score(xml_note()))
output.write_text(xml_score(xml_note('D')))
with self.assertRaisesRegex(ValueError, 'pitch letter'):
audit_roundtrip(source, output)
def test_audit_rejects_staff_details_and_barline_changes(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
document = parse(xml_score(xml_note()))
for field, value in (('staff_details', {'lines': 6, 'tunings': []}), ('barlines', {'right': 'light-light'})):
with self.subTest(field=field):
original = copy.deepcopy(document)
original['staves'][0]['measures'][0][field] = value
write_musicxml(source, original)
write_musicxml(output, document)
with self.assertRaisesRegex(ValueError, 'engraver changed musical content'):
audit_roundtrip(source, output)
def test_audit_rejects_relocated_tuplet_brackets(self):
modulation = '32'
source = parse(xml_score(''.join(xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6)), divisions=3))
span = {'id': 'tuplet-1', 'voice': '1', 'start': notes(source)[0]['id'], 'end': notes(source)[2]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'}
source['staves'][0]['tuplets'] = [span]
for note in notes(source)[:3]:
note['tuplet_ids'] = ['tuplet-1']
changed = copy.deepcopy(source)
changed['staves'][0]['tuplets'][0].update(start=notes(changed)[3]['id'], end=notes(changed)[5]['id'])
for index, note in enumerate(notes(changed)):
note['tuplet_ids'] = ['tuplet-1'] if index >= 3 else []
with tempfile.TemporaryDirectory() as temporary:
before, after = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
write_musicxml(before, source)
write_musicxml(after, changed)
with self.assertRaisesRegex(ValueError, 'members'):
audit_roundtrip(before, after)
def test_bundle_validation_checks_pages_and_rejects_symlink_escape(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
assets = root / 'assets'
assets.mkdir()
data = Path('tests/fixtures/engraving.png').read_bytes()
page = assets / 'page.png'
page.write_bytes(data)
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
bundle = {'schema_version': 3, 'music': {k: v for k, v in document.items() if k != 'schema_version'}, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
path = assets / 'score.json'
path.write_text(json.dumps(bundle))
self.assertEqual(load_json(path), document)
page.write_bytes(data + b'corrupt')
with self.assertRaisesRegex(ValueError, 'checksum'):
load_json(path)
page.unlink()
outside = root / 'outside.png'
outside.write_bytes(data)
page.symlink_to(outside)
with self.assertRaisesRegex(ValueError, 'escapes'):
load_json(path)
def test_python_engraver_visual_golden_and_determinism(self):
from PIL import Image, ImageChops
from generator.score import write_json
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
before = None
for _ in range(2):
write_json(root / 'fixture.json', document)
engrave_tree(root, Path('package/assets/fonts/petaluma'))
bundle = json.loads((root / 'fixture.json').read_text())
self.assertEqual(bundle['music'], {k: v for k, v in document.items() if k != 'schema_version'})
self.assertEqual(len(bundle['presentation']['pages']), 1)
page = root / bundle['presentation']['pages'][0]['path']
with Image.open(page) as actual, Image.open('tests/fixtures/engraving-python.png') as expected:
self.assertIsNone(ImageChops.difference(actual.convert('RGB'), expected.convert('RGB')).getbbox())
if before is not None:
self.assertEqual(bundle, before)
before = bundle