Music Notation
ci / checks (push) Successful in 43m19s

This commit is contained in:
khodowany
2026-09-15 01:30:42 -05:00
parent fe42d811fa
commit 1d1bd3e871
41 changed files with 1546 additions and 5236 deletions
-3
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+34
View File
@@ -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>
-71
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+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()).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')
+9 -1
View File
@@ -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>')
+4 -5
View File
@@ -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):
+211
View File
@@ -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
+1 -10
View File
@@ -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: