from contextlib import closing import json import sqlite3 import tempfile import unittest from pathlib import Path from generator.catalog import APPLICATION_ID, build_catalog def bundle(title='Fixture', key='C', part='Lead'): return { 'music': {'metadata': {'title': title, 'composer': 'Composer', 'key_signature': key, 'time_signature': '4/4'}, 'staves': [{'name': part, 'arrangement_part_type': part.lower(), 'measures': [{'voices': []}]}]}, 'presentation': {'width': 976, 'height': 484, 'pages': [{'path': 'fixture.pages/1.png', 'sha256': '0' * 64}]}, } def write_score(root, relative, document=None): path = root / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(document or bundle()), encoding='utf-8') return path class CatalogTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) self.root = Path(self.temporary.name) (self.root / 'songs').mkdir() def rows(self, table): with closing(sqlite3.connect(self.root / 'catalog.db')) as connection: connection.row_factory = sqlite3.Row return [dict(row) for row in connection.execute(f'SELECT * FROM {table} ORDER BY id')] def test_metadata_relative_paths_and_complete_documents(self): document = bundle('Song', 'F') write_score(self.root, 'songs/tune.json', document) for part in ('Acoustic', 'Electric', 'Vocal'): write_score(self.root, f'practice/001/{part.lower()}.json', bundle('Exercise', 'Eb', part)) database = build_catalog(self.root) self.assertEqual(database, self.root / 'catalog.db') song = self.rows('songs')[0] self.assertEqual(json.loads(song['source_json']), document) self.assertEqual((song['id'], song['part'], song['source_path']), ('tune', 'Lead', 'songs/tune.json')) self.assertEqual((song['measure_count'], song['total_pages']), (1, 1)) practice = self.rows('practice_exercises') self.assertEqual([row['id'] for row in practice], ['001-acoustic', '001-electric', '001-vocal']) self.assertEqual(practice[0]['exercise_id'], '001') self.assertNotIn('practice_type_id', practice[0]) self.assertNotIn('lesson_group_id', practice[0]) self.assertEqual(practice[0]['key_signature'], 'Eb') self.assertNotIn('authored_key_signature', practice[0]) with closing(sqlite3.connect(database)) as connection: self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], APPLICATION_ID) self.assertEqual(connection.execute('PRAGMA integrity_check').fetchall(), [('ok',)]) self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('practice_exercises',), ('songs',)]) self.assertFalse(Path(str(database) + '-wal').exists()) self.assertFalse(Path(str(database) + '-journal').exists()) def test_rebuild_is_deterministic_relocatable_and_replaces_deleted_rows(self): score = write_score(self.root, 'songs/tune.json') database = build_catalog(self.root) previous = database.read_bytes() build_catalog(self.root) self.assertEqual(database.read_bytes(), previous) with tempfile.TemporaryDirectory() as other: root = Path(other) write_score(root, 'songs/tune.json') self.assertEqual(build_catalog(root).read_bytes(), previous) score.rename(score.with_name('renamed.json')) build_catalog(self.root) self.assertEqual([row['id'] for row in self.rows('songs')], ['renamed']) score.with_name('renamed.json').unlink() build_catalog(self.root) self.assertEqual(self.rows('songs'), []) def test_invalid_input_preserves_existing_database(self): score = write_score(self.root, 'songs/tune.json') database = build_catalog(self.root) previous = database.read_bytes() for document in ({}, bundle(title=''), bundle(key=None)): score.write_text(json.dumps(document)) with self.assertRaises(ValueError): build_catalog(self.root) self.assertEqual(database.read_bytes(), previous) self.assertFalse(list(self.root.glob('.vamp-catalog-*'))) def test_missing_parts_and_metadata_disagreement_fail(self): for part in ('Acoustic', 'Electric'): write_score(self.root, f'practice/001/{part.lower()}.json', bundle(part=part)) with self.assertRaisesRegex(ValueError, 'all three parts'): build_catalog(self.root) write_score(self.root, 'practice/001/vocal.json', bundle(key='F', part='Vocal')) with self.assertRaisesRegex(ValueError, 'disagree'): build_catalog(self.root) def test_keyless_parts_preserve_their_original_key(self): for part in ('Acoustic', 'Electric', 'Vocal'): write_score(self.root, f'practice/001/{part.lower()}.json', bundle(key='No key', part=part)) build_catalog(self.root) self.assertTrue(all(row['key_signature'] == 'No key' for row in self.rows('practice_exercises'))) def test_invalid_layout_and_escaping_source_fail(self): with self.assertRaisesRegex(ValueError, 'directory not found'): build_catalog(self.root / 'missing') self.assertFalse((self.root / 'missing').exists()) score = write_score(self.root, 'songs/nested/tune.json') with self.assertRaisesRegex(ValueError, 'unexpected songs'): build_catalog(self.root) score.unlink() (self.root / 'songs/escape.json').symlink_to('/etc/passwd') with self.assertRaises(ValueError): build_catalog(self.root) def test_runtime_asset_packaging_excludes_score_json(self): import subprocess write_score(self.root, 'songs/tune.json') build_catalog(self.root) (self.root / 'songs/tune.png').write_bytes(b'page') with tempfile.TemporaryDirectory() as temporary: destination = Path(temporary) subprocess.run(['cmake', f'-DSOURCE={self.root}', f'-DDESTINATION={destination}', '-P', 'cmake/runtime_assets.cmake'], check=True, capture_output=True) self.assertTrue((destination / 'catalog.db').exists()) for name in ('vamp.example.json', 'vamp.development.json'): config = json.loads((Path('package/config') / name).read_text()) self.assertEqual(Path(config['database']['path']).name, 'catalog.db') self.assertTrue((destination / 'songs/tune.png').exists()) self.assertFalse(list(destination.rglob('*.json')))