+105
-79
@@ -126,16 +126,19 @@ auto navigation_state() -> vamp_zero::app_state {
|
||||
};
|
||||
auto exercise = vamp_zero::practice_exercise{
|
||||
.id = "exercise-1",
|
||||
.practice_type_id = "scale",
|
||||
.practice_type_title = "Scales",
|
||||
.lesson_group_id = "lesson-1",
|
||||
.lesson_group_title = "Major",
|
||||
.title = "C Major",
|
||||
.composer = "Composer",
|
||||
.authored_key_signature = "C",
|
||||
.variants = {variant},
|
||||
};
|
||||
exercise.variants.clear();
|
||||
for (auto key : vamp_zero::practice_key_names()) {
|
||||
variant.id = "exercise-" + std::string{key};
|
||||
variant.key_signature = key;
|
||||
exercise.variants.push_back(variant);
|
||||
}
|
||||
auto practice = vamp_zero::group_practice_exercises({exercise});
|
||||
BOOST_REQUIRE(practice);
|
||||
return vamp_zero::app_state{
|
||||
.catalog = std::make_shared<vamp_zero::catalogue const>(vamp_zero::catalogue{
|
||||
.performance = vamp_zero::group_library_parts({part}),
|
||||
@@ -283,69 +286,97 @@ 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 catalog = std::make_shared<vamp_zero::catalogue>(*state.catalog);
|
||||
auto &exercise = catalog->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.total_pages = 3U;
|
||||
exercise.variants.push_back(variant);
|
||||
catalog->practice.types.front().id = type;
|
||||
state.catalog = catalog;
|
||||
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);
|
||||
BOOST_AUTO_TEST_CASE(practice_key_navigation_and_remote_display) {
|
||||
auto state = navigation_state();
|
||||
auto catalog = std::make_shared<vamp_zero::catalogue>(*state.catalog);
|
||||
auto &variant = catalog->practice.exercises.front().variants[2];
|
||||
variant.source_path = "exercise-d.json";
|
||||
variant.total_pages = 3U;
|
||||
state.catalog = catalog;
|
||||
state.selected_main_menu_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_EXERCISE_MENU);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
state.audio.metronome_enabled = true;
|
||||
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) == 13U);
|
||||
for (auto index = std::uint32_t{}; index < 3U; ++index) {
|
||||
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.catalog->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);
|
||||
auto playing = state;
|
||||
playing.audio.playback_enabled = true;
|
||||
auto control = vamp_zero::control_state{{}, playing};
|
||||
control.finish_playback(playing.score_session);
|
||||
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PRACTICE_KEY_MENU);
|
||||
BOOST_TEST(control.snapshot().selected_practice_key_index == 2U);
|
||||
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));
|
||||
}
|
||||
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.catalog->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);
|
||||
auto playing = state;
|
||||
playing.audio.playback_enabled = true;
|
||||
auto control = vamp_zero::control_state{{}, playing};
|
||||
control.finish_playback(playing.score_session);
|
||||
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PRACTICE_KEY_MENU);
|
||||
BOOST_TEST(control.snapshot().selected_practice_key_index == 3U);
|
||||
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 == 3U);
|
||||
BOOST_TEST(state.active_practice_id.empty());
|
||||
for (auto index = std::uint32_t{}; index < 3U; ++index) {
|
||||
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_TEST(state.selected_practice_exercise_index == 1U);
|
||||
auto status = vamp_zero::app_status_json(state).as_object().at("practice").as_object();
|
||||
BOOST_TEST(!status.contains("types"));
|
||||
BOOST_TEST(status.at("exercises").as_array().size() == 1U);
|
||||
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::MAIN_MENU);
|
||||
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(flat_practice_wraps_and_empty_catalogue_returns_to_main) {
|
||||
auto state = navigation_state();
|
||||
auto catalog = std::make_shared<vamp_zero::catalogue>(*state.catalog);
|
||||
auto first = catalog->practice.exercises.front();
|
||||
for (auto index = std::uint32_t{2}; index <= 366U; ++index) {
|
||||
auto exercise = first;
|
||||
exercise.id = std::to_string(index);
|
||||
catalog->practice.exercises.push_back(std::move(exercise));
|
||||
}
|
||||
state.catalog = catalog;
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
||||
BOOST_TEST(state.selected_practice_exercise_index == 366U);
|
||||
BOOST_TEST(vamp_zero::selected_practice_exercise(state)->id == "366");
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_practice_exercise_index == 0U);
|
||||
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_KEY_MENU);
|
||||
state.catalog = vamp_zero::empty_catalogue();
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU);
|
||||
BOOST_TEST(state.selected_practice_exercise_index == 0U);
|
||||
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::MAIN_MENU);
|
||||
}
|
||||
|
||||
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");
|
||||
auto expected_exercises = std::size_t{};
|
||||
for (auto const &entry : std::filesystem::recursive_directory_iterator{std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "data/practice"}) {
|
||||
for (auto const &entry : std::filesystem::directory_iterator{std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "data"}) {
|
||||
if (entry.is_regular_file() && entry.path().extension() == ".xml") {
|
||||
++expected_exercises;
|
||||
}
|
||||
@@ -360,7 +391,6 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
|
||||
BOOST_REQUIRE_MESSAGE(library.has_value(), (library ? "" : library.error().describe()));
|
||||
BOOST_TEST(library->exercises.size() == expected_exercises);
|
||||
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) {
|
||||
@@ -394,8 +424,10 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
|
||||
auto assets = root / "assets";
|
||||
auto config = test_database_config(root / "vamp.db");
|
||||
std::filesystem::create_directories(assets / "songs");
|
||||
for (auto name : {"chord/001_lesson/010-cycles", "chord/001_lesson/002-cycles", "chord/002_lesson/001-cycles", "melody/001_lesson/127-cycles"}) {
|
||||
write_text(assets / "practice" / name / "c.json", score_asset_json("Shared Title"));
|
||||
for (auto name : {"010", "002", "001", "127"}) {
|
||||
for (auto key : vamp_zero::practice_key_names()) {
|
||||
write_text(assets / "practice" / name / (std::string{key} + ".json"), score_asset_json("Shared Title", std::string{key}));
|
||||
}
|
||||
}
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path));
|
||||
auto io = boost::asio::io_context{};
|
||||
@@ -404,30 +436,24 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
|
||||
auto library = future.get();
|
||||
BOOST_REQUIRE(library);
|
||||
BOOST_REQUIRE_EQUAL(library->exercises.size(), 4U);
|
||||
BOOST_REQUIRE_EQUAL(library->types.size(), 2U);
|
||||
BOOST_TEST(library->types.front().id == "chord");
|
||||
BOOST_REQUIRE_EQUAL(library->types.front().lesson_groups.size(), 2U);
|
||||
auto const &lesson = library->types.front().lesson_groups.front();
|
||||
BOOST_TEST(lesson.id == "001_lesson");
|
||||
BOOST_REQUIRE_EQUAL(lesson.exercises.size(), 2U);
|
||||
BOOST_TEST(library->exercises[lesson.exercises[0]].id == "chord-001_lesson-002-cycles");
|
||||
BOOST_TEST(library->exercises[lesson.exercises[1]].id == "chord-001_lesson-010-cycles");
|
||||
BOOST_TEST(library->exercises[0].id == "001");
|
||||
BOOST_TEST(library->exercises[1].id == "002");
|
||||
BOOST_TEST(library->exercises[2].id == "010");
|
||||
BOOST_TEST(library->exercises[3].id == "127");
|
||||
for (auto const &exercise : library->exercises) {
|
||||
BOOST_TEST(exercise.title == "Shared Title");
|
||||
}
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path));
|
||||
std::filesystem::rename(assets / "practice/chord/001_lesson", assets / "practice/chord/003_renamed_lesson");
|
||||
std::filesystem::rename(assets / "practice/chord/003_renamed_lesson/002-cycles", assets / "practice/chord/003_renamed_lesson/099-renamed");
|
||||
std::filesystem::rename(assets / "practice/002", assets / "practice/099");
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path));
|
||||
auto rows = query_database(root / "vamp.db", "SELECT id FROM practice_exercises ORDER BY id");
|
||||
BOOST_REQUIRE(rows);
|
||||
BOOST_REQUIRE_EQUAL(rows->rows.size(), 4U);
|
||||
BOOST_TEST(!has_value(*rows, 0U, "chord-001_lesson-002-cycles-c"));
|
||||
BOOST_TEST(!has_value(*rows, 0U, "chord-001_lesson-010-cycles-c"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "chord-003_renamed_lesson-099-renamed-c"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "chord-003_renamed_lesson-010-cycles-c"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "chord-002_lesson-001-cycles-c"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "melody-001_lesson-127-cycles-c"));
|
||||
BOOST_REQUIRE_EQUAL(rows->rows.size(), 48U);
|
||||
BOOST_TEST(!has_value(*rows, 0U, "002-C"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "099-C"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "010-C"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "001-C"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "127-C"));
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
|
||||
+39
-52
@@ -4,78 +4,65 @@ import tempfile
|
||||
import unittest
|
||||
|
||||
from generator.practice.generate import generate_practice_scores, key_file_name
|
||||
from generator.score import slugify, validate_generated_tree
|
||||
from generator.arrange.generate import generate_performance_scores
|
||||
from generator.score import validate_generated_tree
|
||||
from generator.summary import validate_practice_variants
|
||||
from generator.transposition import TARGET_KEYS
|
||||
from tests.test_musicxml import xml_note, xml_score
|
||||
|
||||
class CatalogueTests(unittest.TestCase):
|
||||
def test_every_practice_source_has_its_own_complete_output(self):
|
||||
source = Path('data/practice')
|
||||
sources = sorted(source.rglob('*.xml'))
|
||||
self.assertTrue(sources)
|
||||
expected = set()
|
||||
for path in sources:
|
||||
relative = path.relative_to(source)
|
||||
self.assertEqual(len(relative.parts), 3, str(path))
|
||||
self.assertIn(relative.parts[0], ('chord', 'melody'), str(path))
|
||||
for key in TARGET_KEYS:
|
||||
expected.add(relative.parent / slugify(path.stem) / key_file_name(key))
|
||||
self.assertEqual(len(expected), len(sources) * len(TARGET_KEYS))
|
||||
def test_every_numbered_source_has_its_own_complete_output(self):
|
||||
source = Path('data')
|
||||
sources = sorted(source.glob('*.xml'))
|
||||
self.assertEqual([p.name for p in sources], [f'{i:03}.xml' for i in range(1, 367)])
|
||||
expected = {Path(path.stem) / key_file_name(key) for path in sources for key in TARGET_KEYS}
|
||||
self.assertEqual(len(expected), 4392)
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary)
|
||||
self.assertEqual(generate_practice_scores(source, output), len(expected))
|
||||
self.assertEqual({p.relative_to(output) for p in output.rglob('*.json')}, expected)
|
||||
validate_practice_variants(output)
|
||||
validate_generated_tree(output)
|
||||
def test_numbered_paths_and_duplicate_titles_remain_distinct(self):
|
||||
names = ('chord/001_lesson/001-cycles.xml', 'chord/001_lesson/010-cycles.xml', 'chord/002_lesson/001-cycles.xml', 'melody/001_lesson/127-cycles.xml')
|
||||
def test_numbers_and_duplicate_titles_remain_distinct_and_songs_are_excluded(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
|
||||
for name in names:
|
||||
path = source / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
self.assertEqual(generate_practice_scores(source, output), len(names) * len(TARGET_KEYS))
|
||||
for name in names:
|
||||
directory = output / Path(name).with_suffix('')
|
||||
(source / 'songs').mkdir(parents=True)
|
||||
for name in ('001.xml', '010.xml', '366.xml', 'songs/song.xml'):
|
||||
(source / name).write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
self.assertEqual(generate_practice_scores(source, output), 36)
|
||||
self.assertEqual(sorted(p.name for p in output.iterdir()), ['001', '010', '366'])
|
||||
for directory in output.iterdir():
|
||||
self.assertEqual({p.name for p in directory.glob('*.json')}, {key_file_name(key) for key in TARGET_KEYS})
|
||||
self.assertEqual(json.loads((directory / 'c.json').read_text())['metadata']['title'], 'Fixture')
|
||||
def test_invalid_source_locations_fail_before_writing(self):
|
||||
for name in ('chords/001_lesson/001-exercise.xml', 'chord/001-exercise.xml', 'melody/001_lesson/extra/001-exercise.xml'):
|
||||
def test_invalid_names_and_legacy_nesting_preserve_output(self):
|
||||
for name in ('000.xml', '1.xml', '001-copy.xml', '001_copy.xml', 'chord/001.xml', 'practice/melody/lesson/001.xml'):
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
|
||||
for relative in ('chord/001_lesson/001-valid.xml', name):
|
||||
for relative in ('002.xml', name):
|
||||
path = source / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
with self.assertRaisesRegex(ValueError, 'practice source must be stored'):
|
||||
output.mkdir()
|
||||
sentinel = output / 'existing'
|
||||
sentinel.write_text('unchanged')
|
||||
with self.assertRaisesRegex(ValueError, 'practice sources'):
|
||||
generate_practice_scores(source, output)
|
||||
self.assertFalse(output.exists())
|
||||
def test_normalized_filename_collisions_fail_before_writing(self):
|
||||
self.assertEqual(list(output.iterdir()), [sentinel])
|
||||
self.assertEqual(sentinel.read_text(), 'unchanged')
|
||||
def test_missing_and_empty_variant_directories_fail(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
|
||||
lesson = source / 'chord' / '001_lesson'
|
||||
lesson.mkdir(parents=True)
|
||||
for name in ('001-Cycles.xml', '001_cycles.xml'):
|
||||
(lesson / name).write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
with self.assertRaisesRegex(ValueError, 'output path collides'):
|
||||
generate_practice_scores(source, output)
|
||||
self.assertFalse(output.exists())
|
||||
def test_runtime_id_collisions_preserve_existing_output(self):
|
||||
root = Path(temporary)
|
||||
(root / '001').mkdir()
|
||||
with self.assertRaisesRegex(ValueError, 'practice generator drift'):
|
||||
validate_practice_variants(root)
|
||||
def test_songs_generate_only_authored_key_parts(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
|
||||
names = ('chord/one-two/three.xml', 'chord/one/two-three.xml')
|
||||
for name in names:
|
||||
path = source / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
output.mkdir()
|
||||
sentinel = output / 'existing.json'
|
||||
sentinel.write_text('unchanged', encoding='utf-8')
|
||||
with self.assertRaisesRegex(ValueError, 'practice exercise ID collision') as error:
|
||||
generate_practice_scores(source, output)
|
||||
for name in names:
|
||||
self.assertIn(name, str(error.exception))
|
||||
self.assertEqual(list(output.iterdir()), [sentinel])
|
||||
self.assertEqual(sentinel.read_text(), 'unchanged')
|
||||
source, output = Path(temporary) / 'songs', Path(temporary) / 'output'
|
||||
source.mkdir()
|
||||
(source / 'song.xml').write_text(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
|
||||
self.assertEqual(generate_performance_scores(source, output), 3)
|
||||
self.assertEqual({p.name for p in output.iterdir()}, {'song-acoustic.json', 'song-electric.json', 'song-vocal.json'})
|
||||
for path in output.iterdir():
|
||||
metadata = json.loads(path.read_text())['metadata']
|
||||
self.assertEqual(metadata['key_signature'], 'Eb')
|
||||
self.assertNotIn('variant_tonic', metadata)
|
||||
|
||||
@@ -35,16 +35,17 @@ class CatalogueTests(unittest.TestCase):
|
||||
document = bundle('Song', 'F')
|
||||
write_score(self.root, 'songs/tune.json', document)
|
||||
for key in ('c', 'd'):
|
||||
write_score(self.root, f'practice/chord/001_lesson/exercise/{key}.json', bundle('Exercise', key.upper()))
|
||||
write_score(self.root, f'practice/001/{key}.json', bundle('Exercise', key.upper()))
|
||||
database = build_catalogue(self.root)
|
||||
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], ['chord-001_lesson-exercise-c', 'chord-001_lesson-exercise-d'])
|
||||
self.assertEqual(practice[0]['practice_type_title'], 'Chord Practice')
|
||||
self.assertEqual(practice[0]['lesson_group_title'], '001 Lesson')
|
||||
self.assertEqual([row['id'] for row in practice], ['001-c', '001-d'])
|
||||
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]['authored_key_signature'], 'Eb')
|
||||
with closing(sqlite3.connect(database)) as connection:
|
||||
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], APPLICATION_ID)
|
||||
@@ -79,19 +80,19 @@ class CatalogueTests(unittest.TestCase):
|
||||
build_catalogue(self.root)
|
||||
self.assertEqual(database.read_bytes(), previous)
|
||||
self.assertFalse(list(self.root.glob('.vamp-catalogue-*')))
|
||||
def test_path_collisions_and_authored_key_disagreement_fail(self):
|
||||
write_score(self.root, 'practice/chord/one-two/three/c.json')
|
||||
def test_legacy_paths_and_authored_key_disagreement_fail(self):
|
||||
write_score(self.root, 'practice/001/c.json')
|
||||
conflicting = write_score(self.root, 'practice/chord/one/two-three/c.json')
|
||||
with self.assertRaisesRegex(ValueError, 'ID collision'):
|
||||
with self.assertRaisesRegex(ValueError, 'unexpected practice'):
|
||||
build_catalogue(self.root)
|
||||
conflicting.unlink()
|
||||
write_score(self.root, 'practice/chord/one-two/three/d.json', bundle(key='D', authored='F'))
|
||||
write_score(self.root, 'practice/001/d.json', bundle(key='D', authored='F'))
|
||||
with self.assertRaisesRegex(ValueError, 'authored key'):
|
||||
build_catalogue(self.root)
|
||||
def test_keyless_display_metadata_keeps_original_music(self):
|
||||
document = bundle(key='No key', authored='No key')
|
||||
document['music']['metadata']['variant_tonic'] = 'D'
|
||||
write_score(self.root, 'practice/chord/lesson/exercise/d.json', document)
|
||||
write_score(self.root, 'practice/001/d.json', document)
|
||||
build_catalogue(self.root)
|
||||
row = self.rows('practice_exercises')[0]
|
||||
self.assertEqual(row['key_signature'], 'D (no key)')
|
||||
|
||||
@@ -141,19 +141,17 @@ struct catalogue_database {
|
||||
}
|
||||
};
|
||||
|
||||
auto catalogue_exercise(std::string key, std::string authored = "Eb", std::string id = "exercise", std::string lesson = "lesson") -> vamp_zero::practice_exercise {
|
||||
auto variant_id = id + "-" + key;
|
||||
return {
|
||||
auto catalogue_exercise(std::string authored = "Eb", std::string id = "001") -> vamp_zero::practice_exercise {
|
||||
auto result = vamp_zero::practice_exercise{
|
||||
.id = std::move(id),
|
||||
.practice_type_id = "melody",
|
||||
.practice_type_title = "Melody Practice",
|
||||
.lesson_group_id = std::move(lesson),
|
||||
.lesson_group_title = "Lesson",
|
||||
.title = "Exercise",
|
||||
.composer = "Composer",
|
||||
.authored_key_signature = std::move(authored),
|
||||
.variants = {{.id = std::move(variant_id), .key_signature = std::move(key), .measure_count = 3, .total_pages = 2}},
|
||||
};
|
||||
for (auto key : vamp_zero::practice_key_names()) {
|
||||
result.variants.push_back({.id = result.id + "-" + std::string{key}, .key_signature = std::string{key}, .measure_count = 3, .total_pages = 2});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -163,7 +161,7 @@ BOOST_AUTO_TEST_CASE(catalogue_snapshots_heartbeat_and_navigation_share_immutabl
|
||||
{.id = "other", .title = "A Tune", .composer = "Composer", .part = "Lead", .total_pages = 1},
|
||||
{.id = "chosen", .title = "B Tune", .composer = "Composer", .part = "Lead", .total_pages = 2},
|
||||
});
|
||||
auto practice = vamp_zero::group_practice_exercises({catalogue_exercise("D", "Eb", "chosen", "b"), catalogue_exercise("C", "Eb", "other", "a")});
|
||||
auto practice = vamp_zero::group_practice_exercises({catalogue_exercise("Eb", "002"), catalogue_exercise("Eb", "001")});
|
||||
BOOST_REQUIRE(practice);
|
||||
auto catalog = std::make_shared<vamp_zero::catalogue const>(vamp_zero::catalogue{std::move(performance), std::move(*practice)});
|
||||
auto original = vamp_zero::app_state{
|
||||
@@ -171,9 +169,7 @@ BOOST_AUTO_TEST_CASE(catalogue_snapshots_heartbeat_and_navigation_share_immutabl
|
||||
.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU,
|
||||
.selected_song_index = 2,
|
||||
.selected_part_index = 1,
|
||||
.selected_practice_type_index = 1,
|
||||
.selected_practice_lesson_index = 2,
|
||||
.selected_practice_exercise_index = 1,
|
||||
.selected_practice_exercise_index = 2,
|
||||
};
|
||||
auto control = vamp_zero::control_state{{}, original};
|
||||
auto observed = std::vector<vamp_zero::app_state>{};
|
||||
@@ -249,31 +245,37 @@ BOOST_AUTO_TEST_CASE(catalogue_performance_groups_use_title_and_composer_with_pa
|
||||
BOOST_TEST(library.parts[library.groups[1].parts[1]].id == "vocal");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_practice_groups_require_authored_key_agreement_and_index_exercises) {
|
||||
BOOST_AUTO_TEST_CASE(catalogue_practice_requires_complete_ordered_keys_and_authored_key_agreement) {
|
||||
for (auto authored : {"Eb", "C dorian", "No key", ""}) {
|
||||
auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise("D", authored), catalogue_exercise("C", authored), catalogue_exercise("F", "F", "second", "second")});
|
||||
auto first = catalogue_exercise(authored);
|
||||
std::reverse(first.variants.begin(), first.variants.end());
|
||||
auto last = first.variants.back();
|
||||
first.variants.pop_back();
|
||||
auto fragment = catalogue_exercise(authored);
|
||||
fragment.variants = {last};
|
||||
auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise("F", "002"), first, fragment});
|
||||
BOOST_REQUIRE(grouped);
|
||||
BOOST_REQUIRE_EQUAL(grouped->exercises.size(), 2U);
|
||||
auto const &exercise = grouped->exercises[0];
|
||||
BOOST_TEST(exercise.id == "001");
|
||||
BOOST_TEST(exercise.authored_key_signature == authored);
|
||||
BOOST_REQUIRE_EQUAL(exercise.variants.size(), 2U);
|
||||
BOOST_TEST(exercise.variants[0].key_signature == "C");
|
||||
BOOST_TEST(exercise.variants[1].key_signature == "D");
|
||||
BOOST_REQUIRE_EQUAL(grouped->types.size(), 1U);
|
||||
auto const &lessons = grouped->types[0].lesson_groups;
|
||||
BOOST_REQUIRE_EQUAL(lessons.size(), 2U);
|
||||
for (auto index = std::size_t{}; index < lessons.size(); ++index) {
|
||||
BOOST_REQUIRE_EQUAL(lessons[index].exercises.size(), 1U);
|
||||
BOOST_TEST(lessons[index].exercises[0] == index);
|
||||
BOOST_TEST(grouped->exercises[lessons[index].exercises[0]].lesson_group_id == lessons[index].id);
|
||||
BOOST_REQUIRE_EQUAL(exercise.variants.size(), 12U);
|
||||
for (auto index = std::size_t{}; index < exercise.variants.size(); ++index) {
|
||||
BOOST_TEST(exercise.variants[index].key_signature == vamp_zero::practice_key_names()[index]);
|
||||
}
|
||||
}
|
||||
for (auto conflict : {"F", ""}) {
|
||||
auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise("C"), catalogue_exercise("D", conflict)});
|
||||
auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise(), catalogue_exercise(conflict)});
|
||||
BOOST_REQUIRE(!grouped);
|
||||
BOOST_TEST(grouped.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
BOOST_TEST(grouped.error().message().find("authored key") != std::string_view::npos);
|
||||
}
|
||||
auto missing = catalogue_exercise();
|
||||
missing.variants.pop_back();
|
||||
BOOST_TEST(!vamp_zero::group_practice_exercises({missing}));
|
||||
auto duplicate = catalogue_exercise();
|
||||
duplicate.variants.back().key_signature = "C";
|
||||
BOOST_TEST(!vamp_zero::group_practice_exercises({duplicate}));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_authored_key_metadata_is_optional_but_must_be_a_nonempty_string) {
|
||||
@@ -281,7 +283,7 @@ BOOST_AUTO_TEST_CASE(catalogue_authored_key_metadata_is_optional_but_must_be_a_n
|
||||
BOOST_REQUIRE(legacy);
|
||||
BOOST_TEST(legacy->authored_key_signature.empty());
|
||||
for (auto authored : {"Eb", "C minor", "C dorian", "No key"}) {
|
||||
auto document = vamp_zero::parse_score_document(catalogue_bundle("Exercise", "D", authored), "/assets/practice/melody/lesson/exercise/d.json");
|
||||
auto document = vamp_zero::parse_score_document(catalogue_bundle("Exercise", "D", authored), "/assets/practice/001/d.json");
|
||||
BOOST_REQUIRE(document);
|
||||
BOOST_TEST(document->authored_key_signature == authored);
|
||||
}
|
||||
@@ -300,7 +302,7 @@ BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports
|
||||
auto song = catalogue_bundle("Song", "F");
|
||||
auto exercise = catalogue_bundle("Exercise", "D", "Eb");
|
||||
db.write("songs/shared.json", song);
|
||||
db.write("practice/melody/lesson/exercise/d.json", exercise);
|
||||
db.write("practice/001/d.json", exercise);
|
||||
BOOST_REQUIRE(db.build());
|
||||
db.query("UPDATE practice_exercises SET id = 'shared'");
|
||||
std::filesystem::remove_all(db.assets);
|
||||
@@ -313,7 +315,7 @@ BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports
|
||||
BOOST_TEST(document->key_signature == (is_song ? "F" : "D"));
|
||||
BOOST_TEST(document->authored_key_signature == (is_song ? "" : "Eb"));
|
||||
BOOST_TEST(document->source_json == (is_song ? song : exercise));
|
||||
BOOST_TEST(document->source_path == (db.assets / (is_song ? "songs/shared.json" : "practice/melody/lesson/exercise/d.json")).string());
|
||||
BOOST_TEST(document->source_path == (db.assets / (is_song ? "songs/shared.json" : "practice/001/d.json")).string());
|
||||
BOOST_TEST(document->measure_count == 3U);
|
||||
BOOST_REQUIRE_EQUAL(document->pages.size(), 2U);
|
||||
BOOST_TEST(document->pages[1].path == "fixture.pages/2.png");
|
||||
@@ -359,12 +361,12 @@ BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score
|
||||
db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, "
|
||||
"key_signature, measure_count, total_pages, authored_key_signature) "
|
||||
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2, '')");
|
||||
for (auto key : {"C", "D"}) {
|
||||
db.query("INSERT INTO practice_exercises(id, exercise_id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, "
|
||||
for (auto key : vamp_zero::practice_key_names()) {
|
||||
db.query("INSERT INTO practice_exercises(id, exercise_id, "
|
||||
"title, composer, key_signature, source_json, source_path, measure_count, total_pages, authored_key_signature) "
|
||||
"VALUES(?1, 'melody-lesson-exercise', 'melody', 'Melody Practice', 'lesson', 'Lesson', 'Metadata Exercise', 'Composer', ?2, '{}', ?3, 3, 2, 'Eb')",
|
||||
{noto::sqlite_value{std::string{"variant-"} + key}, noto::sqlite_value{std::string{key}},
|
||||
noto::sqlite_value{std::string{"practice/melody/lesson/exercise/"} + key + ".json"}});
|
||||
"VALUES(?1, '001', 'Metadata Exercise', 'Composer', ?2, '{}', ?3, 3, 2, 'Eb')",
|
||||
{noto::sqlite_value{std::string{"variant-"} + std::string{key}}, noto::sqlite_value{std::string{key}},
|
||||
noto::sqlite_value{std::string{"practice/001/"} + std::string{key} + ".json"}});
|
||||
}
|
||||
auto songs = db.rows("songs");
|
||||
auto exercises = db.rows("practice_exercises");
|
||||
@@ -381,9 +383,9 @@ BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises.size(), 1U);
|
||||
BOOST_TEST(practice->exercises[0].title == "Metadata Exercise");
|
||||
BOOST_TEST(practice->exercises[0].authored_key_signature == "Eb");
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 2U);
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 12U);
|
||||
BOOST_TEST(practice->exercises[0].variants[0].key_signature == "C");
|
||||
BOOST_TEST(practice->exercises[0].variants[1].key_signature == "D");
|
||||
BOOST_TEST(practice->exercises[0].variants[2].key_signature == "D");
|
||||
for (auto const &variant : practice->exercises[0].variants) {
|
||||
BOOST_TEST(variant.measure_count == 3U);
|
||||
BOOST_TEST(variant.total_pages == 2U);
|
||||
@@ -416,14 +418,14 @@ BOOST_AUTO_TEST_CASE(catalogue_validation_rejects_missing_legacy_and_corrupt_fil
|
||||
BOOST_REQUIRE(practice);
|
||||
BOOST_TEST(songs->parts.empty());
|
||||
BOOST_TEST(practice->exercises.empty());
|
||||
for (auto version : {0, 2}) {
|
||||
for (auto version : {0, 1, 3}) {
|
||||
db.query("PRAGMA user_version = " + std::to_string(version));
|
||||
auto result = validate();
|
||||
BOOST_REQUIRE(!result);
|
||||
BOOST_TEST(result.error().message().find("reinstall") != std::string_view::npos);
|
||||
BOOST_TEST(std::get<std::int64_t>(db.query("PRAGMA user_version").rows[0][0].value) == version);
|
||||
}
|
||||
db.query("PRAGMA user_version = 1");
|
||||
db.query("PRAGMA user_version = 2");
|
||||
db.query("PRAGMA application_id = 0");
|
||||
BOOST_TEST(!validate());
|
||||
auto output = std::ofstream{db.config.path, std::ios::trunc};
|
||||
@@ -437,10 +439,12 @@ BOOST_AUTO_TEST_CASE(catalogue_validation_rejects_missing_legacy_and_corrupt_fil
|
||||
BOOST_AUTO_TEST_CASE(catalogue_reads_readonly_database_without_json_and_resolves_relocated_assets) {
|
||||
auto db = catalogue_database{};
|
||||
db.write("songs/tune.json", catalogue_bundle("Relocated"));
|
||||
db.write("practice/chord/lesson/exercise/c.json", catalogue_bundle("Exercise", "C", "Eb"));
|
||||
for (auto key : vamp_zero::practice_key_names()) {
|
||||
db.write(std::filesystem::path{"practice/001"} / (std::string{key} + ".json"), catalogue_bundle("Exercise", std::string{key}, "Eb"));
|
||||
}
|
||||
BOOST_REQUIRE(db.build());
|
||||
std::filesystem::remove(db.assets / "songs/tune.json");
|
||||
std::filesystem::remove(db.assets / "practice/chord/lesson/exercise/c.json");
|
||||
std::filesystem::remove_all(db.assets / "practice");
|
||||
auto moved = db.directory.path / "relocated";
|
||||
std::filesystem::rename(db.assets, moved);
|
||||
db.assets = moved;
|
||||
|
||||
@@ -31,17 +31,21 @@ def all_pitches(document):
|
||||
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.assertIn(TabPosition(1, 20, 84), positions_for_pitch(Fraction(84)))
|
||||
self.assertFalse(positions_for_pitch(Fraction(85)))
|
||||
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.assertEqual([p.fret for p in positions_for_string(1, 64, tuple(range(12)))], list(range(21)))
|
||||
for fret in range(16, 21):
|
||||
self.assertIn(TabPosition(6, fret, 40 + fret), positions_for_pitch(Fraction(40 + fret)))
|
||||
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)
|
||||
self.assertTrue(within_fret_span((TabPosition(6, 0, 40), TabPosition(1, 20, 84))))
|
||||
self.assertFalse(within_fret_span((TabPosition(4, 10, 60), TabPosition(1, 20, 84))))
|
||||
content = xml_note('C', extra=technical(1, 20), octave=6)
|
||||
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)))
|
||||
with self.assertRaisesRegex(ValueError, '0-20'):
|
||||
validate_guitar_practice(parse(xml_score(xml_note('C', alter=1, extra=technical(1, 21), octave=6), TAB_STAFF)))
|
||||
with self.assertRaisesRegex(ValueError, '0-20'):
|
||||
validate_guitar_practice(parse(xml_score(xml_note('D', alter=-1, extra=technical(6, 21)), TAB_STAFF)))
|
||||
def test_arranger_does_not_truncate_candidate_positions(self):
|
||||
def candidates(string, open_midi, pitch_classes):
|
||||
if string == 6:
|
||||
@@ -89,7 +93,7 @@ class GuitarPracticeTests(unittest.TestCase):
|
||||
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):
|
||||
def test_keyless_reference_required_for_practice(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')
|
||||
@@ -197,31 +201,29 @@ class GuitarPracticeTests(unittest.TestCase):
|
||||
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 = root / 'data/001.xml'
|
||||
source.parent.mkdir(parents=True, exist_ok=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)
|
||||
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=Path('package/assets'), check=False)
|
||||
with patch('generator.__main__.engrave_tree') as engrave:
|
||||
with self.assertRaisesRegex(ValueError, r'wide.xml, target key C:.*measure 1'):
|
||||
with self.assertRaisesRegex(ValueError, r'001.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):
|
||||
def test_practice_requires_twelve_deterministic_variants(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
source, output = Path(temp) / 'source', Path(temp) / 'output'
|
||||
for practice_type in ('chord', 'melody'):
|
||||
path = source / practice_type / '001_lesson' / '001-exercise.xml'
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
self.assertEqual(generate_practice_scores(source, output), 24)
|
||||
source.mkdir()
|
||||
(source / '001.xml').write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
self.assertEqual(generate_practice_scores(source, output), 12)
|
||||
validate_practice_variants(output)
|
||||
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')}
|
||||
generate_practice_scores(source, 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'):
|
||||
(output / '001/g.json').unlink()
|
||||
with self.assertRaisesRegex(ValueError, 'practice generator drift'):
|
||||
validate_practice_variants(output)
|
||||
|
||||
@@ -184,8 +184,11 @@ auto make_assets(vamp_zero::config const &config) -> void {
|
||||
auto root = std::filesystem::path{config.runtime.assets_path};
|
||||
write_score(root / "songs" / "first.json");
|
||||
write_score(root / "songs" / "second.json");
|
||||
write_score(root / "practice" / "melody" / "lesson" / "exercise" / "c.json");
|
||||
write_score(root / "practice" / "melody" / "lesson" / "exercise" / "d.json", "D");
|
||||
for (auto key : vamp_zero::practice_key_names()) {
|
||||
auto name = std::string{key};
|
||||
name.front() = static_cast<std::char_t>(name.front() - 'A' + 'a');
|
||||
write_score(root / "practice" / "001" / (name + ".json"), std::string{key});
|
||||
}
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(root, config.database.path));
|
||||
}
|
||||
|
||||
@@ -360,7 +363,7 @@ BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalogue_without_hardware_or_hear
|
||||
BOOST_REQUIRE(exercises);
|
||||
BOOST_TEST(songs->parts.size() == 2U);
|
||||
BOOST_REQUIRE_EQUAL(exercises->exercises.size(), 1U);
|
||||
BOOST_TEST(exercises->exercises.front().variants.size() == 2U);
|
||||
BOOST_TEST(exercises->exercises.front().variants.size() == 12U);
|
||||
write_file(config.runtime.heartbeat_path, "existing heartbeat");
|
||||
auto heartbeat_time = std::filesystem::last_write_time(config.runtime.heartbeat_path);
|
||||
config.runtime.exit_after_startup = true;
|
||||
@@ -372,7 +375,7 @@ BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalogue_without_hardware_or_hear
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_validates_later_pages_parts_and_practice_variants) {
|
||||
for (std::char_t const *relative : {"songs/first.pages/2.png", "songs/second.pages/2.png", "practice/melody/lesson/exercise/d.pages/2.png"}) {
|
||||
for (std::char_t const *relative : {"songs/first.pages/2.png", "songs/second.pages/2.png", "practice/001/d.pages/2.png"}) {
|
||||
auto root = temporary_directory{};
|
||||
auto config = fixture_config(root.path);
|
||||
make_assets(config);
|
||||
@@ -391,7 +394,7 @@ BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_
|
||||
auto assets = std::filesystem::path{config.runtime.assets_path};
|
||||
auto fixture = std::filesystem::path{VAMP_TEST_PLAYBACK_ASSETS_DIR};
|
||||
std::filesystem::copy(fixture / "audio", assets / "audio", std::filesystem::copy_options::recursive);
|
||||
auto path = assets / "practice/melody/practice/exercise/c.json";
|
||||
auto path = assets / "practice/001/c.json";
|
||||
write_score(path);
|
||||
auto score = boost::json::parse(read_file(path));
|
||||
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
|
||||
|
||||
+12
-14
@@ -117,20 +117,18 @@ class AuthoredKeyMetadataTests(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
source, output = Path(temp) / 'source', Path(temp) / 'output'
|
||||
text = xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>')
|
||||
for practice_type in ('chord', 'melody'):
|
||||
path = source / practice_type / 'lesson' / 'exercise.xml'
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(text, encoding='utf-8')
|
||||
self.assertEqual(generate_practice_scores(source, output), 24)
|
||||
for practice_type in ('chord', 'melody'):
|
||||
for key in TARGET_KEYS:
|
||||
with self.subTest(practice_type=practice_type, key=key):
|
||||
result = load_json(output / practice_type / 'lesson' / 'exercise' / 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((source / practice_type / 'lesson' / 'exercise.xml').read_text(encoding='utf-8'), text)
|
||||
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'<key><fifths>{fifths}</fifths><mode>{mode}</mode></key>'))
|
||||
|
||||
@@ -95,7 +95,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
output = root / 'output'
|
||||
output.mkdir()
|
||||
(output / 'last-good').write_text('preserved')
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), clean=True, check=False)
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
|
||||
with self.assertRaises(ValueError):
|
||||
generate_assets(args)
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
@@ -117,10 +117,9 @@ class MusicModelTests(unittest.TestCase):
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
source = data / 'songs' / 'fixture.xml'
|
||||
source.write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), clean=True, check=False)
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
|
||||
generate_assets(args)
|
||||
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||
args.clean = False
|
||||
generate_assets(args)
|
||||
self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
source.unlink()
|
||||
|
||||
@@ -40,17 +40,18 @@ class PlaybackTests(unittest.TestCase):
|
||||
unpitched = '<note><unpitched><display-step>C</display-step><display-octave>4</display-octave></unpitched><duration>4</duration><type>whole</type></note>'
|
||||
with self.assertRaises(PlaybackUnavailable):
|
||||
compile_timeline(parse(xml_score(unpitched)))
|
||||
def test_guitar_octave_depends_on_category_not_lesson_name(self):
|
||||
def test_guitar_octave_depends_on_score_metadata_not_path(self):
|
||||
from generator.playback import compile_tree
|
||||
from generator.score import write_json
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = parse(xml_score(xml_note()))
|
||||
for relative in ('practice/melody/chord/score.json', 'practice/chord/lesson/score.json'):
|
||||
write_json(root / relative, source)
|
||||
for part in ('practice', 'acoustic', 'electric', 'vocal'):
|
||||
source['staves'][0]['arrangement_part_type'] = part
|
||||
write_json(root / f'{part}.json', source)
|
||||
compile_tree(root)
|
||||
for relative, pitch in [('practice/melody/chord/score.json', 60), ('practice/chord/lesson/score.json', 48)]:
|
||||
playback = json.loads((root / relative).read_text())['playback']
|
||||
for part, pitch in [('practice', 48), ('acoustic', 48), ('electric', 48), ('vocal', 60)]:
|
||||
playback = json.loads((root / f'{part}.json').read_text())['playback']
|
||||
self.assertEqual(playback['samples'][0]['id'], generate_tone(root, Fraction(pitch))['id'])
|
||||
def test_audio_assets_validate_and_reject_corruption(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
||||
@@ -117,14 +117,12 @@ class RepeatTests(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
data = root / 'data'
|
||||
lesson = data / 'practice/melody/lesson'
|
||||
lesson.mkdir(parents=True)
|
||||
(data / 'songs').mkdir()
|
||||
(lesson / 'repeat.xml').write_text(text)
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
(data / '001.xml').write_text(text)
|
||||
output = root / 'assets'
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
|
||||
generate_assets(args)
|
||||
bundle = json.loads((output / 'practice/melody/lesson/repeat/c.json').read_text())
|
||||
bundle = json.loads((output / 'practice/001/c.json').read_text())
|
||||
self.assertEqual(len(bundle['music']['staves'][0]['measures']), 2)
|
||||
self.assertEqual(len(bundle['playback']['events']), 4)
|
||||
self.assertEqual(fraction(bundle['playback']['duration']), 16)
|
||||
|
||||
@@ -25,12 +25,10 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
data, output = root / 'data', root / 'output'
|
||||
lesson = data / 'practice' / 'chord' / 'lesson'
|
||||
lesson.mkdir(parents=True)
|
||||
(data / 'songs').mkdir()
|
||||
source = lesson / 'fixture.xml'
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
source = data / '001.xml'
|
||||
source.write_text(xml_score(xml_note()))
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), clean=False, check=False)
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
|
||||
generate_assets(args)
|
||||
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||
self.assertTrue(list(output.rglob('*.png')))
|
||||
@@ -41,13 +39,12 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
generate_assets(args)
|
||||
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
source.write_text(xml_score(xml_note()))
|
||||
renamed = lesson.with_name('002_renamed_lesson') / '127-renamed-exercise.xml'
|
||||
renamed.parent.mkdir()
|
||||
renamed = data / '127.xml'
|
||||
source.rename(renamed)
|
||||
source = renamed
|
||||
generate_assets(args)
|
||||
self.assertFalse((output / 'practice/chord/lesson').exists())
|
||||
self.assertEqual(len(list((output / 'practice/chord/002_renamed_lesson/127-renamed-exercise').glob('*.json'))), 12)
|
||||
self.assertFalse((output / 'practice/001').exists())
|
||||
self.assertEqual(len(list((output / 'practice/127').glob('*.json'))), 12)
|
||||
source.unlink()
|
||||
generate_assets(args)
|
||||
self.assertFalse(list(output.rglob('*.png')))
|
||||
|
||||
@@ -319,7 +319,7 @@ BOOST_AUTO_TEST_CASE(score_loader_completion_can_overlap_stop_and_consumer_destr
|
||||
BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_variant) {
|
||||
auto catalog = std::make_shared<vamp_zero::catalogue>();
|
||||
catalog->performance.parts = {{.id = "part-a"}, {.id = "part-b"}};
|
||||
catalog->practice.exercises = {{.id = "scale", .practice_type_id = "melody", .variants = {{.id = "scale-c"}, {.id = "scale-d"}}}};
|
||||
catalog->practice.exercises = {{.id = "scale", .variants = {{.id = "scale-c"}, {.id = "scale-d"}}}};
|
||||
auto state = vamp_zero::app_state{.catalog = catalog};
|
||||
BOOST_CHECK(!vamp_zero::selected_score_reference(state));
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
||||
@@ -337,10 +337,6 @@ BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_vari
|
||||
BOOST_CHECK(reference->category == vamp_zero::score_category::PRACTICE);
|
||||
state.selected_practice_key_index = 0;
|
||||
BOOST_CHECK(!vamp_zero::selected_score_reference(state));
|
||||
catalog->practice.exercises.front().practice_type_id = "guitar";
|
||||
reference = vamp_zero::selected_score_reference(state);
|
||||
BOOST_REQUIRE(reference);
|
||||
BOOST_CHECK_EQUAL(reference->id, "scale-c");
|
||||
state.catalog.reset();
|
||||
BOOST_CHECK(!vamp_zero::selected_score_reference(state));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user