@@ -2,6 +2,8 @@
|
||||
|
||||
#include <cerrno>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
@@ -13,6 +15,12 @@
|
||||
|
||||
namespace vamp_test {
|
||||
|
||||
inline auto part_filename(std::string_view part) -> std::string {
|
||||
auto result = std::string{part};
|
||||
result.front() = static_cast<std::char_t>(result.front() - 'A' + 'a');
|
||||
return result + ".json";
|
||||
}
|
||||
|
||||
inline auto build_catalogue(std::filesystem::path const &assets, std::filesystem::path const &database) -> std::bool_t {
|
||||
auto source = assets.string();
|
||||
auto destination = database.string();
|
||||
|
||||
+51
-59
@@ -92,7 +92,7 @@ auto has_value(noto::query_result const &result, std::size_t column, std::string
|
||||
});
|
||||
}
|
||||
|
||||
auto score_asset_json(std::string title, std::string key = "C") -> std::string {
|
||||
auto score_asset_json(std::string title, std::string key = "C", std::string part = "Lead") -> std::string {
|
||||
auto value = boost::json::parse(R"json({
|
||||
"schema_version":3,
|
||||
"music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
|
||||
@@ -103,6 +103,7 @@ auto score_asset_json(std::string title, std::string key = "C") -> std::string {
|
||||
auto &metadata = value.as_object()["music"].as_object()["metadata"].as_object();
|
||||
metadata["title"] = std::move(title);
|
||||
metadata["key_signature"] = std::move(key);
|
||||
value.at("music").at("staves").as_array().front().as_object()["name"] = std::move(part);
|
||||
return boost::json::serialize(value);
|
||||
}
|
||||
|
||||
@@ -117,8 +118,7 @@ auto navigation_state() -> vamp_zero::app_state {
|
||||
.measure_count = 1,
|
||||
.total_pages = 2,
|
||||
};
|
||||
auto variant = vamp_zero::practice_exercise::score_variant{
|
||||
.id = "exercise-c",
|
||||
auto practice_part = vamp_zero::library_part{
|
||||
.key_signature = "C",
|
||||
.source_path = "exercise.json",
|
||||
.measure_count = 1,
|
||||
@@ -128,14 +128,11 @@ auto navigation_state() -> vamp_zero::app_state {
|
||||
.id = "exercise-1",
|
||||
.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);
|
||||
for (auto part_name : vamp_zero::practice_part_names()) {
|
||||
practice_part.id = "exercise-" + std::string{part_name};
|
||||
practice_part.part = part_name;
|
||||
exercise.parts.push_back(practice_part);
|
||||
}
|
||||
auto practice = vamp_zero::group_practice_exercises({exercise});
|
||||
BOOST_REQUIRE(practice);
|
||||
@@ -286,12 +283,12 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) {
|
||||
BOOST_TEST(count > 0U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(practice_key_navigation_and_remote_display) {
|
||||
BOOST_AUTO_TEST_CASE(practice_part_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;
|
||||
auto &practice_part = catalog->practice.exercises.front().parts[2];
|
||||
practice_part.source_path = "exercise-vocal.json";
|
||||
practice_part.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);
|
||||
@@ -299,23 +296,23 @@ BOOST_AUTO_TEST_CASE(practice_key_navigation_and_remote_display) {
|
||||
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);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_PART_MENU);
|
||||
BOOST_TEST(vamp_zero::practice_part_row_count(state) == 4U);
|
||||
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::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());
|
||||
auto selected = vamp_zero::selected_practice_part(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(selected->id == "exercise-Vocal");
|
||||
BOOST_TEST(selected->source_path == "exercise-vocal.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(std::string{remote.part} == "Vocal");
|
||||
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);
|
||||
@@ -323,11 +320,11 @@ BOOST_AUTO_TEST_CASE(practice_key_navigation_and_remote_display) {
|
||||
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);
|
||||
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PRACTICE_PART_MENU);
|
||||
BOOST_TEST(control.snapshot().selected_practice_part_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_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_PART_MENU);
|
||||
BOOST_TEST(state.selected_practice_part_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);
|
||||
@@ -362,7 +359,7 @@ BOOST_AUTO_TEST_CASE(flat_practice_wraps_and_empty_catalogue_returns_to_main) {
|
||||
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);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_PART_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);
|
||||
@@ -372,11 +369,11 @@ BOOST_AUTO_TEST_CASE(flat_practice_wraps_and_empty_catalogue_returns_to_main) {
|
||||
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()));
|
||||
BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_parts) {
|
||||
auto root = std::filesystem::temp_directory_path() / ("product-vamp-parts-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::directory_iterator{std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "data"}) {
|
||||
for (auto const &entry : std::filesystem::directory_iterator{std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "data" / "practice"}) {
|
||||
if (entry.is_regular_file() && entry.path().extension() == ".xml") {
|
||||
++expected_exercises;
|
||||
}
|
||||
@@ -391,27 +388,26 @@ 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_REQUIRE_EQUAL(exercise.variants.size(), 12U);
|
||||
auto keys = vamp_zero::practice_key_names();
|
||||
for (auto index = std::size_t{}; index < keys.size(); ++index) {
|
||||
auto const &key = exercise.variants[index].key_signature;
|
||||
BOOST_TEST(key.substr(0, key.find(' ')) == keys[index]);
|
||||
BOOST_TEST(exercise.variants[index].total_pages > 0U);
|
||||
BOOST_TEST(exercise.variants[index].measure_count > 0U);
|
||||
BOOST_REQUIRE_EQUAL(exercise.parts.size(), 3U);
|
||||
auto parts = vamp_zero::practice_part_names();
|
||||
for (auto index = std::size_t{}; index < parts.size(); ++index) {
|
||||
BOOST_TEST(exercise.parts[index].part == parts[index]);
|
||||
BOOST_TEST(exercise.parts[index].total_pages > 0U);
|
||||
BOOST_TEST(exercise.parts[index].measure_count > 0U);
|
||||
if (iteration == 0U) {
|
||||
auto const &variant = exercise.variants[index];
|
||||
auto const &practice_part = exercise.parts[index];
|
||||
io.restart();
|
||||
auto loaded = boost::asio::co_spawn(io, vamp_zero::async_load_score(io.get_executor(), config, {vamp_zero::score_category::PRACTICE, variant.id}, VAMP_TEST_ASSETS_DIR), boost::asio::use_future);
|
||||
auto loaded = boost::asio::co_spawn(io, vamp_zero::async_load_score(io.get_executor(), config, {vamp_zero::score_category::PRACTICE, practice_part.id}, VAMP_TEST_ASSETS_DIR), boost::asio::use_future);
|
||||
io.run();
|
||||
auto score = loaded.get();
|
||||
BOOST_REQUIRE_MESSAGE(score, (score ? "" : score.error().describe()));
|
||||
BOOST_TEST(score->title == exercise.title);
|
||||
BOOST_TEST(score->composer == exercise.composer);
|
||||
BOOST_TEST(score->key_signature == variant.key_signature);
|
||||
BOOST_TEST(score->authored_key_signature == exercise.authored_key_signature);
|
||||
BOOST_TEST(score->measure_count == variant.measure_count);
|
||||
BOOST_TEST(score->pages.size() == variant.total_pages);
|
||||
BOOST_TEST(score->source_path == variant.source_path);
|
||||
BOOST_TEST(score->key_signature == practice_part.key_signature);
|
||||
BOOST_TEST(score->part == practice_part.part);
|
||||
BOOST_TEST(score->measure_count == practice_part.measure_count);
|
||||
BOOST_TEST(score->pages.size() == practice_part.total_pages);
|
||||
BOOST_TEST(score->source_path == practice_part.source_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,8 +421,8 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
|
||||
auto config = test_database_config(root / "vamp.db");
|
||||
std::filesystem::create_directories(assets / "songs");
|
||||
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}));
|
||||
for (auto part_name : vamp_zero::practice_part_names()) {
|
||||
write_text(assets / "practice" / name / vamp_test::part_filename(part_name), score_asset_json("Shared Title", "C", std::string{part_name}));
|
||||
}
|
||||
}
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path));
|
||||
@@ -448,25 +444,21 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
|
||||
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(), 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"));
|
||||
BOOST_REQUIRE_EQUAL(rows->rows.size(), 12U);
|
||||
BOOST_TEST(!has_value(*rows, 0U, "002-acoustic"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "099-acoustic"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "010-acoustic"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "001-acoustic"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "127-acoustic"));
|
||||
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_AUTO_TEST_CASE(keyless_scores_keep_their_authored_key) {
|
||||
auto source = score_asset_json("Keyless", "No key", "Vocal");
|
||||
auto document = vamp_zero::parse_score_document(source, "vocal.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_TEST(document->key_signature == "No key");
|
||||
BOOST_TEST(document->part == "Vocal");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(integer_parsing_checks_range_sign_and_entire_input) {
|
||||
|
||||
+19
-18
@@ -3,39 +3,40 @@ from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from generator.practice.generate import generate_practice_scores, key_file_name
|
||||
from generator.practice.generate import generate_practice_scores
|
||||
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 generator.summary import validate_practice_parts
|
||||
from generator.parts import PART_TYPES
|
||||
from unittest.mock import patch
|
||||
from tests.test_musicxml import xml_note, xml_score
|
||||
|
||||
class CatalogueTests(unittest.TestCase):
|
||||
def test_every_numbered_source_has_its_own_complete_output(self):
|
||||
source = Path('data')
|
||||
def test_every_numbered_source_is_wired_to_three_part_paths(self):
|
||||
source = Path('data/practice')
|
||||
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:
|
||||
expected = {Path(path.stem) / (part + '.json') for path in sources for part in PART_TYPES}
|
||||
self.assertEqual(len(expected), 1098)
|
||||
with tempfile.TemporaryDirectory() as temporary, patch('generator.practice.generate.practice_document', side_effect=lambda document, part: document):
|
||||
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_practice_parts(output)
|
||||
validate_generated_tree(output)
|
||||
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'
|
||||
(source / 'songs').mkdir(parents=True)
|
||||
for name in ('001.xml', '010.xml', '366.xml', 'songs/song.xml'):
|
||||
source.mkdir(parents=True)
|
||||
for name in ('000.xml', '001.xml', '010.xml', '366.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'])
|
||||
self.assertEqual(generate_practice_scores(source, output), 12)
|
||||
self.assertEqual(sorted(p.name for p in output.iterdir()), ['000', '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')
|
||||
self.assertEqual({p.name for p in directory.glob('*.json')}, {(part + '.json') for part in PART_TYPES})
|
||||
self.assertEqual(json.loads((directory / 'vocal.json').read_text())['metadata']['title'], 'Fixture')
|
||||
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'):
|
||||
for name in ('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 ('002.xml', name):
|
||||
@@ -49,12 +50,12 @@ class CatalogueTests(unittest.TestCase):
|
||||
generate_practice_scores(source, output)
|
||||
self.assertEqual(list(output.iterdir()), [sentinel])
|
||||
self.assertEqual(sentinel.read_text(), 'unchanged')
|
||||
def test_missing_and_empty_variant_directories_fail(self):
|
||||
def test_empty_part_directory_fails(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / '001').mkdir()
|
||||
with self.assertRaisesRegex(ValueError, 'practice generator drift'):
|
||||
validate_practice_variants(root)
|
||||
validate_practice_parts(root)
|
||||
def test_songs_generate_only_authored_key_parts(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = Path(temporary) / 'songs', Path(temporary) / 'output'
|
||||
|
||||
@@ -7,11 +7,11 @@ from pathlib import Path
|
||||
|
||||
from generator.catalogue import APPLICATION_ID, SCHEMA_VERSION, build_catalogue
|
||||
|
||||
def bundle(title='Fixture', key='C', authored='Eb'):
|
||||
def bundle(title='Fixture', key='C', part='Lead'):
|
||||
return {
|
||||
'schema_version': 3,
|
||||
'music': {'metadata': {'title': title, 'composer': 'Composer', 'key_signature': key, 'time_signature': '4/4', 'authored_key_signature': authored},
|
||||
'staves': [{'name': 'Lead', 'arrangement_part_type': 'practice', 'measures': [{'voices': []}]}]},
|
||||
'music': {'metadata': {'title': title, 'composer': 'Composer', 'key_signature': key, 'time_signature': '4/4'},
|
||||
'staves': [{'name': part, 'arrangement_part_type': part.lower(), 'measures': [{'voices': []}]}]},
|
||||
'presentation': {'width': 976, 'height': 484, 'pages': [{'path': 'fixture.pages/1.png', 'sha256': '0' * 64}]},
|
||||
}
|
||||
|
||||
@@ -34,19 +34,20 @@ class CatalogueTests(unittest.TestCase):
|
||||
def test_metadata_relative_paths_and_complete_documents(self):
|
||||
document = bundle('Song', 'F')
|
||||
write_score(self.root, 'songs/tune.json', document)
|
||||
for key in ('c', 'd'):
|
||||
write_score(self.root, f'practice/001/{key}.json', bundle('Exercise', key.upper()))
|
||||
for part in ('Acoustic', 'Electric', 'Vocal'):
|
||||
write_score(self.root, f'practice/001/{part.lower()}.json', bundle('Exercise', 'Eb', part))
|
||||
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], ['001-c', '001-d'])
|
||||
self.assertEqual([row['id'] for row in practice], ['001-acoustic', '001-electric', '001-vocal'])
|
||||
self.assertEqual(practice[0]['exercise_id'], '001')
|
||||
self.assertNotIn('practice_type_id', practice[0])
|
||||
self.assertNotIn('lesson_group_id', practice[0])
|
||||
self.assertEqual(practice[0]['authored_key_signature'], 'Eb')
|
||||
self.assertEqual(practice[0]['key_signature'], 'Eb')
|
||||
self.assertNotIn('authored_key_signature', practice[0])
|
||||
with closing(sqlite3.connect(database)) as connection:
|
||||
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], APPLICATION_ID)
|
||||
self.assertEqual(connection.execute('PRAGMA user_version').fetchone()[0], SCHEMA_VERSION)
|
||||
@@ -74,29 +75,25 @@ class CatalogueTests(unittest.TestCase):
|
||||
score = write_score(self.root, 'songs/tune.json')
|
||||
database = build_catalogue(self.root)
|
||||
previous = database.read_bytes()
|
||||
for document in ({}, bundle(title=''), bundle(authored='')):
|
||||
for document in ({}, bundle(title=''), bundle(key=None)):
|
||||
score.write_text(json.dumps(document))
|
||||
with self.assertRaises(ValueError):
|
||||
build_catalogue(self.root)
|
||||
self.assertEqual(database.read_bytes(), previous)
|
||||
self.assertFalse(list(self.root.glob('.vamp-catalogue-*')))
|
||||
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, 'unexpected practice'):
|
||||
def test_missing_parts_and_metadata_disagreement_fail(self):
|
||||
for part in ('Acoustic', 'Electric'):
|
||||
write_score(self.root, f'practice/001/{part.lower()}.json', bundle(part=part))
|
||||
with self.assertRaisesRegex(ValueError, 'all three parts'):
|
||||
build_catalogue(self.root)
|
||||
conflicting.unlink()
|
||||
write_score(self.root, 'practice/001/d.json', bundle(key='D', authored='F'))
|
||||
with self.assertRaisesRegex(ValueError, 'authored key'):
|
||||
write_score(self.root, 'practice/001/vocal.json', bundle(key='F', part='Vocal'))
|
||||
with self.assertRaisesRegex(ValueError, 'disagree'):
|
||||
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/001/d.json', document)
|
||||
def test_keyless_parts_preserve_their_original_key(self):
|
||||
for part in ('Acoustic', 'Electric', 'Vocal'):
|
||||
write_score(self.root, f'practice/001/{part.lower()}.json', bundle(key='No key', part=part))
|
||||
build_catalogue(self.root)
|
||||
row = self.rows('practice_exercises')[0]
|
||||
self.assertEqual(row['key_signature'], 'D (no key)')
|
||||
self.assertEqual(json.loads(row['source_json']), document)
|
||||
self.assertTrue(all(row['key_signature'] == 'No key' for row in self.rows('practice_exercises')))
|
||||
def test_invalid_layout_and_escaping_source_fail(self):
|
||||
with self.assertRaisesRegex(ValueError, 'directory not found'):
|
||||
build_catalogue(self.root / 'missing')
|
||||
|
||||
@@ -38,7 +38,7 @@ concept retains_score_json = requires(T value) { value.source_json; };
|
||||
|
||||
static_assert(!retains_score_json<vamp_zero::library_part>);
|
||||
static_assert(!retains_score_json<vamp_zero::practice_exercise>);
|
||||
static_assert(!retains_score_json<vamp_zero::practice_exercise::score_variant>);
|
||||
static_assert(!retains_score_json<vamp_zero::library_part>);
|
||||
|
||||
struct catalogue_directory {
|
||||
std::filesystem::path path;
|
||||
@@ -79,7 +79,7 @@ auto catalogue_query(boost::asio::any_io_executor executor, vamp_zero::database_
|
||||
co_return result;
|
||||
}
|
||||
|
||||
auto catalogue_bundle(std::string_view title, std::string_view key = "C", std::optional<std::string_view> authored = std::nullopt) -> std::string {
|
||||
auto catalogue_bundle(std::string_view title, std::string_view key = "C", std::string_view part = "Lead") -> std::string {
|
||||
auto value = boost::json::parse(R"json({
|
||||
"schema_version":3,
|
||||
"music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
|
||||
@@ -91,9 +91,7 @@ auto catalogue_bundle(std::string_view title, std::string_view key = "C", std::o
|
||||
auto &metadata = value.at("music").at("metadata").as_object();
|
||||
metadata["title"] = title;
|
||||
metadata["key_signature"] = key;
|
||||
if (authored) {
|
||||
metadata["authored_key_signature"] = *authored;
|
||||
}
|
||||
value.at("music").at("staves").as_array().front().as_object()["name"] = part;
|
||||
return boost::json::serialize(value);
|
||||
}
|
||||
|
||||
@@ -111,7 +109,7 @@ struct catalogue_database {
|
||||
}
|
||||
auto rows(std::string const &table, std::string const &where = "") const -> std::vector<std::string> {
|
||||
auto result = query("SELECT json_array(id, title, composer, key_signature, source_path, source_json, "
|
||||
"measure_count, total_pages, authored_key_signature) FROM " +
|
||||
"measure_count, total_pages) FROM " +
|
||||
table + where + " ORDER BY id");
|
||||
auto rows = std::vector<std::string>{};
|
||||
for (auto const &row : result.rows) {
|
||||
@@ -146,10 +144,9 @@ auto catalogue_exercise(std::string authored = "Eb", std::string id = "001") ->
|
||||
.id = std::move(id),
|
||||
.title = "Exercise",
|
||||
.composer = "Composer",
|
||||
.authored_key_signature = std::move(authored),
|
||||
};
|
||||
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});
|
||||
for (auto part_name : vamp_zero::practice_part_names()) {
|
||||
result.parts.push_back({.id = result.id + "-" + std::string{part_name}, .key_signature = authored, .part = std::string{part_name}, .measure_count = 3, .total_pages = 2});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -199,19 +196,19 @@ BOOST_AUTO_TEST_CASE(catalogue_snapshots_heartbeat_and_navigation_share_immutabl
|
||||
}
|
||||
original.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
|
||||
auto practice_control = vamp_zero::control_state{{}, original};
|
||||
auto keys = practice_control.apply(vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(vamp_zero::to_string(keys.mode) == "practice_key_menu");
|
||||
auto parts = practice_control.apply(vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(vamp_zero::to_string(parts.mode) == "practice_part_menu");
|
||||
auto selected = practice_control.apply(vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(selected.catalog.get() == catalog.get());
|
||||
BOOST_TEST(vamp_zero::selected_practice_variant(selected, catalog->practice.exercises[1]) == &catalog->practice.exercises[1].variants[0]);
|
||||
BOOST_TEST(vamp_zero::selected_practice_part(selected, catalog->practice.exercises[1]) == &catalog->practice.exercises[1].parts[0]);
|
||||
auto exercise = practice_control.apply(vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(vamp_zero::to_string(exercise.mode) == "practice_score");
|
||||
BOOST_TEST(exercise.catalog.get() == catalog.get());
|
||||
BOOST_TEST(vamp_zero::active_practice(exercise) == &catalog->practice.exercises[1]);
|
||||
BOOST_TEST(practice_control.apply(vamp_protocol::page_command::NEXT).page_index == 1U);
|
||||
BOOST_TEST(practice_control.heartbeat().catalog.get() == catalog.get());
|
||||
BOOST_TEST(keys.active_practice_id.empty());
|
||||
BOOST_TEST(keys.selected_practice_key_index == 0U);
|
||||
BOOST_TEST(parts.active_practice_id.empty());
|
||||
BOOST_TEST(parts.selected_practice_part_index == 0U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_performance_groups_use_title_and_composer_with_part_indices) {
|
||||
@@ -245,66 +242,53 @@ 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_requires_complete_ordered_keys_and_authored_key_agreement) {
|
||||
BOOST_AUTO_TEST_CASE(catalogue_practice_requires_complete_ordered_parts_and_key_agreement) {
|
||||
for (auto authored : {"Eb", "C dorian", "No key", ""}) {
|
||||
auto first = catalogue_exercise(authored);
|
||||
std::reverse(first.variants.begin(), first.variants.end());
|
||||
auto last = first.variants.back();
|
||||
first.variants.pop_back();
|
||||
std::reverse(first.parts.begin(), first.parts.end());
|
||||
auto last = first.parts.back();
|
||||
first.parts.pop_back();
|
||||
auto fragment = catalogue_exercise(authored);
|
||||
fragment.variants = {last};
|
||||
fragment.parts = {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(), 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]);
|
||||
BOOST_TEST(exercise.parts.front().key_signature == authored);
|
||||
BOOST_REQUIRE_EQUAL(exercise.parts.size(), 3U);
|
||||
for (auto index = std::size_t{}; index < exercise.parts.size(); ++index) {
|
||||
BOOST_TEST(exercise.parts[index].part == vamp_zero::practice_part_names()[index]);
|
||||
}
|
||||
}
|
||||
for (auto conflict : {"F", ""}) {
|
||||
auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise(), catalogue_exercise(conflict)});
|
||||
auto first = catalogue_exercise();
|
||||
auto second = catalogue_exercise(conflict);
|
||||
second.parts = {second.parts.back()};
|
||||
first.parts.pop_back();
|
||||
auto grouped = vamp_zero::group_practice_exercises({first, second});
|
||||
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);
|
||||
BOOST_TEST(grouped.error().message().find("disagree on key") != std::string_view::npos);
|
||||
}
|
||||
auto missing = catalogue_exercise();
|
||||
missing.variants.pop_back();
|
||||
missing.parts.pop_back();
|
||||
BOOST_TEST(!vamp_zero::group_practice_exercises({missing}));
|
||||
auto duplicate = catalogue_exercise();
|
||||
duplicate.variants.back().key_signature = "C";
|
||||
duplicate.parts.back().part = "Acoustic";
|
||||
BOOST_TEST(!vamp_zero::group_practice_exercises({duplicate}));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_authored_key_metadata_is_optional_but_must_be_a_nonempty_string) {
|
||||
auto legacy = vamp_zero::parse_score_document(catalogue_bundle("Legacy", "D"), "legacy.json");
|
||||
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/001/d.json");
|
||||
BOOST_REQUIRE(document);
|
||||
BOOST_TEST(document->authored_key_signature == authored);
|
||||
}
|
||||
for (auto invalid : {boost::json::value{}, boost::json::value{false}, boost::json::value{42}, boost::json::value{""}, boost::json::value{boost::json::array{}}}) {
|
||||
auto value = boost::json::parse(catalogue_bundle("Invalid"));
|
||||
value.at("music").at("metadata").as_object()["authored_key_signature"] = invalid;
|
||||
auto document = vamp_zero::parse_score_document(boost::json::serialize(value), "invalid.json");
|
||||
BOOST_REQUIRE(!document);
|
||||
BOOST_TEST(document.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
BOOST_TEST(document.error().message().find("authored_key_signature") != std::string_view::npos);
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports_missing_or_invalid) {
|
||||
auto db = catalogue_database{};
|
||||
auto song = catalogue_bundle("Song", "F");
|
||||
auto exercise = catalogue_bundle("Exercise", "D", "Eb");
|
||||
auto exercise = catalogue_bundle("Exercise", "D", "Electric");
|
||||
db.write("songs/shared.json", song);
|
||||
db.write("practice/001/d.json", exercise);
|
||||
for (auto part : vamp_zero::practice_part_names()) {
|
||||
db.write(std::filesystem::path{"practice/001"} / vamp_test::part_filename(part), catalogue_bundle("Exercise", "D", part));
|
||||
}
|
||||
BOOST_REQUIRE(db.build());
|
||||
db.query("UPDATE practice_exercises SET id = 'shared'");
|
||||
db.query("UPDATE practice_exercises SET id = 'shared' WHERE part = 'Electric'");
|
||||
std::filesystem::remove_all(db.assets);
|
||||
for (auto category : {vamp_zero::score_category::PERFORMANCE, vamp_zero::score_category::PRACTICE}) {
|
||||
auto is_song = category == vamp_zero::score_category::PERFORMANCE;
|
||||
@@ -313,9 +297,8 @@ BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports
|
||||
BOOST_TEST(document->id == "shared");
|
||||
BOOST_TEST(document->title == (is_song ? "Song" : "Exercise"));
|
||||
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/001/d.json")).string());
|
||||
BOOST_TEST(document->source_path == (db.assets / (is_song ? "songs/shared.json" : "practice/001/electric.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,14 +342,14 @@ BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score
|
||||
BOOST_REQUIRE(db.build());
|
||||
// Complete cached metadata must not require parsing or retaining the score body.
|
||||
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 : vamp_zero::practice_key_names()) {
|
||||
"key_signature, measure_count, total_pages) "
|
||||
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2)");
|
||||
for (auto part_name : vamp_zero::practice_part_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, '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"}});
|
||||
"title, composer, key_signature, source_json, source_path, measure_count, total_pages, part) "
|
||||
"VALUES(?1, '001', 'Metadata Exercise', 'Composer', 'Eb', '{}', ?3, 3, 2, ?2)",
|
||||
{noto::sqlite_value{std::string{"practice_part-"} + std::string{part_name}}, noto::sqlite_value{std::string{part_name}},
|
||||
noto::sqlite_value{std::string{"practice/001/"} + std::string{part_name} + ".json"}});
|
||||
}
|
||||
auto songs = db.rows("songs");
|
||||
auto exercises = db.rows("practice_exercises");
|
||||
@@ -382,27 +365,27 @@ BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score
|
||||
BOOST_TEST(performance->parts[0].total_pages == 2U);
|
||||
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(), 12U);
|
||||
BOOST_TEST(practice->exercises[0].variants[0].key_signature == "C");
|
||||
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);
|
||||
BOOST_TEST(practice->exercises[0].parts.front().key_signature == "Eb");
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises[0].parts.size(), 3U);
|
||||
BOOST_TEST(practice->exercises[0].parts[0].part == "Acoustic");
|
||||
BOOST_TEST(practice->exercises[0].parts[2].part == "Vocal");
|
||||
for (auto const &practice_part : practice->exercises[0].parts) {
|
||||
BOOST_TEST(practice_part.measure_count == 3U);
|
||||
BOOST_TEST(practice_part.total_pages == 2U);
|
||||
}
|
||||
}
|
||||
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
|
||||
BOOST_TEST(db.rows("practice_exercises") == exercises, boost::test_tools::per_element());
|
||||
auto song = db.score(vamp_zero::score_category::PERFORMANCE, "tune");
|
||||
auto exercise = db.score(vamp_zero::score_category::PRACTICE, "variant-D");
|
||||
auto exercise = db.score(vamp_zero::score_category::PRACTICE, "practice_part-Vocal");
|
||||
BOOST_REQUIRE(!song);
|
||||
BOOST_REQUIRE(!exercise);
|
||||
BOOST_TEST(song.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
BOOST_TEST(exercise.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
db.query("UPDATE practice_exercises SET authored_key_signature = 'F' WHERE id = 'variant-D'");
|
||||
db.query("UPDATE practice_exercises SET key_signature = 'F' WHERE id = 'practice_part-Vocal'");
|
||||
auto disagreement = db.practice();
|
||||
BOOST_REQUIRE(!disagreement);
|
||||
BOOST_TEST(disagreement.error().message().find("authored key") != std::string_view::npos);
|
||||
BOOST_TEST(disagreement.error().message().find("disagree on key") != std::string_view::npos);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_validation_rejects_missing_legacy_and_corrupt_files_without_repair) {
|
||||
@@ -418,14 +401,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, 1, 3}) {
|
||||
for (auto version : {0, 1, 2}) {
|
||||
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 = 2");
|
||||
db.query("PRAGMA user_version = 3");
|
||||
db.query("PRAGMA application_id = 0");
|
||||
BOOST_TEST(!validate());
|
||||
auto output = std::ofstream{db.config.path, std::ios::trunc};
|
||||
@@ -439,8 +422,8 @@ 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"));
|
||||
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"));
|
||||
for (auto part_name : vamp_zero::practice_part_names()) {
|
||||
db.write(std::filesystem::path{"practice/001"} / vamp_test::part_filename(part_name), catalogue_bundle("Exercise", "Eb", part_name));
|
||||
}
|
||||
BOOST_REQUIRE(db.build());
|
||||
std::filesystem::remove(db.assets / "songs/tune.json");
|
||||
@@ -459,7 +442,7 @@ BOOST_AUTO_TEST_CASE(catalogue_reads_readonly_database_without_json_and_resolves
|
||||
BOOST_REQUIRE(score);
|
||||
BOOST_TEST(score->source_path == (moved / "songs/tune.json").string());
|
||||
BOOST_TEST(songs->parts[0].source_path == score->source_path);
|
||||
BOOST_TEST(practice->exercises[0].authored_key_signature == "Eb");
|
||||
BOOST_TEST(practice->exercises[0].parts.front().key_signature == "Eb");
|
||||
BOOST_TEST((std::filesystem::last_write_time(db.config.path) == timestamp));
|
||||
BOOST_TEST(!std::filesystem::exists(db.config.path + "-wal"));
|
||||
BOOST_TEST(!std::filesystem::exists(db.config.path + "-shm"));
|
||||
|
||||
+45
-2
@@ -9,6 +9,7 @@ from generator.engraving.audit import audit_layout
|
||||
from generator.engraving.notation import ScoreIndex
|
||||
from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, rational
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.practice.generate import practice_document
|
||||
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||
|
||||
class EngravingTests(unittest.TestCase):
|
||||
@@ -110,8 +111,50 @@ class EngravingTests(unittest.TestCase):
|
||||
percussion = '<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><type>whole</type><notehead>x</notehead></note>'
|
||||
source = parse(xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
|
||||
self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole')
|
||||
tab = parse(xml_score(xml_note(extra='<notations><technical><string>2</string><fret>1</fret></technical></notations>'), attributes='<clef><sign>TAB</sign><line>5</line></clef><staff-details><staff-lines>6</staff-lines></staff-details>'))
|
||||
self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '1')
|
||||
tab = practice_document(parse(xml_score(xml_note('C', octave=7))), 'electric')
|
||||
self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '20')
|
||||
def test_generated_tab_shares_columns_and_systems_without_duplicating_music(self):
|
||||
content = ''.join(xml_note('G', 1, '64th') for _ in range(64))
|
||||
source = practice_document(parse(xml_score(content, divisions=16)), 'electric')
|
||||
layout = self.layout(source)
|
||||
systems = [s for page in layout.pages for s in page.systems]
|
||||
self.assertGreater(len(systems), 1)
|
||||
self.assertEqual(sum(len(s.fragments) for s in systems), 64)
|
||||
for system in systems:
|
||||
heads = [p for p in system.primitives if p.role == 'head']
|
||||
tabs = [p for p in system.primitives if p.role == 'tab']
|
||||
self.assertEqual(len(heads), len(tabs))
|
||||
self.assertEqual(len({p.points[0][1] for p in system.primitives if p.role == 'tab-staff'}), 6)
|
||||
self.assertLess(max(p.box.bottom for p in system.primitives if not p.role.startswith('tab')), min(p.box.top for p in system.primitives if p.role.startswith('tab')))
|
||||
self.assertEqual({p.sources[-1] for p in heads}, {p.sources[-1] for p in tabs})
|
||||
self.assertTrue(all(any(abs(head.points[0][0] - tab.points[0][0]) < 0.01 for head in heads if head.sources[-1] == tab.sources[-1]) for tab in tabs))
|
||||
from generator.playback import compile_timeline
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 64)
|
||||
def test_generated_tab_ties_and_barline_styles(self):
|
||||
content = xml_note('E', 2, 'half', extra='<tie type="start"/>') + xml_note('E', 2, 'half', extra='<tie type="stop"/>') + '<barline location="right"><bar-style>dashed</bar-style></barline>'
|
||||
source = practice_document(parse(xml_score(content)), 'electric')
|
||||
layout = self.layout(source)
|
||||
self.assertEqual(len(self.primitives(layout, 'tab-tie')), 1)
|
||||
self.assertEqual(len(self.primitives(layout, 'barline')), len(self.primitives(layout, 'tab-barline')))
|
||||
for system in (s for page in layout.pages for s in page.systems):
|
||||
baselines = {role: max(p.points[0][1] for p in system.primitives if p.role == role) for role in ('staff', 'tab-staff')}
|
||||
notation = [p for p in system.primitives if p.role == 'barline']
|
||||
tabs = [p for p in system.primitives if p.role == 'tab-barline']
|
||||
for bar, tab in zip(notation, tabs):
|
||||
for (x1, y1), (x2, y2) in zip(bar.points, tab.points):
|
||||
self.assertAlmostEqual(x1, x2)
|
||||
self.assertAlmostEqual((y1 - baselines['staff']) * 5 / 4, y2 - baselines['tab-staff'])
|
||||
def test_adjacent_string_tab_numerals_do_not_overlap(self):
|
||||
from generator.guitar import place_guitar
|
||||
content = ''.join(xml_note(step, octave=octave, prefix='<chord/>' if index else '') for index, (step, octave) in enumerate((('G', 3), ('C', 4), ('E', 4), ('G', 4), ('C', 5), ('E', 5))))
|
||||
source = parse(xml_score(content))
|
||||
place_guitar(source)
|
||||
layout = self.layout(source)
|
||||
tabs = self.primitives(layout, 'tab')
|
||||
self.assertEqual(len(tabs), 6)
|
||||
for i, first in enumerate(tabs):
|
||||
for second in tabs[i + 1:]:
|
||||
self.assertFalse(first.box.overlaps(second.box, 0.1))
|
||||
def test_lyrics_hyphens_and_extenders(self):
|
||||
begin = '<lyric number="1"><syllabic>begin</syllabic><text>hel</text></lyric>'
|
||||
end = '<lyric number="1"><syllabic>end</syllabic><text>lo</text><extend type="start"/></lyric>'
|
||||
|
||||
@@ -137,7 +137,8 @@ class EngravingCacheTests(unittest.TestCase):
|
||||
def test_parallel_generation_preserves_assets_on_worker_failure(self):
|
||||
data, output = self.root / 'data', self.root / 'output'
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
source = data / '001.xml'
|
||||
(data / 'practice').mkdir()
|
||||
source = data / 'practice/001.xml'
|
||||
source.write_text(xml_score(xml_note()))
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2)
|
||||
generate_assets(args)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import argparse
|
||||
import copy
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
import random
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from generator import harmony
|
||||
from generator.__main__ import generate_assets
|
||||
from generator.arrange.generate import arrange_document
|
||||
from generator.guitar import Placement, TabPosition, chord_positions, chord_requirements, nearest_positions, place_guitar, position_center, positions_for_pitch, within_fret_span
|
||||
from generator.model import fraction, pitch_number, rational, validate_document
|
||||
from generator.parts import PART_TYPES
|
||||
from generator.pitch import concert_document
|
||||
from generator.playback import compile_timeline
|
||||
from generator.practice.generate import generate_practice_scores, practice_document
|
||||
from generator.summary import validate_practice_parts
|
||||
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||
|
||||
class GuitarTests(unittest.TestCase):
|
||||
def test_fret_boundaries_and_span(self):
|
||||
self.assertIn(TabPosition(6, 0, 40), positions_for_pitch(Fraction(40)))
|
||||
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.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, 20, 84))))
|
||||
self.assertFalse(within_fret_span((TabPosition(4, 10, 60), TabPosition(1, 20, 84))))
|
||||
def test_theory_controls_omissions_and_bass(self):
|
||||
for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'):
|
||||
with self.subTest(symbol=symbol):
|
||||
required, allowed, bass = chord_requirements(harmony.parse_symbol(symbol))
|
||||
candidates = chord_positions(required, allowed, bass)
|
||||
self.assertTrue(candidates)
|
||||
for candidate in candidates:
|
||||
covered = {p.midi % 12 for p in candidate}
|
||||
self.assertTrue(set(required) <= covered <= set(allowed))
|
||||
self.assertEqual(len(candidate), len({p.string_number for p in candidate}))
|
||||
self.assertTrue(within_fret_span(candidate))
|
||||
if bass is not None:
|
||||
self.assertEqual(min(p.midi for p in candidate) % 12, bass)
|
||||
self.assertIn(2, chord_requirements(harmony.parse_symbol('Cmadd9'))[0])
|
||||
self.assertNotIn(7, chord_requirements(harmony.parse_symbol('C7'))[0])
|
||||
self.assertIn(7, chord_requirements(harmony.parse_symbol('CM'))[0])
|
||||
self.assertEqual(chord_requirements({**harmony.parse_symbol('CM'), 'inversion': 0})[2], 0)
|
||||
self.assertEqual(chord_requirements({**harmony.parse_symbol('CM'), 'inversion': 1})[2], 4)
|
||||
self.assertTrue(any(p.fret > 12 for c in chord_positions(*chord_requirements(harmony.parse_symbol('CM'))) for p in c))
|
||||
def test_melody_placement_preserves_notes_timing_and_sound(self):
|
||||
source = parse(xml_score(xml_note('E', 2, 'half', octave=4) + xml_note('F', 2, 'half', octave=4)))
|
||||
before = copy.deepcopy(source)
|
||||
electric = practice_document(source, 'electric')
|
||||
vocal = practice_document(source, 'vocal')
|
||||
validate_document(electric)
|
||||
self.assertEqual(source, before)
|
||||
self.assertEqual(compile_timeline(electric)['events'], compile_timeline(vocal)['events'])
|
||||
self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in compile_timeline(electric)['events']], [(rational(fraction(e['pitch']) - 12), e['onset'], e['duration']) for e in compile_timeline(source)['events']])
|
||||
for original, generated in zip(notes(source), notes(electric)):
|
||||
self.assertEqual(pitch_number(generated['pitches'][0]), pitch_number(original['pitches'][0]))
|
||||
self.assertIn('tablature', generated['pitches'][0])
|
||||
def test_instrument_transposition_and_octave_clef_applied_once(self):
|
||||
for attributes, expected in (('', 48), ('<transpose><chromatic>-12</chromatic><diatonic>-7</diatonic></transpose>', 48), ('<transpose><chromatic>0</chromatic></transpose>', 60), ('<clef><sign>G</sign><line>2</line><clef-octave-change>-1</clef-octave-change></clef>', 60), ('<clef><sign>F</sign><line>4</line></clef>', 48)):
|
||||
source = parse(xml_score(xml_note(), attributes))
|
||||
for part in ('electric', 'vocal'):
|
||||
document = practice_document(source, part)
|
||||
self.assertEqual(fraction(compile_timeline(document)['events'][0]['pitch']), expected)
|
||||
def test_written_middle_c_and_c_sharp_six_have_matching_tab_and_audio(self):
|
||||
for octave, alter, expected, tab in ((4, 0, 48, {'string': 5, 'fret': 3}), (6, 1, 73, {'string': 1, 'fret': 9}), (7, 0, 84, {'string': 1, 'fret': 20})):
|
||||
source = parse(xml_score(xml_note(octave=octave, alter=alter)))
|
||||
document = practice_document(source, 'electric')
|
||||
tone = notes(document)[0]['pitches'][0]
|
||||
self.assertEqual(pitch_number(tone), 12 * (octave + 1) + alter)
|
||||
self.assertEqual(tone['tablature'], tab)
|
||||
self.assertEqual(fraction(compile_timeline(document)['events'][0]['pitch']), expected)
|
||||
validate_document(document)
|
||||
def test_guitar_normalization_is_idempotent_and_respects_timed_transposition(self):
|
||||
change = '<attributes><transpose><chromatic>0</chromatic></transpose></attributes>'
|
||||
source = parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half')))
|
||||
before = copy.deepcopy(source)
|
||||
concert = concert_document(source, octave_transposing=True)
|
||||
self.assertEqual(concert_document(concert, octave_transposing=True), concert)
|
||||
self.assertEqual(source, before)
|
||||
document = practice_document(source, 'electric')
|
||||
self.assertEqual([fraction(e['pitch']) for e in compile_timeline(document)['events']], [48, 60])
|
||||
def test_all_parts_share_written_octave_convention_in_songs_and_practice(self):
|
||||
chord = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'
|
||||
source = parse(xml_score(chord + xml_note()))
|
||||
for generate in (practice_document, arrange_document):
|
||||
for part in PART_TYPES:
|
||||
with self.subTest(generator=generate.__name__, part=part):
|
||||
document = generate(source, part)
|
||||
validate_document(document)
|
||||
written = {p['id']: pitch_number(p) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
|
||||
events = compile_timeline(document)['events']
|
||||
self.assertTrue(events)
|
||||
self.assertTrue(all(fraction(e['pitch']) == written[e['id']] - 12 for e in events))
|
||||
if part == 'vocal':
|
||||
self.assertEqual(set(written.values()), {60})
|
||||
self.assertTrue(all(m['transpose']['octave_change'] == -1 for m in document['staves'][0]['measures']))
|
||||
def test_ties_and_overlapping_voices_keep_strings(self):
|
||||
content = xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>')
|
||||
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
|
||||
document = parse(xml_score(content))
|
||||
place_guitar(document)
|
||||
first, continuation, other = [p['tablature'] for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']]
|
||||
self.assertEqual(first, continuation)
|
||||
self.assertNotEqual(continuation['string'], other['string'])
|
||||
self.assertEqual(len(compile_timeline(document)['events']), 2)
|
||||
def test_proximity_chooses_a_path_without_relocating_tied_notes(self):
|
||||
source = parse(xml_score(xml_note('B', 2, 'half', octave=5) + xml_note('C', 2, 'half', octave=6)))
|
||||
place_guitar(source)
|
||||
tabs = [n['pitches'][0]['tablature'] for n in notes(source)]
|
||||
self.assertEqual(tabs, [{'string': 1, 'fret': 19}, {'string': 1, 'fret': 20}])
|
||||
def test_grid_search_matches_exhaustive_costs_and_tie_breaks(self):
|
||||
randomizer = random.Random(17)
|
||||
states = {}
|
||||
for index in range(40):
|
||||
positions = tuple(TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3))
|
||||
states[index] = Placement(tuple(randomizer.randrange(8) for _ in range(3)), tuple((str(i), p) for i, p in enumerate(positions)), None, ())
|
||||
grid = nearest_positions(states)
|
||||
for x, row in enumerate(grid):
|
||||
for y, actual in enumerate(row):
|
||||
candidates = []
|
||||
for index, state in enumerate(states.values()):
|
||||
a, b = position_center(tuple(p for _, p in state.active))
|
||||
candidates.append(((state.cost[0] + abs(x - a) + abs(y - b), *state.cost[1:]), index))
|
||||
self.assertEqual(actual[:2], min(candidates))
|
||||
def test_generated_chord_retains_strings_when_a_melody_enters(self):
|
||||
content = xml_note() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + xml_note('C', 2, 'half', octave=7, extra='<voice>2</voice>')
|
||||
document = parse(xml_score(content))
|
||||
measure = document['staves'][0]['measures'][0]
|
||||
measure['transpose']['octave_change'] = -1
|
||||
chord, melody = [n for v in measure['voices'] for n in v['notes']]
|
||||
chord['pitches'] = []
|
||||
chord['_harmony'] = harmony.parse_symbol('CM')
|
||||
place_guitar(document)
|
||||
validate_document(document)
|
||||
self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 20})
|
||||
self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']])
|
||||
self.assertEqual(len(compile_timeline(document)['events']), 4)
|
||||
def test_more_than_six_simultaneous_tones_fail_without_omission(self):
|
||||
content = '<backup><duration>4</duration></backup>'.join(xml_note(extra=f'<voice>{i}</voice>') for i in range(7))
|
||||
document = parse(xml_score(content))
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, 'no guitar placement'):
|
||||
place_guitar(document)
|
||||
self.assertEqual(before, document)
|
||||
def test_source_contract_and_unplayable_music_fail(self):
|
||||
cases = [(xml_note(octave=7, alter=1), '', '0-20'), (xml_note(alter='0.5'), '', '0-20'),
|
||||
(xml_note() + xml_note('E', prefix='<chord/>'), '', 'monophonic'),
|
||||
(xml_note() + '<backup><duration>4</duration></backup>' + xml_note('E', extra='<voice>2</voice>'), '', 'one monophonic voice'),
|
||||
(xml_note(), '<staff-details><capo>1</capo></staff-details>', 'capo')]
|
||||
for content, attributes, error in cases:
|
||||
with self.subTest(error=error), self.assertRaisesRegex(ValueError, error):
|
||||
practice_document(parse(xml_score(content, attributes)), 'electric')
|
||||
for content, attributes in [(xml_note(), '<clef><sign>TAB</sign></clef>'), (xml_note(extra='<notations><technical><fingering>1</fingering></technical></notations>'), '')]:
|
||||
with self.assertRaisesRegex(ValueError, 'authored TAB'):
|
||||
parse(xml_score(content, attributes))
|
||||
def test_three_parts_are_deterministic_without_transposition(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
|
||||
source.mkdir()
|
||||
(source / '000.xml').write_text(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
|
||||
self.assertEqual(generate_practice_scores(source, output), 3)
|
||||
validate_practice_parts(output)
|
||||
first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
|
||||
generate_practice_scores(source, output)
|
||||
self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')})
|
||||
(output / '000/vocal.json').unlink()
|
||||
with self.assertRaisesRegex(ValueError, 'practice generator drift'):
|
||||
validate_practice_parts(output)
|
||||
def test_unplayable_generation_preserves_previous_assets(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / 'data/songs').mkdir(parents=True)
|
||||
(root / 'data/practice').mkdir()
|
||||
(root / 'data/practice/001.xml').write_text(xml_score(xml_note(octave=7, alter=1)))
|
||||
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'), check=False)
|
||||
with self.assertRaisesRegex(ValueError, '001.xml, part electric:.*0-20'):
|
||||
generate_assets(args)
|
||||
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
@@ -1,229 +0,0 @@
|
||||
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, 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(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, 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-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:
|
||||
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_all_keys_preserve_exact_voicings_and_adjust_g_register(self):
|
||||
harmony = '<harmony><root><root-step>G</root-step></root><kind>dominant</kind></harmony>'
|
||||
content = xml_note('G', 2, 'half') + xml_note('B', 2, 'half', prefix='<chord/>') + xml_note('F', 2, 'half', prefix='<chord/>', octave=5)
|
||||
content += xml_note('A', 2, 'half', alter=-1, octave=3) + xml_note('G', 2, 'half', alter=-1, prefix='<chord/>') + xml_note('C', 2, 'half', prefix='<chord/>', octave=5)
|
||||
source = practice_document(parse(xml_score(harmony + content)))
|
||||
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'], 'DM/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_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')
|
||||
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/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'), check=False)
|
||||
with patch('generator.__main__.engrave_tree') as engrave:
|
||||
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_practice_requires_twelve_deterministic_variants(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
source, output = Path(temp) / 'source', Path(temp) / 'output'
|
||||
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')})
|
||||
(output / '001/g.json').unlink()
|
||||
with self.assertRaisesRegex(ValueError, 'practice generator drift'):
|
||||
validate_practice_variants(output)
|
||||
@@ -7,7 +7,7 @@ from generator.model import rational
|
||||
from generator.musicxml import MusicXMLError, load_musicxml
|
||||
from generator.musicxml_writer import write_musicxml
|
||||
from generator.practice.generate import practice_document
|
||||
from generator.transposition import transpose_document
|
||||
from generator.pitch import concert_document
|
||||
from tests.test_musicxml import parse, xml_note, xml_score
|
||||
|
||||
def chord_xml(text, kind='other', root='C', extra=''):
|
||||
@@ -54,10 +54,11 @@ class HarmonyTests(unittest.TestCase):
|
||||
self.assertEqual(chord['symbol'], label)
|
||||
self.assertTrue(chord['chord_optional'])
|
||||
self.assertTrue(chord['parenthesized'])
|
||||
practice = practice_document(source)['staves'][0]['measures'][0]['harmonies'][0]
|
||||
practice = practice_document(source, 'vocal')['staves'][0]['measures'][0]['harmonies'][0]
|
||||
self.assertNotIn('chord_optional', practice)
|
||||
self.assertEqual(harmony.symbol(practice), label)
|
||||
shifted = transpose_document(source, 'D')
|
||||
source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0}
|
||||
shifted = concert_document(source)
|
||||
result = shifted['staves'][0]['measures'][0]['harmonies'][0]
|
||||
self.assertEqual(result['symbol'], transposed)
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
@@ -87,7 +88,7 @@ class HarmonyTests(unittest.TestCase):
|
||||
source = parse(xml_score(chord_xml('7', 'dominant', extra='<degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree>') + xml_note()))
|
||||
self.assertEqual(source['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'C7b9')
|
||||
def test_current_exercise_and_song_exports_import_without_edits(self):
|
||||
exercise = load_musicxml(Path('data/001.xml'))
|
||||
exercise = load_musicxml(Path('data/practice/001.xml'))
|
||||
measures = exercise['staves'][0]['measures']
|
||||
self.assertEqual(len(measures), 20)
|
||||
self.assertEqual(sum(len(n['pitches']) for m in measures for v in m['voices'] for n in v['notes']), 70)
|
||||
|
||||
+10
-10
@@ -165,11 +165,11 @@ auto checksum(std::string const &bytes) -> std::string {
|
||||
return result;
|
||||
}
|
||||
|
||||
auto write_score(std::filesystem::path const &path, std::string key = "C") -> void {
|
||||
auto write_score(std::filesystem::path const &path, std::string part = "Lead") -> void {
|
||||
auto score = boost::json::parse(R"({"schema_version":3,"music":{"metadata":{"title":"Fixture","composer":"Composer",
|
||||
"key_signature":"C","time_signature":"4/4"},"staves":[{"name":"Lead","measures":[{"voices":[]}]}]},
|
||||
"presentation":{"width":976,"height":484,"pages":[]}})");
|
||||
score.at("music").at("metadata").as_object()["key_signature"] = key;
|
||||
score.at("music").at("staves").as_array().front().as_object()["name"] = part;
|
||||
auto &pages = score.at("presentation").at("pages").as_array();
|
||||
for (auto index = std::uint32_t{1}; index <= 2U; ++index) {
|
||||
auto relative = path.stem().string() + ".pages/" + std::to_string(index) + ".png";
|
||||
@@ -184,10 +184,10 @@ 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");
|
||||
for (auto key : vamp_zero::practice_key_names()) {
|
||||
auto name = std::string{key};
|
||||
for (auto part_name : vamp_zero::practice_part_names()) {
|
||||
auto name = std::string{part_name};
|
||||
name.front() = static_cast<std::char_t>(name.front() - 'A' + 'a');
|
||||
write_score(root / "practice" / "001" / (name + ".json"), std::string{key});
|
||||
write_score(root / "practice" / "001" / (name + ".json"), std::string{part_name});
|
||||
}
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(root, config.database.path));
|
||||
}
|
||||
@@ -363,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() == 12U);
|
||||
BOOST_TEST(exercises->exercises.front().parts.size() == 3U);
|
||||
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;
|
||||
@@ -374,8 +374,8 @@ BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalogue_without_hardware_or_hear
|
||||
require_no_probe(config, true);
|
||||
}
|
||||
|
||||
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/001/d.pages/2.png"}) {
|
||||
BOOST_AUTO_TEST_CASE(readiness_validates_later_pages_and_parts) {
|
||||
for (std::char_t const *relative : {"songs/first.pages/2.png", "songs/second.pages/2.png", "practice/001/electric.pages/2.png"}) {
|
||||
auto root = temporary_directory{};
|
||||
auto config = fixture_config(root.path);
|
||||
make_assets(config);
|
||||
@@ -394,8 +394,8 @@ 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/001/c.json";
|
||||
write_score(path);
|
||||
auto path = assets / "practice/001/acoustic.json";
|
||||
write_score(path, "Acoustic");
|
||||
auto score = boost::json::parse(read_file(path));
|
||||
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
|
||||
write_file(path, boost::json::serialize(score));
|
||||
|
||||
+11
-63
@@ -10,13 +10,18 @@ from generator.engraving import Fonts, Style, engrave_tree, layout_score
|
||||
from generator.engraving.attributes import staff_attributes
|
||||
from generator.engraving.notation import ScoreIndex
|
||||
from generator.model import key_from_fifths, parse_pitch_name, rational, validate_document
|
||||
from generator.practice.generate import generate_practice_scores, key_file_name, practice_document
|
||||
from generator.score import load_json, write_json
|
||||
from generator.transposition import TARGET_KEYS, concert_document, shift_key, transpose_document
|
||||
from generator.pitch import concert_document, shift_key
|
||||
from tests.test_musicxml import parse, xml_note, xml_score
|
||||
|
||||
FONT_ROOT = Path('package/assets/fonts/petaluma')
|
||||
|
||||
def instrument_score(document, steps, semitones):
|
||||
source = copy.deepcopy(document)
|
||||
for measure in source['staves'][0]['measures']:
|
||||
measure['transpose'] = {'diatonic': steps, 'chromatic': semitones, 'octave_change': 0}
|
||||
return concert_document(source)
|
||||
|
||||
def key_change(old, new, local=False, attributes=''):
|
||||
initial = f'<key><fifths>{old}</fifths></key>' + attributes
|
||||
change = f'<attributes><key><cancel>{old}</cancel><fifths>{new}</fifths></key></attributes>'
|
||||
@@ -38,7 +43,7 @@ class KeyCancellationTests(unittest.TestCase):
|
||||
with self.subTest(old=old, new=new, target=target, local=local):
|
||||
source = key_change(old, new, local)
|
||||
before = copy.deepcopy(source)
|
||||
result = transpose_document(source, target)
|
||||
result = instrument_score(source, *({'A': (1, 2), 'Ab': (-1, -2), 'G': (1, 2), 'F': (-1, -2)}[target]))
|
||||
validate_document(result)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual(measures[0]['key'], key_from_fifths(expected_old))
|
||||
@@ -66,7 +71,7 @@ class KeyCancellationTests(unittest.TestCase):
|
||||
self.assertEqual(source, before)
|
||||
def test_metadata_and_initial_signature_transpose_cancellation(self):
|
||||
source = parse(xml_score(xml_note(), '<key><cancel>1</cancel><fifths>0</fifths></key>'))
|
||||
result = transpose_document(source, 'D', octave_shift=1)
|
||||
result = instrument_score(source, 1, 2)
|
||||
expected = key_from_fifths(2) | {'cancel': 3}
|
||||
self.assertEqual(result['metadata']['key'], expected)
|
||||
self.assertEqual(result['staves'][0]['measures'][0]['key'], expected)
|
||||
@@ -81,7 +86,7 @@ class KeyCancellationTests(unittest.TestCase):
|
||||
def test_unchanged_accidentals_do_not_receive_naturals(self):
|
||||
for old, new, target in [(1, 0, 'F'), (-1, 0, 'G'), (1, 1, 'A')]:
|
||||
with self.subTest(old=old, new=new, target=target):
|
||||
result = transpose_document(key_change(old, new), target)
|
||||
result = instrument_score(key_change(old, new), *({'F': (-1, -2), 'G': (1, 2), 'A': (1, 2)}[target]))
|
||||
index = ScoreIndex(result)
|
||||
glyphs = staff_attributes(index, index.state(Fraction(4)), Fraction(4), self.fonts, 12, 'change', ('key',))
|
||||
self.assertNotIn('accidentalNatural', [p.value for p in glyphs])
|
||||
@@ -99,7 +104,7 @@ class KeyCancellationTests(unittest.TestCase):
|
||||
change['key']['cancel'] = 14
|
||||
before = copy.deepcopy(source)
|
||||
with self.subTest(local=local), self.assertRaisesRegex(ValueError, 'key cancellation.*represented'):
|
||||
transpose_document(source, 'D')
|
||||
instrument_score(source, 1, 2)
|
||||
self.assertEqual(source, before)
|
||||
def test_concert_transposition_uses_the_same_interval_for_cancellation(self):
|
||||
attributes = '<transpose><diatonic>1</diatonic><chromatic>2</chromatic></transpose>'
|
||||
@@ -111,60 +116,3 @@ class KeyCancellationTests(unittest.TestCase):
|
||||
measures = result['staves'][0]['measures']
|
||||
change = measures[0]['attributes'][0] if local else measures[1]
|
||||
self.assertEqual(change['key'], key_from_fifths(2) | {'cancel': 3})
|
||||
|
||||
class AuthoredKeyMetadataTests(unittest.TestCase):
|
||||
def test_eb_authored_key_survives_every_generated_practice_variant(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
source, output = Path(temp) / 'source', Path(temp) / 'output'
|
||||
text = xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>')
|
||||
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>'))
|
||||
if mode == 'none':
|
||||
source['metadata']['transpose_origin'] = parse_pitch_name('C')
|
||||
before = copy.deepcopy(source)
|
||||
base = practice_document(source)
|
||||
self.assertEqual(base['metadata']['authored_key_signature'], label)
|
||||
for key in TARGET_KEYS:
|
||||
with self.subTest(mode=mode, key=key):
|
||||
result = transpose_document(base, key)
|
||||
validate_document(result)
|
||||
self.assertEqual(result['metadata']['authored_key_signature'], label)
|
||||
self.assertEqual(result['metadata']['key_signature'], 'No key' if mode == 'none' else f'{key} {mode}')
|
||||
self.assertEqual(result['metadata']['variant_tonic'], key)
|
||||
self.assertEqual(source, before)
|
||||
def test_existing_authored_key_is_retained_when_practice_is_rebuilt(self):
|
||||
source = parse(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
|
||||
before = copy.deepcopy(source)
|
||||
variant = transpose_document(practice_document(source), 'D')
|
||||
result = transpose_document(practice_document(variant), 'F')
|
||||
self.assertEqual(result['metadata']['authored_key_signature'], 'Eb')
|
||||
self.assertEqual(result['metadata']['key_signature'], 'F')
|
||||
self.assertEqual(source, before)
|
||||
def test_schema3_bundles_accept_optional_authored_key(self):
|
||||
source = parse(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
|
||||
self.assertNotIn('authored_key_signature', source['metadata'])
|
||||
for authored in (False, True):
|
||||
with self.subTest(authored=authored), tempfile.TemporaryDirectory() as temp:
|
||||
result = transpose_document(practice_document(source) if authored else source, 'D')
|
||||
root = Path(temp)
|
||||
path = root / 'variant.json'
|
||||
write_json(path, result)
|
||||
engrave_tree(root, FONT_ROOT)
|
||||
bundle = json.loads(path.read_text(encoding='utf-8'))
|
||||
self.assertEqual(bundle['schema_version'], 3)
|
||||
self.assertEqual(bundle['music']['metadata']['key_signature'], 'D')
|
||||
self.assertEqual(bundle['music']['metadata'].get('authored_key_signature'), 'Eb' if authored else None)
|
||||
self.assertEqual(load_json(path), result)
|
||||
|
||||
@@ -8,12 +8,13 @@ from unittest.mock import patch
|
||||
|
||||
from generator import harmony
|
||||
from generator.__main__ import generate_assets
|
||||
from generator.arrange.generate import active_chord, arrange_document, generated_voice, propagated_chords
|
||||
from generator.arrange.fretboard import pitches_for_voicing
|
||||
from generator.arrange.generate import arrange_document
|
||||
from generator.parts import active_chord, generated_voice, propagated_chords
|
||||
from generator.guitar import chord_pitches, chord_positions, chord_requirements
|
||||
from generator.model import beat_lengths, fraction, key_from_fifths, pitch_number, rational, validate_document
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.styles import BUILT_IN_STYLES, canonical_style
|
||||
from generator.transposition import concert_document, shift_pitch, transpose_document
|
||||
from generator.pitch import concert_document, shift_pitch
|
||||
from tests.test_musicxml import parse, notes, xml_note, xml_score
|
||||
|
||||
class MusicModelTests(unittest.TestCase):
|
||||
@@ -30,7 +31,8 @@ class MusicModelTests(unittest.TestCase):
|
||||
for label, expected in cases.items():
|
||||
self.assertEqual(set(harmony.pitch_classes(harmony.parse_symbol(label))), expected, label)
|
||||
for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]:
|
||||
pitches = pitches_for_voicing(label)
|
||||
definition = harmony.parse_symbol(label)
|
||||
pitches = chord_pitches(definition, chord_positions(*chord_requirements(definition))[0])
|
||||
self.assertTrue(essentials.issubset({pitch_number(p) % 12 for p in pitches}), label)
|
||||
with self.assertRaises(ValueError):
|
||||
harmony.parse_symbol('Cunrecognised')
|
||||
@@ -39,8 +41,8 @@ class MusicModelTests(unittest.TestCase):
|
||||
p['alter'] = rational(p['alter'])
|
||||
self.assertEqual(shift_pitch(p, 0, 0), p)
|
||||
self.assertEqual(pitch_number(shift_pitch(p, 1, 2)), pitch_number(p) + 2)
|
||||
source = parse(xml_score(xml_note(), attributes='<key><fifths>-3</fifths><mode>minor</mode></key>'))
|
||||
result = transpose_document(source, 'D')
|
||||
source = parse(xml_score(xml_note(), attributes='<key><fifths>-3</fifths><mode>minor</mode></key><transpose><diatonic>1</diatonic><chromatic>2</chromatic></transpose>'))
|
||||
result = concert_document(source)
|
||||
self.assertEqual(result['metadata']['key_signature'], 'D minor')
|
||||
self.assertEqual(result['metadata']['key']['alterations'], key_from_fifths(-1, 'minor')['alterations'])
|
||||
def test_additive_and_compound_grouping(self):
|
||||
@@ -105,6 +107,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
root = Path(temp)
|
||||
data = root / 'data'
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
(data / 'practice').mkdir()
|
||||
(data / 'songs' / 'bad.xml').write_text(xml_score(xml_note(duration=5)), encoding='utf-8')
|
||||
output = root / 'output'
|
||||
output.mkdir()
|
||||
@@ -129,6 +132,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
data, output = Path(temp) / 'data', Path(temp) / 'output'
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
(data / 'practice').mkdir()
|
||||
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'), check=False)
|
||||
|
||||
@@ -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_score_metadata_not_path(self):
|
||||
def test_sounding_pitch_depends_on_instrument_transposition_not_part_or_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 part in ('practice', 'acoustic', 'electric', 'vocal'):
|
||||
for part in ('acoustic', 'electric', 'vocal'):
|
||||
source['staves'][0]['arrangement_part_type'] = part
|
||||
source['staves'][0]['measures'][0]['transpose']['octave_change'] = -1 if part != 'vocal' else 0
|
||||
write_json(root / f'{part}.json', source)
|
||||
compile_tree(root)
|
||||
for part, pitch in [('practice', 48), ('acoustic', 48), ('electric', 48), ('vocal', 60)]:
|
||||
for part, pitch in [('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):
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import copy
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from generator.engraving import Fonts, Style, layout_score
|
||||
from generator.engraving.layout import make_system
|
||||
from generator.engraving.notation import ScoreIndex
|
||||
from generator.model import rational
|
||||
from generator.playback import compile_timeline
|
||||
from generator.scat import add_scat, pulse, syllable
|
||||
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||
|
||||
class ScatTests(unittest.TestCase):
|
||||
def test_syllables_use_duration_pulse_phrase_and_previous_consonant(self):
|
||||
self.assertEqual(syllable(Fraction(2), Fraction(1), False, True, ''), 'doo')
|
||||
self.assertEqual(syllable(Fraction(1, 2), Fraction(1), True, False, ''), 'ba')
|
||||
self.assertEqual(syllable(Fraction(1, 2), Fraction(1), False, False, ''), 'dit')
|
||||
self.assertEqual(syllable(Fraction(1, 2), Fraction(1), False, False, 'dit'), 'da')
|
||||
self.assertEqual(syllable(Fraction(1, 2), Fraction(1), True, True, ''), 'dat')
|
||||
self.assertEqual(syllable(Fraction(1, 2), None, False, False, ''), 'ba')
|
||||
def test_compound_additive_meter_and_pickups(self):
|
||||
measure = {'meter': {'groups': [{'beats': [6], 'unit': 8}]}, 'duration': rational(3)}
|
||||
self.assertEqual(pulse(measure, Fraction(3, 2)), (Fraction(3, 2), True))
|
||||
measure['meter']['groups'][0]['beats'] = [3, 2]
|
||||
self.assertEqual(pulse(measure, Fraction(3, 2)), (Fraction(1), True))
|
||||
measure.update(implicit=True, duration=rational(Fraction(1, 2)))
|
||||
self.assertEqual(pulse(measure, Fraction()), (Fraction(1), False))
|
||||
def test_ties_sustain_one_syllable_and_stop_before_rests(self):
|
||||
content = xml_note(duration=1, kind='quarter', extra='<tie type="start"/>') + xml_note(duration=1, kind='quarter', extra='<tie type="stop"/>')
|
||||
content += '<note><rest/><duration>2</duration><type>half</type></note>'
|
||||
document = parse(xml_score(content))
|
||||
original = copy.deepcopy(document)
|
||||
before = compile_timeline(document)
|
||||
add_scat(document)
|
||||
self.assertEqual(compile_timeline(document), before)
|
||||
self.assertEqual(notes(document)[0]['lyric'], 'doo')
|
||||
self.assertEqual(notes(document)[0]['lyrics'][0]['extend_to'], rational(2))
|
||||
self.assertNotIn('lyric', notes(document)[1])
|
||||
self.assertNotIn('lyrics', notes(document)[2])
|
||||
again = copy.deepcopy(original)
|
||||
add_scat(again)
|
||||
self.assertEqual(document, again)
|
||||
fonts = Fonts(Path('package/assets/fonts/petaluma'))
|
||||
layout = layout_score(document, Style(), fonts)
|
||||
system = layout.pages[0].systems[0]
|
||||
rest = next(a.x for a in system.anchors if a.fragment.kind == 'rest')
|
||||
self.assertTrue(all(p.box.right < rest for p in system.primitives if p.role == 'lyric-extension'))
|
||||
def test_cross_bar_tie_has_one_lyric_and_page_continuations(self):
|
||||
text = xml_score(xml_note(extra='<tie type="start"/>'))
|
||||
text = text.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/>') + '</measure></part>')
|
||||
document = parse(text)
|
||||
add_scat(document)
|
||||
self.assertEqual(sum(bool(n.get('lyric')) for n in notes(document)), 1)
|
||||
fonts = Fonts(Path('package/assets/fonts/petaluma'))
|
||||
for start, end in ((0, 4), (4, 8)):
|
||||
system = make_system(ScoreIndex(document), start, end, fonts, Style())
|
||||
self.assertTrue(any(p.role == 'lyric-extension' for p in system.primitives))
|
||||
def test_long_tie_chain_draws_one_extender_per_system(self):
|
||||
text = xml_score(xml_note(extra='<tie type="start"/>'))
|
||||
text = text.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/><tie type="start"/>') + '</measure><measure number="3">' + xml_note(extra='<tie type="stop"/>') + '</measure></part>')
|
||||
document = parse(text)
|
||||
add_scat(document)
|
||||
fonts = Fonts(Path('package/assets/fonts/petaluma'))
|
||||
index = ScoreIndex(document)
|
||||
for start, end in ((0, 4), (4, 8), (8, 12), (0, 12)):
|
||||
system = make_system(index, start, end, fonts, Style())
|
||||
self.assertEqual(sum(p.role == 'lyric-extension' for p in system.primitives), 1)
|
||||
def test_straight_offbeat_attacks_and_cues(self):
|
||||
document = parse(xml_score(''.join(xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2))
|
||||
before = compile_timeline(document)
|
||||
add_scat(document)
|
||||
self.assertEqual([n['lyric'] for n in notes(document)], ['ba', 'dit', 'ba', 'dit', 'ba', 'dit', 'ba', 'dat'])
|
||||
self.assertEqual(compile_timeline(document), before)
|
||||
document = parse(xml_score(xml_note(prefix='<cue/>')))
|
||||
add_scat(document)
|
||||
self.assertNotIn('lyrics', notes(document)[0])
|
||||
+15
-13
@@ -18,7 +18,7 @@ from generator.musicxml import load_musicxml
|
||||
from generator.musicxml_writer import write_musicxml
|
||||
from generator.practice.generate import practice_document
|
||||
from generator.score import load_json
|
||||
from generator.transposition import transpose_document
|
||||
from generator.pitch import concert_document
|
||||
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||
|
||||
class ScoreBundleTests(unittest.TestCase):
|
||||
@@ -27,7 +27,8 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
root = Path(temporary)
|
||||
data, output = root / 'data', root / 'output'
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
source = data / '001.xml'
|
||||
(data / 'practice').mkdir()
|
||||
source = data / 'practice/001.xml'
|
||||
source.write_text(xml_score(xml_note()))
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
|
||||
generate_assets(args)
|
||||
@@ -48,12 +49,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 = data / '127.xml'
|
||||
renamed = data / 'practice/127.xml'
|
||||
source.rename(renamed)
|
||||
source = renamed
|
||||
generate_assets(args)
|
||||
self.assertFalse((output / 'practice/001').exists())
|
||||
self.assertEqual(len(list((output / 'practice/127').glob('*.json'))), 12)
|
||||
self.assertEqual(len(list((output / 'practice/127').glob('*.json'))), 3)
|
||||
source.unlink()
|
||||
generate_assets(args)
|
||||
self.assertFalse(list(output.rglob('*.png')))
|
||||
@@ -72,7 +73,7 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
measure = source['staves'][0]['measures'][0]
|
||||
self.assertEqual([v['id'] for v in measure['voices']], ['1', '2'])
|
||||
self.assertNotIn('tie_from', measure['voices'][1]['notes'][0]['pitches'][0])
|
||||
for document in (source, practice_document(source), transpose_document(source, 'D'), arrange_document(source, 'vocal')):
|
||||
for document in (source, concert_document(source), arrange_document(source, 'vocal')):
|
||||
validate_document(document)
|
||||
restored = self.round_trip(document)
|
||||
validate_document(restored)
|
||||
@@ -95,7 +96,8 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
self.assertEqual(restored['staves'][0]['measures'][1]['barlines'], {'right': 'light-heavy'})
|
||||
def test_microtones_remain_exact_after_transposition_and_export(self):
|
||||
source = parse(xml_score(xml_note(alter='0.5', extra='<accidental>quarter-sharp</accidental>')))
|
||||
transposed = transpose_document(source, 'D')
|
||||
source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0}
|
||||
transposed = concert_document(source)
|
||||
self.assertEqual(pitch_number(notes(transposed)[0]['pitches'][0]), Fraction(125, 2))
|
||||
restored = self.round_trip(transposed)
|
||||
self.assertEqual(notes(restored)[0]['pitches'], notes(transposed)[0]['pitches'])
|
||||
@@ -131,13 +133,13 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
source = parse(xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
|
||||
restored = self.round_trip(source)
|
||||
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
||||
tab = parse(xml_score(xml_note(extra='<notations><technical><string>2</string><fret>1</fret></technical></notations>'), attributes='<clef><sign>TAB</sign><line>5</line></clef><staff-details><staff-lines>6</staff-lines></staff-details>'))
|
||||
self.assertEqual(notes(self.round_trip(tab))[0]['pitches'], notes(tab)[0]['pitches'])
|
||||
for document in (source, tab):
|
||||
validate_document(practice_document(document))
|
||||
for part in ('vocal', 'acoustic', 'electric'):
|
||||
with self.assertRaisesRegex(ValueError, 'conversion is not qualified'):
|
||||
arrange_document(document, part)
|
||||
with self.assertRaisesRegex(ValueError, 'authored TAB'):
|
||||
parse(xml_score(xml_note(), attributes='<clef><sign>TAB</sign></clef>'))
|
||||
for part in ('vocal', 'acoustic', 'electric'):
|
||||
with self.assertRaisesRegex(ValueError, 'pitched'):
|
||||
practice_document(source, part)
|
||||
with self.assertRaisesRegex(ValueError, 'pitched'):
|
||||
arrange_document(source, part)
|
||||
def test_percussion_instrument_definitions_round_trip_and_are_audited(self):
|
||||
tree = ElementTree.fromstring(xml_score('<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><instrument id="drum"/><type>whole</type></note>', attributes='<clef><sign>percussion</sign></clef>'))
|
||||
definition = tree.find('part-list/score-part')
|
||||
|
||||
@@ -316,10 +316,10 @@ BOOST_AUTO_TEST_CASE(score_loader_completion_can_overlap_stop_and_consumer_destr
|
||||
BOOST_CHECK(entered_status == std::future_status::ready);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_variant) {
|
||||
BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_part) {
|
||||
auto catalog = std::make_shared<vamp_zero::catalogue>();
|
||||
catalog->performance.parts = {{.id = "part-a"}, {.id = "part-b"}};
|
||||
catalog->practice.exercises = {{.id = "scale", .variants = {{.id = "scale-c"}, {.id = "scale-d"}}}};
|
||||
catalog->practice.exercises = {{.id = "scale", .parts = {{.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;
|
||||
@@ -330,12 +330,12 @@ BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_vari
|
||||
BOOST_CHECK(reference->category == vamp_zero::score_category::PERFORMANCE);
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_SCORE;
|
||||
state.active_practice_id = "scale";
|
||||
state.selected_practice_key_index = 2;
|
||||
state.selected_practice_part_index = 2;
|
||||
reference = vamp_zero::selected_score_reference(state);
|
||||
BOOST_REQUIRE(reference);
|
||||
BOOST_CHECK_EQUAL(reference->id, "scale-d");
|
||||
BOOST_CHECK(reference->category == vamp_zero::score_category::PRACTICE);
|
||||
state.selected_practice_key_index = 0;
|
||||
state.selected_practice_part_index = 0;
|
||||
BOOST_CHECK(!vamp_zero::selected_score_reference(state));
|
||||
state.catalog.reset();
|
||||
BOOST_CHECK(!vamp_zero::selected_score_reference(state));
|
||||
|
||||
Reference in New Issue
Block a user