+164
-132
@@ -1,191 +1,200 @@
|
||||
import copy
|
||||
import argparse
|
||||
from fractions import Fraction
|
||||
import copy
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.etree import ElementTree
|
||||
import unittest.mock
|
||||
import xml.etree.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
|
||||
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 generator.practice.generate
|
||||
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 = Path(temporary)
|
||||
root = pathlib.Path(temporary)
|
||||
data, output = root / 'data', root / 'output'
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
(data / 'practice').mkdir()
|
||||
source = data / 'practice/0001.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)
|
||||
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')))
|
||||
legacy_source = source.with_name('001.xml')
|
||||
source.rename(legacy_source)
|
||||
with self.assertRaisesRegex(ValueError, 'four-digit'):
|
||||
generate_assets(args)
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
legacy_source.rename(source)
|
||||
(data / 'rhythms' / 'drafts').mkdir(parents=True)
|
||||
for path in ('rhythms/0001.xml', 'rhythms/drafts/0002.xml'):
|
||||
(data / path).write_text('<unfinished')
|
||||
generate_assets(args)
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
misplaced = data / '0001.xml'
|
||||
misplaced.write_text(xml_score(xml_note()))
|
||||
misplaced.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
with self.assertRaisesRegex(ValueError, 'belong directly under'):
|
||||
generate_assets(args)
|
||||
generator.__main__.generate_assets(args)
|
||||
misplaced.unlink()
|
||||
with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')):
|
||||
with unittest.mock.patch('generator.playback.compile_tree', side_effect=ValueError('invalid generated audio')):
|
||||
with self.assertRaisesRegex(ValueError, 'audio'):
|
||||
generate_assets(args)
|
||||
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(xml_score(xml_note() + '<barline><repeat direction="backward"/></barline>'))
|
||||
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'):
|
||||
generate_assets(args)
|
||||
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(xml_score(xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
|
||||
with self.assertRaisesRegex(EngravingError, 'unavailableGlyph'):
|
||||
generate_assets(args)
|
||||
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(xml_score(xml_note()))
|
||||
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
renamed = data / 'practice/0127.xml'
|
||||
source.rename(renamed)
|
||||
source = renamed
|
||||
legacy_output = output / 'practice/001'
|
||||
legacy_output.mkdir()
|
||||
(legacy_output / 'electric.json').write_text('{}')
|
||||
generate_assets(args)
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertFalse(legacy_output.exists())
|
||||
self.assertFalse((output / 'practice/0001').exists())
|
||||
self.assertEqual(len(list((output / 'practice/0127').glob('*.json'))), 3)
|
||||
source.unlink()
|
||||
generate_assets(args)
|
||||
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 = Path(temporary) / 'roundtrip.xml'
|
||||
write_musicxml(path, document)
|
||||
return load_musicxml(path)
|
||||
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 = parse(xml_score(xml_note()))
|
||||
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):
|
||||
validate_document(malformed)
|
||||
generator.model.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'<voice>1</voice><tie type="{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 += xml_note('C', extra='<voice>2</voice>')
|
||||
source = parse(xml_score(content))
|
||||
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, concert_document(source), arrange_document(source, 'vocal')):
|
||||
validate_document(document)
|
||||
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)
|
||||
validate_document(restored)
|
||||
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):
|
||||
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='<tie type="start"/>'))
|
||||
text = text.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/>') + '<barline><bar-style>light-heavy</bar-style></barline></measure></part>')
|
||||
source = parse(text)
|
||||
index = ScoreIndex(source)
|
||||
fonts = Fonts(Path('package/assets/fonts/petaluma'))
|
||||
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 = make_system(index, start, end, fonts, Style())
|
||||
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([fraction(e['duration']) for e in compile_timeline(source)['events']], [8])
|
||||
self.assertEqual([generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(source)['events']], [8])
|
||||
restored = self.round_trip(source)
|
||||
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
||||
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 = parse(xml_score(xml_note(alter='0.5', extra='<accidental>quarter-sharp</accidental>')))
|
||||
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 = concert_document(source)
|
||||
self.assertEqual(pitch_number(notes(transposed)[0]['pitches'][0]), Fraction(125, 2))
|
||||
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(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.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 = 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}]
|
||||
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 = parse(xml_score(xml_note() + xml_note('E', 2, 'half', prefix='<chord/>')))
|
||||
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(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
||||
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
|
||||
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'):
|
||||
parse(xml_score(xml_note() + '<backup><duration>4</duration></backup>' + xml_note('E')))
|
||||
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 = parse(xml_score(xml_note(extra=lyrics)))
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra=lyrics)))
|
||||
restored = self.round_trip(source)
|
||||
self.assertEqual(notes(restored)[0]['lyrics'], notes(source)[0]['lyrics'])
|
||||
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 = parse(xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
|
||||
restored = self.round_trip(source)
|
||||
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
||||
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
|
||||
with self.assertRaisesRegex(ValueError, 'authored TAB'):
|
||||
parse(xml_score(xml_note(), attributes='<clef><sign>TAB</sign></clef>'))
|
||||
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'):
|
||||
practice_document(source, part)
|
||||
generator.practice.generate.practice_document(source, part, rhythms=tests.rhythm_fixtures.library())
|
||||
with self.assertRaisesRegex(ValueError, 'pitched'):
|
||||
arrange_document(source, part)
|
||||
generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
|
||||
def test_percussion_instrument_definitions_round_trip_and_are_audited(self):
|
||||
tree = ElementTree.fromstring(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>'))
|
||||
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(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(ElementTree.fromstring('<midi-instrument id="drum"><midi-channel>10</midi-channel><midi-unpitched>39</midi-unpitched></midi-instrument>'))
|
||||
source = parse(ElementTree.tostring(tree, encoding='unicode'))
|
||||
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 = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
||||
write_musicxml(before, source)
|
||||
write_musicxml(after, changed)
|
||||
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'):
|
||||
audit_roundtrip(before, after)
|
||||
tests.musicxml_roundtrip.audit_roundtrip(before, after)
|
||||
source['staves'][0]['instruments'] = []
|
||||
with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
|
||||
validate_document(source)
|
||||
generator.model.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')):
|
||||
for path in sorted(path for category in ('practice', 'songs') for path in (pathlib.Path('data') / category).glob('*.xml')):
|
||||
with self.subTest(path=path):
|
||||
source = load_musicxml(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))
|
||||
@@ -195,95 +204,118 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
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()))
|
||||
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(EngravingError, 'cannot load bundled'):
|
||||
engrave_tree(root, root / 'missing-fonts')
|
||||
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):
|
||||
from PIL import Image
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / 'page.png'
|
||||
path = pathlib.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)
|
||||
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 = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
||||
source.write_text(xml_score(xml_note()))
|
||||
output.write_text(xml_score(xml_note('D')))
|
||||
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'):
|
||||
audit_roundtrip(source, output)
|
||||
tests.musicxml_roundtrip.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()))
|
||||
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
|
||||
write_musicxml(source, original)
|
||||
write_musicxml(output, document)
|
||||
generator.musicxml_writer.write_musicxml(source, original)
|
||||
generator.musicxml_writer.write_musicxml(output, document)
|
||||
with self.assertRaisesRegex(ValueError, 'engraver changed musical content'):
|
||||
audit_roundtrip(source, output)
|
||||
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 = 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 = 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 notes(source)[:3]:
|
||||
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=notes(changed)[3]['id'], end=notes(changed)[5]['id'])
|
||||
for index, note in enumerate(notes(changed)):
|
||||
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 = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
||||
write_musicxml(before, source)
|
||||
write_musicxml(after, changed)
|
||||
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'):
|
||||
audit_roundtrip(before, after)
|
||||
tests.musicxml_roundtrip.audit_roundtrip(before, after)
|
||||
def test_bundle_validation_checks_pages_and_rejects_symlink_escape(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
root = pathlib.Path(temporary)
|
||||
assets = root / 'assets'
|
||||
assets.mkdir()
|
||||
data = Path('tests/fixtures/engraving.png').read_bytes()
|
||||
data = pathlib.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()}]}}
|
||||
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(load_json(path), document)
|
||||
self.assertEqual(generator.score.load_json(path), document)
|
||||
page.write_bytes(data + b'corrupt')
|
||||
with self.assertRaisesRegex(ValueError, 'checksum'):
|
||||
load_json(path)
|
||||
generator.score.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'))
|
||||
generator.score.load_json(path)
|
||||
def test_final_validation_reuses_content_checks_and_rechecks_files(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(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 / 'practice' / 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 / 'practice/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 / 'practice/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):
|
||||
write_json(root / 'fixture.json', document)
|
||||
engrave_tree(root, Path('package/assets/fonts/petaluma'))
|
||||
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 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())
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user