Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from generator import harmony
|
||||
from generator.musicxml import load_musicxml
|
||||
|
||||
def same(left, right, location: str) -> None:
|
||||
if left != right:
|
||||
raise ValueError(f'{location}: engraver changed musical content ({left!r} -> {right!r})')
|
||||
|
||||
def event_positions(staff: dict) -> dict:
|
||||
return {event['id']: (mi, vi, ni) for mi, measure in enumerate(staff['measures']) for vi, voice in enumerate(measure['voices']) for ni, event in enumerate(voice['notes'])}
|
||||
|
||||
def tuplet_members(staff: dict, identifier: str) -> list:
|
||||
positions = event_positions(staff)
|
||||
return [positions[event['id']] for measure in staff['measures'] for voice in measure['voices'] for event in voice['notes'] if identifier in event.get('tuplet_ids', [])]
|
||||
|
||||
def audit_roundtrip(source: Path, output: Path) -> None:
|
||||
before = load_musicxml(source)['staves'][0]
|
||||
after = load_musicxml(output)['staves'][0]
|
||||
before_positions, after_positions = event_positions(before), event_positions(after)
|
||||
before_instruments = {i['id']: i for i in before.get('instruments', [])}
|
||||
after_instruments = {i['id']: i for i in after.get('instruments', [])}
|
||||
same(len(before['measures']), len(after['measures']), 'measure count')
|
||||
for original, rendered in zip(before['measures'], after['measures']):
|
||||
location = original['id']
|
||||
for field in ('duration', 'key', 'meter', 'clef', 'transpose', 'attributes'):
|
||||
same(original[field], rendered[field], f'{location} {field}')
|
||||
same({'capo': 0, **original['staff_details']}, {'capo': 0, **rendered['staff_details']}, f'{location} staff details')
|
||||
for side, style in original.get('barlines', {}).items():
|
||||
same(style, rendered.get('barlines', {}).get(side, 'regular'), f'{location} {side} barline')
|
||||
same(original.get('tempos', []), rendered.get('tempos', []), f'{location} tempos')
|
||||
same(original.get('section_label', ''), rendered.get('section_label', ''), f'{location} section label')
|
||||
same(len(original['harmonies']), len(rendered['harmonies']), f'{location} harmony count')
|
||||
for a, b in zip(original['harmonies'], rendered['harmonies']):
|
||||
for field in ('root', 'bass', 'onset'):
|
||||
same(a.get(field), b.get(field), f'{location} harmony {field}')
|
||||
same(harmony.degrees(a), harmony.degrees(b), f'{location} harmony degrees')
|
||||
same(len(original['voices']), len(rendered['voices']), f'{location} voice count')
|
||||
for first, second in zip(original['voices'], rendered['voices']):
|
||||
same(len(first['notes']), len(second['notes']), f'{location} voice {first["id"]} event count')
|
||||
for a, b in zip(first['notes'], second['notes']):
|
||||
event_location = f'{location}, voice {first["id"]}, {a["id"]}'
|
||||
for field in ('kind', 'onset', 'duration', 'written', 'hidden'):
|
||||
same(a[field], b[field], f'{event_location} {field}')
|
||||
for field in ('grace', 'cue', 'measure_rest', 'rest_position', 'lyrics'):
|
||||
same(a.get(field), b.get(field), f'{event_location} {field}')
|
||||
for field in ('stem', 'beams', 'notehead'):
|
||||
if field in a:
|
||||
same(a[field], b.get(field), f'{event_location} explicit {field}')
|
||||
same(len(a['pitches']), len(b['pitches']), f'{event_location} chord tone count')
|
||||
for p, q in zip(a['pitches'], b['pitches']):
|
||||
if p.get('instrument') and 'unpitched' in p:
|
||||
first_instrument = before_instruments[p['instrument']]
|
||||
second_instrument = after_instruments.get(q.get('instrument'), {})
|
||||
same(first_instrument.get('midi', {}).get('midi-unpitched'), second_instrument.get('midi', {}).get('midi-unpitched'), f'{event_location} percussion instrument')
|
||||
for field in ('letter', 'alter', 'octave', 'duration', 'written', 'unpitched', 'tablature', 'notehead', 'cue'):
|
||||
same(p.get(field), q.get(field), f'{event_location} pitch {field}')
|
||||
for field in ('stem', 'print'):
|
||||
if field in p:
|
||||
same(p[field], q.get(field), f'{event_location} pitch {field}')
|
||||
for field in ('tie_from', 'tie_to'):
|
||||
same(bool(p.get(field)), bool(q.get(field)), f'{event_location} {field}')
|
||||
if display := p.get('accidental_display'):
|
||||
other = q.get('accidental_display', {})
|
||||
same(display.get('name'), other.get('name'), f'{event_location} accidental')
|
||||
for field in ('parentheses', 'bracket'):
|
||||
same(display.get(field, 'no'), other.get(field, 'no'), f'{event_location} accidental {field}')
|
||||
for field in ('cautionary', 'editorial'):
|
||||
if field in display:
|
||||
same(display[field], other.get(field, 'no'), f'{event_location} accidental {field}')
|
||||
if tremolo := a.get('tremolo'):
|
||||
other = b.get('tremolo', {})
|
||||
for field in ('type', 'marks'):
|
||||
same(tremolo[field], other.get(field), f'{event_location} tremolo {field}')
|
||||
if 'partner' in tremolo:
|
||||
same(before_positions.get(tremolo['partner']), after_positions.get(other.get('partner')), f'{event_location} tremolo partner')
|
||||
same(len(before.get('tuplets', [])), len(after.get('tuplets', [])), 'tuplet count')
|
||||
for a, b in zip(before.get('tuplets', []), after.get('tuplets', [])):
|
||||
for field in ('note_count', 'space_count', 'depth', 'show_number', 'show_type'):
|
||||
same(a.get(field), b.get(field), f'{a["id"]} {field}')
|
||||
if a['bracket'] != 'auto':
|
||||
same(a['bracket'], b['bracket'], f'{a["id"]} bracket')
|
||||
same(tuplet_members(before, a['id']), tuplet_members(after, b['id']), f'{a["id"]} members')
|
||||
for endpoint in ('start', 'end'):
|
||||
same(before_positions[a[endpoint]], after_positions[b[endpoint]], f'{a["id"]} {endpoint}')
|
||||
for field in ('tuplet_actual', 'tuplet_normal'):
|
||||
if a.get(field, {}).get('type'):
|
||||
same(a[field], b.get(field), f'{a["id"]} {field}')
|
||||
same([{k: v for k, v in s.items() if k != 'id'} for s in before.get('octave_shifts', [])],
|
||||
[{k: v for k, v in s.items() if k != 'id'} for s in after.get('octave_shifts', [])], 'octave spans')
|
||||
@@ -12,7 +12,7 @@ class BuildBootstrapTests(unittest.TestCase):
|
||||
spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py')
|
||||
bootstrap = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bootstrap)
|
||||
digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes() + (root / 'tools/install_engraver.py').read_bytes()).hexdigest()
|
||||
digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes()).hexdigest()
|
||||
self.assertEqual(bootstrap.build_requirements_digest(), digest)
|
||||
self.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile())
|
||||
package = types.ModuleType('modulus')
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import copy
|
||||
from dataclasses import replace
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from generator.engraving import EngravingError, Fonts, Style, layout_score, rasterize_page
|
||||
from generator.engraving.audit import audit_layout
|
||||
from generator.engraving.notation import ScoreIndex
|
||||
from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, rational
|
||||
from generator.musicxml import load_musicxml
|
||||
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||
|
||||
class EngravingTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.fonts = Fonts(Path('package/assets/fonts/petaluma'))
|
||||
def layout(self, source, style=None):
|
||||
before = copy.deepcopy(source)
|
||||
layout = layout_score(source, style or Style(), self.fonts)
|
||||
self.assertEqual(source, before)
|
||||
return layout
|
||||
def primitives(self, layout, role):
|
||||
return [p for page in layout.pages for p in page.primitives if p.role == role]
|
||||
def test_notation_fixture_visual_golden(self):
|
||||
from PIL import Image, ImageChops
|
||||
layout = self.layout(load_musicxml(Path('tests/fixtures/notation.xml')))
|
||||
self.assertEqual(len(layout.pages), 1)
|
||||
actual = rasterize_page(layout.pages[0], self.fonts)
|
||||
with Image.open('tests/fixtures/notation-python.png') as expected:
|
||||
self.assertIsNone(ImageChops.difference(actual, expected.convert('RGB')).getbbox())
|
||||
def test_every_written_note_and_rest_value(self):
|
||||
for kind in NOTE_TYPES:
|
||||
for dots in (0, 1, 3):
|
||||
duration = TYPE_LENGTHS[kind] * (2 - Fraction(1, 2 ** dots))
|
||||
for rest in (False, True):
|
||||
with self.subTest(kind=kind, dots=dots, rest=rest):
|
||||
content = xml_note(duration=duration.numerator, kind=kind, extra='<dot/>' * dots)
|
||||
if rest:
|
||||
content = f'<note><rest/><duration>{duration.numerator}</duration><type>{kind}</type>' + '<dot/>' * dots + '</note>'
|
||||
source = parse(xml_score(content, divisions=duration.denominator, implicit=True))
|
||||
layout = self.layout(source)
|
||||
self.assertEqual(len(self.primitives(layout, 'dot')), dots)
|
||||
self.assertEqual(len(self.primitives(layout, 'rest' if rest else 'head')), 1)
|
||||
def test_sixteen_dots_are_not_truncated(self):
|
||||
duration = TYPE_LENGTHS['quarter'] * (2 - Fraction(1, 2 ** 16))
|
||||
source = parse(xml_score(xml_note(duration=duration.numerator, kind='quarter', extra='<dot/>' * 16), divisions=duration.denominator, implicit=True))
|
||||
self.assertEqual(len(self.primitives(self.layout(source), 'dot')), 16)
|
||||
def test_arbitrary_voice_ids_and_more_than_four_voices(self):
|
||||
content = ''
|
||||
for i in range(6):
|
||||
if i:
|
||||
content += '<backup><duration>4</duration></backup>'
|
||||
content += xml_note(extra=f'<voice>part-{i}</voice>')
|
||||
source = parse(xml_score(content))
|
||||
self.assertEqual(len(self.primitives(self.layout(source), 'head')), 6)
|
||||
def test_microtonal_numeric_fallback_and_explicit_missing_glyph(self):
|
||||
source = parse(xml_score(xml_note(alter='0.1')))
|
||||
self.assertEqual(self.primitives(self.layout(source), 'accidental')[0].value, '(+1/10 st)')
|
||||
notes(source)[0]['pitches'][0]['accidental_display'] = {'name': 'other', 'smufl': 'missingVampGlyph'}
|
||||
with self.assertRaisesRegex(EngravingError, 'measure 1, voice 1.*missingVampGlyph'):
|
||||
self.layout(source)
|
||||
def test_accidentals_are_shared_by_simultaneous_voices_without_losing_conflicts(self):
|
||||
content = xml_note(duration=1, kind='quarter', alter=1, extra='<voice>a</voice>')
|
||||
content += xml_note(duration=1, kind='quarter', alter=1, extra='<voice>a</voice>')
|
||||
content += xml_note(duration=2, kind='half', extra='<voice>a</voice>')
|
||||
source = parse(xml_score(content))
|
||||
self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp', 'accidentalNatural'])
|
||||
conflict = parse(xml_score(xml_note(alter=1, extra='<voice>a</voice>') + '<backup><duration>4</duration></backup>' + xml_note(extra='<voice>b</voice>')))
|
||||
self.assertEqual(len(self.primitives(self.layout(conflict), 'accidental')), 2)
|
||||
def test_dense_measure_splits_exactly_without_modifying_music(self):
|
||||
content = ''.join(xml_note('G', 1, '64th', extra='<voice>fast</voice>') for _ in range(64))
|
||||
content += '<backup><duration>64</duration></backup>' + xml_note('C', 64, 'whole', extra='<voice>held</voice>')
|
||||
source = parse(xml_score(content, divisions=16))
|
||||
layout = self.layout(source)
|
||||
systems = [s for p in layout.pages for s in p.systems]
|
||||
self.assertGreater(len(systems), 1)
|
||||
held = source['staves'][0]['measures'][0]['voices'][1]['notes'][0]['pitches'][0]['id']
|
||||
pieces = [f for s in systems for f in s.fragments if f.source == held]
|
||||
self.assertEqual(sum(f.length() for f in pieces), 4)
|
||||
self.assertGreater(len(pieces), 1)
|
||||
self.assertTrue(self.primitives(layout, 'tie'))
|
||||
for page in layout.pages:
|
||||
self.assertEqual(rasterize_page(page, self.fonts).size, (976, 484))
|
||||
def test_hidden_pitch_does_not_suppress_a_visible_accidental(self):
|
||||
source = parse(xml_score(xml_note(duration=2, kind='half', alter=1) + xml_note(duration=2, kind='half', alter=1)))
|
||||
notes(source)[0]['hidden'] = True
|
||||
self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp'])
|
||||
def test_octave_shift_changes_display_not_pitch(self):
|
||||
source = parse(xml_score(xml_note(octave=5)))
|
||||
original = ScoreIndex(source).pitch_position(notes(source)[0]['pitches'][0], Fraction())
|
||||
source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': rational(0), 'end': rational(4), 'octaves': -1}]
|
||||
index = ScoreIndex(source)
|
||||
self.assertEqual(index.pitch_position(notes(source)[0]['pitches'][0], Fraction()), original - 7)
|
||||
self.assertTrue(any(p.value == '8va' for p in self.primitives(self.layout(source), 'octave-shift')))
|
||||
def test_wide_pitch_range_can_break_inside_a_measure_vertically(self):
|
||||
source = parse(xml_score(xml_note(duration=2, kind='half', octave=10) + xml_note(duration=2, kind='half', octave=-1)))
|
||||
layout = self.layout(source)
|
||||
self.assertEqual(len(layout.pages), 2)
|
||||
self.assertEqual(len(self.primitives(layout, 'head')), 2)
|
||||
def test_cue_grace_and_tremolo_are_drawn(self):
|
||||
grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
|
||||
source = parse(xml_score(grace + xml_note(duration=4, kind='whole', prefix='<cue/>')))
|
||||
layout = self.layout(source)
|
||||
self.assertEqual(len(self.primitives(layout, 'head')), 2)
|
||||
self.assertEqual(len(self.primitives(layout, 'grace-slash')), 1)
|
||||
source = parse(xml_score(xml_note(duration=2, kind='half', extra='<notations><ornaments><tremolo type="single">8</tremolo></ornaments></notations>'), implicit=True))
|
||||
self.assertEqual(len(self.primitives(self.layout(source), 'tremolo')), 8)
|
||||
def test_percussion_and_tab_use_explicit_display_positions(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>'))
|
||||
self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole')
|
||||
tab = parse(xml_score(xml_note(extra='<notations><technical><string>2</string><fret>1</fret></technical></notations>'), attributes='<clef><sign>TAB</sign><line>5</line></clef><staff-details><staff-lines>6</staff-lines></staff-details>'))
|
||||
self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '1')
|
||||
def test_lyrics_hyphens_and_extenders(self):
|
||||
begin = '<lyric number="1"><syllabic>begin</syllabic><text>hel</text></lyric>'
|
||||
end = '<lyric number="1"><syllabic>end</syllabic><text>lo</text><extend type="start"/></lyric>'
|
||||
source = parse(xml_score(xml_note(duration=1, kind='quarter', extra=begin) + xml_note(duration=1, kind='quarter', extra=end) + xml_note(duration=2, kind='half')))
|
||||
layout = self.layout(source)
|
||||
self.assertTrue(self.primitives(layout, 'lyric-hyphen'))
|
||||
self.assertTrue(self.primitives(layout, 'lyric-extension'))
|
||||
def test_nested_and_cross_bar_tuplets_keep_span_ownership(self):
|
||||
from xml.etree import ElementTree
|
||||
modulation = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
||||
content = ''.join(xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6))
|
||||
tree = ElementTree.fromstring(xml_score(content, divisions=3))
|
||||
second = ElementTree.SubElement(tree.find('part'), 'measure', number='2')
|
||||
for element in ElementTree.fromstring(xml_score(content, divisions=3)).findall('part/measure/note'):
|
||||
second.append(element)
|
||||
source = parse(ElementTree.tostring(tree, encoding='unicode'))
|
||||
events = notes(source) + notes(source, 1)
|
||||
spans = [{'id': 'outer', 'voice': '1', 'start': events[0]['id'], 'end': events[-1]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'yes', 'show_number': 'both', 'show_type': 'both', 'line_shape': 'curved'}, {'id': 'inner', 'voice': '1', 'start': events[1]['id'], 'end': events[3]['id'], 'note_count': 3, 'space_count': 2, 'depth': 1, 'bracket': 'yes', 'show_number': 'actual'}]
|
||||
source['staves'][0]['tuplets'] = spans
|
||||
for i, event in enumerate(events):
|
||||
event['tuplet_ids'] = ['outer'] + (['inner'] if 1 <= i <= 3 else [])
|
||||
layout = self.layout(source, Style(width=600))
|
||||
self.assertGreater(len([s for p in layout.pages for s in p.systems]), 1)
|
||||
self.assertEqual({p.sources[0] for p in self.primitives(layout, 'tuplet-bracket')}, {'outer', 'inner'})
|
||||
def test_paired_tremolo_and_double_stems(self):
|
||||
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
|
||||
content = ''.join(xml_note(step, 2, 'whole', extra=modulation + f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>') for step, action in [('C', 'start'), ('G', 'stop')])
|
||||
self.assertEqual(len(self.primitives(self.layout(parse(xml_score(content))), 'tremolo')), 3)
|
||||
source = parse(xml_score(xml_note(duration=1, kind='eighth', extra='<stem>double</stem>'), divisions=2, implicit=True))
|
||||
layout = self.layout(source)
|
||||
self.assertEqual(len(self.primitives(layout, 'stem')), 2)
|
||||
self.assertEqual(len(self.primitives(layout, 'flag')), 2)
|
||||
def test_meter_symbols_and_timed_attributes(self):
|
||||
for symbol in ('common', 'cut', 'single-number', 'note', 'dotted-note', 'normal'):
|
||||
source = parse(xml_score(xml_note()).replace('<time>', f'<time symbol="{symbol}">'))
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertTrue(self.primitives(self.layout(source), 'meter'))
|
||||
change = '<attributes><key><fifths>-3</fifths></key><clef><sign>C</sign><line>3</line></clef></attributes>'
|
||||
source = parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half')))
|
||||
layout = self.layout(source)
|
||||
self.assertEqual([p.value for p in self.primitives(layout, 'clef')], ['gClef', 'cClef'])
|
||||
def test_lyric_extender_continues_on_systems_without_syllables(self):
|
||||
lyrics = '<lyric number="1"><text>Ah</text><extend type="start"/></lyric>'
|
||||
content = ''.join(xml_note('G', 1, '64th', extra=lyrics if i == 0 else '') for i in range(64))
|
||||
source = parse(xml_score(content, divisions=16))
|
||||
layout = self.layout(source)
|
||||
systems = [s for p in layout.pages for s in p.systems]
|
||||
self.assertGreater(len(systems), 1)
|
||||
self.assertTrue(all(any(p.role == 'lyric-extension' for p in s.primitives) for s in systems))
|
||||
def test_audit_catches_removed_pitch_and_accidental_primitives(self):
|
||||
source = parse(xml_score(xml_note(alter=1)))
|
||||
layout = self.layout(source)
|
||||
for role in ('head', 'accidental'):
|
||||
pages = tuple(replace(p, primitives=tuple(g for g in p.primitives if g.role != role)) for p in layout.pages)
|
||||
with self.assertRaisesRegex(EngravingError, 'no drawing primitive|accidental was omitted'):
|
||||
audit_layout(source, replace(layout, pages=pages), self.fonts)
|
||||
def test_fingerprint_includes_style_and_fixed_overflow_errors(self):
|
||||
source = parse(xml_score(xml_note()))
|
||||
a = self.layout(source)
|
||||
b = self.layout(source, Style(column_gap=14))
|
||||
self.assertNotEqual(a.fingerprint, b.fingerprint)
|
||||
with self.assertRaisesRegex(EngravingError, 'page width'):
|
||||
self.layout(source, Style(width=100))
|
||||
+38
-16
@@ -1,17 +1,17 @@
|
||||
import copy
|
||||
import argparse
|
||||
from fractions import Fraction
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
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.engraving import EngravingError, check_page, engrave_tree, qualify
|
||||
from generator.engraving.audit import audit_roundtrip
|
||||
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
|
||||
@@ -21,6 +21,29 @@ 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='<accidental smufl="unavailableGlyph">other</accidental>')))
|
||||
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.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'
|
||||
@@ -48,8 +71,8 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
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))
|
||||
with self.assertRaisesRegex(EngravingError, 'microtonal'):
|
||||
qualify(unqualified)
|
||||
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()))
|
||||
@@ -61,8 +84,9 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
source = parse(xml_score(xml_note() + xml_note('E', 2, 'half', prefix='<chord/>')))
|
||||
restored = self.round_trip(source)
|
||||
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
||||
with self.assertRaisesRegex(EngravingError, 'independently sustained'):
|
||||
qualify(source)
|
||||
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() + '<backup><duration>4</duration></backup>' + xml_note('E')))
|
||||
@@ -113,15 +137,14 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
self.assertEqual(original['duration'], result['duration'])
|
||||
for v, w in zip(original['voices'], result['voices']):
|
||||
self.assertEqual(v['notes'], w['notes'])
|
||||
def test_wrong_engine_version_does_not_produce_a_bundle(self):
|
||||
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 patch.dict(os.environ, {'VAMP_MUSESCORE': '/engine'}), patch('generator.engraving.run_engine', return_value='MuseScore 3.6'):
|
||||
with self.assertRaisesRegex(EngravingError, 'expected MuseScore'):
|
||||
engrave_tree(root, Path('package/assets/fonts/petaluma'))
|
||||
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
|
||||
@@ -176,7 +199,7 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
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': 'MuseScore Studio', 'engine_version': '4.7.4', 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
|
||||
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)
|
||||
@@ -189,8 +212,7 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
page.symlink_to(outside)
|
||||
with self.assertRaisesRegex(ValueError, 'escapes'):
|
||||
load_json(path)
|
||||
@unittest.skipUnless(os.environ.get('VAMP_MUSESCORE'), 'pinned engraver integration runs in the managed build image')
|
||||
def test_pinned_engine_visual_golden_and_determinism(self):
|
||||
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'))
|
||||
@@ -204,7 +226,7 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
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.png') as expected:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user