Files
product-vamp/tests/test_printing.py
T
khodowany 3f63b255ac
ci / checks (push) Failing after 23m26s
Fix Playability
2026-10-08 19:04:06 -05:00

169 lines
9.1 KiB
Python

"""Build-only print layout, PDF contents and reproducible cache behavior."""
import generator.score_io
import copy
import dataclasses
import io
import json
import math
import pathlib
import tempfile
import unittest
import unittest.mock
import PIL.Image
import PIL.PdfParser
import generator.arrange.generate
import generator.engraving.audit
import generator.engraving.fonts
import generator.model
import generator.musicxml
import generator.printing
import tests.solo_fixtures
import tests.test_musicxml
class PrintableParts(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.font_path = pathlib.Path('package/assets/fonts/petaluma')
cls.fonts = generator.engraving.fonts.Fonts(cls.font_path)
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = pathlib.Path(temporary.name)
self.source = tests.solo_fixtures.song(count=2, chords=('C7',))
def tree(self, name, document=None):
root = self.root / name
(root / 'songs').mkdir(parents=True)
generator.score_io.write_json(root / 'songs/part.json', document or self.source)
return root
def test_letter_pagination_preserves_complete_order_without_transition_duplicates(self):
source = tests.solo_fixtures.song(count=32, chords=('C7',))
before = copy.deepcopy(source)
layout = generator.printing.layout_part(source, self.fonts)
self.assertGreater(len(layout.pages), 1)
self.assertGreater(len(layout.pages[0].systems), 2)
self.assertEqual(source, before)
systems = [s for p in layout.pages for s in p.systems]
self.assertEqual(systems[0].start, 0)
self.assertEqual(systems[-1].end, 128)
self.assertTrue(all(a.end == b.start for a, b in zip(systems, systems[1:])))
for page in layout.pages:
self.assertEqual((page.width, page.height), (1224, 1584))
self.assertTrue(all(p.box.left >= 72 and p.box.right <= 1152 for p in page.primitives))
self.assertTrue(all(p.box.top >= 72 and p.box.bottom <= 1512 for p in page.primitives))
# Sharing content audits must not relax the screen's two-system contract.
with self.assertRaisesRegex(ValueError, 'two music systems'):
generator.engraving.audit.audit_layout(source, layout, self.fonts)
def test_content_audits_remain_active_for_print(self):
source = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
layout = generator.printing.layout_part(source, self.fonts)
roles = {p.role for page in layout.pages for p in page.primitives}
self.assertTrue({'tie', 'lyric', 'harmony', 'accidental'} <= roles)
page = layout.pages[0]
broken = dataclasses.replace(page, primitives=tuple(p for p in page.primitives if p.role != 'tie'))
with self.assertRaisesRegex(ValueError, 'tie was omitted'):
generator.engraving.audit.audit_layout(source, dataclasses.replace(layout, pages=(broken,)), self.fonts, systems_per_page=None)
def test_tight_page_uses_the_actual_footer_outline(self):
source = tests.solo_fixtures.song(count=32, chords=('C7',))
original = generator.printing.layout_part(source, self.fonts)
style = generator.printing.STYLE
# Two systems just fit the old nominal 20-unit footer reservation. The
# page-number outline needs more space than that arithmetic assumes.
height = math.ceil(original.pages[0].systems[1].box.bottom + style.margin_y + 20 + style.page_clearance)
with unittest.mock.patch('generator.printing.STYLE', dataclasses.replace(style, height=height)):
layout = generator.printing.layout_part(source, self.fonts)
for page in layout.pages:
footer = next(p for p in page.primitives if p.role == 'page_number')
self.assertGreaterEqual(footer.box.top - page.systems[-1].box.bottom, style.page_clearance - .01)
def test_guitar_tab_pickups_and_final_three_bar_hold_are_included(self):
source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter'), implicit=True)
source = source.replace('</measure></part>', '</measure><measure number="2"><harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note() + '</measure></part>')
source = tests.test_musicxml.parse(source)
for part in ('acoustic', 'electric', 'vocal'):
document = generator.arrange.generate.arrange_document(source, part)
if part != 'vocal':
document['staves'][0]['tablature'] = True
before = copy.deepcopy(document)
layout = generator.printing.layout_part(document, self.fonts)
self.assertEqual(document, before)
self.assertEqual(layout.pages[-1].systems[-1].end, sum(generator.model.fraction(m['duration']) for m in document['staves'][0]['measures']))
self.assertTrue(all(m['ending_sustain'] for m in document['staves'][0]['measures'][-3:]))
roles = {p.role for page in layout.pages for p in page.primitives}
self.assertIn('rest', roles)
if part != 'vocal':
self.assertTrue({'tab', 'tab_staff', 'tie'} <= roles)
def test_pdf_pages_are_letter_300dpi_white_grayscale_and_reproducible(self):
source = tests.solo_fixtures.song(count=32, chords=('C7',))
first, second = self.root / 'first.pdf', self.root / 'second.pdf'
count = generator.printing.write_pdf(first, source, self.fonts)
self.assertGreater(count, 1)
generator.printing.write_pdf(second, source, self.fonts)
self.assertEqual(first.read_bytes(), second.read_bytes())
pdf = PIL.PdfParser.PdfParser(filename=str(first))
try:
self.assertEqual(len(pdf.pages), count)
self.assertNotIn(b'CreationDate', pdf.info)
self.assertNotIn(b'ModDate', pdf.info)
for reference in pdf.pages:
page = pdf.read_indirect(reference)
self.assertEqual(page[b'MediaBox'], [0, 0, 612, 792])
stream = pdf.read_indirect(page[b'Resources'][b'XObject'][b'image'])
with PIL.Image.open(io.BytesIO(stream.buf)) as image:
self.assertEqual(image.size, (2550, 3300))
self.assertEqual(image.mode, 'L')
self.assertEqual(image.getextrema(), (0, 255))
for box in ((0, 0, 150, 3300), (2400, 0, 2550, 3300), (0, 0, 2550, 150), (0, 3150, 2550, 3300)):
with image.crop(box) as margin:
self.assertEqual(margin.getextrema(), (255, 255))
finally:
pdf.close()
with self.assertRaisesRegex(ValueError, 'page count'):
generator.printing.validate_pdf(first, count + 1)
def test_single_page_cache_hit_corruption_and_stale_removal(self):
root, cache = self.tree('assets'), self.root / 'cache'
self.assertEqual(generator.printing.generate_tree(root, self.font_path, cache_dir=cache), 1)
target = root / 'print/part.pdf'
original = target.read_bytes()
self.assertEqual(generator.printing.validate_pdf(target), 1)
warm = self.tree('warm')
with unittest.mock.patch('generator.printing.write_pdf', side_effect=AssertionError('cache miss')):
generator.printing.generate_tree(warm, self.font_path, cache_dir=cache)
self.assertEqual(original, (warm / 'print/part.pdf').read_bytes())
asset = next(cache.rglob('part.pdf'))
asset.write_bytes(original + b'corrupt')
with unittest.mock.patch('generator.printing.write_pdf', wraps=generator.printing.write_pdf) as render:
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
render.assert_called_once()
self.assertEqual(asset.read_bytes(), original)
info = next(cache.rglob('print.json'))
metadata = json.loads(info.read_text())
info.write_text(json.dumps({**metadata, 'pages': metadata['pages'] + 1}))
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
self.assertEqual(json.loads(info.read_text()), metadata)
(root / 'songs/part.json').unlink()
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
self.assertFalse(list((root / 'print').iterdir()))
def test_print_cache_tracks_music_style_and_fonts(self):
root, cache = self.tree('assets'), self.root / 'cache'
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
changed = copy.deepcopy(self.source)
changed['metadata']['title'] = 'Another title'
generator.score_io.write_json(root / 'songs/part.json', changed)
with unittest.mock.patch('generator.printing.write_pdf', wraps=generator.printing.write_pdf) as render:
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
render.assert_called_once()
key = generator.printing.renderer_key(self.fonts)
with unittest.mock.patch('generator.printing.STYLE', dataclasses.replace(generator.printing.STYLE, system_gap=40)):
self.assertNotEqual(key, generator.printing.renderer_key(self.fonts))
with unittest.mock.patch.object(self.fonts, 'fingerprint', return_value='changed-fonts'):
self.assertNotEqual(key, generator.printing.renderer_key(self.fonts))
def test_print_cache_cannot_overlap_published_assets(self):
root = self.tree('assets')
for cache in (root, root / 'cache', root.parent):
with self.assertRaisesRegex(ValueError, 'must not overlap'):
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
if __name__ == '__main__':
unittest.main()