Engraver
ci / checks (push) Successful in 45m44s

This commit is contained in:
khodowany
2026-09-16 18:17:42 -05:00
parent 1d1bd3e871
commit 7a83b5435f
29 changed files with 13678 additions and 322 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+92
View File
@@ -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')
+1 -1
View File
@@ -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')
+177
View File
@@ -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
View File
@@ -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)