329 lines
22 KiB
Python
329 lines
22 KiB
Python
import argparse
|
|
import copy
|
|
import fractions
|
|
import hashlib
|
|
import json
|
|
import pathlib
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
import unittest.mock
|
|
import xml.etree.ElementTree
|
|
|
|
import PIL.Image
|
|
import PIL.ImageChops
|
|
|
|
import generator.__main__
|
|
import generator.arrange.generate
|
|
import generator.engraving.fonts
|
|
import generator.engraving.layout
|
|
import generator.engraving.notation
|
|
import generator.engraving.pipeline
|
|
import generator.engraving.types
|
|
import generator.engraving.validation
|
|
import generator.model
|
|
import generator.musicxml
|
|
import generator.musicxml_writer
|
|
import generator.pico_artwork
|
|
import generator.pitch
|
|
import generator.playback
|
|
import tests.arrangement_fixtures
|
|
import generator.score
|
|
import tests.musicxml_roundtrip
|
|
import tests.rhythm_fixtures
|
|
import tests.test_musicxml
|
|
|
|
class ScoreBundleTests(unittest.TestCase):
|
|
def test_real_asset_regeneration_removes_pages_and_rolls_back_font_failure(self):
|
|
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = pathlib.Path(temporary)
|
|
data, output = root / 'data', root / 'output'
|
|
(data / 'songs').mkdir(parents=True)
|
|
shutil.copytree(pathlib.Path('data/scales'), data / 'scales')
|
|
source = data / 'songs/fixture.xml'
|
|
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
|
|
generator.__main__.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')))
|
|
(data / 'rhythms' / 'drafts').mkdir(parents=True)
|
|
for path in ('rhythms/0001.xml', 'rhythms/drafts/0002.xml'):
|
|
(data / path).write_text('<unfinished')
|
|
generator.__main__.generate_assets(args)
|
|
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
|
scale = data / 'scales/major-scale-modes/0002.xml'
|
|
previous = scale.read_bytes()
|
|
scale.write_text('<unfinished')
|
|
with self.assertRaisesRegex(ValueError, '0002.xml'):
|
|
generator.__main__.generate_assets(args)
|
|
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
|
scale.write_bytes(previous)
|
|
misplaced = data / '0001.xml'
|
|
misplaced.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
with self.assertRaisesRegex(ValueError, 'belong directly under'):
|
|
generator.__main__.generate_assets(args)
|
|
misplaced.unlink()
|
|
with unittest.mock.patch('generator.playback.compile_tree', side_effect=ValueError('invalid generated audio')):
|
|
with self.assertRaisesRegex(ValueError, 'audio'):
|
|
generator.__main__.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(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note() + '<barline><repeat direction="backward"/></barline>'))
|
|
with self.assertRaisesRegex(ValueError, 'write the music out'):
|
|
generator.__main__.generate_assets(args)
|
|
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
|
dense = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64))
|
|
source.write_text(tests.test_musicxml.xml_score(dense, divisions=16))
|
|
with self.assertRaisesRegex(generator.engraving.types.EngravingError, r'songs/fixture-electric.json: measure 1 .*complete measure exceeds'):
|
|
generator.__main__.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(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
|
|
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
|
|
generator.__main__.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(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
renamed = data / 'songs/renamed.xml'
|
|
source.rename(renamed)
|
|
source = renamed
|
|
legacy_output = output / 'obsolete'
|
|
legacy_output.mkdir()
|
|
(legacy_output / 'electric.json').write_text('{}')
|
|
generator.__main__.generate_assets(args)
|
|
self.assertFalse(legacy_output.exists())
|
|
self.assertFalse(list((output / 'songs').glob('fixture*')))
|
|
self.assertEqual(len(list((output / 'songs').glob('renamed-*.json'))), 3)
|
|
source.unlink()
|
|
generator.__main__.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 = pathlib.Path(temporary) / 'roundtrip.xml'
|
|
generator.musicxml_writer.write_musicxml(path, document)
|
|
return generator.musicxml.load_musicxml(path)
|
|
def test_score_structure_is_validated_without_schema_labels(self):
|
|
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
self.assertEqual(set(document), {'metadata', 'staves'})
|
|
for malformed in (None, [], {}, {'metadata': {}, 'staves': []}, {**document, 'staves': None}, {**document, 'staves': [{}]}):
|
|
with self.subTest(document=malformed), self.assertRaises(ValueError):
|
|
generator.model.validate_document(malformed)
|
|
def test_multiple_voices_keep_independent_ties_and_transformations(self):
|
|
def half(action):
|
|
return tests.test_musicxml.xml_note(duration=2, kind='half', extra=f'<voice>1</voice><tie type="{action}"/>')
|
|
content = half('start') + half('stop') + '<backup><duration>4</duration></backup>'
|
|
content += tests.test_musicxml.xml_note('C', extra='<voice>2</voice>')
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.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, generator.pitch.concert_document(source), generator.arrange.generate.arrange_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())):
|
|
generator.model.validate_document(document)
|
|
restored = self.round_trip(document)
|
|
generator.model.validate_document(restored)
|
|
self.assertTrue(any(len(m['voices']) == 2 for m in restored['staves'][0]['measures']))
|
|
def test_contiguous_ties_continue_across_systems_without_repeat_state(self):
|
|
text = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<tie type="start"/>'))
|
|
text = text.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '<barline><bar-style>light-heavy</bar-style></barline></measure></part>')
|
|
source = tests.test_musicxml.parse(text)
|
|
index = generator.engraving.notation.ScoreIndex(source)
|
|
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
|
|
for start, end in ((0, 4), (4, 8)):
|
|
system = generator.engraving.layout.make_system(index, start, end, fonts, generator.engraving.types.Style())
|
|
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
|
|
self.assertEqual([generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(source)['events']], [8])
|
|
restored = self.round_trip(source)
|
|
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
|
|
self.assertEqual(restored['staves'][0]['measures'][1]['barlines'], {'right': 'light-heavy'})
|
|
def test_microtones_remain_exact_after_transposition_and_export(self):
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.5', extra='<accidental>quarter-sharp</accidental>')))
|
|
source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0}
|
|
transposed = generator.pitch.concert_document(source)
|
|
self.assertEqual(generator.model.pitch_number(tests.test_musicxml.notes(transposed)[0]['pitches'][0]), fractions.Fraction(125, 2))
|
|
restored = self.round_trip(transposed)
|
|
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(transposed)[0]['pitches'])
|
|
unqualified = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.1')))
|
|
self.assertEqual(generator.model.fraction(tests.test_musicxml.notes(unqualified)[0]['pitches'][0]['alter']), fractions.Fraction(1, 10))
|
|
layout = generator.engraving.layout.layout_score(unqualified, generator.engraving.types.Style(), generator.engraving.fonts.Fonts(pathlib.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 = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
xml.etree.ElementTree.SubElement(tree.find('part'), 'measure', number='2').append(xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_note()))
|
|
source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(tree, encoding='unicode'))
|
|
source['staves'][0]['octave_shifts'] = [{'id': 'octave-1', 'start': generator.model.rational(0), 'end': generator.model.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 = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note() + tests.test_musicxml.xml_note('E', 2, 'half', prefix='<chord/>')))
|
|
restored = self.round_trip(source)
|
|
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
|
|
layout = generator.engraving.layout.layout_score(source, generator.engraving.types.Style(), generator.engraving.fonts.Fonts(pathlib.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'):
|
|
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note('E')))
|
|
def test_multiple_lyric_verses_and_elisions_round_trip(self):
|
|
lyrics = '<lyric number="1"><syllabic>begin</syllabic><text>one</text><elision> </elision><text>two</text><extend type="start"/></lyric>'
|
|
lyrics += '<lyric number="2"><syllabic>single</syllabic><text>three</text></lyric>'
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra=lyrics)))
|
|
restored = self.round_trip(source)
|
|
self.assertEqual(tests.test_musicxml.notes(restored)[0]['lyrics'], tests.test_musicxml.notes(source)[0]['lyrics'])
|
|
def test_percussion_and_tablature_are_semantic_not_pitch_guesses(self):
|
|
percussion = '<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><type>whole</type><notehead>x</notehead></note>'
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
|
|
restored = self.round_trip(source)
|
|
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
|
|
with self.assertRaisesRegex(ValueError, 'authored TAB'):
|
|
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes='<clef><sign>TAB</sign></clef>'))
|
|
for part in ('vocal', 'acoustic', 'electric'):
|
|
with self.assertRaisesRegex(ValueError, 'pitched'):
|
|
tests.arrangement_fixtures.section_document(source, part, rhythms=tests.rhythm_fixtures.library())
|
|
with self.assertRaisesRegex(ValueError, 'pitched'):
|
|
generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
|
|
def test_percussion_instrument_definitions_round_trip_and_are_audited(self):
|
|
tree = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score('<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><instrument id="drum"/><type>whole</type></note>', attributes='<clef><sign>percussion</sign></clef>'))
|
|
definition = tree.find('part-list/score-part')
|
|
definition.append(xml.etree.ElementTree.fromstring('<score-instrument id="drum"><instrument-name>Snare</instrument-name><instrument-sound>drum.snare-drum</instrument-sound><solo/><virtual-instrument><virtual-name>Kit</virtual-name></virtual-instrument></score-instrument>'))
|
|
definition.append(xml.etree.ElementTree.fromstring('<midi-instrument id="drum"><midi-channel>10</midi-channel><midi-unpitched>39</midi-unpitched></midi-instrument>'))
|
|
source = tests.test_musicxml.parse(xml.etree.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 = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
|
|
generator.musicxml_writer.write_musicxml(before, source)
|
|
generator.musicxml_writer.write_musicxml(after, changed)
|
|
with self.assertRaisesRegex(ValueError, 'percussion instrument'):
|
|
tests.musicxml_roundtrip.audit_roundtrip(before, after)
|
|
source['staves'][0]['instruments'] = []
|
|
with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
|
|
generator.model.validate_document(source)
|
|
def test_catalog_roundtrip_preserves_written_pitch_and_rhythm(self):
|
|
for path in sorted(pathlib.Path('data/songs').glob('*.xml')):
|
|
with self.subTest(path=path):
|
|
source = generator.musicxml.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 = pathlib.Path(temporary)
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
path = root / 'fixture.json'
|
|
path.write_text(json.dumps(source))
|
|
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'cannot load bundled'):
|
|
generator.engraving.pipeline.engrave_tree(root, root / 'missing-fonts')
|
|
self.assertEqual(json.loads(path.read_text()), source)
|
|
def test_blank_and_wrong_sized_pages_are_rejected(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
path = pathlib.Path(temporary) / 'page.png'
|
|
for size in ((976, 484), (100, 100)):
|
|
PIL.Image.new('RGB', size, 'white').save(path)
|
|
with self.assertRaises(generator.engraving.types.EngravingError):
|
|
generator.engraving.validation.check_page(path)
|
|
def test_audit_rejects_an_engraver_pitch_change(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
source, output = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
|
|
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
output.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')))
|
|
with self.assertRaisesRegex(ValueError, 'pitch letter'):
|
|
tests.musicxml_roundtrip.audit_roundtrip(source, output)
|
|
def test_audit_rejects_staff_details_and_barline_changes(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
source, output = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
|
|
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.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
|
|
generator.musicxml_writer.write_musicxml(source, original)
|
|
generator.musicxml_writer.write_musicxml(output, document)
|
|
with self.assertRaisesRegex(ValueError, 'engraver changed musical content'):
|
|
tests.musicxml_roundtrip.audit_roundtrip(source, output)
|
|
def test_audit_rejects_relocated_tuplet_brackets(self):
|
|
modulation = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6)), divisions=3))
|
|
span = {'id': 'tuplet-1', 'voice': '1', 'start': tests.test_musicxml.notes(source)[0]['id'], 'end': tests.test_musicxml.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 tests.test_musicxml.notes(source)[:3]:
|
|
note['tuplet_ids'] = ['tuplet-1']
|
|
changed = copy.deepcopy(source)
|
|
changed['staves'][0]['tuplets'][0].update(start=tests.test_musicxml.notes(changed)[3]['id'], end=tests.test_musicxml.notes(changed)[5]['id'])
|
|
for index, note in enumerate(tests.test_musicxml.notes(changed)):
|
|
note['tuplet_ids'] = ['tuplet-1'] if index >= 3 else []
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
before, after = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
|
|
generator.musicxml_writer.write_musicxml(before, source)
|
|
generator.musicxml_writer.write_musicxml(after, changed)
|
|
with self.assertRaisesRegex(ValueError, 'members'):
|
|
tests.musicxml_roundtrip.audit_roundtrip(before, after)
|
|
def test_bundle_validation_checks_pages_and_rejects_symlink_escape(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = pathlib.Path(temporary)
|
|
assets = root / 'assets'
|
|
assets.mkdir()
|
|
data = pathlib.Path('tests/fixtures/engraving.png').read_bytes()
|
|
page = assets / 'page.png'
|
|
page.write_bytes(data)
|
|
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
|
|
bundle = {'music': document, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, '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(generator.score.load_json(path), document)
|
|
page.write_bytes(data + b'corrupt')
|
|
with self.assertRaisesRegex(ValueError, 'checksum'):
|
|
generator.score.load_json(path)
|
|
page.unlink()
|
|
outside = root / 'outside.png'
|
|
outside.write_bytes(data)
|
|
page.symlink_to(outside)
|
|
with self.assertRaisesRegex(ValueError, 'escapes'):
|
|
generator.score.load_json(path)
|
|
def test_final_validation_reuses_content_checks_and_rechecks_files(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = pathlib.Path(temporary)
|
|
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
for index in range(3):
|
|
generator.score.write_json(root / 'songs' / f'{index}.json', document)
|
|
trusted = generator.engraving.pipeline.engrave_tree(root, pathlib.Path('package/assets/fonts/petaluma'))
|
|
generator.pico_artwork.generate_titles(root, pathlib.Path('package/assets/fonts/petaluma'))
|
|
with unittest.mock.patch('generator.engraving.validation.check_content', side_effect=AssertionError('already validated content')):
|
|
generator.score.validate_generated_tree(root, trusted)
|
|
page = root / 'songs/2.pages/0001.png'
|
|
original = page.read_bytes()
|
|
page.write_bytes(original + b'changed')
|
|
with self.assertRaisesRegex(ValueError, 'checksum'):
|
|
generator.score.validate_generated_tree(root, trusted)
|
|
page.write_bytes(original)
|
|
with unittest.mock.patch('generator.engraving.validation.check_content', wraps=generator.engraving.validation.check_content) as decode:
|
|
generator.score.validate_generated_tree(root)
|
|
decode.assert_called_once()
|
|
PIL.Image.new('RGB', (976, 484), 'white').save(page)
|
|
path = root / 'songs/2.json'
|
|
bundle = json.loads(path.read_text())
|
|
bundle['presentation']['pages'][0]['sha256'] = hashlib.sha256(page.read_bytes()).hexdigest()
|
|
path.write_text(json.dumps(bundle))
|
|
with self.assertRaisesRegex(ValueError, 'blank page'):
|
|
generator.score.validate_generated_tree(root, trusted)
|
|
def test_python_engraver_visual_golden_and_determinism(self):
|
|
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = pathlib.Path(temporary)
|
|
before = None
|
|
for _ in range(2):
|
|
generator.score.write_json(root / 'fixture.json', document)
|
|
generator.engraving.pipeline.engrave_tree(root, pathlib.Path('package/assets/fonts/petaluma'))
|
|
bundle = json.loads((root / 'fixture.json').read_text())
|
|
self.assertEqual(bundle['music'], document)
|
|
self.assertEqual(len(bundle['presentation']['pages']), 1)
|
|
page = root / bundle['presentation']['pages'][0]['path']
|
|
with PIL.Image.open(page) as actual, PIL.Image.open('tests/fixtures/engraving-python.png') as expected:
|
|
self.assertIsNone(PIL.ImageChops.difference(actual.convert('RGB'), expected.convert('RGB')).getbbox())
|
|
if before is not None:
|
|
self.assertEqual(bundle, before)
|
|
before = bundle
|