diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 2346a5d..22faa0b 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -34,7 +34,9 @@ At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-v ## Score Generation -The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody and chord practice sources are emitted in all 12 keys at build time. Generated JSON keeps notation metadata while removing generator-only optional chord markers. The current catalogue produces six performance scores and 48 practice variants (54 JSON bundles). +The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody and chord practice sources are emitted in all 12 keys at build time. Generated JSON keeps notation metadata while removing generator-only optional chord markers. The catalogue produces three bundles per song and twelve per practice exercise; source counts are discovered, not fixed in code. + +Practice sources live at `data/practice///.xml`. For example, `chord/001_three_note_shell_voicings/001-cycles.xml` generates `practice/chord/001_three_note_shell_voicings/001-cycles/{c,db,d,eb,e,f,gb,g,ab,a,bb,b}.json` and corresponding page directories. Keep numeric prefixes zero-padded to the same width for menu ordering. Lesson folders determine lesson labels; MusicXML work/movement titles determine exercise labels, falling back to the filename stem when absent. Repeated titles do not merge exercises: identity includes the practice type, lesson folder, and normalized filename stem. Invalid nesting, unknown practice types, and colliding normalized filenames fail generation. Adding or renaming a source needs no registry edit; the next build discovers it, replaces obsolete generated paths, and runtime catalogue import removes obsolete managed database entries. The pipeline is MuseScore -> uncompressed `.xml` -> musical model -> automatic arranger/transposer -> semantic JSON plus engraved pages. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. Independent rhythmic voices remain separate; simultaneous chord tones retain their own spelling, written duration, and tie links. The packaged music describes each transformed part or practice variant, not an archival copy of the source XML. diff --git a/data/practice/chord/001_three_note_shell_voicings/002-cycles.xml b/data/practice/chord/001_three_note_shell_voicings/002-cycles.xml index 30d6ca8..40daab8 100644 --- a/data/practice/chord/001_three_note_shell_voicings/002-cycles.xml +++ b/data/practice/chord/001_three_note_shell_voicings/002-cycles.xml @@ -37,6 +37,7 @@ dominant + G @@ -63,22 +64,33 @@ 4 quarter + + + D + -1 + + dominant + - G + D + -1 4 4 quarter + flat - B - 4 + C + -1 + 5 4 quarter + flat @@ -123,13 +135,22 @@ 4 quarter + + + G + -1 + + dominant + - C + G + -1 4 4 quarter + flat @@ -144,11 +165,13 @@ - E + F + -1 5 4 quarter + flat @@ -186,10 +209,16 @@ quarter flat + + + B + + dominant + - F - 4 + B + 3 4 quarter @@ -206,12 +235,13 @@ - E - -1 + D + 1 5 4 quarter + sharp @@ -249,12 +279,18 @@ 4 quarter + natural + + + E + + dominant + - B - -1 - 3 + E + 4 4 quarter @@ -262,12 +298,13 @@ - A - -1 + G + 1 4 4 quarter + sharp @@ -318,225 +355,10 @@ flat - - E - -1 - 4 - - 4 - quarter - - - - - G - 4 - - 4 - quarter - - - - - D - -1 - 5 - - 4 - quarter - - - - A - -1 - - dominant - - - - A - -1 - 3 - - 4 - quarter - flat - - - - - G - -1 - 4 - - 4 - quarter - flat - - - - - C - 5 - - 4 - quarter - - - - A - -1 - 3 - - 4 - quarter - - - - - G - -1 - 4 - - 4 - quarter - - - - - C - 5 - - 4 - quarter - - - - - - D - -1 - - major-seventh - - - - D - -1 - 4 - - 4 - quarter - flat - - - - - F - 4 - - 4 - quarter - - - - - C - 5 - - 4 - quarter - - - - D - -1 - 4 - - 4 - quarter - - - - - F - 4 - - 4 - quarter - - - - - C - 5 - - 4 - quarter - - - - D - -1 - - major-sixth - - - - D - -1 - 4 - - 4 - quarter - - - - - F - 4 - - 4 - quarter - - - - - B - -1 - 4 - - 4 - quarter - flat - - - - D - -1 - 4 - - 4 - quarter - - - - - F - 4 - - 4 - quarter - - - - - B - -1 - 4 - - 4 - quarter + + 12 + half + diff --git a/data/practice/melody/001_diatonic_excercises/001-the-diatonic-scales.xml b/data/practice/melody/001_diatonic_excercises/001-the-diatonic-scales.xml index 377ca6b..0d056bf 100644 --- a/data/practice/melody/001_diatonic_excercises/001-the-diatonic-scales.xml +++ b/data/practice/melody/001_diatonic_excercises/001-the-diatonic-scales.xml @@ -6,7 +6,7 @@ Soundslice MusicXML exporter - 2026-09-14 + 2026-09-16 diff --git a/data/practice/melody/001_diatonic_excercises/002-the-diatonic-scales.xml b/data/practice/melody/001_diatonic_excercises/002-the-diatonic-scales.xml index 99d2c04..1655bfd 100644 --- a/data/practice/melody/001_diatonic_excercises/002-the-diatonic-scales.xml +++ b/data/practice/melody/001_diatonic_excercises/002-the-diatonic-scales.xml @@ -6,7 +6,7 @@ Soundslice MusicXML exporter - 2026-09-14 + 2026-09-16 diff --git a/data/practice/melody/001_diatonic_excercises/003-the-diatonic-scales.xml b/data/practice/melody/001_diatonic_excercises/003-the-diatonic-scales.xml index 66fa726..4ccac5f 100644 --- a/data/practice/melody/001_diatonic_excercises/003-the-diatonic-scales.xml +++ b/data/practice/melody/001_diatonic_excercises/003-the-diatonic-scales.xml @@ -6,7 +6,7 @@ Soundslice MusicXML exporter - 2026-09-14 + 2026-09-16 diff --git a/generator/practice/generate.py b/generator/practice/generate.py index a7afa2b..a3dbe8d 100644 --- a/generator/practice/generate.py +++ b/generator/practice/generate.py @@ -63,13 +63,18 @@ def generate_key_variants(source_path: Path, source_root: Path, output_dir: Path def generate_practice_scores(source_dir: Path, output_dir: Path) -> int: if not source_dir.exists(): return 0 + sources = sorted(source_dir.rglob('*.xml')) + destinations: dict[Path, Path] = {} + for path in sources: + parts = path.relative_to(source_dir).parts + if len(parts) != 3 or parts[0] not in ('chord', 'melody'): + raise ValueError(f'{path}: practice source must be stored as practice///.xml') + destination = Path(parts[0]) / parts[1] / slugify(path.stem) + if destination in destinations: + raise ValueError(f'{path}: practice output path collides with {destinations[destination]}: {destination}') + destinations[destination] = path count = 0 - chord_root = source_dir / "chord" - if chord_root.exists(): - for path in sorted(chord_root.rglob("*.xml")): - count += generate_chord_practice(path, chord_root, output_dir) - melody_root = source_dir / "melody" - if melody_root.exists(): - for path in sorted(melody_root.rglob("*.xml")): - count += generate_melody_practice(path, melody_root, output_dir) + for destination, path in destinations.items(): + practice_type = destination.parts[0] + count += generate_key_variants(path, source_dir / practice_type, output_dir, practice_type) return count diff --git a/src/vamp_zero/database.cpp b/src/vamp_zero/database.cpp index 6e58037..561dd88 100644 --- a/src/vamp_zero/database.cpp +++ b/src/vamp_zero/database.cpp @@ -40,6 +40,24 @@ auto text_value(noto::sqlite_value const &value) -> std::string { return text == nullptr ? std::string{} : *text; } +auto close_catalogue_query(noto::connection &connection, noto::statement &statement, std::expected status) -> boost::asio::awaitable> { + auto finalized = co_await statement.async_finalize(); + if (!finalized) { + modulus::log_error("catalogue query finalization failed: " + finalized.error().describe()); + if (status) { + status = std::unexpected(finalized.error()); + } + } + auto closed = co_await connection.async_close(); + if (!closed) { + modulus::log_error("catalogue connection close failed: " + closed.error().describe()); + if (status) { + status = std::unexpected(closed.error()); + } + } + co_return status; +} + auto execute_schema_statement(noto::connection &connection, std::string statement) -> boost::asio::awaitable> { auto result = co_await connection.async_execute(std::move(statement)); if (!result) { @@ -465,7 +483,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec co_return std::unexpected(connected.error()); } auto selected = - co_await connection.async_execute("SELECT id, title, composer, part, source_path, source_json, key_signature FROM " + co_await connection.async_prepare("SELECT id, title, composer, part, source_path, source_json, key_signature FROM " "songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE"); if (!selected) { auto close = co_await connection.async_close(); @@ -475,18 +493,25 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec co_return std::unexpected(selected.error()); } auto parts = std::vector{}; - for (auto const &row : selected->rows) { + auto status = std::expected{}; + for (;;) { + auto next = co_await selected->async_step(); + if (!next) { + status = std::unexpected(next.error()); + break; + } + if (!*next) { + break; + } + auto const &row = **next; if (row.size() < 7U) { continue; } auto source_json = text_value(row[5]); auto parsed = vamp_zero::parse_score_document(source_json, text_value(row[4])); if (!parsed) { - auto close = co_await connection.async_close(); - if (!close) { - modulus::log_error("database close failed after parse error: " + close.error().describe()); - } - co_return std::unexpected(parsed.error()); + status = std::unexpected(parsed.error()); + break; } parts.push_back({ .id = text_value(row[0]), @@ -500,7 +525,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec .total_pages = vamp_zero::score_page_count(*parsed), }); } - auto closed = co_await connection.async_close(); + auto closed = co_await close_catalogue_query(connection, *selected, std::move(status)); if (!closed) { co_return std::unexpected(closed.error()); } @@ -514,7 +539,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo if (!connected) { co_return std::unexpected(connected.error()); } - auto selected = co_await connection.async_execute("SELECT id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, " + auto selected = co_await connection.async_prepare("SELECT id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, " "key_signature, source_path, source_json FROM practice_exercises ORDER BY practice_type_id COLLATE NOCASE, " "lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE, title COLLATE NOCASE, id COLLATE NOCASE"); if (!selected) { @@ -525,18 +550,25 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo co_return std::unexpected(selected.error()); } auto exercises = std::vector{}; - for (auto const &row : selected->rows) { + auto status = std::expected{}; + for (;;) { + auto next = co_await selected->async_step(); + if (!next) { + status = std::unexpected(next.error()); + break; + } + if (!*next) { + break; + } + auto const &row = **next; if (row.size() < 10U) { continue; } auto source_json = text_value(row[9]); auto parsed = vamp_zero::parse_score_document(source_json, text_value(row[8])); if (!parsed) { - auto close = co_await connection.async_close(); - if (!close) { - modulus::log_error("database close failed after practice parse error: " + close.error().describe()); - } - co_return std::unexpected(parsed.error()); + status = std::unexpected(parsed.error()); + break; } auto type_id = text_value(row[1]); auto lesson_id = text_value(row[3]); @@ -566,7 +598,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo .variants = {std::move(variant)}, }); } - auto closed = co_await connection.async_close(); + auto closed = co_await close_catalogue_query(connection, *selected, std::move(status)); if (!closed) { co_return std::unexpected(closed.error()); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 67195b7..394afc7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,7 +14,7 @@ target_compile_definitions(product-vamp-test PRIVATE VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets" ) add_test(NAME product-vamp COMMAND product-vamp-test) -set_tests_properties(product-vamp PROPERTIES TIMEOUT 60) +set_tests_properties(product-vamp PROPERTIES TIMEOUT 180) add_test( NAME product-vamp-generator COMMAND ${Python3_EXECUTABLE} -m unittest discover -s tests -p test_*.py diff --git a/tests/test.cpp b/tests/test.cpp index 5f663a2..57641c0 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -377,6 +377,13 @@ BOOST_AUTO_TEST_CASE(chord_and_melody_practice_share_key_navigation_and_remote_d 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"}) { + if (entry.is_regular_file() && entry.path().extension() == ".xml") { + ++expected_exercises; + } + } + BOOST_REQUIRE(expected_exercises > 0U); BOOST_REQUIRE(run_ensure_database(config)); for (auto iteration = std::uint32_t{}; iteration < 2U; ++iteration) { BOOST_REQUIRE(run_import(config, VAMP_TEST_ASSETS_DIR)); @@ -384,14 +391,15 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) { 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); + 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) { - BOOST_TEST(exercise.variants[index].key_signature == keys[index]); + auto const &key = exercise.variants[index].key_signature; + BOOST_TEST(key.substr(0, key.find(' ')) == keys[index]); BOOST_REQUIRE(vamp_zero::parse_score_document(exercise.variants[index].source_json, exercise.variants[index].source_path)); } } @@ -399,6 +407,78 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) { std::filesystem::remove_all(root); } +BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_refresh_after_rename) { + auto root = std::filesystem::temp_directory_path() / ("product-vamp-paths-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + 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")); + } + auto library = vamp_zero::load_practice_library(assets.string()); + 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(lesson.exercises[0].id == "chord-001_lesson-002-cycles"); + BOOST_TEST(lesson.exercises[1].id == "chord-001_lesson-010-cycles"); + for (auto const &exercise : library->exercises) { + BOOST_TEST(exercise.title == "Shared Title"); + } + BOOST_REQUIRE(run_ensure_database(config)); + BOOST_REQUIRE(run_import(config, assets)); + 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"); + BOOST_REQUIRE(run_import(config, assets)); + 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")); + std::filesystem::remove_all(root); +} + +BOOST_AUTO_TEST_CASE(catalogue_queries_handle_empty_results_and_invalid_stored_scores) { + auto root = std::filesystem::temp_directory_path() / ("product-vamp-query-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 invalid_rows : {false, true}) { + if (invalid_rows) { + for (auto table : {"songs", "practice_exercises"}) { + BOOST_REQUIRE(query_database(root / "vamp.db", std::string{"INSERT INTO "} + table + "(id, title, source_json, created_at_ms, updated_at_ms) VALUES('invalid', 'Invalid', '{}', 0, 0)")); + } + } + auto io = boost::asio::io_context{}; + auto performance_future = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config), boost::asio::use_future); + auto practice_future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config), boost::asio::use_future); + io.run(); + auto performance = performance_future.get(); + auto practice = practice_future.get(); + if (invalid_rows) { + BOOST_REQUIRE(!performance); + BOOST_REQUIRE(!practice); + auto invalid_score = vamp_zero::parse_score_document("{}", ""); + BOOST_REQUIRE(!invalid_score); + BOOST_TEST(performance.error().message() == invalid_score.error().message()); + BOOST_TEST(practice.error().message() == invalid_score.error().message()); + } else { + BOOST_REQUIRE(performance); + BOOST_REQUIRE(practice); + BOOST_TEST(performance->parts.empty()); + BOOST_TEST(practice->exercises.empty()); + } + } + 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(); diff --git a/tests/test_catalogue.py b/tests/test_catalogue.py new file mode 100644 index 0000000..17b24d3 --- /dev/null +++ b/tests/test_catalogue.py @@ -0,0 +1,64 @@ +import json +from pathlib import Path +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.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)) + 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') + 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('') + 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'): + 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): + 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'): + generate_practice_scores(source, output) + self.assertFalse(output.exists()) + def test_normalized_filename_collisions_fail_before_writing(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()) diff --git a/tests/test_guitar_practice.py b/tests/test_guitar_practice.py index 5693c24..70db139 100644 --- a/tests/test_guitar_practice.py +++ b/tests/test_guitar_practice.py @@ -51,8 +51,11 @@ class GuitarPracticeTests(unittest.TestCase): 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'))) + def test_all_keys_preserve_exact_voicings_and_adjust_g_register(self): + harmony = 'Gdominant' + content = xml_note('G', 2, 'half') + xml_note('B', 2, 'half', prefix='') + xml_note('F', 2, 'half', prefix='', octave=5) + content += xml_note('A', 2, 'half', alter=-1, octave=3) + xml_note('G', 2, 'half', alter=-1, prefix='') + xml_note('C', 2, 'half', prefix='', octave=5) + source = practice_document(parse(xml_score(harmony + content))) original = copy.deepcopy(source) for key in TARGET_KEYS: with self.subTest(key=key): @@ -209,11 +212,15 @@ class GuitarPracticeTests(unittest.TestCase): 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) + 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) 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) + 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'): diff --git a/tests/test_musicxml.py b/tests/test_musicxml.py index b60d3c5..90948f4 100644 --- a/tests/test_musicxml.py +++ b/tests/test_musicxml.py @@ -45,7 +45,7 @@ class MusicXMLTests(unittest.TestCase): self.assertEqual(len(score['staves'][0]['octave_shifts']), 1) def test_catalogue_imports(self): sources = sorted(Path('data').rglob('*.xml')) - self.assertEqual(len(sources), 6) + self.assertTrue(sources) for path in sources: with self.subTest(path=path): self.assertEqual(load_musicxml(path)['schema_version'], 3) diff --git a/tests/test_score_bundle.py b/tests/test_score_bundle.py index 10b22ab..618b4c9 100644 --- a/tests/test_score_bundle.py +++ b/tests/test_score_bundle.py @@ -40,6 +40,14 @@ class ScoreBundleTests(unittest.TestCase): with self.assertRaisesRegex(EngravingError, 'unavailableGlyph'): 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() + 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) source.unlink() generate_assets(args) self.assertFalse(list(output.rglob('*.png')))