import copy import argparse from fractions import Fraction import hashlib import json from pathlib import Path import tempfile import unittest from unittest.mock import patch from xml.etree import ElementTree from generator.arrange.generate import arrange_document from generator.__main__ import generate_assets from generator.engraving import ENGINE_NAME, 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.pitch import concert_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) (data / 'practice').mkdir() source = data / 'practice/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'))) (data / 'rhythms' / 'drafts').mkdir(parents=True) for path in ('rhythms/001.xml', 'rhythms/drafts/002.xml'): (data / path).write_text('')) with self.assertRaisesRegex(ValueError, 'write the music out'): 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 / 'practice/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'))), 3) 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_score_structure_is_validated_without_schema_labels(self): document = parse(xml_score(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): validate_document(malformed) 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, concert_document(source), 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_contiguous_ties_continue_across_systems_without_repeat_state(self): from generator.engraving.layout import make_system from generator.engraving.notation import ScoreIndex from generator.playback import compile_timeline text = xml_score(xml_note(extra='')) text = text.replace('', '' + xml_note(extra='') + 'light-heavy') source = parse(text) index = ScoreIndex(source) fonts = Fonts(Path('package/assets/fonts/petaluma')) for start, end in ((0, 4), (4, 8)): system = make_system(index, start, end, fonts, Style()) self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives)) self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [8]) restored = self.round_trip(source) self.assertEqual(notes(restored)[0]['pitches'], 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 = parse(xml_score(xml_note(alter='0.5', extra='quarter-sharp'))) source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0} transposed = concert_document(source) 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']) with self.assertRaisesRegex(ValueError, 'authored TAB'): parse(xml_score(xml_note(), attributes='TAB')) for part in ('vocal', 'acoustic', 'electric'): with self.assertRaisesRegex(ValueError, 'pitched'): practice_document(source, part) with self.assertRaisesRegex(ValueError, 'pitched'): arrange_document(source, 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_catalog_roundtrip_preserves_written_pitch_and_rhythm(self): for path in sorted(path for category in ('practice', 'songs') for path in (Path('data') / category).glob('*.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 = {'music': document, 'presentation': {'engine': 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(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'], document) 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