Transpose
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
#include <vamp_pico/http_client.hpp>
|
||||
#include <vamp_zero/database.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/parse.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
@@ -321,6 +322,95 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) {
|
||||
BOOST_TEST(count > 0U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(chord_and_melody_practice_share_key_navigation_and_remote_display) {
|
||||
for (auto type : {"chord", "melody"}) {
|
||||
auto state = navigation_state();
|
||||
auto &exercise = state.practice.exercises.front();
|
||||
exercise.practice_type_id = type;
|
||||
auto variant = exercise.variants.front();
|
||||
variant.id = "exercise-d";
|
||||
variant.key_signature = "D";
|
||||
variant.source_path = "exercise-d.json";
|
||||
variant.source_json = score_asset_json("Exercise", "D");
|
||||
variant.total_pages = 3U;
|
||||
exercise.variants.push_back(variant);
|
||||
state.practice.types.front().id = type;
|
||||
state.practice.types.front().lesson_groups.front().exercises = {exercise};
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
|
||||
state.selected_practice_type_index = 1U;
|
||||
state.selected_practice_lesson_index = 1U;
|
||||
state.selected_practice_exercise_index = 1U;
|
||||
state.audio.metronome_enabled = true;
|
||||
BOOST_TEST(vamp_zero::supports_key_selection(exercise));
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU);
|
||||
BOOST_TEST(vamp_zero::practice_key_row_count(state) == 3U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_SCORE);
|
||||
BOOST_TEST(state.audio.metronome_enabled);
|
||||
auto selected = vamp_zero::selected_practice_variant(state, state.practice.exercises.front());
|
||||
BOOST_REQUIRE(selected != nullptr);
|
||||
BOOST_TEST(selected->id == "exercise-d");
|
||||
BOOST_TEST(selected->source_path == "exercise-d.json");
|
||||
BOOST_TEST(vamp_zero::active_pages(state) == 3U);
|
||||
auto remote = vamp_pico::remote_display_state{};
|
||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
|
||||
BOOST_TEST(remote.active);
|
||||
BOOST_TEST(std::string{remote.part} == "Key: D");
|
||||
BOOST_TEST(remote.pages == 3U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.page_index == 1U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU);
|
||||
BOOST_TEST(state.selected_practice_key_index == 2U);
|
||||
BOOST_TEST(state.active_practice_id.empty());
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU);
|
||||
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
|
||||
auto root = std::filesystem::temp_directory_path() / ("product-vamp-keys-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
auto config = test_database_config(root / "vamp.db");
|
||||
BOOST_REQUIRE(run_ensure_database(config));
|
||||
for (auto iteration = std::uint32_t{}; iteration < 2U; ++iteration) {
|
||||
BOOST_REQUIRE(run_import(config, VAMP_TEST_ASSETS_DIR));
|
||||
auto io = boost::asio::io_context{};
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config), boost::asio::use_future);
|
||||
io.run();
|
||||
auto library = future.get();
|
||||
BOOST_REQUIRE(library);
|
||||
BOOST_TEST(library->exercises.size() == 4U);
|
||||
for (auto const &exercise : library->exercises) {
|
||||
BOOST_TEST(vamp_zero::supports_key_selection(exercise));
|
||||
BOOST_REQUIRE_EQUAL(exercise.variants.size(), 12U);
|
||||
auto keys = vamp_zero::practice_key_names();
|
||||
for (auto index = std::size_t{}; index < keys.size(); ++index) {
|
||||
BOOST_TEST(exercise.variants[index].key_signature == keys[index]);
|
||||
BOOST_REQUIRE(vamp_zero::parse_score_document(exercise.variants[index].source_json, exercise.variants[index].source_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(keyless_practice_variants_display_their_transposition_reference) {
|
||||
auto source = boost::json::parse(score_asset_json("Keyless", "No key"));
|
||||
auto &music = source.as_object()["music"].as_object();
|
||||
music["metadata"].as_object()["variant_tonic"] = "D";
|
||||
music["staves"].as_array().front().as_object()["arrangement_part_type"] = "practice";
|
||||
auto document = vamp_zero::parse_score_document(boost::json::serialize(source), "d.json");
|
||||
BOOST_REQUIRE(document);
|
||||
BOOST_TEST(document->key_signature == "D (no key)");
|
||||
auto retained = boost::json::parse(document->source_json);
|
||||
BOOST_TEST(retained.as_object()["music"].as_object()["metadata"].as_object()["key_signature"].as_string() == "No key");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(integer_parsing_checks_range_sign_and_entire_input) {
|
||||
BOOST_TEST(vamp_zero::parse_integer<std::uint16_t>("65535", "port").value_or(0) == 65535U);
|
||||
BOOST_TEST(vamp_zero::parse_integer<std::int32_t>("-2147483648", "octave").value_or(0) == std::numeric_limits<std::int32_t>::min());
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import argparse
|
||||
import copy
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from generator.__main__ import generate_assets
|
||||
from generator.arrange.fretboard import find_playable_voicing, positions_for_string
|
||||
from generator.guitar import TabPosition, positions_for_pitch, within_fret_span
|
||||
from generator.model import fraction, key_from_fifths, pitch_number, rational, validate_document
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.musicxml_writer import write_musicxml
|
||||
from generator.practice.generate import generate_practice_scores, practice_document
|
||||
from generator.practice.guitar import guitar_tones, transpose_guitar_practice, validate_guitar_practice
|
||||
from generator.summary import validate_practice_variants
|
||||
from generator.transposition import TARGET_KEYS, transpose_document, transposition_interval
|
||||
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||
|
||||
OCTAVE_CLEF = '<clef><sign>G</sign><line>2</line><clef-octave-change>-1</clef-octave-change></clef>'
|
||||
GUITAR_TRANSPOSE = '<transpose><diatonic>0</diatonic><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>'
|
||||
TAB_STAFF = '<clef><sign>TAB</sign><line>5</line></clef><staff-details><staff-lines>6</staff-lines></staff-details>'
|
||||
|
||||
def technical(string, fret):
|
||||
return f'<notations><technical><string>{string}</string><fret>{fret}</fret></technical></notations>'
|
||||
|
||||
def all_pitches(document):
|
||||
return [p for staff in document['staves'] for measure in staff['measures'] for voice in measure['voices'] for note in voice['notes'] for p in note['pitches']]
|
||||
|
||||
class GuitarPracticeTests(unittest.TestCase):
|
||||
def test_fret_boundaries_and_crossing_twelve(self):
|
||||
self.assertIn(TabPosition(6, 0, 40), positions_for_pitch(Fraction(40)))
|
||||
self.assertIn(TabPosition(1, 15, 79), positions_for_pitch(Fraction(79)))
|
||||
self.assertFalse(positions_for_pitch(Fraction(80)))
|
||||
self.assertFalse(positions_for_pitch(Fraction(129, 2)))
|
||||
self.assertEqual([p.fret for p in positions_for_string(1, 64, tuple(range(12)))], list(range(16)))
|
||||
self.assertTrue(within_fret_span((TabPosition(4, 11, 61), TabPosition(3, 12, 67), TabPosition(2, 14, 73))))
|
||||
self.assertTrue(within_fret_span((TabPosition(6, 0, 40), TabPosition(1, 15, 79))))
|
||||
self.assertFalse(within_fret_span((TabPosition(4, 10, 60), TabPosition(1, 15, 79))))
|
||||
content = xml_note('G', extra=technical(1, 15), octave=5)
|
||||
validate_guitar_practice(parse(xml_score(content, TAB_STAFF)))
|
||||
with self.assertRaisesRegex(ValueError, '0-15'):
|
||||
validate_guitar_practice(parse(xml_score(xml_note('G', alter=1, extra=technical(1, 16), octave=5), TAB_STAFF)))
|
||||
def test_arranger_does_not_truncate_candidate_positions(self):
|
||||
def candidates(string, open_midi, pitch_classes):
|
||||
if string == 6:
|
||||
return [TabPosition(6, 15, 48)] * 8 + [TabPosition(6, 1, 48)]
|
||||
return {4: [TabPosition(4, 1, 52)], 3: [TabPosition(3, 1, 58)], 2: [TabPosition(2, 1, 60)]}.get(string, [])
|
||||
with patch('generator.arrange.fretboard.positions_for_string', side_effect=candidates):
|
||||
voicing = find_playable_voicing('C7')
|
||||
self.assertEqual(len(voicing), 4)
|
||||
self.assertTrue(all(p.fret == 1 for p in voicing))
|
||||
def test_current_catalogue_all_keys_preserve_exact_voicings(self):
|
||||
source = practice_document(load_musicxml(Path('data/practice/chord/01_three_note_shell_voicings/01-cycles.xml')))
|
||||
original = copy.deepcopy(source)
|
||||
for key in TARGET_KEYS:
|
||||
with self.subTest(key=key):
|
||||
result = transpose_guitar_practice(source, key)
|
||||
validate_document(result)
|
||||
validate_guitar_practice(result)
|
||||
_, semitones = transposition_interval(source, key)
|
||||
adjustment = 12 if key == 'G' else 0
|
||||
self.assertEqual(result, transpose_document(source, key, octave_shift=adjustment // 12))
|
||||
shifts = {pitch_number(b) - pitch_number(a) for a, b in zip(all_pitches(source), all_pitches(result))}
|
||||
self.assertEqual(shifts, {semitones + adjustment})
|
||||
if key == 'D':
|
||||
self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'A7')
|
||||
self.assertEqual(source, original)
|
||||
def test_non_c_source_modes_and_slash_harmony(self):
|
||||
harmony = '<harmony><root><root-step>E</root-step><root-alter>-1</root-alter></root><kind>major</kind><bass><bass-step>G</bass-step></bass></harmony>'
|
||||
source = parse(xml_score(harmony + xml_note('E', alter=-1), '<key><fifths>-3</fifths><mode>major</mode></key>'))
|
||||
result = transpose_guitar_practice(source, 'D')
|
||||
self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 62)
|
||||
self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'D/F#')
|
||||
for mode, fifths in [('minor', -3), ('dorian', -2)]:
|
||||
source = parse(xml_score(xml_note(), f'<key><fifths>{fifths}</fifths><mode>{mode}</mode></key>'))
|
||||
result = transpose_guitar_practice(source, 'D')
|
||||
self.assertEqual(result['metadata']['key_signature'], f'D {mode}')
|
||||
def test_local_keys_spelling_timing_and_ties_survive(self):
|
||||
source = parse(xml_score(xml_note('B', 2, 'half', alter=1, extra='<tie type="start"/>') + xml_note('B', 2, 'half', alter=1, extra='<tie type="stop"/>')))
|
||||
source['staves'][0]['measures'][0]['attributes'] = [{'onset': rational(2), 'key': key_from_fifths(-1)}]
|
||||
result = transpose_guitar_practice(source, 'D')
|
||||
validate_document(result)
|
||||
self.assertEqual(result, transpose_document(source, 'D'))
|
||||
self.assertEqual(notes(result)[0]['pitches'][0]['letter'], 'c')
|
||||
self.assertEqual(fraction(notes(result)[0]['pitches'][0]['alter']), 2)
|
||||
self.assertEqual(result['staves'][0]['measures'][0]['attributes'][0]['key'], key_from_fifths(1))
|
||||
def test_keyless_reference_required_for_both_practice_types(self):
|
||||
source = parse(xml_score(xml_note(), '<key><fifths>0</fifths><mode>none</mode></key>'))
|
||||
with self.assertRaisesRegex(ValueError, 'vamp:tonic'):
|
||||
transpose_guitar_practice(source, 'D')
|
||||
source['metadata']['transpose_origin'] = {'letter': 'c', 'alter': rational(0)}
|
||||
result = transpose_guitar_practice(source, 'D')
|
||||
self.assertEqual(result['metadata']['key_signature'], 'No key')
|
||||
self.assertEqual(result['metadata']['variant_tonic'], 'D')
|
||||
self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 62)
|
||||
def test_octave_adjustment_does_not_change_harmony_or_key(self):
|
||||
source = parse(xml_score(xml_note()))
|
||||
low = transpose_document(source, 'D')
|
||||
high = transpose_document(source, 'D', octave_shift=1)
|
||||
self.assertEqual(low['metadata'], high['metadata'])
|
||||
self.assertEqual(pitch_number(notes(high)[0]['pitches'][0]) - pitch_number(notes(low)[0]['pitches'][0]), 12)
|
||||
with self.assertRaisesRegex(ValueError, 'integer'):
|
||||
transpose_document(source, 'D', octave_shift=0.5)
|
||||
def test_instrument_transposition_clefs_and_tab_are_applied_once(self):
|
||||
plain = parse(xml_score(xml_note('E', octave=3)))
|
||||
explicit = parse(xml_score(xml_note('E', octave=3), GUITAR_TRANSPOSE))
|
||||
octave_clef = parse(xml_score(xml_note('E', octave=2), OCTAVE_CLEF))
|
||||
tab = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF))
|
||||
both = parse(xml_score(xml_note('E', octave=3), GUITAR_TRANSPOSE + OCTAVE_CLEF))
|
||||
for document in (plain, explicit, octave_clef, tab, both):
|
||||
self.assertEqual(guitar_tones(document)[0].midi, 40)
|
||||
validate_guitar_practice(document)
|
||||
source = parse(xml_score(xml_note('E', 2, 'half', octave=3) + '<attributes>' + GUITAR_TRANSPOSE + '</attributes>' + xml_note('E', 2, 'half', octave=3)))
|
||||
self.assertEqual([tone.midi for tone in guitar_tones(source)], [40, 40])
|
||||
def test_explicit_zero_transposition_overrides_implicit_guitar_octave(self):
|
||||
zero = '<transpose><chromatic>0</chromatic></transpose>'
|
||||
source = parse(xml_score(xml_note('E', octave=2), zero))
|
||||
self.assertTrue(source['staves'][0]['measures'][0]['transpose']['explicit'])
|
||||
self.assertEqual(guitar_tones(source)[0].midi, 40)
|
||||
validate_guitar_practice(source)
|
||||
content = xml_note('E', 2, 'half', octave=3) + '<attributes>' + zero + '</attributes>' + xml_note('E', 2, 'half', octave=2)
|
||||
self.assertEqual([tone.midi for tone in guitar_tones(parse(xml_score(content, GUITAR_TRANSPOSE)))], [40, 40])
|
||||
def test_real_quintuplets_and_standard_tab_tuning_survive_transposition(self):
|
||||
content = ''
|
||||
for index in range(5):
|
||||
tuplet = '<tuplet type="start"/>' if index == 0 else '<tuplet type="stop"/>' if index == 4 else ''
|
||||
extra = '<time-modification><actual-notes>5</actual-notes><normal-notes>4</normal-notes></time-modification><notations>' + tuplet + '</notations>'
|
||||
content += xml_note(duration=4, kind='quarter', extra=extra)
|
||||
source = parse(xml_score(content, divisions=5))
|
||||
result = transpose_guitar_practice(source, 'Db')
|
||||
validate_document(result)
|
||||
self.assertEqual([fraction(n['duration']) for n in notes(result)], [Fraction(4, 5)] * 5)
|
||||
self.assertEqual(source['staves'][0]['tuplets'], result['staves'][0]['tuplets'])
|
||||
tuning = '<staff-details><staff-lines>6</staff-lines>'
|
||||
for line, (step, octave) in enumerate((('E', 2), ('A', 2), ('D', 3), ('G', 3), ('B', 3), ('E', 4)), 1):
|
||||
tuning += f'<staff-tuning line="{line}"><tuning-step>{step}</tuning-step><tuning-octave>{octave}</tuning-octave></staff-tuning>'
|
||||
tuning += '</staff-details>'
|
||||
source = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF + tuning))
|
||||
result = transpose_guitar_practice(source, 'D')
|
||||
self.assertEqual(notes(result)[0]['pitches'][0]['tablature'], {'string': 6, 'fret': 2})
|
||||
self.assertEqual(result['staves'][0]['measures'][0]['staff_details'], source['staves'][0]['measures'][0]['staff_details'])
|
||||
def test_xml_roundtrip_preserves_implicit_and_explicit_transposition(self):
|
||||
for attributes in ('', GUITAR_TRANSPOSE, '<transpose><chromatic>0</chromatic></transpose>'):
|
||||
source = parse(xml_score(xml_note(), attributes))
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / 'roundtrip.xml'
|
||||
write_musicxml(path, source)
|
||||
result = load_musicxml(path)
|
||||
self.assertEqual(source['staves'][0]['measures'][0]['transpose'], result['staves'][0]['measures'][0]['transpose'])
|
||||
self.assertEqual(guitar_tones(source)[0].midi, guitar_tones(result)[0].midi)
|
||||
def test_tab_full_interval_is_applied_before_validating_fret(self):
|
||||
source = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF))
|
||||
with self.assertRaisesRegex(ValueError, 'measure 1.*tablature'):
|
||||
transpose_document(source, 'G')
|
||||
result = transpose_guitar_practice(source, 'G')
|
||||
self.assertEqual(notes(result)[0]['pitches'][0]['tablature'], {'string': 6, 'fret': 7})
|
||||
self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 47)
|
||||
def test_held_strings_cannot_be_reassigned_when_another_voice_enters(self):
|
||||
content = xml_note('E', extra=technical(1, 0)) + '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>'
|
||||
content += xml_note('F', 3, 'half', extra='<dot/><voice>2</voice>' + technical(1, 1))
|
||||
source = parse(xml_score(content, OCTAVE_CLEF))
|
||||
with self.assertRaisesRegex(ValueError, 'held/tied strings'):
|
||||
validate_guitar_practice(source)
|
||||
notes(source)[0]['pitches'][0].pop('tablature')
|
||||
validate_guitar_practice(source)
|
||||
def test_tied_strings_and_unequal_chord_durations(self):
|
||||
start = xml_note('E', 1, 'quarter', extra='<tie type="start"/>' + technical(1, 0))
|
||||
stop = xml_note('E', 3, 'half', extra='<dot/><tie type="stop"/>')
|
||||
other = xml_note('F', 3, 'half', prefix='<chord/>', extra='<dot/>' + technical(1, 1))
|
||||
source = parse(xml_score(start + stop + other, OCTAVE_CLEF))
|
||||
with self.assertRaisesRegex(ValueError, 'held/tied strings'):
|
||||
validate_guitar_practice(source)
|
||||
notes(source)[0]['pitches'][0].pop('tie_to')
|
||||
notes(source)[1]['pitches'][0].pop('tie_from')
|
||||
validate_guitar_practice(source)
|
||||
content = xml_note('E', 2, 'half', extra=technical(1, 0)) + xml_note('B', 1, 'quarter', prefix='<chord/>', extra=technical(2, 0), octave=3)
|
||||
content += '<backup><duration>2</duration></backup><forward><duration>1</duration></forward>' + xml_note('C', 3, 'half', extra='<dot/><voice>2</voice>' + technical(2, 1))
|
||||
validate_guitar_practice(parse(xml_score(content, OCTAVE_CLEF)))
|
||||
def test_grace_notes_at_tie_boundary_do_not_drop_the_held_string(self):
|
||||
start = xml_note('E', 1, 'quarter', extra='<tie type="start"/>' + technical(1, 0))
|
||||
grace = '<note><grace/><pitch><step>B</step><octave>3</octave></pitch><type>eighth</type></note>'
|
||||
stop = xml_note('E', 3, 'half', extra='<dot/><tie type="stop"/>')
|
||||
validate_guitar_practice(parse(xml_score(start + grace + stop, OCTAVE_CLEF)))
|
||||
def test_unsupported_tuning_capo_and_microtones_fail_explicitly(self):
|
||||
for attributes, error in [('<staff-details><capo>1</capo></staff-details>', 'capo'), ('<staff-details><staff-tuning line="1"><tuning-step>D</tuning-step><tuning-octave>2</tuning-octave></staff-tuning></staff-details>', 'standard six-string tuning')]:
|
||||
with self.assertRaisesRegex(ValueError, error):
|
||||
transpose_guitar_practice(parse(xml_score(xml_note(), attributes)), 'C')
|
||||
with self.assertRaisesRegex(ValueError, 'no whole-exercise guitar register'):
|
||||
transpose_guitar_practice(parse(xml_score(xml_note(alter='0.5'))), 'C')
|
||||
def test_no_register_and_failed_generation_preserve_previous_assets(self):
|
||||
content = xml_note('E', octave=2) + xml_note('E', octave=7, prefix='<chord/>')
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
(root / 'data/songs').mkdir(parents=True)
|
||||
source = root / 'data/practice/chord/lesson/wide.xml'
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text(xml_score(content), encoding='utf-8')
|
||||
output = root / 'output'
|
||||
output.mkdir()
|
||||
(output / 'last-good').write_text('preserved')
|
||||
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=Path('package/assets'), clean=True, check=False)
|
||||
with patch('generator.__main__.engrave_tree') as engrave:
|
||||
with self.assertRaisesRegex(ValueError, r'wide.xml, target key C:.*measure 1'):
|
||||
generate_assets(args)
|
||||
engrave.assert_not_called()
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
self.assertEqual(list(output.iterdir()), [output / 'last-good'])
|
||||
def test_all_practice_types_require_twelve_deterministic_variants(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
output = Path(temp)
|
||||
self.assertEqual(generate_practice_scores(Path('data/practice'), output), 48)
|
||||
validate_practice_variants(output)
|
||||
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')}
|
||||
generate_practice_scores(Path('data/practice'), output)
|
||||
self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')})
|
||||
next((output / 'chord').rglob('g.json')).unlink()
|
||||
with self.assertRaisesRegex(ValueError, 'chord practice generator drift'):
|
||||
validate_practice_variants(output)
|
||||
Reference in New Issue
Block a user