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 generator.theme 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') shutil.copytree(pathlib.Path('data/sequences/2_4'), data / 'sequences/2_4') 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('')) 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='other'))) 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'1') content = half('start') + half('stop') + '4' content += tests.test_musicxml.xml_note('C', extra='2') 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='')) text = text.replace('', '' + tests.test_musicxml.xml_note(extra='') + 'light-heavy') 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='quarter-sharp'))) 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=''))) 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() + '4' + tests.test_musicxml.xml_note('E'))) def test_multiple_lyric_verses_and_elisions_round_trip(self): lyrics = 'beginone two' lyrics += 'singlethree' 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 = 'C54wholex' source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(percussion, attributes='percussion')) 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='TAB')) 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('C54whole', attributes='percussion')) definition = tree.find('part-list/score-part') definition.append(xml.etree.ElementTree.fromstring('Snaredrum.snare-drumKit')) definition.append(xml.etree.ElementTree.fromstring('1039')) 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 = '32' 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_python.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), generator.theme.rgb('background')).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