@@ -14,9 +14,6 @@ target_compile_definitions(product-vamp-test PRIVATE
|
||||
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
|
||||
)
|
||||
add_test(NAME product-vamp COMMAND product-vamp-test)
|
||||
add_executable(vamp-render-score EXCLUDE_FROM_ALL render_score.cpp)
|
||||
target_link_libraries(vamp-render-score PRIVATE vamp-zero-core)
|
||||
target_compile_features(vamp-render-score PRIVATE cxx_std_23)
|
||||
set_tests_properties(product-vamp PROPERTIES TIMEOUT 60)
|
||||
add_test(
|
||||
NAME product-vamp-generator
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<score-partwise version="4.0">
|
||||
<work><work-title>Engraving Qualification</work-title></work>
|
||||
<part-list><score-part id="P1"><part-name>Lead</part-name></score-part></part-list>
|
||||
<part id="P1">
|
||||
<measure number="1">
|
||||
<attributes>
|
||||
<divisions>6</divisions>
|
||||
<key><fifths>0</fifths></key>
|
||||
<time><beats>4</beats><beat-type>4</beat-type></time>
|
||||
<clef><sign>G</sign><line>2</line></clef>
|
||||
</attributes>
|
||||
<harmony><root><root-step>F</root-step></root><kind>major-seventh</kind></harmony>
|
||||
<harmony><root><root-step>G</root-step></root><kind>dominant</kind><offset>12</offset></harmony>
|
||||
<note>
|
||||
<pitch><step>C</step><alter>0.5</alter><octave>5</octave></pitch>
|
||||
<duration>12</duration><tie type="start"/><voice>1</voice><type>half</type>
|
||||
<accidental>quarter-sharp</accidental><stem>up</stem>
|
||||
<notations><tied type="start"/></notations>
|
||||
<lyric number="1"><syllabic>single</syllabic><text>Hold</text></lyric>
|
||||
</note>
|
||||
<note>
|
||||
<pitch><step>C</step><alter>0.5</alter><octave>5</octave></pitch>
|
||||
<duration>12</duration><tie type="stop"/><voice>1</voice><type>half</type><stem>up</stem>
|
||||
<notations><tied type="stop"/></notations>
|
||||
</note>
|
||||
<backup><duration>24</duration></backup>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>6</duration><voice>2</voice><type>quarter</type><stem>down</stem></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>6</duration><voice>2</voice><type>quarter</type><stem>down</stem></note>
|
||||
<note><pitch><step>B</step><octave>3</octave></pitch><duration>6</duration><voice>2</voice><type>quarter</type><stem>down</stem></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>6</duration><voice>2</voice><type>quarter</type><stem>down</stem></note>
|
||||
</measure>
|
||||
</part>
|
||||
</score-partwise>
|
||||
@@ -1,71 +0,0 @@
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <raylib.h>
|
||||
#include <rlgl.h>
|
||||
#include <luma.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/parse.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
class score_view final : public luma::control {
|
||||
public:
|
||||
score_view(luma::application &app, vamp_zero::score_document score, std::string output, std::size_t page, std::bool_t &saved)
|
||||
: app_{app}, score_{std::move(score)}, output_{std::move(output)}, page_{page}, saved_{saved} {}
|
||||
auto measure(luma::draw_context &, luma::theme const &, luma::constraints) -> luma::size override {
|
||||
return {976, 484};
|
||||
}
|
||||
auto draw(luma::draw_context &context, luma::theme const &) const -> void override {
|
||||
vamp_zero::draw_score_page(context, bounds(), score_, page_);
|
||||
rlDrawRenderBatchActive();
|
||||
auto image = LoadImageFromScreen();
|
||||
saved_ = ExportImage(image, output_.c_str());
|
||||
UnloadImage(image);
|
||||
app_.request_stop();
|
||||
}
|
||||
private:
|
||||
luma::application &app_;
|
||||
vamp_zero::score_document score_;
|
||||
std::string output_;
|
||||
std::size_t page_;
|
||||
std::bool_t &saved_;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
int main(int argc, std::char_t **argv) {
|
||||
if (argc < 3) {
|
||||
std::cerr << "Usage: vamp-render-score input.json output.png [page]\n";
|
||||
return 2;
|
||||
}
|
||||
auto page = std::size_t{};
|
||||
if (argc > 3) {
|
||||
auto parsed = vamp_zero::parse_integer<std::size_t>(argv[3], "page");
|
||||
if (!parsed) {
|
||||
std::cerr << parsed.error().describe() << '\n';
|
||||
return 2;
|
||||
}
|
||||
page = *parsed;
|
||||
}
|
||||
auto input = std::ifstream{argv[1]};
|
||||
auto json = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
||||
auto score = vamp_zero::parse_score_document(json, argv[1]);
|
||||
if (!score) {
|
||||
std::cerr << score.error().describe() << '\n';
|
||||
return 1;
|
||||
}
|
||||
auto app = luma::application{
|
||||
{.title = "Notation Regression", .display = {.width = 976, .height = 484, .target_fps = 30, .fullscreen = false}}};
|
||||
auto saved = std::bool_t{false};
|
||||
auto root = app.set_root(std::make_unique<score_view>(app, std::move(*score), argv[2], page, saved));
|
||||
if (!root) {
|
||||
return 1;
|
||||
}
|
||||
auto ran = app.run();
|
||||
return ran && saved ? 0 : 1;
|
||||
}
|
||||
+79
-994
File diff suppressed because it is too large
Load Diff
@@ -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()).hexdigest()
|
||||
digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes() + (root / 'tools/install_engraver.py').read_bytes()).hexdigest()
|
||||
self.assertEqual(bootstrap.build_requirements_digest(), digest)
|
||||
self.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile())
|
||||
package = types.ModuleType('modulus')
|
||||
|
||||
@@ -4,6 +4,7 @@ from fractions import Fraction
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from generator import harmony
|
||||
from generator.__main__ import generate_assets
|
||||
@@ -16,6 +17,10 @@ from generator.transposition import concert_document, shift_pitch, transpose_doc
|
||||
from tests.test_musicxml import parse, notes, xml_note, xml_score
|
||||
|
||||
class MusicModelTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
engraving = patch('generator.__main__.engrave_tree')
|
||||
engraving.start()
|
||||
self.addCleanup(engraving.stop)
|
||||
def test_chord_semantics_are_not_voicing_heuristics(self):
|
||||
cases = {'C': {0, 4, 7}, 'Cdim': {0, 3, 6}, 'Cdim7': {0, 3, 6, 9}, 'Csus2': {0, 2, 7},
|
||||
'C7b5': {0, 4, 6, 10}, 'G7b9': {7, 11, 2, 5, 8}, 'Fmaj7#11': {5, 9, 0, 4, 11}}
|
||||
@@ -28,6 +33,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
harmony.parse_symbol('Cunrecognised')
|
||||
def test_transposition_preserves_spelling_octaves_modes(self):
|
||||
for p in ({'letter': 'b', 'alter': 1, 'octave': 4}, {'letter': 'c', 'alter': -1, 'octave': 4}, {'letter': 'f', 'alter': 2, 'octave': 4}):
|
||||
p['alter'] = rational(p['alter'])
|
||||
self.assertEqual(shift_pitch(p, 0, 0), p)
|
||||
self.assertEqual(pitch_number(shift_pitch(p, 1, 2)), pitch_number(p) + 2)
|
||||
source = parse(xml_score(xml_note(), attributes='<key><fifths>-3</fifths><mode>minor</mode></key>'))
|
||||
@@ -99,7 +105,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
source = parse(xml_score(xml_note(), attributes='<transpose><diatonic>-1</diatonic><chromatic>-2</chromatic></transpose>'))
|
||||
concert = concert_document(source)
|
||||
p = notes(concert)[0]['pitches'][0]
|
||||
self.assertEqual((p['letter'], p['alter'], p['octave']), ('b', -1, 3))
|
||||
self.assertEqual((p['letter'], fraction(p['alter']), p['octave']), ('b', -1, 3))
|
||||
self.assertEqual(concert_document(concert), concert)
|
||||
self.assertEqual(concert['metadata']['key_signature'], 'Bb')
|
||||
def test_deterministic_generation_and_removal(self):
|
||||
@@ -111,11 +117,13 @@ class MusicModelTests(unittest.TestCase):
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), clean=True, check=False)
|
||||
generate_assets(args)
|
||||
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||
args.clean = False
|
||||
generate_assets(args)
|
||||
self.assertEqual(first, {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('*.json')))
|
||||
self.assertFalse(list(output.rglob('*.png')))
|
||||
def test_pickup_tie_survives_vocal_restoration(self):
|
||||
source = xml_score(xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), implicit=True)
|
||||
source = source.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/>') + '</measure></part>')
|
||||
|
||||
@@ -48,7 +48,7 @@ class MusicXMLTests(unittest.TestCase):
|
||||
self.assertEqual(len(sources), 6)
|
||||
for path in sources:
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(load_musicxml(path)['schema_version'], 2)
|
||||
self.assertEqual(load_musicxml(path)['schema_version'], 3)
|
||||
def test_all_written_values_and_dots(self):
|
||||
for kind in NOTE_TYPES:
|
||||
for dots in (0, 1, 2, 3):
|
||||
@@ -63,7 +63,7 @@ class MusicXMLTests(unittest.TestCase):
|
||||
def test_doctype_allowed_but_entities_rejected(self):
|
||||
source = xml_score(xml_note())
|
||||
source = source.replace('<score-partwise', '<!DOCTYPE score-partwise SYSTEM "https://invalid.test/partwise.dtd"><score-partwise', 1)
|
||||
self.assertEqual(parse(source)['schema_version'], 2)
|
||||
self.assertEqual(parse(source)['schema_version'], 3)
|
||||
malicious = xml_score(xml_note()).replace('<score-partwise', '<!DOCTYPE score-partwise [<!ENTITY secret SYSTEM "file:///etc/passwd">]><score-partwise', 1)
|
||||
with self.assertRaises(MusicXMLError):
|
||||
parse(malicious)
|
||||
@@ -137,11 +137,10 @@ class MusicXMLTests(unittest.TestCase):
|
||||
for extra in extras:
|
||||
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||
parse(xml_score(xml_note(extra=extra)))
|
||||
for extra in ('<barline><repeat direction="backward"/></barline>', '<backup><duration>4</duration></backup>', '<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
|
||||
for extra in ('<barline><repeat direction="backward"/></barline>', '<backup><duration>5</duration></backup>', '<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
|
||||
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||
parse(xml_score(xml_note() + extra))
|
||||
with self.assertRaises(MusicXMLError):
|
||||
parse(xml_score(xml_note(alter='0.5')))
|
||||
self.assertEqual(fraction(notes(parse(xml_score(xml_note(alter='0.5'))))[0]['pitches'][0]['alter']), Fraction(1, 2))
|
||||
with self.assertRaises(MusicXMLError):
|
||||
parse(xml_score(xml_note(extra='<tie type="stop"/>')))
|
||||
with self.assertRaises(MusicXMLError):
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import copy
|
||||
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.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.transposition import transpose_document
|
||||
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||
|
||||
class ScoreBundleTests(unittest.TestCase):
|
||||
def round_trip(self, document):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / 'roundtrip.xml'
|
||||
write_musicxml(path, document)
|
||||
return load_musicxml(path)
|
||||
def test_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}"/>')
|
||||
content = half('start') + half('stop') + '<backup><duration>4</duration></backup>'
|
||||
content += xml_note('C', extra='<voice>2</voice>')
|
||||
source = parse(xml_score(content))
|
||||
measure = source['staves'][0]['measures'][0]
|
||||
self.assertEqual([v['id'] for v in measure['voices']], ['1', '2'])
|
||||
self.assertNotIn('tie_from', measure['voices'][1]['notes'][0]['pitches'][0])
|
||||
for document in (source, practice_document(source), transpose_document(source, 'D'), arrange_document(source, 'vocal')):
|
||||
validate_document(document)
|
||||
restored = self.round_trip(document)
|
||||
validate_document(restored)
|
||||
self.assertTrue(any(len(m['voices']) == 2 for m in restored['staves'][0]['measures']))
|
||||
def test_microtones_remain_exact_after_transposition_and_export(self):
|
||||
source = parse(xml_score(xml_note(alter='0.5', extra='<accidental>quarter-sharp</accidental>')))
|
||||
transposed = transpose_document(source, 'D')
|
||||
self.assertEqual(pitch_number(notes(transposed)[0]['pitches'][0]), Fraction(125, 2))
|
||||
restored = self.round_trip(transposed)
|
||||
self.assertEqual(notes(restored)[0]['pitches'], notes(transposed)[0]['pitches'])
|
||||
unqualified = parse(xml_score(xml_note(alter='0.1')))
|
||||
self.assertEqual(fraction(notes(unqualified)[0]['pitches'][0]['alter']), Fraction(1, 10))
|
||||
with self.assertRaisesRegex(EngravingError, 'microtonal'):
|
||||
qualify(unqualified)
|
||||
def test_octave_shift_stop_at_a_bar_boundary_is_emitted_once(self):
|
||||
tree = ElementTree.fromstring(xml_score(xml_note()))
|
||||
ElementTree.SubElement(tree.find('part'), 'measure', number='2').append(ElementTree.fromstring(xml_note()))
|
||||
source = parse(ElementTree.tostring(tree, encoding='unicode'))
|
||||
source['staves'][0]['octave_shifts'] = [{'id': 'octave-1', 'start': rational(0), 'end': rational(4), 'octaves': 1}]
|
||||
restored = self.round_trip(source)
|
||||
self.assertEqual(source['staves'][0]['octave_shifts'], restored['staves'][0]['octave_shifts'])
|
||||
def test_unequal_chord_tones_are_retained_but_never_silently_flattened(self):
|
||||
source = parse(xml_score(xml_note() + xml_note('E', 2, 'half', prefix='<chord/>')))
|
||||
restored = self.round_trip(source)
|
||||
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
||||
with self.assertRaisesRegex(EngravingError, 'independently sustained'):
|
||||
qualify(source)
|
||||
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')))
|
||||
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)))
|
||||
restored = self.round_trip(source)
|
||||
self.assertEqual(notes(restored)[0]['lyrics'], notes(source)[0]['lyrics'])
|
||||
def test_percussion_and_tablature_are_semantic_not_pitch_guesses(self):
|
||||
percussion = '<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>'))
|
||||
restored = self.round_trip(source)
|
||||
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
||||
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(notes(self.round_trip(tab))[0]['pitches'], notes(tab)[0]['pitches'])
|
||||
for document in (source, tab):
|
||||
validate_document(practice_document(document))
|
||||
for part in ('vocal', 'acoustic', 'electric'):
|
||||
with self.assertRaisesRegex(ValueError, 'conversion is not qualified'):
|
||||
arrange_document(document, part)
|
||||
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>'))
|
||||
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'))
|
||||
self.assertEqual(self.round_trip(source)['staves'][0]['instruments'], source['staves'][0]['instruments'])
|
||||
changed = copy.deepcopy(source)
|
||||
changed['staves'][0]['instruments'][0]['midi']['midi-unpitched'] = '40'
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
before, after = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
||||
write_musicxml(before, source)
|
||||
write_musicxml(after, changed)
|
||||
with self.assertRaisesRegex(ValueError, 'percussion instrument'):
|
||||
audit_roundtrip(before, after)
|
||||
source['staves'][0]['instruments'] = []
|
||||
with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
|
||||
validate_document(source)
|
||||
def test_catalogue_roundtrip_preserves_written_pitch_and_rhythm(self):
|
||||
for path in Path('data').rglob('*.xml'):
|
||||
with self.subTest(path=path):
|
||||
source = load_musicxml(path)
|
||||
restored = self.round_trip(source)
|
||||
a, b = source['staves'][0]['measures'], restored['staves'][0]['measures']
|
||||
self.assertEqual(len(a), len(b))
|
||||
for original, result in zip(a, b):
|
||||
self.assertEqual(original['duration'], result['duration'])
|
||||
for v, w in zip(original['voices'], result['voices']):
|
||||
self.assertEqual(v['notes'], w['notes'])
|
||||
def test_wrong_engine_version_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'))
|
||||
self.assertEqual(json.loads(path.read_text()), source)
|
||||
def test_blank_and_wrong_sized_pages_are_rejected(self):
|
||||
from PIL import Image
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / 'page.png'
|
||||
for size in ((976, 484), (100, 100)):
|
||||
Image.new('RGB', size, 'white').save(path)
|
||||
with self.assertRaises(EngravingError):
|
||||
check_page(path)
|
||||
def test_audit_rejects_an_engraver_pitch_change(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
||||
source.write_text(xml_score(xml_note()))
|
||||
output.write_text(xml_score(xml_note('D')))
|
||||
with self.assertRaisesRegex(ValueError, 'pitch letter'):
|
||||
audit_roundtrip(source, output)
|
||||
def test_audit_rejects_staff_details_and_barline_changes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
||||
document = parse(xml_score(xml_note()))
|
||||
for field, value in (('staff_details', {'lines': 6, 'tunings': []}), ('barlines', {'right': 'light-light'})):
|
||||
with self.subTest(field=field):
|
||||
original = copy.deepcopy(document)
|
||||
original['staves'][0]['measures'][0][field] = value
|
||||
write_musicxml(source, original)
|
||||
write_musicxml(output, document)
|
||||
with self.assertRaisesRegex(ValueError, 'engraver changed musical content'):
|
||||
audit_roundtrip(source, output)
|
||||
def test_audit_rejects_relocated_tuplet_brackets(self):
|
||||
modulation = '<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['staves'][0]['tuplets'] = [span]
|
||||
for note in notes(source)[:3]:
|
||||
note['tuplet_ids'] = ['tuplet-1']
|
||||
changed = copy.deepcopy(source)
|
||||
changed['staves'][0]['tuplets'][0].update(start=notes(changed)[3]['id'], end=notes(changed)[5]['id'])
|
||||
for index, note in enumerate(notes(changed)):
|
||||
note['tuplet_ids'] = ['tuplet-1'] if index >= 3 else []
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
before, after = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
||||
write_musicxml(before, source)
|
||||
write_musicxml(after, changed)
|
||||
with self.assertRaisesRegex(ValueError, 'members'):
|
||||
audit_roundtrip(before, after)
|
||||
def test_bundle_validation_checks_pages_and_rejects_symlink_escape(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
assets = root / 'assets'
|
||||
assets.mkdir()
|
||||
data = Path('tests/fixtures/engraving.png').read_bytes()
|
||||
page = assets / 'page.png'
|
||||
page.write_bytes(data)
|
||||
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
|
||||
bundle = {'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()}]}}
|
||||
path = assets / 'score.json'
|
||||
path.write_text(json.dumps(bundle))
|
||||
self.assertEqual(load_json(path), document)
|
||||
page.write_bytes(data + b'corrupt')
|
||||
with self.assertRaisesRegex(ValueError, 'checksum'):
|
||||
load_json(path)
|
||||
page.unlink()
|
||||
outside = root / 'outside.png'
|
||||
outside.write_bytes(data)
|
||||
page.symlink_to(outside)
|
||||
with self.assertRaisesRegex(ValueError, 'escapes'):
|
||||
load_json(path)
|
||||
@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):
|
||||
from PIL import Image, ImageChops
|
||||
from generator.score import write_json
|
||||
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
before = None
|
||||
for _ in range(2):
|
||||
write_json(root / 'fixture.json', document)
|
||||
engrave_tree(root, Path('package/assets/fonts/petaluma'))
|
||||
bundle = json.loads((root / 'fixture.json').read_text())
|
||||
self.assertEqual(bundle['music'], {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:
|
||||
self.assertIsNone(ImageChops.difference(actual.convert('RGB'), expected.convert('RGB')).getbbox())
|
||||
if before is not None:
|
||||
self.assertEqual(bundle, before)
|
||||
before = bundle
|
||||
@@ -9,17 +9,8 @@ from pathlib import Path
|
||||
|
||||
from generator.__main__ import copy_runtime_fonts
|
||||
from tools import generate_pico_font
|
||||
from tools import generate_score_metrics
|
||||
|
||||
class FontAssetsTests(unittest.TestCase):
|
||||
def test_score_metrics_use_fixed_width_codepoints(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "generated/score_metrics.hpp"
|
||||
generate_score_metrics.generate(root / "package/assets/fonts/petaluma/Petaluma.otf", output)
|
||||
content = output.read_text(encoding="ascii")
|
||||
self.assertIn("#include <cstdint>", content)
|
||||
self.assertIn("std::int32_t codepoint", content)
|
||||
def test_font_generator_requires_kerning_backend(self):
|
||||
with unittest.mock.patch.object(generate_pico_font.features, "check_feature", return_value=False):
|
||||
with self.assertRaisesRegex(RuntimeError, "Raqm"):
|
||||
@@ -30,7 +21,7 @@ class FontAssetsTests(unittest.TestCase):
|
||||
output = Path(directory)
|
||||
copy_runtime_fonts(root / "package/assets", output)
|
||||
files = {path.name for path in (output / "fonts/petaluma").iterdir()}
|
||||
self.assertEqual(files, {"Petaluma.otf", "PetalumaScript.otf", "OFL.txt"})
|
||||
self.assertEqual(files, {"PetalumaScript.otf", "OFL.txt"})
|
||||
def test_font_generator_uses_explicit_build_output(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
||||
Reference in New Issue
Block a user