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' lesson = data / 'practice' / 'chord' / 'lesson' lesson.mkdir(parents=True) (data / 'songs').mkdir() source = lesson / 'fixture.xml' source.write_text(xml_score(xml_note())) args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), clean=False, 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 = lesson.with_name('002_renamed_lesson') / '127-renamed-exercise.xml' renamed.parent.mkdir() source.rename(renamed) source = renamed generate_assets(args) self.assertFalse((output / 'practice/chord/lesson').exists()) self.assertEqual(len(list((output / 'practice/chord/002_renamed_lesson/127-renamed-exercise').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