import argparse import json import unittest from pathlib import Path import tempfile from unittest.mock import patch from generator.model import fraction, validate_document from generator.musicxml import MusicXMLError from generator.playback import compile_timeline from generator.repeats import expand_document, performed_order from tests.test_musicxml import parse, xml_note, xml_score def score_bars(bars): return xml_score(bars[0]).replace('', '' + ''.join(f'{bar}' for i, bar in enumerate(bars[1:])) + '') def boundary(direction, times=None): side = 'left' if direction == 'forward' else 'right' count = f' times="{times}"' if times else '' return f'' def ending(numbers, action, repeat=False): side = 'left' if action == 'start' else 'right' return f'' + ('' if repeat else '') + '' class RepeatTests(unittest.TestCase): def test_repeat_count_and_implicit_start(self): source = parse(score_bars([xml_note(), xml_note('D') + boundary('backward', 3)])) self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1] * 3) expanded = expand_document(source) validate_document(expanded) self.assertEqual(len(compile_timeline(source)['events']), 6) def test_combined_endings(self): source = parse(score_bars([boundary('forward') + xml_note(), ending('1,2', 'start') + xml_note('D') + ending('1,2', 'stop', True), ending('3', 'start') + xml_note('E') + ending('3', 'discontinue')])) self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 0, 1, 0, 2]) self.assertIn('endings', source['staves'][0]['measures'][1]) self.assertNotIn('endings', expand_document(source)['staves'][0]['measures'][1]) def test_nested_repeat(self): source = parse(score_bars([boundary('forward') + xml_note(), boundary('forward') + xml_note('D') + boundary('backward'), xml_note('E') + boundary('backward')])) self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 1, 2] * 2) def test_sequential_and_expansion_limit(self): source = parse(score_bars([xml_note() + boundary('backward'), boundary('forward') + xml_note('D') + boundary('backward', 3)])) self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 0, 1, 1, 1]) with self.assertRaisesRegex(MusicXMLError, '10000'): parse(score_bars([boundary('forward') + xml_note(), xml_note() + boundary('backward', 10000)])) def test_bad_endings_and_open_repeat(self): for content in (boundary('forward') + xml_note(), ending('1', 'start') + xml_note(), ending('1', 'start') + xml_note() + ending('1', 'stop')): with self.assertRaises(MusicXMLError): parse(xml_score(content)) def test_ties_reidentify_each_pass(self): source = parse(score_bars([boundary('forward') + xml_note(extra=''), xml_note(extra='') + boundary('backward')])) validate_document(expand_document(source)) self.assertEqual(len(compile_timeline(source)['events']), 2) def test_ties_across_repeat_jump_and_unreachable_pass(self): first = xml_note(extra='') last = xml_note(extra='') source = parse(score_bars([boundary('forward') + first, last + boundary('backward')])) self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [4, 8, 4]) for bad in (first.replace('time-only="2"', ''), first.replace('time-only="2"', 'time-only="3"')): with self.assertRaisesRegex(MusicXMLError, 'tie'): parse(score_bars([boundary('forward') + bad, last + boundary('backward')])) def test_printed_tie_does_not_create_sound_tie(self): source = parse(score_bars([boundary('forward') + xml_note(extra=''), xml_note(extra='') + boundary('backward')])) self.assertEqual(len(compile_timeline(source)['events']), 4) def test_ties_into_both_endings(self): source = parse(score_bars([boundary('forward') + xml_note(extra=''), ending('1', 'start') + xml_note(extra='') + ending('1', 'stop', True), ending('2', 'start') + xml_note(extra='') + ending('2', 'discontinue')])) self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [8, 8]) def test_cross_measure_tuplets_and_tremolos_remap(self): extra = '32' bars = [boundary('forward'), ''] for index in range(3): bars[0] += xml_note(duration=4, kind='half', extra=extra + ('' if index == 0 else '')) bars[1] += xml_note(duration=4, kind='half', extra=extra + ('' if index == 2 else '')) bars[1] += boundary('backward') source = parse(score_bars(bars).replace('1', '3')) expanded = expand_document(source) validate_document(expanded) self.assertEqual(len(expanded['staves'][0]['tuplets']), 2) modulation = '21' bars = [xml_note(step, kind='breve', extra=modulation + f'3') for step, action in [('C', 'start'), ('G', 'stop')]] source = parse(score_bars([boundary('forward') + bars[0], bars[1] + boundary('backward')])) validate_document(expand_document(source)) self.assertEqual(len(compile_timeline(source)['events']), 128) def test_no_repeat_is_unchanged(self): source = parse(xml_score(xml_note())) self.assertEqual(source, expand_document(source)) def test_roundtrip_and_engraving(self): from generator.musicxml_writer import write_musicxml from generator.musicxml import load_musicxml from generator.engraving import Fonts, Style, layout_score source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')])) with tempfile.TemporaryDirectory() as directory: path = Path(directory) / 'repeat.xml' write_musicxml(path, source) restored = load_musicxml(path) self.assertEqual(performed_order(source['staves'][0]['measures']), performed_order(restored['staves'][0]['measures'])) layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma'))) items = [p for page in layout.pages for p in page.primitives] self.assertEqual(sum(p.role == 'repeat-dot' for p in items), 4) self.assertEqual([p.value for p in items if p.role == 'ending-label'], ['1.', '2.']) def test_printed_tie_continues_across_system_boundary(self): from generator.engraving import Fonts, Style from generator.engraving.layout import make_system from generator.engraving.notation import ScoreIndex source = parse(score_bars([boundary('forward') + xml_note(extra=''), xml_note(extra='') + boundary('backward')])) 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)) def test_build_pipeline_keeps_compact_practice_and_compiles_performed_order(self): from generator.__main__ import generate_assets from generator.arrange.generate import arrange_document text = score_bars([boundary('forward') + xml_note(), xml_note('D') + boundary('backward')]) source = parse(text) self.assertEqual(arrange_document(source, 'vocal'), arrange_document(expand_document(source), 'vocal')) with tempfile.TemporaryDirectory() as directory: root = Path(directory) data = root / 'data' (data / 'songs').mkdir(parents=True) (data / '001.xml').write_text(text) output = root / 'assets' args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False) generate_assets(args) bundle = json.loads((output / 'practice/001/c.json').read_text()) self.assertEqual(len(bundle['music']['staves'][0]['measures']), 2) self.assertEqual(len(bundle['playback']['events']), 4) self.assertEqual(fraction(bundle['playback']['duration']), 16) before = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()} generate_assets(args) self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}) with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')): with self.assertRaisesRegex(ValueError, 'audio'): generate_assets(args) self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}) def test_alternate_ending_does_not_inherit_skipped_key_change(self): source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + '2' + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')])) expanded = expand_document(source) self.assertEqual([m['key']['fifths'] for m in expanded['staves'][0]['measures']], [0, 2, 0, 0])