Cleanupf
ci / checks (push) Successful in 1h15m45s

This commit is contained in:
khodowany
2026-09-23 23:54:19 -05:00
parent a0faa9b565
commit da5640291a
24 changed files with 366 additions and 233 deletions
+9
View File
@@ -152,6 +152,15 @@ void write_text(std::filesystem::path const &path, std::string const &text) {
}
BOOST_AUTO_TEST_CASE(runtime_config_uses_headless_and_ignores_unknown_fields) {
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({"runtime":{"headless":true,"obsolete_setting":"ignored"},"audio":{"enabled":false}})json"));
BOOST_REQUIRE(parsed);
BOOST_TEST(parsed->runtime.headless);
BOOST_TEST(!parsed->audio.enabled);
auto invalid = vamp_zero::parse_config(boost::json::parse(R"json({"runtime":{"headless":"yes"}})json"));
BOOST_TEST(!invalid);
}
BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
auto defaults = vamp_zero::parse_config(boost::json::parse(R"json({})json"));
BOOST_REQUIRE(defaults);
+1 -1
View File
@@ -18,7 +18,7 @@ class CatalogueTests(unittest.TestCase):
self.assertEqual([p.name for p in sources], [f'{i:03}.xml' for i in range(1, 367)])
expected = {Path(path.stem) / (part + '.json') for path in sources for part in PART_TYPES}
self.assertEqual(len(expected), 1098)
with tempfile.TemporaryDirectory() as temporary, patch('generator.practice.generate.practice_document', side_effect=lambda document, part: document):
with tempfile.TemporaryDirectory() as temporary, patch('generator.practice.generate._practice_document', side_effect=lambda document, part: document):
output = Path(temporary)
self.assertEqual(generate_practice_scores(source, output), len(expected))
self.assertEqual({p.relative_to(output) for p in output.rglob('*.json')}, expected)
+39
View File
@@ -30,6 +30,45 @@ class EngravingTests(unittest.TestCase):
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_accidental_work_is_bounded_by_local_event_count(self):
class Unscannable(list):
def __iter__(self):
raise AssertionError('whole-score event scan')
class Counted(list):
visited = 0
def __iter__(self):
for value in super().__iter__():
Counted.visited += 1
yield value
for count in (100, 200, 400):
content = ''.join(xml_note(duration=1, kind='quarter', alter=i % 2) for i in range(4))
extra = ''.join(f'<measure number="{i + 1}">{content}</measure>' for i in range(1, count))
document = parse(xml_score(content).replace('</part></score-partwise>', extra + '</part></score-partwise>'))
index = ScoreIndex(document)
expected = index.accidentals
index.events = Unscannable(index.events)
index.events_by_onset = {time: Counted(events) for time, events in index.events_by_onset.items()}
Counted.visited = 0
self.assertEqual(index.accidental_state(), expected)
self.assertEqual(Counted.visited, count * 4)
def test_indexed_columns_preserve_sustains_and_timed_annotations(self):
from generator.engraving.layout import column
class Unscannable(list):
def __iter__(self):
raise AssertionError('whole-score scan from column layout')
content = xml_note(extra='<voice>held</voice>') + '<backup><duration>4</duration></backup>'
content += ''.join(xml_note('G', 1, 'quarter', extra='<voice>moving</voice>') for _ in range(4))
content += '<harmony><root><root-step>F</root-step></root><kind>major</kind><offset>-2</offset></harmony>'
content += '<direction><direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>90</per-minute></metronome></direction-type><offset>-2</offset></direction>'
index = ScoreIndex(parse(xml_score(content)))
expected = column(index, Fraction(2), Fraction(2), Fraction(4), self.fonts, Style())
index.events = Unscannable(index.events)
index.measures = Unscannable(index.measures)
actual = column(index, Fraction(2), Fraction(2), Fraction(4), self.fonts, Style())
self.assertEqual(actual, expected)
self.assertEqual(len(index.events_at(Fraction(2), include_sustained=True)), 2)
self.assertTrue(any(p.role == 'harmony' for p in actual[0]))
self.assertEqual(len(index.events_at(Fraction(4), include_sustained=True)), 0)
def test_every_written_note_and_rest_value(self):
for kind in NOTE_TYPES:
for dots in (0, 1, 3):
+6
View File
@@ -13,6 +13,12 @@ from generator.engraving.cache import renderer_fingerprint, restore_pages, score
from tests.test_musicxml import parse, xml_note, xml_score
class EngravingCacheTests(unittest.TestCase):
def test_ci_snapshot_tracks_all_generator_inputs_and_keeps_fallback(self):
workflow = Path('.gitea/workflows/ci.yml').read_text()
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'data/**/*.xml', 'data/*.xml')", workflow)
self.assertIn('restore-keys: product-vamp-pages-v1-${{ runner.os }}-', workflow)
self.assertIn("if: steps.engraving-cache.outputs.cache-hit != 'true'", workflow)
self.assertIn('key: ${{ steps.engraving-cache.outputs.cache-primary-key }}', workflow)
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
+1 -1
View File
@@ -132,7 +132,7 @@ auto fixture_config(std::filesystem::path const &root) -> vamp_zero::config {
auto config_file(std::filesystem::path const &root, vamp_zero::config const &config) -> std::string {
auto json = boost::json::object{
{"modulus", modulus::to_json(config.modulus)},
{"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"hardware", "real"}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}},
{"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}},
{"database", {{"path", config.database.path}}},
{"control", {{"endpoint", ligo::to_json(config.control.endpoint)}, {"zero_ip", config.control.zero_ip}}},
{"audio", {{"enabled", true}, {"playback_enabled", true}, {"metronome_enabled", true}}},
+114
View File
@@ -0,0 +1,114 @@
import argparse
import copy
from fractions import Fraction
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from generator.__main__ import generate_assets
from generator.arrange.generate import _arrange_document, arrange_document, generate_performance_scores
from generator.model import fraction, rational, validate_document
from generator.musicxml import load_musicxml
from generator.parts import PART_TYPES, generated_voice, prepare_document
from generator.pitch import concert_document
from generator.playback import compile_timeline
from generator.practice.generate import _practice_document, practice_document
from tests.test_musicxml import notes, parse, xml_note, xml_score
def xml_harmony(root='C', kind='major', offset=0):
return f'<harmony><root><root-step>{root}</root-step></root><kind>{kind}</kind><offset>{offset}</offset></harmony>'
class PartGenerationTests(unittest.TestCase):
def test_accompaniment_ignores_melody_attacks_and_sustains_to_changes(self):
source = parse(xml_score(xml_harmony() + xml_harmony('F', offset=3) + ''.join(xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2))
for result in (practice_document(source, 'acoustic'), arrange_document(source, 'electric')):
validate_document(result)
for measure in result['staves'][0]['measures']:
events = measure['voices'][0]['notes']
if measure.get('section_label') in ('Rhythm Lead-in', 'Guitar Melody', 'Guitar Solo'):
continue
self.assertEqual([fraction(n['onset']) for n in events], [0, Fraction(3, 2)])
self.assertEqual([fraction(n['duration']) for n in events], [Fraction(3, 2), Fraction(5, 2)])
self.assertTrue(all(n['pitches'] for n in events))
self.assertTrue(compile_timeline(result)['events'])
def test_harmony_carry_and_explicit_silence(self):
content = xml_harmony(offset=1) + xml_harmony(kind='none', offset=2) + xml_harmony('F', offset=3) + xml_note()
source = xml_score(content).replace('</measure></part>', '</measure><measure number="2">' + xml_note() + '</measure><measure number="3">' + xml_harmony(kind='none') + xml_note() + '</measure><measure number="4">' + xml_note() + '</measure></part>')
result = practice_document(parse(source), 'acoustic')
self.assertEqual([n['kind'] for n in notes(result)], ['rest', 'note', 'rest', 'note'])
self.assertEqual([fraction(n['onset']) for n in notes(result)], [0, 1, 2, 3])
self.assertEqual([n['kind'] for n in notes(result, 1)], ['note'])
self.assertEqual(fraction(notes(result, 1)[0]['duration']), 4)
self.assertEqual([n['kind'] for n in notes(result, 2)], ['rest'])
self.assertEqual([n['kind'] for n in notes(result, 3)], ['rest'])
def test_accompaniment_handles_pickups_meters_and_coincident_markers(self):
for duration, kind, attributes, implicit in ((1, 'quarter', '', True), (3, 'half', '<time><beats>3</beats><beat-type>4</beat-type></time>', False)):
source = parse(xml_score(xml_harmony() + xml_harmony('F') + xml_note(duration=duration, kind=kind, extra='<dot/>' if duration == 3 else ''), attributes, implicit=implicit))
measure = source['staves'][0]['measures'][0]
events = generated_voice(measure, 'accompaniment', None)
self.assertEqual(len(events), 1)
self.assertEqual(fraction(events[0]['duration']), duration)
self.assertEqual(events[0]['_harmony']['root']['letter'], 'f')
source = parse(xml_score(xml_harmony() + xml_note(duration=1, kind='quarter') + '<attributes><time><beats>6</beats><beat-type>8</beat-type></time></attributes>' + xml_note(duration=3, kind='half', extra='<dot/>')))
self.assertEqual(len(generated_voice(source['staves'][0]['measures'][0], 'accompaniment', None)), 1)
def test_exercise_006_has_accompaniment_in_every_measure(self):
result = practice_document(load_musicxml(Path('data/practice/006.xml')), 'acoustic')
self.assertEqual(len(result['staves'][0]['measures']), 12)
for measure in result['staves'][0]['measures']:
self.assertTrue(any(n['pitches'] for v in measure['voices'] for n in v['notes']), measure['id'])
validate_document(result)
self.assertTrue(compile_timeline(result)['events'])
def test_prepared_document_and_parts_have_independent_ownership(self):
source = parse(xml_score(xml_harmony() + xml_note()))
source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': rational(0), 'end': rational(4), 'octaves': -1}]
original = copy.deepcopy(source)
for builder, wrapper, practice in ((_practice_document, practice_document, True), (_arrange_document, arrange_document, False)):
prepared = prepare_document(source, practice=practice)
before = copy.deepcopy(prepared)
parts = [builder(prepared, part) for part in PART_TYPES]
self.assertEqual(prepared, before)
self.assertEqual(parts, [wrapper(source, part) for part in PART_TYPES])
vocal = copy.deepcopy(parts[2])
parts[1]['metadata']['title'] = 'changed'
notes(parts[1])[0]['pitches'].clear()
if parts[1]['staves'][0]['octave_shifts']:
parts[1]['staves'][0]['octave_shifts'][0]['octaves'] = 9
self.assertEqual(parts[2], vocal)
self.assertEqual(prepared, before)
self.assertEqual(source, original)
def test_song_slug_collisions_fail_before_writes(self):
for names in (('one-two', 'one_two'), ('Song', 'song'), ('!!!', '???')):
with self.subTest(names=names), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source, output = root / 'songs', root / 'output'
source.mkdir()
output.mkdir()
(output / 'sentinel').write_text('preserved')
for name in names:
(source / (name + '.xml')).write_text(xml_score(xml_note()))
with self.assertRaisesRegex(ValueError, 'conflicting output path') as raised:
generate_performance_scores(source, output)
for name in names:
self.assertIn(str(source / (name + '.xml')), str(raised.exception))
self.assertEqual([p.name for p in output.iterdir()], ['sentinel'])
def test_pipeline_parses_and_normalizes_each_source_once(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for category, name in (('songs', 'song'), ('practice', '001')):
(root / 'data' / category).mkdir(parents=True)
(root / 'data' / category / (name + '.xml')).write_text(xml_score(xml_harmony() + xml_note()))
args = argparse.Namespace(data_dir=root / 'data', output_dir=root / 'output', static_assets_dir=Path('package/assets'), check=False)
with patch('generator.arrange.generate.load_musicxml', wraps=load_musicxml) as song_load, patch('generator.practice.generate.load_musicxml', wraps=load_musicxml) as practice_load, patch('generator.parts.concert_document', wraps=concert_document) as normalize, patch('generator.__main__.engrave_tree'), patch('generator.__main__.build_catalogue'), patch('generator.__main__.compile_tree'):
generate_assets(args)
song_load.assert_called_once()
practice_load.assert_called_once()
self.assertEqual(normalize.call_count, 2)
original = {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()}
(root / 'data/practice/002.xml').write_text('<broken')
with self.assertRaises(ValueError):
generate_assets(args)
self.assertEqual(original, {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()})
self.assertEqual(len(list(args.output_dir.rglob('*.json'))), 6)
self.assertEqual(json.loads((args.output_dir / 'songs/song-vocal.json').read_text())['metadata']['title'], 'Fixture')
+17
View File
@@ -10,6 +10,23 @@ from generator.playback import PlaybackUnavailable, compile_timeline, generate_t
from tests.test_musicxml import notes, parse, xml_note, xml_score
class PlaybackTests(unittest.TestCase):
def test_grace_steals_accumulate_at_both_ends_of_a_donor(self):
grace = '<note><grace {}/><pitch><step>{}</step><octave>4</octave></pitch><type>eighth</type></note>'
content = grace.format('steal-time-following="25"', 'D') + xml_note(duration=2, kind='half') + grace.format('steal-time-previous="25"', 'E') + xml_note('F', 2, 'half')
result = compile_timeline(parse(xml_score(content)))
self.assertEqual([(fraction(e['pitch']), fraction(e['onset']), fraction(e['duration'])) for e in result['events']], [(62, 0, Fraction(1, 2)), (60, Fraction(1, 2), 1), (64, Fraction(3, 2), Fraction(1, 2)), (65, 2, 2)])
for percent in (50, 60):
with self.subTest(percent=percent), self.assertRaisesRegex(PlaybackUnavailable, 'grace timing consumes'):
compile_timeline(parse(xml_score(content.replace('"25"', f'"{percent}"'))))
chord = content.replace(xml_note(duration=2, kind='half'), xml_note(duration=2, kind='half') + xml_note('G', 1, 'quarter', prefix='<chord/>'))
with self.assertRaisesRegex(PlaybackUnavailable, 'chord tone'):
compile_timeline(parse(xml_score(chord)))
def test_grace_previous_and_make_time_preserve_their_timing(self):
for mode, expected, total in [('steal-time-previous="25"', [(0, Fraction(3, 2)), (Fraction(3, 2), Fraction(1, 2)), (2, 2)], 4), ('make-time="1"', [(0, 2), (2, 1), (3, 2)], 5)]:
grace = f'<note><grace {mode}/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>'
result = compile_timeline(parse(xml_score(xml_note(duration=2, kind='half') + grace + xml_note('E', 2, 'half'))))
self.assertEqual([(fraction(e['onset']), fraction(e['duration'])) for e in result['events']], expected)
self.assertEqual(fraction(result['duration']), total)
def test_chords_and_trailing_rest(self):
document = parse(xml_score(xml_note(duration=2, kind='half') + xml_note('E', 1, 'quarter', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'))
result = compile_timeline(document)