Files
product-vamp/tests/test_engraving_cache.py
T
khodowany eae432aa83
ci / checks (push) Successful in 50m39s
Soloing
2026-10-01 06:31:14 -05:00

368 lines
22 KiB
Python

import argparse
import contextlib
import copy
import hashlib
import io
import json
import pathlib
import shutil
import tempfile
import unittest
import unittest.mock
import PIL.Image
import PIL.ImageChops
import generator.__main__
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.pipeline
import generator.engraving.raster
import generator.engraving.types
import generator.engraving.validation
import generator.pico_artwork
import generator.score
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
class EngravingCacheTests(unittest.TestCase):
def test_ci_snapshot_tracks_all_generator_inputs_and_keeps_fallback(self):
workflow = pathlib.Path('.gitea/workflows/ci.yml').read_text()
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'generator/palette.json', 'data/songs/*.xml', 'data/rhythms/**/*.xml', 'data/scales/**/*.xml', 'data/sequences/**/*.xml')", workflow)
self.assertIn('python3 modulus.py python -m generator --renderer-fingerprint', workflow)
self.assertIn('restore-keys: product_vamp_pages_${{ steps.renderer.outputs.fingerprint }}_', workflow)
self.assertIn('path: build/build_common/engraving_cache/${{ steps.renderer.outputs.fingerprint }}', 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)
self.root = pathlib.Path(self.temporary.name)
self.cache = self.root / 'cache'
self.fonts = pathlib.Path('package/assets/fonts/petaluma')
self.document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
def write_scores(self, root, documents):
root.mkdir(parents=True, exist_ok=True)
for index, document in enumerate(documents):
(root / f'{index:03}.json').write_text(json.dumps(document), encoding='utf-8')
def files(self, root):
return {p.relative_to(root): p.read_bytes() for p in root.rglob('*') if p.is_file()}
def seed_cache(self):
root = self.root / 'seed'
self.write_scores(root, [self.document])
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
return next(self.cache.rglob('pages.json')).parent
def titled(self, title):
document = copy.deepcopy(self.document)
document['metadata']['title'] = title
return document
def body_calls(self, render):
return [call for call in render.call_args_list if any(p.role == 'staff' for p in call.args[1])]
def test_cold_titles_group_before_dispatch_and_render_body_once(self):
root = self.root / 'cold'
self.write_scores(root, [self.titled(f'Fixture {index}') for index in range(8)])
progress = io.StringIO()
with unittest.mock.patch('generator.engraving.layout.layout_systems', wraps=generator.engraving.layout.layout_systems) as layout, unittest.mock.patch('generator.engraving.raster.rasterize_band', wraps=generator.engraving.raster.rasterize_band) as render, unittest.mock.patch('generator.engraving.pipeline.concurrent.futures.ProcessPoolExecutor', side_effect=AssertionError('one body must have one owner')), contextlib.redirect_stdout(progress):
generator.engraving.pipeline.engrave_tree(root, self.fonts, jobs=4, progress=True)
layout.assert_called_once()
self.assertEqual(len(self.body_calls(render)), 1)
self.assertIn('fullscore 0, bodyhits 7, bodies 1, renderedpages 8', progress.getvalue())
self.assertIn('Engraving timings:', progress.getvalue())
for phase in ('prepare', 'layout', 'pagination', 'raster', 'cache', 'validation', 'wall'):
self.assertIn(phase + ' ', progress.getvalue())
def test_title_change_reuses_persistent_notation_and_matches_full_page(self):
self.seed_cache()
document = self.titled('Fixture A')
root = self.root / 'title'
self.write_scores(root, [document])
with unittest.mock.patch('generator.engraving.raster.rasterize_band', wraps=generator.engraving.raster.rasterize_band) as render:
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
self.assertFalse(self.body_calls(render))
self.assertEqual(len(list(self.cache.rglob('band.json'))), 1)
family = generator.engraving.fonts.Fonts(self.fonts)
layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), family)
with PIL.Image.open(root / '000.pages/0001.png') as actual:
reference = generator.engraving.raster.rasterize_page(layout.pages[0], family)
self.assertIsNone(PIL.ImageChops.difference(actual, reference).getbbox())
def test_duplicate_fullscores_share_rendering_without_persistent_cache(self):
root = self.root / 'duplicate'
self.write_scores(root, [self.document] * 8)
with unittest.mock.patch('generator.engraving.raster.compose_page', wraps=generator.engraving.raster.compose_page) as render, unittest.mock.patch('generator.engraving.validation.check_content', wraps=generator.engraving.validation.check_content) as decode:
trusted = generator.engraving.pipeline.engrave_tree(root, self.fonts, jobs=4)
render.assert_called_once()
decode.assert_called_once()
self.assertEqual(len(trusted), 1)
def test_full_page_validation_is_once_per_content_but_every_file_is_read(self):
self.seed_cache()
root = self.root / 'validation'
self.write_scores(root, [self.document] * 4)
with unittest.mock.patch('generator.engraving.validation.check_content', wraps=generator.engraving.validation.check_content) as decode:
trusted = generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
decode.assert_called_once()
read_bytes = pathlib.Path.read_bytes
reads = []
def read(path):
reads.append(path)
return read_bytes(path)
pages = sorted(root.glob('*.pages/*.png'))
digest = next(iter(trusted))
with unittest.mock.patch.object(pathlib.Path, 'read_bytes', read):
for path in pages:
generator.engraving.validation.check_page(path, trusted, expected_sha256=digest)
self.assertCountEqual(reads, pages)
decode.assert_called_once()
pages[-1].write_bytes(pages[-1].read_bytes() + b'changed')
with self.assertRaisesRegex(ValueError, 'checksum'):
generator.engraving.validation.check_page(pages[-1], trusted, expected_sha256=digest)
pages[-1].unlink()
pages[-1].symlink_to(pages[0])
with self.assertRaisesRegex(ValueError, 'regular PNG'):
generator.engraving.validation.check_page(pages[-1], trusted, expected_sha256=digest)
def test_font_content_change_creates_new_shared_notation_namespace(self):
self.seed_cache()
fonts = self.root / 'fonts'
shutil.copytree(self.fonts, fonts)
metadata = fonts / 'petaluma_metadata.json'
metadata.write_bytes(metadata.read_bytes() + b'\n')
root = self.root / 'font-change'
self.write_scores(root, [self.document])
with unittest.mock.patch('generator.engraving.raster.rasterize_band', wraps=generator.engraving.raster.rasterize_band) as render:
generator.engraving.pipeline.engrave_tree(root, fonts, cache_dir=self.cache)
self.assertEqual(len(self.body_calls(render)), 1)
self.assertEqual(len(list(self.cache.iterdir())), 2)
self.assertEqual(len(list(self.cache.rglob('band.json'))), 2)
def test_corrupt_shared_bands_are_rejected_and_repaired(self):
self.seed_cache()
manifest = next(self.cache.rglob('band.json'))
asset = manifest.with_name('band.png')
original, metadata = asset.read_bytes(), manifest.read_bytes()
for index, corruption in enumerate(('checksum', 'undecodable', 'blank', 'wrong_size', 'manifest', 'context', 'symlink', 'directory')):
with self.subTest(corruption=corruption):
if corruption in ('checksum', 'undecodable'):
asset.write_bytes(original + b'changed' if corruption == 'checksum' else original[:24])
elif corruption in ('blank', 'wrong_size'):
dimensions = json.loads(metadata)
size = (dimensions['width'], dimensions['height']) if corruption == 'blank' else (10, 10)
PIL.Image.new('RGB', size, 'white').save(asset)
elif corruption == 'manifest':
manifest.write_text('invalid json')
elif corruption == 'context':
manifest.write_text(json.dumps({**json.loads(metadata), 'context': 'different'}))
elif corruption == 'symlink':
external = self.root / 'external.png'
external.write_bytes(original)
asset.unlink()
asset.symlink_to(external)
else:
asset.unlink()
asset.mkdir()
if corruption in ('undecodable', 'blank', 'wrong_size'):
manifest.write_text(json.dumps({**json.loads(metadata), 'sha256': hashlib.sha256(asset.read_bytes()).hexdigest()}))
root = self.root / f'corrupt-band-{index}'
self.write_scores(root, [self.titled(f'Fixture A {index}')])
with unittest.mock.patch('generator.engraving.raster.rasterize_band', wraps=generator.engraving.raster.rasterize_band) as render:
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
self.assertEqual(len(self.body_calls(render)), 1)
self.assertEqual(asset.read_bytes(), original)
self.assertEqual(json.loads(manifest.read_bytes()), json.loads(metadata))
def test_band_eviction_reloads_final_pixels_without_rerendering(self):
family = generator.engraving.fonts.Fonts(self.fonts)
style = generator.engraving.types.Style()
documents = [self.document, tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')))]
pages = [generator.engraving.layout.layout_score(document, style, family).pages[0] for document in documents]
result = generator.engraving.pipeline.GroupResult()
bands = generator.engraving.pipeline.BodyBands(self.root / 'spool', None, result, limit=1)
self.addCleanup(bands.close)
original = None
for index in (0, 1, 0):
page = pages[index]
notation = generator.engraving.cache.notation_fingerprint(documents[index], 'renderer')
image = bands.get(notation, page, generator.engraving.raster.page_bands(page)[1], family)
if index == 0:
if original is None:
original = image.tobytes()
else:
self.assertEqual(image.tobytes(), original)
self.assertEqual(len(bands.images), 1)
self.assertEqual(result.bodies, 2)
self.assertEqual(result.bodyhits, 1)
def test_cache_hit_preserves_bundle_bytes_across_output_directories(self):
cold, warm = self.root / 'cold', self.root / 'warm'
self.write_scores(cold, [self.document])
generator.engraving.pipeline.engrave_tree(cold, self.fonts, cache_dir=self.cache)
self.write_scores(warm, [self.document])
with unittest.mock.patch('generator.engraving.layout.layout_systems', side_effect=AssertionError('cache miss')):
with unittest.mock.patch('generator.engraving.raster.rasterize_page', side_effect=AssertionError('cache miss')):
generator.engraving.pipeline.engrave_tree(warm, self.fonts, cache_dir=self.cache)
self.assertEqual(self.files(cold), self.files(warm))
def test_playback_is_not_restored_from_page_cache(self):
self.seed_cache()
changed = copy.deepcopy(self.document)
changed['playback'] = {'new-playback': 'independent of page rendering'}
root = self.root / 'warm'
self.write_scores(root, [changed])
with unittest.mock.patch('generator.engraving.layout.layout_systems', side_effect=AssertionError('cache miss')):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
self.assertEqual(json.loads((root / '000.json').read_text())['playback'], changed['playback'])
def test_changed_music_and_renderer_invalidate_pages(self):
self.seed_cache()
changed = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')).replace('Fixture', 'Changed'))
self.assertNotEqual(generator.engraving.cache.score_fingerprint(self.document, 'renderer'), generator.engraving.cache.score_fingerprint(changed, 'renderer'))
self.assertNotEqual(generator.engraving.cache.score_fingerprint(self.document, 'renderer'), generator.engraving.cache.score_fingerprint(self.document, 'new-renderer'))
root = self.root / 'changed'
self.write_scores(root, [changed])
with unittest.mock.patch('generator.engraving.layout.layout_systems', wraps=generator.engraving.layout.layout_systems) as render:
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
render.assert_called_once()
self.assertEqual(len(list(self.cache.rglob('pages.json'))), 2)
def test_renderer_fingerprint_tracks_fonts_style_code_and_dependencies(self):
fonts, style = generator.engraving.fonts.Fonts(self.fonts), generator.engraving.types.Style()
expected = generator.engraving.cache.renderer_fingerprint(fonts, style)
self.assertEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, style))
self.assertNotEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, generator.engraving.types.Style(margin_x=29)))
with unittest.mock.patch.object(fonts, 'fingerprint', return_value='changed-fonts'):
self.assertNotEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, style))
with unittest.mock.patch('generator.engraving.cache.importlib.metadata.version', return_value='new-version'):
self.assertNotEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, style))
with unittest.mock.patch('generator.engraving.cache.platform.python_version', return_value='0.0.0'):
self.assertNotEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, style))
read_bytes = pathlib.Path.read_bytes
with unittest.mock.patch.object(pathlib.Path, 'read_bytes', lambda path: read_bytes(path) + (b'\n' if path.name == 'raster.py' else b'')):
self.assertNotEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, style))
with unittest.mock.patch.object(pathlib.Path, 'read_bytes', lambda path: read_bytes(path) + (b'\n' if path.name == 'palette.json' else b'')):
self.assertNotEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, style))
def test_corrupt_or_missing_pages_are_regenerated_and_repaired(self):
entry = self.seed_cache()
page = entry / '0001.png'
original = page.read_bytes()
for corruption in ('missing', 'checksum', 'undecodable', 'manifest', 'page_directory', 'manifest_directory'):
with self.subTest(corruption=corruption):
if corruption == 'missing':
page.unlink()
elif corruption == 'checksum':
page.write_bytes(original + b'changed')
elif corruption == 'undecodable':
page.write_bytes(original[:24])
(entry / 'pages.json').write_text(json.dumps([{'path': page.name, 'sha256': hashlib.sha256(page.read_bytes()).hexdigest()}]))
elif corruption == 'manifest':
(entry / 'pages.json').write_text('invalid json')
else:
target = page if corruption == 'page_directory' else entry / 'pages.json'
target.unlink()
target.mkdir()
root = self.root / corruption
self.write_scores(root, [self.document])
with unittest.mock.patch('generator.engraving.layout.layout_systems', wraps=generator.engraving.layout.layout_systems) as render:
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
render.assert_called_once()
self.assertEqual(page.read_bytes(), original)
self.assertIsNotNone(generator.engraving.cache.restore_pages(entry, self.root / 'restored'))
def test_cache_manifest_cannot_escape_entry_or_inject_bundle_fields(self):
entry = self.seed_cache()
original = json.loads((entry / 'pages.json').read_text())
for invalid in ([], {}, [{'path': '../outside.png', 'sha256': original[0]['sha256']}], [{**original[0], 'extra': True}], [{**original[0], 'sha256': None}], [{**original[0], 'sha256': 1}]):
with self.subTest(manifest=invalid):
(entry / 'pages.json').write_text(json.dumps(invalid))
self.assertIsNone(generator.engraving.cache.restore_pages(entry, self.root / 'output'))
(entry / 'pages.json').write_text(json.dumps(original))
page = entry / '0001.png'
external = self.root / 'outside.png'
page.rename(external)
page.symlink_to(external)
self.assertIsNone(generator.engraving.cache.restore_pages(entry, self.root / 'output'))
alias = self.root / 'alias'
alias.symlink_to(entry, target_is_directory=True)
self.assertIsNone(generator.engraving.cache.restore_pages(alias, self.root / 'output'))
def test_parallel_output_and_shared_cache_are_deterministic(self):
documents = []
for index in range(4):
for letter in ('C', 'D'):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(letter)))
document['metadata']['title'] = f'Fixture {index}'
documents.append(document)
serial, parallel, warm = (self.root / name for name in ('serial', 'parallel', 'warm'))
self.write_scores(serial, documents)
generator.engraving.pipeline.engrave_tree(serial, self.fonts)
for root in (parallel, warm):
self.write_scores(root, documents)
progress = io.StringIO()
with contextlib.redirect_stdout(progress):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache, jobs=2, progress=True)
self.assertEqual(self.files(serial), self.files(root))
if root == parallel:
self.assertIn('fullscore 0, bodyhits 6, bodies 2, renderedpages 8', progress.getvalue())
else:
self.assertIn('fullscore 8, bodyhits 0, bodies 0, renderedpages 0', progress.getvalue())
self.assertEqual(len(list(self.cache.rglob('pages.json'))), 8)
self.assertEqual(len(list(self.cache.rglob('band.json'))), 2)
self.assertFalse(list(self.cache.rglob('.engraving_*')))
def test_environment_change_gets_new_namespace_and_clear_progress(self):
old = self.seed_cache()
root = self.root / 'new-environment'
self.write_scores(root, [self.document])
progress = io.StringIO()
with unittest.mock.patch('generator.engraving.cache.platform.python_version', return_value='0.0.0'), contextlib.redirect_stdout(progress):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache, progress=True)
self.assertIn('cold namespace', progress.getvalue())
self.assertIn('0 reused, 1 missing, 0 rejected', progress.getvalue())
self.assertTrue((old / 'pages.json').is_file())
self.assertEqual(len(list(self.cache.iterdir())), 2)
self.write_scores(root, [self.document])
progress = io.StringIO()
with unittest.mock.patch('generator.engraving.cache.platform.python_version', return_value='0.0.0'), contextlib.redirect_stdout(progress), unittest.mock.patch('generator.engraving.layout.layout_systems', side_effect=AssertionError('cache miss')):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache, progress=True)
self.assertIn('1 reused, 0 missing, 0 rejected', progress.getvalue())
def test_namespace_symlink_is_rejected(self):
self.cache.mkdir()
(self.cache / 'renderer').symlink_to(self.root, target_is_directory=True)
with self.assertRaisesRegex(ValueError, 'must not be symlinks'):
generator.engraving.cache.renderer_namespace(self.cache, 'renderer')
def test_only_changed_score_is_rendered_and_validation_reads_json_once(self):
root = self.root / 'scores'
self.write_scores(root / 'songs', [self.document, self.document])
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
changed = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')))
self.write_scores(root / 'songs', [self.document, changed])
with unittest.mock.patch('generator.engraving.layout.layout_systems', wraps=generator.engraving.layout.layout_systems) as render:
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
render.assert_called_once()
generator.pico_artwork.generate_titles(root, self.fonts)
read_text = pathlib.Path.read_text
reads = []
def read(path, *args, **kwargs):
reads.append(path)
return read_text(path, *args, **kwargs)
with unittest.mock.patch.object(pathlib.Path, 'read_text', read):
generator.score.validate_generated_tree(root)
self.assertCountEqual(reads, sorted(root.rglob('*.json')))
def test_invalid_worker_count_and_overlapping_cache_are_rejected(self):
root = self.root / 'assets'
self.write_scores(root, [self.document])
for jobs in (0, -1, 17, True):
with self.assertRaisesRegex(ValueError, 'between 1 and 16'):
generator.engraving.pipeline.engrave_tree(root, self.fonts, jobs=jobs)
for cache in (root, root / 'cache', self.root):
with self.assertRaisesRegex(ValueError, 'must not overlap'):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=cache)
def test_parallel_generation_preserves_assets_on_worker_failure(self):
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
data, output = self.root / 'data', self.root / 'output'
(data / 'songs').mkdir(parents=True)
source = data / 'songs/fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2)
generator.__main__.generate_assets(args)
original = self.files(output)
generator.__main__.generate_assets(args)
self.assertEqual(original, self.files(output))
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
generator.__main__.generate_assets(args)
self.assertEqual(original, self.files(output))
self.assertFalse(list(output.parent.glob('.vamp_assets_*')))