import copy from fractions import Fraction import json from pathlib import Path import tempfile import unittest from xml.etree import ElementTree from generator.engraving import Fonts, Style, engrave_tree, layout_score from generator.engraving.attributes import staff_attributes from generator.engraving.notation import ScoreIndex from generator.model import key_from_fifths, parse_pitch_name, rational, validate_document from generator.practice.generate import generate_practice_scores, key_file_name, practice_document from generator.score import load_json, write_json from generator.transposition import TARGET_KEYS, concert_document, shift_key, transpose_document from tests.test_musicxml import parse, xml_note, xml_score FONT_ROOT = Path('package/assets/fonts/petaluma') def key_change(old, new, local=False, attributes=''): initial = f'{old}' + attributes change = f'{old}{new}' if local: return parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half'), initial)) root = ElementTree.fromstring(xml_score(xml_note(), initial)) root.find('part').append(ElementTree.fromstring('' + change + xml_note() + '')) return parse(ElementTree.tostring(root, encoding='unicode')) class KeyCancellationTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.fonts = Fonts(FONT_ROOT) def test_sharp_flat_and_cross_sign_changes_keep_their_placement(self): cases = [(1, 0, 'A', 3, 2, -54), (-2, -1, 'Ab', -4, -3, -36), (-1, -2, 'G', 1, 0, -48), (1, 2, 'F', -1, 0, -24)] for old, new, target, expected_old, expected_new, natural_y in cases: for local in (False, True): with self.subTest(old=old, new=new, target=target, local=local): source = key_change(old, new, local) before = copy.deepcopy(source) result = transpose_document(source, target) validate_document(result) measures = result['staves'][0]['measures'] self.assertEqual(measures[0]['key'], key_from_fifths(expected_old)) change = measures[0]['attributes'][0] if local else measures[1] self.assertEqual(change['key'], key_from_fifths(expected_new) | {'cancel': expected_old}) self.assertEqual(len(measures), 1 if local else 2) if local: self.assertEqual(len(measures[0]['attributes']), 1) self.assertEqual(change['onset'], rational(2)) else: self.assertTrue(all(not m['attributes'] for m in measures)) self.assertEqual(change['number'], '2') time = Fraction(2 if local else 4) index = ScoreIndex(result) glyphs = staff_attributes(index, index.state(time), time, self.fonts, 12, 'change', ('key',)) naturals = [p for p in glyphs if p.value == 'accidentalNatural'] self.assertEqual(len(naturals), 1) self.assertEqual(naturals[0].points[0][1], natural_y) self.assertEqual([p.value for p in glyphs[1:]], ['accidentalSharp' if expected_new > 0 else 'accidentalFlat'] * abs(expected_new)) layout = layout_score(result, Style(), self.fonts) reference = 'measure-1:attributes:0' if local else 'measure-2:initial' drawn = [p for page in layout.pages for p in page.primitives if p.role == 'accidental' and p.sources == (reference,)] self.assertEqual([p.value for p in drawn], [p.value for p in glyphs]) self.assertEqual([p.points[0][1] - drawn[0].points[0][1] for p in drawn], [p.points[0][1] - glyphs[0].points[0][1] for p in glyphs]) self.assertEqual(source, before) def test_metadata_and_initial_signature_transpose_cancellation(self): source = parse(xml_score(xml_note(), '10')) result = transpose_document(source, 'D', octave_shift=1) expected = key_from_fifths(2) | {'cancel': 3} self.assertEqual(result['metadata']['key'], expected) self.assertEqual(result['staves'][0]['measures'][0]['key'], expected) def test_zero_and_modal_cancellations_reference_the_old_signature(self): for mode in ('major', 'minor', 'dorian', 'none'): with self.subTest(mode=mode): key = key_from_fifths(-1, mode) | {'cancel': 0} result = shift_key(key, 1, 2) self.assertEqual(result['cancel'], 2) self.assertEqual(result['mode'], mode) self.assertEqual(result['alterations'], key_from_fifths(1)['alterations']) def test_unchanged_accidentals_do_not_receive_naturals(self): for old, new, target in [(1, 0, 'F'), (-1, 0, 'G'), (1, 1, 'A')]: with self.subTest(old=old, new=new, target=target): result = transpose_document(key_change(old, new), target) index = ScoreIndex(result) glyphs = staff_attributes(index, index.state(Fraction(4)), Fraction(4), self.fonts, 12, 'change', ('key',)) self.assertNotIn('accidentalNatural', [p.value for p in glyphs]) def test_unrepresentable_cancellation_fails_without_mutating_source(self): for fifths, steps, semitones in [(14, 1, 2), (-14, -1, -2), (0, 0, Fraction(1, 2))]: key = key_from_fifths(0) | {'cancel': fifths} before = copy.deepcopy(key) with self.subTest(fifths=fifths, semitones=semitones), self.assertRaisesRegex(ValueError, 'key cancellation.*represented'): shift_key(key, steps, semitones) self.assertEqual(key, before) for local in (False, True): source = key_change(0, 0, local) measures = source['staves'][0]['measures'] change = measures[0]['attributes'][0] if local else measures[1] change['key']['cancel'] = 14 before = copy.deepcopy(source) with self.subTest(local=local), self.assertRaisesRegex(ValueError, 'key cancellation.*represented'): transpose_document(source, 'D') self.assertEqual(source, before) def test_concert_transposition_uses_the_same_interval_for_cancellation(self): attributes = '12' for local in (False, True): with self.subTest(local=local): source = key_change(1, 0, local, attributes) result = concert_document(source) self.assertEqual(result, concert_document(result)) measures = result['staves'][0]['measures'] change = measures[0]['attributes'][0] if local else measures[1] self.assertEqual(change['key'], key_from_fifths(2) | {'cancel': 3}) class AuthoredKeyMetadataTests(unittest.TestCase): def test_eb_authored_key_survives_every_generated_practice_variant(self): with tempfile.TemporaryDirectory() as temp: source, output = Path(temp) / 'source', Path(temp) / 'output' text = xml_score(xml_note('E', alter=-1), '-3') source.mkdir() path = source / '001.xml' path.write_text(text, encoding='utf-8') self.assertEqual(generate_practice_scores(source, output), 12) for key in TARGET_KEYS: with self.subTest(key=key): result = load_json(output / '001' / key_file_name(key)) self.assertEqual(result['schema_version'], 3) self.assertEqual(result['metadata']['authored_key_signature'], 'Eb') self.assertEqual(result['metadata']['key_signature'], key) self.assertEqual(result['metadata']['variant_tonic'], key) self.assertEqual(path.read_text(encoding='utf-8'), text) def test_modes_and_keyless_authored_labels_survive_variants(self): for mode, fifths, label in [('minor', -3, 'C minor'), ('dorian', -2, 'C dorian'), ('mixolydian', -1, 'C mixolydian'), ('none', 0, 'No key')]: source = parse(xml_score(xml_note(), f'{fifths}{mode}')) if mode == 'none': source['metadata']['transpose_origin'] = parse_pitch_name('C') before = copy.deepcopy(source) base = practice_document(source) self.assertEqual(base['metadata']['authored_key_signature'], label) for key in TARGET_KEYS: with self.subTest(mode=mode, key=key): result = transpose_document(base, key) validate_document(result) self.assertEqual(result['metadata']['authored_key_signature'], label) self.assertEqual(result['metadata']['key_signature'], 'No key' if mode == 'none' else f'{key} {mode}') self.assertEqual(result['metadata']['variant_tonic'], key) self.assertEqual(source, before) def test_existing_authored_key_is_retained_when_practice_is_rebuilt(self): source = parse(xml_score(xml_note('E', alter=-1), '-3')) before = copy.deepcopy(source) variant = transpose_document(practice_document(source), 'D') result = transpose_document(practice_document(variant), 'F') self.assertEqual(result['metadata']['authored_key_signature'], 'Eb') self.assertEqual(result['metadata']['key_signature'], 'F') self.assertEqual(source, before) def test_schema3_bundles_accept_optional_authored_key(self): source = parse(xml_score(xml_note('E', alter=-1), '-3')) self.assertNotIn('authored_key_signature', source['metadata']) for authored in (False, True): with self.subTest(authored=authored), tempfile.TemporaryDirectory() as temp: result = transpose_document(practice_document(source) if authored else source, 'D') root = Path(temp) path = root / 'variant.json' write_json(path, result) engrave_tree(root, FONT_ROOT) bundle = json.loads(path.read_text(encoding='utf-8')) self.assertEqual(bundle['schema_version'], 3) self.assertEqual(bundle['music']['metadata']['key_signature'], 'D') self.assertEqual(bundle['music']['metadata'].get('authored_key_signature'), 'Eb' if authored else None) self.assertEqual(load_json(path), result)