Improve Catalog
ci / checks (push) Successful in 46m42s

This commit is contained in:
khodowany
2026-09-27 11:03:43 -05:00
parent a416a80dd9
commit 142d843fac
418 changed files with 440 additions and 324488 deletions
+19
View File
@@ -0,0 +1,19 @@
import unittest.mock
import generator.arrange.form
import generator.arrange.generate
import generator.parts
def section_document(document, part_type, *, rhythms, plan=None):
"""Exercise the real part pipeline with a single section instead of a full song."""
prepared = generator.parts.prepare_document(document)
role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type]
section = generator.arrange.form.ArrangementSection('', 0, len(prepared['staves'][0]['measures']), 0, role)
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section]):
if plan is not None:
plans = {0: plan}
elif part_type == 'acoustic':
plans = generator.arrange.generate.plan_arrangement(prepared, rhythms)
else:
plans = {}
return generator.arrange.generate._arrange_document(prepared, part_type, plans)
-7
View File
@@ -3,7 +3,6 @@
#include <cerrno>
#include <filesystem>
#include <string>
#include <string_view>
#include <sys/wait.h>
#include <unistd.h>
@@ -15,12 +14,6 @@
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_catalog(std::filesystem::path const &assets, std::filesystem::path const &database) -> std::bool_t {
auto source = assets.string();
auto destination = database.string();
+74 -227
View File
@@ -16,7 +16,6 @@
#include <string_view>
#include <thread>
#include <utility>
#include <variant>
#include <vector>
#include <boost/asio/co_spawn.hpp>
@@ -27,8 +26,6 @@
#include <boost/json/serialize.hpp>
#include <boost/json/array.hpp>
#include <noto.hpp>
#include <vamp_protocol/page_control.hpp>
#include <vamp_zero/config.hpp>
#include <vamp_pico/http_client.hpp>
@@ -42,7 +39,6 @@
#include <vamp_zero/score.hpp>
#include <vamp_zero/types.hpp>
#include "catalog_fixture.hpp"
namespace {
@@ -52,47 +48,6 @@ auto test_database_config(std::filesystem::path const &path) -> vamp_zero::datab
};
}
auto query_database(std::filesystem::path const &path,
std::string sql,
std::vector<noto::sqlite_value> params = {}) -> std::expected<noto::query_result, modulus::error> {
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(
io,
[executor = io.get_executor(), path = path.string(), sql = std::move(sql), params = std::move(params)]() mutable
-> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
auto config = noto::file_connection_config(path, "product-vamp-test");
config.foreign_keys = true;
auto connection = noto::connection{executor, std::move(config)};
auto connected = co_await connection.async_connect();
if (!connected) {
co_return std::unexpected(connected.error());
}
auto selected = co_await connection.async_execute(std::move(sql), std::move(params));
auto closed = co_await connection.async_close();
if (!selected) {
co_return std::unexpected(selected.error());
}
if (!closed) {
co_return std::unexpected(closed.error());
}
co_return selected;
},
boost::asio::use_future);
io.run();
return future.get();
}
auto text_value(noto::sqlite_value const &value) -> std::string {
auto text = std::get_if<std::string>(&value.value);
return text == nullptr ? std::string{} : *text;
}
auto has_value(noto::query_result const &result, std::size_t column, std::string_view expected) -> std::bool_t {
return std::any_of(result.rows.begin(), result.rows.end(), [&](auto const &row) {
return row.size() > column && text_value(row[column]) == expected;
});
}
auto score_asset_json(std::string title, std::string key = "C", std::string part = "Lead") -> std::string {
auto value = boost::json::parse(R"json({
"music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
@@ -118,38 +73,11 @@ auto navigation_state() -> vamp_zero::app_state {
.measure_count = 1,
.total_pages = 2,
};
auto practice_part = vamp_zero::library_part{
.key_signature = "C",
.source_path = "exercise.json",
.measure_count = 1,
.total_pages = 1,
};
auto exercise = vamp_zero::practice_exercise{
.id = "exercise-1",
.title = "C Major",
.composer = "Composer",
};
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);
return vamp_zero::app_state{
.catalog = std::make_shared<vamp_zero::catalog const>(vamp_zero::catalog{
.performance = vamp_zero::group_library_parts({part}),
.practice = std::move(*practice),
}),
.catalog = std::make_shared<vamp_zero::catalog const>(vamp_zero::group_ordered_parts({part})),
};
}
void write_text(std::filesystem::path const &path, std::string const &text) {
std::filesystem::create_directories(path.parent_path());
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
output << text;
}
}
BOOST_AUTO_TEST_CASE(build_identification_uses_git_without_an_application_release_label) {
@@ -230,18 +158,17 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) {
BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) {
auto state = navigation_state();
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_TEST(state.selected_main_menu_index == 2U);
BOOST_TEST(state.selected_main_menu_index == 1U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_TEST(state.audio.playback_enabled);
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::MAIN_MENU));
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_TEST(state.selected_main_menu_index == 3U);
BOOST_TEST(state.selected_main_menu_index == 2U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_TEST(state.audio.metronome_enabled);
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::MAIN_MENU));
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_TEST(state.selected_main_menu_index == 4U);
BOOST_TEST(state.selected_main_menu_index == 3U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_TEST(state.audio.tempo_editing);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
@@ -310,177 +237,97 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalog) {
BOOST_TEST(count > 0U);
}
BOOST_AUTO_TEST_CASE(practice_part_navigation_and_remote_display) {
auto state = navigation_state();
auto catalog = std::make_shared<vamp_zero::catalog>(*state.catalog);
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);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU);
BOOST_AUTO_TEST_CASE(empty_catalog_navigation_and_main_menu_wrap) {
auto state = vamp_zero::app_state{};
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
BOOST_TEST(state.selected_main_menu_index == 3U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_TEST(state.selected_main_menu_index == 0U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_TEST(state.selected_song_index == 0U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::MAIN_MENU);
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
BOOST_TEST(!vamp_zero::app_status_json(state).as_object().contains("practice"));
}
BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display) {
auto parts = std::vector<vamp_zero::library_part>{};
for (auto name : {"Acoustic", "Electric", "Vocal"}) {
parts.push_back({.id = name, .title = "Song", .composer = "Composer", .part = name, .total_pages = 3});
}
auto state = vamp_zero::app_state{
.catalog = std::make_shared<vamp_zero::catalog const>(vamp_zero::group_ordered_parts(std::move(parts))),
};
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_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);
for (auto name : {"Acoustic", "Electric", "Vocal"}) {
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
auto selection = state.selected_part_index;
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
BOOST_TEST(state.active_part_id == name);
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} == name);
BOOST_TEST(remote.pages == 3U);
for (auto index = std::uint32_t{}; index < 4U; ++index) {
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
}
BOOST_TEST(state.page_index == 2U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
BOOST_TEST(state.selected_part_index == selection);
BOOST_TEST(state.active_part_id.empty());
BOOST_TEST(state.audio.metronome_enabled);
}
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_part(state, state.catalog->practice.exercises.front());
BOOST_REQUIRE(selected != nullptr);
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} == "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);
auto playing = state;
playing.audio.playback_enabled = true;
auto control = vamp_zero::control_state{{}, playing};
control.finish_playback(playing.score_session);
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PRACTICE_PART_MENU);
BOOST_TEST(control.snapshot().selected_practice_part_index == 3U);
BOOST_TEST(state.selected_part_index == 0U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
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);
}
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU);
BOOST_TEST(state.selected_practice_exercise_index == 1U);
auto status = vamp_zero::app_status_json(state).as_object().at("practice").as_object();
BOOST_TEST(!status.contains("types"));
BOOST_TEST(status.at("exercises").as_array().size() == 1U);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU);
BOOST_TEST(state.selected_song_index == 1U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::MAIN_MENU);
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
}
BOOST_AUTO_TEST_CASE(flat_practice_wraps_and_empty_catalog_returns_to_main) {
auto state = navigation_state();
auto catalog = std::make_shared<vamp_zero::catalog>(*state.catalog);
auto first = catalog->practice.exercises.front();
for (auto index = std::uint32_t{2}; index <= 366U; ++index) {
auto exercise = first;
exercise.id = std::to_string(index);
exercise.id.insert(0U, 4U - exercise.id.size(), '0');
catalog->practice.exercises.push_back(std::move(exercise));
}
state.catalog = catalog;
state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
BOOST_TEST(state.selected_practice_exercise_index == 366U);
BOOST_TEST(vamp_zero::selected_practice_exercise(state)->id == "0366");
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_TEST(state.selected_practice_exercise_index == 0U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_PART_MENU);
state.catalog = vamp_zero::empty_catalog();
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU);
BOOST_TEST(state.selected_practice_exercise_index == 0U);
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::MAIN_MENU);
}
BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_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" / "practice"}) {
BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_song_parts) {
auto config = test_database_config(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "catalog.db");
auto expected = std::size_t{};
for (auto const &entry : std::filesystem::directory_iterator{std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "data/songs"}) {
if (entry.is_regular_file() && entry.path().extension() == ".xml") {
++expected_exercises;
expected += 3U;
}
}
BOOST_REQUIRE(expected_exercises > 0U);
BOOST_REQUIRE(expected > 0U);
for (auto iteration = std::uint32_t{}; iteration < 2U; ++iteration) {
config.path = (std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "catalog.db").string();
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config, VAMP_TEST_ASSETS_DIR), boost::asio::use_future);
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_catalog(io.get_executor(), config, VAMP_TEST_ASSETS_DIR), boost::asio::use_future);
io.run();
auto library = future.get();
BOOST_REQUIRE_MESSAGE(library.has_value(), (library ? "" : library.error().describe()));
BOOST_TEST(library->exercises.size() == expected_exercises);
for (auto const &exercise : library->exercises) {
BOOST_TEST(exercise.id.size() == 4U);
BOOST_TEST(exercise.id.find_first_not_of("0123456789") == std::string::npos);
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 &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, 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 == 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);
}
}
auto catalog = future.get();
BOOST_REQUIRE_MESSAGE(catalog, (catalog ? "" : catalog.error().describe()));
BOOST_REQUIRE_EQUAL(catalog->parts.size(), expected);
for (auto const &part : catalog->parts) {
io.restart();
auto loaded = boost::asio::co_spawn(io, vamp_zero::async_load_score(io.get_executor(), config, {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 == part.title);
BOOST_TEST(score->composer == part.composer);
BOOST_TEST(score->key_signature == part.key_signature);
BOOST_TEST(score->part == part.part);
BOOST_TEST(score->measure_count == part.measure_count);
BOOST_TEST(score->pages.size() == part.total_pages);
BOOST_TEST(score->source_path == part.source_path);
}
}
std::filesystem::remove_all(root);
}
BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_refresh_after_rename) {
auto root = std::filesystem::temp_directory_path() / ("product-vamp-paths-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
auto assets = root / "assets";
auto config = test_database_config(root / "vamp.db");
std::filesystem::create_directories(assets / "songs");
for (auto name : {"0010", "0002", "0001", "0127"}) {
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_catalog(assets, config.path));
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config, assets.string()), boost::asio::use_future);
io.run();
auto library = future.get();
BOOST_REQUIRE(library);
BOOST_REQUIRE_EQUAL(library->exercises.size(), 4U);
BOOST_TEST(library->exercises[0].id == "0001");
BOOST_TEST(library->exercises[1].id == "0002");
BOOST_TEST(library->exercises[2].id == "0010");
BOOST_TEST(library->exercises[3].id == "0127");
for (auto const &exercise : library->exercises) {
BOOST_TEST(exercise.title == "Shared Title");
}
BOOST_REQUIRE(vamp_test::build_catalog(assets, config.path));
std::filesystem::rename(assets / "practice/0002", assets / "practice/0099");
BOOST_REQUIRE(vamp_test::build_catalog(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(), 12U);
BOOST_TEST(!has_value(*rows, 0U, "0002-acoustic"));
BOOST_TEST(has_value(*rows, 0U, "0099-acoustic"));
BOOST_TEST(has_value(*rows, 0U, "0010-acoustic"));
BOOST_TEST(has_value(*rows, 0U, "0001-acoustic"));
BOOST_TEST(has_value(*rows, 0U, "0127-acoustic"));
std::filesystem::remove_all(root);
}
BOOST_AUTO_TEST_CASE(keyless_scores_keep_their_authored_key) {
+3 -3
View File
@@ -10,7 +10,7 @@ import generator.accompaniment
import generator.model
import generator.parts
import generator.playback
import generator.practice.generate
import tests.arrangement_fixtures
import generator.rhythm
import tests.rhythm_fixtures
import tests.test_musicxml
@@ -337,8 +337,8 @@ class DetachedAccompanimentTests(unittest.TestCase):
before = copy.deepcopy(source)
bars = tied_library(2).for_meter('4/4')[0].bars
plan = generator.accompaniment.Plan(bars, bars, ())
prepared = generator.parts.prepare_document(source, practice=True)
result = generator.practice.generate._practice_document(prepared, 'acoustic', plan)
prepared = generator.parts.prepare_document(source)
result = tests.arrangement_fixtures.section_document(prepared, 'acoustic', rhythms=tests.rhythm_fixtures.library(), plan=plan)
generator.model.validate_document(result)
self.assertEqual(timing(tests.test_musicxml.notes(result)), [(0, 4, 'note')])
self.assertEqual(timing(tests.test_musicxml.notes(result, 1)), [(0, 4, 'rest')])
+3 -3
View File
@@ -11,7 +11,7 @@ import generator.engraving.layout
import generator.engraving.types
import generator.model
import generator.playback
import generator.practice.generate
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.test_accompaniment
import tests.test_musicxml
@@ -123,10 +123,10 @@ class AcousticTests(unittest.TestCase):
generator.acoustic.prepare(document)
generator.acoustic.combine(document)
self.assertEqual(document, before)
def test_song_and_practice_integration_preserve_playback(self):
def test_song_and_section_integration_preserve_playback(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()))
rhythms = tests.rhythm_fixtures.library()
for builder in (generator.arrange.generate.arrange_document, generator.practice.generate.practice_document):
for builder in (generator.arrange.generate.arrange_document, tests.arrangement_fixtures.section_document):
with unittest.mock.patch('generator.acoustic.combine'):
separate = builder(source, 'acoustic', rhythms=rhythms)
combined = builder(source, 'acoustic', rhythms=rhythms)
-49
View File
@@ -2,61 +2,12 @@ import json
import pathlib
import tempfile
import unittest
import unittest.mock
import generator.arrange.generate
import generator.parts
import generator.practice.generate
import generator.score
import generator.summary
import tests.rhythm_fixtures
import tests.test_musicxml
class CatalogTests(unittest.TestCase):
def test_every_numbered_source_is_wired_to_three_part_paths(self):
source = pathlib.Path('data/practice')
sources = sorted(source.glob('*.xml'))
self.assertEqual([p.name for p in sources], [f'{i:04}.xml' for i in range(1, 367)])
expected = {pathlib.Path(path.stem) / (part + '.json') for path in sources for part in generator.parts.PART_TYPES}
self.assertEqual(len(expected), 1098)
with tempfile.TemporaryDirectory() as temporary, unittest.mock.patch('generator.practice.generate._practice_document', side_effect=lambda document, part, plan: document):
output = pathlib.Path(temporary)
self.assertEqual(generator.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library()), len(expected))
self.assertEqual({p.relative_to(output) for p in output.rglob('*.json')}, expected)
generator.summary.validate_practice_parts(output)
generator.score.validate_generated_tree(output)
def test_numbers_and_duplicate_titles_remain_distinct_and_songs_are_excluded(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
source.mkdir(parents=True)
for name in ('1000.xml', '0000.xml', '0001.xml', '0999.xml', '0010.xml', '0366.xml'):
(source / name).write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
self.assertEqual(generator.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library()), 18)
self.assertEqual(sorted(p.name for p in output.iterdir()), ['0000', '0001', '0010', '0366', '0999', '1000'])
for directory in output.iterdir():
self.assertEqual({p.name for p in directory.glob('*.json')}, {(part + '.json') for part in generator.parts.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 ('1.xml', '001.xml', '00001.xml', '\u0660\u0660\u0660\u0661.xml', '0001-copy.xml', '0001_copy.xml', 'chord/0001.xml', 'practice/melody/lesson/0001.xml'):
with self.subTest(name=name), tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
for relative in ('0002.xml', name):
path = source / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
output.mkdir()
sentinel = output / 'existing'
sentinel.write_text('unchanged')
with self.assertRaisesRegex(ValueError, 'practice sources'):
generator.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library())
self.assertEqual(list(output.iterdir()), [sentinel])
self.assertEqual(sentinel.read_text(), 'unchanged')
def test_empty_part_directory_fails(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
(root / '0001').mkdir()
with self.assertRaisesRegex(ValueError, 'practice generator drift'):
generator.summary.validate_practice_parts(root)
def test_songs_generate_only_authored_key_parts(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
+26 -25
View File
@@ -35,8 +35,6 @@ class CatalogTests(unittest.TestCase):
document = bundle('Song', 'F')
document['presentation']['title_image'] = '0123456789abcdef' * 4
write_score(self.root, 'songs/tune.json', document)
for part in ('Acoustic', 'Electric', 'Vocal'):
write_score(self.root, f'practice/0001/{part.lower()}.json', bundle('Exercise', 'Eb', part))
database = generator.catalog.build_catalog(self.root)
self.assertEqual(database, self.root / 'catalog.db')
song = self.rows('songs')[0]
@@ -44,22 +42,12 @@ class CatalogTests(unittest.TestCase):
self.assertEqual((song['id'], song['part'], song['source_path']), ('tune', 'Lead', 'songs/tune.json'))
self.assertEqual((song['measure_count'], song['total_pages']), (1, 1))
self.assertEqual(song['title_image'], document['presentation']['title_image'])
practice = self.rows('practice_exercises')
self.assertEqual([row['id'] for row in practice], ['0001-acoustic', '0001-electric', '0001-vocal'])
self.assertEqual(practice[0]['exercise_id'], '0001')
self.assertNotIn('practice_type_id', practice[0])
self.assertNotIn('lesson_group_id', practice[0])
self.assertEqual(practice[0]['key_signature'], 'Eb')
self.assertTrue(all(row['title_image'] == '0' * 64 for row in practice))
self.assertTrue(all(json.loads(row['source_json'])['presentation']['title_image'] == row['title_image'] for row in practice))
self.assertNotIn('authored_key_signature', practice[0])
with contextlib.closing(sqlite3.connect(database)) as connection:
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], generator.catalog.APPLICATION_ID)
self.assertEqual(connection.execute('PRAGMA integrity_check').fetchall(), [('ok',)])
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('practice_exercises',), ('songs',)])
for table in ('songs', 'practice_exercises'):
columns = {row[1]: row for row in connection.execute(f'PRAGMA table_info({table})')}
self.assertEqual((columns['title_image'][2], columns['title_image'][3]), ('TEXT', 1))
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('songs',)])
columns = {row[1]: row for row in connection.execute('PRAGMA table_info(songs)')}
self.assertEqual((columns['title_image'][2], columns['title_image'][3]), ('TEXT', 1))
self.assertFalse(pathlib.Path(str(database) + '-wal').exists())
self.assertFalse(pathlib.Path(str(database) + '-journal').exists())
def test_rebuild_is_deterministic_relocatable_and_replaces_deleted_rows(self):
@@ -88,14 +76,6 @@ class CatalogTests(unittest.TestCase):
generator.catalog.build_catalog(self.root)
self.assertEqual(database.read_bytes(), previous)
self.assertFalse(list(self.root.glob('.vamp-catalog-*')))
def test_missing_parts_and_metadata_disagreement_fail(self):
for part in ('Acoustic', 'Electric'):
write_score(self.root, f'practice/0001/{part.lower()}.json', bundle(part=part))
with self.assertRaisesRegex(ValueError, 'all three parts'):
generator.catalog.build_catalog(self.root)
write_score(self.root, 'practice/0001/vocal.json', bundle(key='F', part='Vocal'))
with self.assertRaisesRegex(ValueError, 'disagree'):
generator.catalog.build_catalog(self.root)
def test_missing_or_invalid_title_image_preserves_existing_database(self):
score = write_score(self.root, 'songs/tune.json')
database = generator.catalog.build_catalog(self.root)
@@ -113,9 +93,9 @@ class CatalogTests(unittest.TestCase):
self.assertFalse(list(self.root.glob('.vamp-catalog-*')))
def test_keyless_parts_preserve_their_original_key(self):
for part in ('Acoustic', 'Electric', 'Vocal'):
write_score(self.root, f'practice/0001/{part.lower()}.json', bundle(key='No key', part=part))
write_score(self.root, f'songs/{part.lower()}.json', bundle(key='No key', part=part))
generator.catalog.build_catalog(self.root)
self.assertTrue(all(row['key_signature'] == 'No key' for row in self.rows('practice_exercises')))
self.assertTrue(all(row['key_signature'] == 'No key' for row in self.rows('songs')))
def test_invalid_layout_and_escaping_source_fail(self):
with self.assertRaisesRegex(ValueError, 'directory not found'):
generator.catalog.build_catalog(self.root / 'missing')
@@ -140,3 +120,24 @@ class CatalogTests(unittest.TestCase):
self.assertEqual(pathlib.Path(config['database']['path']).name, 'catalog.db')
self.assertTrue((destination / 'songs/tune.png').exists())
self.assertFalse(list(destination.rglob('*.json')))
def test_menu_query_uses_index_and_preserves_binary_identity_order(self):
for name, title, composer, part in (('z', 'Same', 'Bea', 'Acoustic'), ('b', 'Same', 'Ada', 'Vocal'), ('a', 'Same', 'Ada', 'Acoustic'), ('c', 'same', 'Ada', 'Electric'), ('d', 'Same', 'Ada', 'Acoustic')):
document = bundle(title=title, part=part)
document['music']['metadata']['composer'] = composer
write_score(self.root, f'songs/{name}.json', document)
database = generator.catalog.build_catalog(self.root)
query = 'SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image FROM songs ORDER BY title, composer, part, id'
with contextlib.closing(sqlite3.connect(database)) as connection:
self.assertEqual([row[0] for row in connection.execute(query)], ['a', 'd', 'b', 'z', 'c'])
plan = ' '.join(row[3] for row in connection.execute('EXPLAIN QUERY PLAN ' + query))
self.assertIn('songs_menu', plan)
self.assertNotIn('TEMP B-TREE', plan)
def test_failed_population_rolls_back_schema_and_rows(self):
write_score(self.root, 'songs/a.json')
write_score(self.root, 'songs/b.json', bundle(title=''))
with contextlib.closing(sqlite3.connect(':memory:')) as connection:
with self.assertRaises(ValueError):
generator.catalog.populate(connection, self.root)
self.assertFalse(connection.in_transaction)
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema").fetchall(), [])
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], 0)
+62 -158
View File
@@ -37,8 +37,7 @@ template <typename T>
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::library_part>);
static_assert(!retains_score_json<vamp_zero::catalog>);
struct catalog_directory {
std::filesystem::path path;
@@ -127,60 +126,41 @@ struct catalog_database {
auto build() const -> std::bool_t {
return vamp_test::build_catalog(assets, config.path);
}
auto performance() const -> std::expected<vamp_zero::library_state, modulus::error> {
return run_catalog_operation(vamp_zero::async_load_performance_library, config, assets.string());
auto performance() const -> std::expected<vamp_zero::catalog, modulus::error> {
return run_catalog_operation(vamp_zero::async_load_catalog, config, assets.string());
}
auto practice() const -> std::expected<vamp_zero::practice_library, modulus::error> {
return run_catalog_operation(vamp_zero::async_load_practice_library, config, assets.string());
}
auto score(vamp_zero::score_category category, std::string id) const -> std::expected<vamp_zero::score_document, modulus::error> {
return run_catalog_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{category, std::move(id)}, assets.string());
auto score(std::string id) const -> std::expected<vamp_zero::score_document, modulus::error> {
return run_catalog_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{std::move(id)}, assets.string());
}
};
auto catalog_exercise(std::string authored = "Eb", std::string id = "0001") -> vamp_zero::practice_exercise {
auto result = vamp_zero::practice_exercise{
.id = std::move(id),
.title = "Exercise",
.composer = "Composer",
};
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;
}
}
BOOST_AUTO_TEST_CASE(catalog_snapshots_heartbeat_and_navigation_share_immutable_metadata) {
auto performance = vamp_zero::group_library_parts({
auto performance = vamp_zero::group_ordered_parts({
{.id = "other", .title = "A Tune", .composer = "Composer", .part = "Lead", .total_pages = 1},
{.id = "chosen", .title = "B Tune", .composer = "Composer", .part = "Lead", .total_pages = 2},
});
auto practice = vamp_zero::group_practice_exercises({catalog_exercise("Eb", "0002"), catalog_exercise("Eb", "0001")});
BOOST_REQUIRE(practice);
auto catalog = std::make_shared<vamp_zero::catalog const>(vamp_zero::catalog{std::move(performance), std::move(*practice)});
auto catalog = std::make_shared<vamp_zero::catalog const>(std::move(performance));
auto original = vamp_zero::app_state{
.catalog = catalog,
.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU,
.selected_song_index = 2,
.selected_part_index = 1,
.selected_practice_exercise_index = 2,
};
auto control = vamp_zero::control_state{{}, original};
auto observed = std::vector<vamp_zero::app_state>{};
control.set_observer([&](vamp_zero::app_state state) { observed.push_back(std::move(state)); });
auto before = control.snapshot();
BOOST_TEST(before.catalog.get() == catalog.get());
BOOST_TEST(vamp_zero::selected_part(before) == &catalog->performance.parts[1]);
BOOST_TEST(vamp_zero::selected_practice_exercise(before) == &catalog->practice.exercises[1]);
BOOST_TEST(vamp_zero::selected_part(before) == &catalog->parts[1]);
auto heartbeat = control.heartbeat();
BOOST_TEST(heartbeat.catalog.get() == catalog.get());
BOOST_TEST(heartbeat.heartbeat_count == 1U);
BOOST_TEST(heartbeat.pico_connected);
auto playing = control.apply(vamp_protocol::page_command::SELECT);
BOOST_TEST(vamp_zero::to_string(playing.mode) == "performance_score");
BOOST_TEST(vamp_zero::active_part(playing) == &catalog->performance.parts[1]);
BOOST_TEST(vamp_zero::active_part(playing) == &catalog->parts[1]);
BOOST_TEST(control.apply(vamp_protocol::page_command::NEXT).page_index == 1U);
BOOST_TEST(control.apply(vamp_protocol::page_command::PREVIOUS).page_index == 0U);
control.apply(vamp_protocol::page_command::SELECT);
@@ -193,29 +173,14 @@ BOOST_AUTO_TEST_CASE(catalog_snapshots_heartbeat_and_navigation_share_immutable_
for (auto const &snapshot : observed) {
BOOST_TEST(snapshot.catalog.get() == catalog.get());
}
original.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
auto practice_control = vamp_zero::control_state{{}, original};
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_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(parts.active_practice_id.empty());
BOOST_TEST(parts.selected_practice_part_index == 0U);
}
BOOST_AUTO_TEST_CASE(catalog_performance_groups_use_title_and_composer_with_part_indices) {
auto library = vamp_zero::group_library_parts({
{.id = "other-composer", .title = "Shared Title", .composer = "Bea", .part = "Lead"},
{.id = "vocal", .title = "Shared Title", .composer = "Ada", .part = "Vocal"},
auto library = vamp_zero::group_ordered_parts({
{.id = "other-title", .title = "Other Title", .composer = "Ada", .part = "Lead"},
{.id = "guitar", .title = "Shared Title", .composer = "Ada", .part = "Guitar"},
{.id = "vocal", .title = "Shared Title", .composer = "Ada", .part = "Vocal"},
{.id = "other-composer", .title = "Shared Title", .composer = "Bea", .part = "Lead"},
});
BOOST_REQUIRE_EQUAL(library.parts.size(), 4U);
BOOST_REQUIRE_EQUAL(library.groups.size(), 3U);
@@ -241,79 +206,56 @@ BOOST_AUTO_TEST_CASE(catalog_performance_groups_use_title_and_composer_with_part
BOOST_TEST(library.parts[library.groups[1].parts[1]].id == "vocal");
}
BOOST_AUTO_TEST_CASE(catalog_practice_requires_complete_ordered_parts_and_key_agreement) {
for (auto authored : {"Eb", "C dorian", "No key", ""}) {
auto first = catalog_exercise(authored);
std::reverse(first.parts.begin(), first.parts.end());
auto last = first.parts.back();
first.parts.pop_back();
auto fragment = catalog_exercise(authored);
fragment.parts = {last};
auto grouped = vamp_zero::group_practice_exercises({catalog_exercise("F", "0002"), first, fragment});
BOOST_REQUIRE(grouped);
BOOST_REQUIRE_EQUAL(grouped->exercises.size(), 2U);
auto const &exercise = grouped->exercises[0];
BOOST_TEST(exercise.id == "0001");
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 first = catalog_exercise();
auto second = catalog_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("disagree on key") != std::string_view::npos);
}
auto missing = catalog_exercise();
missing.parts.pop_back();
BOOST_TEST(!vamp_zero::group_practice_exercises({missing}));
auto duplicate = catalog_exercise();
duplicate.parts.back().part = "Acoustic";
BOOST_TEST(!vamp_zero::group_practice_exercises({duplicate}));
}
BOOST_AUTO_TEST_CASE(catalog_individual_score_lookup_uses_category_and_reports_missing_or_invalid) {
BOOST_AUTO_TEST_CASE(catalog_individual_score_lookup_is_parameterized_and_reports_missing_or_invalid) {
auto db = catalog_database{};
auto song = catalog_bundle("Song", "F");
auto exercise = catalog_bundle("Exercise", "D", "Electric");
db.write("songs/shared.json", song);
for (auto part : vamp_zero::practice_part_names()) {
db.write(std::filesystem::path{"practice/0001"} / vamp_test::part_filename(part), catalog_bundle("Exercise", "D", part));
BOOST_REQUIRE(db.build());
std::filesystem::remove_all(db.assets);
auto document = db.score("shared");
BOOST_REQUIRE(document);
BOOST_TEST(document->id == "shared");
BOOST_TEST(document->title == "Song");
BOOST_TEST(document->key_signature == "F");
BOOST_TEST(document->source_json == song);
BOOST_TEST(document->source_path == (db.assets / "songs/shared.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");
for (auto id : {"missing", "shared' OR 1=1 --"}) {
auto missing = db.score(id);
BOOST_REQUIRE(!missing);
BOOST_TEST(missing.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::NOT_FOUND));
}
db.query("UPDATE songs SET source_json = '{}' WHERE id = 'shared'");
auto invalid = db.score("shared");
BOOST_REQUIRE(!invalid);
BOOST_TEST(invalid.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
}
BOOST_AUTO_TEST_CASE(catalog_query_orders_and_groups_by_exact_song_identity) {
auto db = catalog_database{};
for (auto const &id : {"upper-bea", "upper-vocal", "upper-acoustic", "lower", "upper-acoustic-second"}) {
db.write(std::filesystem::path{"songs"} / (std::string{id} + ".json"), catalog_bundle("Same"));
}
BOOST_REQUIRE(db.build());
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;
auto document = db.score(category, "shared");
BOOST_REQUIRE(document);
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->source_json == (is_song ? song : exercise));
BOOST_TEST(document->source_path == (db.assets / (is_song ? "songs/shared.json" : "practice/0001/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");
for (auto id : {"missing", "shared' OR 1=1 --"}) {
auto missing = db.score(category, id);
BOOST_REQUIRE(!missing);
BOOST_TEST(missing.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::NOT_FOUND));
}
auto table = std::string{is_song ? "songs" : "practice_exercises"};
db.query("UPDATE " + table + " SET source_json = '{}' WHERE id = 'shared'");
auto invalid = db.score(category, "shared");
BOOST_REQUIRE(!invalid);
BOOST_TEST(invalid.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
BOOST_REQUIRE(db.score(is_song ? vamp_zero::score_category::PRACTICE : vamp_zero::score_category::PERFORMANCE, "shared"));
db.query("UPDATE " + table + " SET source_json = ?1 WHERE id = 'shared'", {noto::sqlite_value{is_song ? song : exercise}});
db.query("UPDATE songs SET composer = 'Ada', part = 'Acoustic'");
db.query("UPDATE songs SET composer = 'Bea' WHERE id = 'upper-bea'");
db.query("UPDATE songs SET part = 'Vocal' WHERE id = 'upper-vocal'");
db.query("UPDATE songs SET title = 'same' WHERE id = 'lower'");
auto result = db.performance();
BOOST_REQUIRE(result);
BOOST_REQUIRE_EQUAL(result->groups.size(), 3U);
auto ids = std::vector<std::string>{};
for (auto const &part : result->parts) {
ids.push_back(part.id);
}
auto expected = std::vector<std::string>{"upper-acoustic", "upper-acoustic-second", "upper-vocal", "upper-bea", "lower"};
BOOST_TEST(ids == expected, boost::test_tools::per_element());
BOOST_TEST(result->groups[0].composer == "Ada");
BOOST_TEST(result->groups[0].parts.size() == 3U);
BOOST_TEST(result->groups[1].composer == "Bea");
BOOST_TEST(result->groups[2].title == "same");
}
BOOST_AUTO_TEST_CASE(catalog_sequential_score_loads_keep_executor_stack_bounded) {
@@ -322,7 +264,7 @@ BOOST_AUTO_TEST_CASE(catalog_sequential_score_loads_keep_executor_stack_bounded)
BOOST_REQUIRE(db.build());
auto repeated_loads = [](boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets)
-> boost::asio::awaitable<std::expected<std::uint32_t, modulus::error>> {
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = "repeated"};
auto reference = vamp_zero::score_reference{.id = "repeated"};
for (auto count = std::uint32_t{}; count < 4096U; ++count) {
auto score = co_await vamp_zero::async_load_score(executor, config, reference, assets);
if (!score) {
@@ -343,48 +285,20 @@ BOOST_AUTO_TEST_CASE(catalog_reload_uses_cached_metadata_without_parsing_score_j
db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, "
"key_signature, measure_count, total_pages, title_image) "
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2, '0000000000000000000000000000000000000000000000000000000000000000')");
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, part, title_image) "
"VALUES(?1, '0001', 'Metadata Exercise', 'Composer', 'Eb', '{}', ?3, 3, 2, ?2, '0000000000000000000000000000000000000000000000000000000000000000')",
{noto::sqlite_value{std::string{"practice_part-"} + std::string{part_name}}, noto::sqlite_value{std::string{part_name}},
noto::sqlite_value{std::string{"practice/0001/"} + std::string{part_name} + ".json"}});
}
auto songs = db.rows("songs");
auto exercises = db.rows("practice_exercises");
for (auto attempt = std::uint32_t{}; attempt < 2U; ++attempt) {
auto performance = db.performance();
auto practice = db.practice();
BOOST_REQUIRE(performance);
BOOST_REQUIRE(practice);
BOOST_REQUIRE_EQUAL(performance->parts.size(), 1U);
BOOST_TEST(performance->parts[0].title == "Metadata Song");
BOOST_TEST(performance->parts[0].key_signature == "F");
BOOST_TEST(performance->parts[0].measure_count == 3U);
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].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, "practice_part-Vocal");
auto song = db.score("tune");
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 key_signature = 'F' WHERE id = 'practice_part-Vocal'");
auto disagreement = db.practice();
BOOST_REQUIRE(!disagreement);
BOOST_TEST(disagreement.error().message().find("disagree on key") != std::string_view::npos);
}
BOOST_AUTO_TEST_CASE(catalog_validation_rejects_missing_incompatible_and_corrupt_files_without_repair) {
@@ -395,12 +309,9 @@ BOOST_AUTO_TEST_CASE(catalog_validation_rejects_missing_incompatible_and_corrupt
BOOST_REQUIRE(db.build());
BOOST_REQUIRE(validate());
auto songs = db.performance();
auto practice = db.practice();
BOOST_REQUIRE(songs);
BOOST_REQUIRE(practice);
BOOST_TEST(songs->parts.empty());
BOOST_TEST(practice->exercises.empty());
for (auto sql : {"ALTER TABLE songs RENAME COLUMN part TO obsolete_part", "ALTER TABLE practice_exercises RENAME COLUMN exercise_id TO obsolete_id", "DROP TABLE songs", "DROP TABLE practice_exercises"}) {
for (auto sql : {"ALTER TABLE songs RENAME COLUMN part TO obsolete_part", "DROP TABLE songs"}) {
db.query(sql);
auto snapshot_sql = "SELECT json_group_array(sql) FROM (SELECT sql FROM sqlite_schema ORDER BY name)";
auto before = std::get<std::string>(db.query(snapshot_sql).rows[0][0].value);
@@ -444,12 +355,8 @@ BOOST_AUTO_TEST_CASE(catalog_validation_checks_column_types_constraints_and_tabl
BOOST_AUTO_TEST_CASE(catalog_reads_readonly_database_without_json_and_resolves_relocated_assets) {
auto db = catalog_database{};
db.write("songs/tune.json", catalog_bundle("Relocated"));
for (auto part_name : vamp_zero::practice_part_names()) {
db.write(std::filesystem::path{"practice/0001"} / vamp_test::part_filename(part_name), catalog_bundle("Exercise", "Eb", part_name));
}
BOOST_REQUIRE(db.build());
std::filesystem::remove(db.assets / "songs/tune.json");
std::filesystem::remove_all(db.assets / "practice");
auto moved = db.directory.path / "relocated";
std::filesystem::rename(db.assets, moved);
db.assets = moved;
@@ -457,14 +364,11 @@ BOOST_AUTO_TEST_CASE(catalog_reads_readonly_database_without_json_and_resolves_r
std::filesystem::permissions(db.config.path, std::filesystem::perms::owner_read);
BOOST_REQUIRE(run_catalog_operation(vamp_zero::async_validate_database, db.config, true));
auto songs = db.performance();
auto practice = db.practice();
auto score = db.score(vamp_zero::score_category::PERFORMANCE, "tune");
auto score = db.score("tune");
BOOST_REQUIRE(songs);
BOOST_REQUIRE(practice);
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].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"));
@@ -481,10 +385,10 @@ BOOST_AUTO_TEST_CASE(catalog_rejects_paths_outside_the_asset_root) {
}
db.query("UPDATE songs SET source_path = ?1", {noto::sqlite_value{std::string{path}}});
BOOST_TEST(!db.performance());
BOOST_TEST(!db.score(vamp_zero::score_category::PERFORMANCE, "tune"));
BOOST_TEST(!db.score("tune"));
}
std::filesystem::create_directory_symlink(db.directory.path, db.assets / "escape");
db.query("UPDATE songs SET source_path = 'escape/tune.json'");
BOOST_TEST(!db.performance());
BOOST_TEST(!db.score(vamp_zero::score_category::PERFORMANCE, "tune"));
BOOST_TEST(!db.score("tune"));
}
+7 -7
View File
@@ -21,7 +21,7 @@ import generator.guitar
import generator.model
import generator.musicxml
import generator.playback
import generator.practice.generate
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.test_accompaniment
import tests.test_musicxml
@@ -40,7 +40,7 @@ class EngravingTests(unittest.TestCase):
def test_tab_omits_rests_but_keeps_standard_notation(self):
content = tests.test_musicxml.xml_note('E', 2, 'half', octave=5) + '<note><rest/><duration>2</duration><type>half</type></note>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
document = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
layout = self.layout(document)
self.assertEqual(len(self.primitives(layout, 'rest')), 1)
self.assertEqual(self.primitives(layout, 'tab-rest'), [])
@@ -55,7 +55,7 @@ class EngravingTests(unittest.TestCase):
def test_guitar_pages_always_pair_systems_including_tall_passages(self):
content = tests.test_musicxml.xml_note('E', octave=5)
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 32))
document = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
layout = self.layout(document)
self.assert_paired_pages(layout)
self.assertEqual(layout.style.space, 10)
@@ -108,7 +108,7 @@ class EngravingTests(unittest.TestCase):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(dense, divisions=16))
documents = [generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml')),
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tied)),
generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())]
tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())]
checked = 0
for sampling in (1, 2, 3, 4):
for document in documents:
@@ -280,11 +280,11 @@ 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 = tests.test_musicxml.parse(tests.test_musicxml.xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole')
tab = generator.practice.generate.practice_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=7))), 'electric', rhythms=tests.rhythm_fixtures.library())
tab = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=6))), 'electric', rhythms=tests.rhythm_fixtures.library())
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(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64))
source = generator.practice.generate.practice_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16)), 'electric', rhythms=tests.rhythm_fixtures.library())
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16)), 'electric', rhythms=tests.rhythm_fixtures.library())
layout = self.layout(source)
systems = [s for page in layout.pages for s in page.systems]
self.assertGreater(len(systems), 1)
@@ -300,7 +300,7 @@ class EngravingTests(unittest.TestCase):
self.assertEqual(len(generator.playback.compile_timeline(source)['events']), 64)
def test_generated_tab_ties_and_barline_styles(self):
content = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>') + '<barline location="right"><bar-style>dashed</bar-style></barline>'
source = generator.practice.generate.practice_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)), 'electric', rhythms=tests.rhythm_fixtures.library())
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)), 'electric', rhythms=tests.rhythm_fixtures.library())
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')))
+4 -5
View File
@@ -29,7 +29,7 @@ import tests.test_musicxml
class EngravingCacheTests(unittest.TestCase):
def test_ci_snapshot_tracks_all_generator_inputs_and_keeps_fallback(self):
workflow = pathlib.Path('.gitea/workflows/ci.yml').read_text()
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'data/practice/*.xml', 'data/songs/*.xml', 'data/rhythms/**/*.xml')", workflow)
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'data/songs/*.xml', 'data/rhythms/**/*.xml')", workflow)
self.assertIn('python3 -m generator --renderer-fingerprint', workflow)
self.assertIn('restore-keys: product-vamp-pages-${{ steps.renderer.outputs.fingerprint }}-', workflow)
self.assertIn('path: build/build_common/engraving-cache/${{ steps.renderer.outputs.fingerprint }}', workflow)
@@ -318,10 +318,10 @@ class EngravingCacheTests(unittest.TestCase):
generator.engraving.cache.renderer_namespace(self.cache, 'renderer')
def test_only_changed_score_is_rendered_and_validation_reads_json_once(self):
root = self.root / 'scores'
self.write_scores(root / 'practice', [self.document, self.document])
self.write_scores(root / 'songs', [self.document, self.document])
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
changed = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')))
self.write_scores(root / 'practice', [self.document, changed])
self.write_scores(root / 'songs', [self.document, changed])
with unittest.mock.patch('generator.engraving.layout.layout_systems', wraps=generator.engraving.layout.layout_systems) as render:
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
render.assert_called_once()
@@ -347,8 +347,7 @@ class EngravingCacheTests(unittest.TestCase):
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
data, output = self.root / 'data', self.root / 'output'
(data / 'songs').mkdir(parents=True)
(data / 'practice').mkdir()
source = data / 'practice/0001.xml'
source = data / 'songs/fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2)
generator.__main__.generate_assets(args)
+19 -27
View File
@@ -15,8 +15,7 @@ import generator.model
import generator.parts
import generator.pitch
import generator.playback
import generator.practice.generate
import generator.summary
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.test_musicxml
@@ -65,25 +64,25 @@ class GuitarTests(unittest.TestCase):
def test_melody_placement_preserves_notes_timing_and_sound(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('F', 2, 'half', octave=4)))
before = copy.deepcopy(source)
electric = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
vocal = generator.practice.generate.practice_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())
electric = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
vocal = tests.arrangement_fixtures.section_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(electric)
self.assertEqual(source, before)
self.assertEqual(generator.playback.compile_timeline(electric)['events'], generator.playback.compile_timeline(vocal)['events'])
self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(electric)['events']], [(generator.model.rational(generator.model.fraction(e['pitch']) - 12), e['onset'], e['duration']) for e in generator.playback.compile_timeline(source)['events']])
self.assertEqual([(e['onset'], e['duration'], generator.model.fraction(e['pitch']) - 12) for e in generator.playback.compile_timeline(electric)['events']], [(e['onset'], e['duration'], generator.model.fraction(e['pitch'])) for e in generator.playback.compile_timeline(vocal)['events']])
self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(electric)['events']], [(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(source)['events']])
for original, generated in zip(tests.test_musicxml.notes(source), tests.test_musicxml.notes(electric)):
self.assertEqual(generator.model.pitch_number(generated['pitches'][0]), generator.model.pitch_number(original['pitches'][0]))
self.assertEqual(generator.model.pitch_number(generated['pitches'][0]), generator.model.pitch_number(original['pitches'][0]) + 12)
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 = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes))
for part in ('electric', 'vocal'):
document = generator.practice.generate.practice_document(source, part, rhythms=tests.rhythm_fixtures.library())
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected)
document = tests.arrangement_fixtures.section_document(source, part, rhythms=tests.rhythm_fixtures.library())
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected + (12 if part == 'electric' else 0))
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 = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave, alter=alter)))
document = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave - 1, alter=alter)))
document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
tone = tests.test_musicxml.notes(document)[0]['pitches'][0]
self.assertEqual(generator.model.pitch_number(tone), 12 * (octave + 1) + alter)
self.assertEqual(tone['tablature'], tab)
@@ -96,12 +95,12 @@ class GuitarTests(unittest.TestCase):
concert = generator.pitch.concert_document(source, octave_transposing=True)
self.assertEqual(generator.pitch.concert_document(concert, octave_transposing=True), concert)
self.assertEqual(source, before)
document = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
self.assertEqual([generator.model.fraction(e['pitch']) for e in generator.playback.compile_timeline(document)['events']], [48, 60])
def test_all_parts_share_written_octave_convention_in_songs_and_practice(self):
document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
self.assertEqual([generator.model.fraction(e['pitch']) for e in generator.playback.compile_timeline(document)['events']], [60, 72])
def test_all_parts_share_written_octave_convention(self):
chord = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord + tests.test_musicxml.xml_note()))
for generate in (generator.practice.generate.practice_document, generator.arrange.generate.arrange_document):
for generate in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document):
for part in generator.parts.PART_TYPES:
with self.subTest(generator=generate.__name__, part=part):
document = generate(source, part, rhythms=tests.rhythm_fixtures.library())
@@ -163,12 +162,10 @@ class GuitarTests(unittest.TestCase):
self.assertEqual(before, document)
def test_source_contract_and_unplayable_music_fail(self):
cases = [(tests.test_musicxml.xml_note(octave=7, alter=1), '', '0-20'), (tests.test_musicxml.xml_note(alter='0.5'), '', '0-20'),
(tests.test_musicxml.xml_note() + tests.test_musicxml.xml_note('E', prefix='<chord/>'), '', 'monophonic'),
(tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note('E', extra='<voice>2</voice>'), '', 'one monophonic voice'),
(tests.test_musicxml.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):
generator.practice.generate.practice_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, attributes)), 'electric', rhythms=tests.rhythm_fixtures.library())
tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, attributes)), 'electric', rhythms=tests.rhythm_fixtures.library())
for content, attributes in [(tests.test_musicxml.xml_note(), '<clef><sign>TAB</sign></clef>'), (tests.test_musicxml.xml_note(extra='<notations><technical><fingering>1</fingering></technical></notations>'), '')]:
with self.assertRaisesRegex(ValueError, 'authored TAB'):
tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, attributes))
@@ -177,20 +174,15 @@ class GuitarTests(unittest.TestCase):
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
source.mkdir()
(source / '0000.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generator.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library()), 3)
generator.summary.validate_practice_parts(output)
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library()), 3)
first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
generator.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library())
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library())
self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')})
(output / '0000/vocal.json').unlink()
with self.assertRaisesRegex(ValueError, 'practice generator drift'):
generator.summary.validate_practice_parts(output)
def test_unplayable_generation_preserves_previous_assets(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
(root / 'data/songs').mkdir(parents=True)
(root / 'data/practice').mkdir()
(root / 'data/practice/0001.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=7, alter=1)))
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=7, alter=1)))
output = root / 'output'
output.mkdir()
(output / 'last-good').write_text('preserved')
@@ -200,7 +192,7 @@ class GuitarTests(unittest.TestCase):
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved')
harmony = '<harmony><root><root-step>C</root-step></root><kind>dominant</kind><degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree><degree><degree-value>11</degree-value><degree-alter>1</degree-alter><degree-type>add</degree-type></degree></harmony>'
(root / 'data/practice/0001.xml').write_text(tests.test_musicxml.xml_score(harmony + tests.test_musicxml.xml_note()))
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(harmony + tests.test_musicxml.xml_note()))
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part acoustic:.*measure 1.*no acoustic grip.*0-20'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
+24 -24
View File
@@ -9,7 +9,7 @@ import generator.harmony
import generator.model
import generator.parts
import generator.playback
import generator.practice.generate
import tests.arrangement_fixtures
import generator.rhythm
import tests.rhythm_fixtures
import tests.test_accompaniment
@@ -17,9 +17,9 @@ import tests.test_musicxml
import tests.test_parts
class GuitarPartTests(unittest.TestCase):
def practice(self, content, part='electric', divisions=1):
def section(self, content, part='electric', divisions=1):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=divisions))
result = generator.practice.generate.practice_document(source, part, rhythms=tests.rhythm_fixtures.library())
result = tests.arrangement_fixtures.section_document(source, part, rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result)
return result
def test_profile_shapes_cover_defining_chord_tones(self):
@@ -40,7 +40,7 @@ class GuitarPartTests(unittest.TestCase):
candidates = generator.guitar.chord_positions(*generator.guitar.chord_requirements(chord, 'electric'), profile='electric')
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
def test_acoustic_combines_independent_rhythms_with_a_fixed_grip(self):
result = self.practice(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
measure = result['staves'][0]['measures'][0]
self.assertEqual(len(measure['voices']), 1)
notes = measure['voices'][0]['notes']
@@ -70,15 +70,15 @@ class GuitarPartTests(unittest.TestCase):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
chord = generator.harmony.parse_symbol(symbol)
source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}]
result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library())
result = tests.arrangement_fixtures.section_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library())
notes = tests.test_musicxml.notes(result)
self.assertEqual({generator.model.pitch_number(p) % 12 for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5}, {expected})
def test_acoustic_bass_and_upper_chord_can_attack_together(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()))
prepared = generator.parts.prepare_document(source, practice=True)
prepared = generator.parts.prepare_document(source)
bar = tests.test_accompaniment.quarter_bar(4, {0, 2})
plan = generator.accompaniment.Plan((bar,), (bar,), ())
result = generator.practice.generate._practice_document(prepared, 'acoustic', plan)
result = tests.arrangement_fixtures.section_document(prepared, 'acoustic', rhythms=tests.rhythm_fixtures.library(), plan=plan)
generator.model.validate_document(result)
self.assertEqual(len(result['staves'][0]['measures'][0]['voices']), 1)
notes = tests.test_musicxml.notes(result)
@@ -89,15 +89,15 @@ class GuitarPartTests(unittest.TestCase):
self.assertIn(tuple(sorted((p.string_number for p in positions), reverse=True)), generator.guitar.ACOUSTIC_STRINGS)
self.assertTrue(generator.guitar.within_fret_span(positions))
def test_long_melody_chords_and_short_notes(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 2, 'half', octave=5) + tests.test_musicxml.xml_note('G', 1, 'quarter', octave=5) + tests.test_musicxml.xml_note('C', 1, 'quarter', octave=6)
notes = tests.test_musicxml.notes(self.practice(content))
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('G', 1, 'quarter', octave=4) + tests.test_musicxml.xml_note('C', 1, 'quarter', octave=5)
notes = tests.test_musicxml.notes(self.section(content))
self.assertIn(len(notes[0]['pitches']), (3, 4))
self.assertEqual([len(n['pitches']) for n in notes[1:]], [1, 1])
self.assertEqual(generator.model.pitch_number(notes[0]['pitches'][0]), 76)
self.assertIn(sorted(p['tablature']['string'] for p in notes[0]['pitches']), ([1, 2, 3], [1, 2, 3, 4]))
def test_tied_quarters_qualify_and_keep_all_strings(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=5, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=5, extra='<tie type="stop"/>') + '<note><rest/><duration>2</duration><type>half</type></note>'
result = self.practice(content)
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="stop"/>') + '<note><rest/><duration>2</duration><type>half</type></note>'
result = self.section(content)
first, second = tests.test_musicxml.notes(result)[:2]
self.assertIn(len(first['pitches']), (3, 4))
self.assertEqual(len(first['pitches']), len(second['pitches']))
@@ -106,8 +106,8 @@ class GuitarPartTests(unittest.TestCase):
self.assertEqual(after['tablature'], before['tablature'])
self.assertEqual({generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(result)['events']}, {2})
def test_chord_change_reattacks_even_for_final_eighth(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=7) + tests.test_musicxml.xml_note('G', 8, 'whole', octave=5)
result = self.practice(content, divisions=2)
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=7) + tests.test_musicxml.xml_note('G', 8, 'whole', octave=4)
result = self.section(content, divisions=2)
notes = tests.test_musicxml.notes(result)
self.assertEqual([generator.model.fraction(n['duration']) for n in notes], [fractions.Fraction(7, 2), fractions.Fraction(1, 2)])
for note in notes:
@@ -118,10 +118,10 @@ class GuitarPartTests(unittest.TestCase):
def test_cross_measure_ties_continue_only_without_a_chord_change(self):
for root in ('C', 'F'):
with self.subTest(root=root):
first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', octave=5, extra='<tie type="start"/>')
second = tests.test_parts.xml_harmony(root) + tests.test_musicxml.xml_note('E', octave=5, extra='<tie type="stop"/>')
first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', octave=4, extra='<tie type="start"/>')
second = tests.test_parts.xml_harmony(root) + tests.test_musicxml.xml_note('E', octave=4, extra='<tie type="stop"/>')
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([first, second]))
result = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
result = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result)
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes']]
self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes))
@@ -133,16 +133,16 @@ class GuitarPartTests(unittest.TestCase):
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes><normal-type>half</normal-type></time-modification>'
if index != 1:
extra += '<notations><tuplet type="' + ('start' if index == 0 else 'stop') + '"/></notations>'
content += tests.test_musicxml.xml_note('E', 4, 'half', octave=5, extra=extra)
result = self.practice(content, divisions=3)
content += tests.test_musicxml.xml_note('E', 4, 'half', octave=4, extra=extra)
result = self.section(content, divisions=3)
notes = tests.test_musicxml.notes(result)
self.assertEqual([generator.model.fraction(n['duration']) for n in notes], [fractions.Fraction(4, 3)] * 2 + [fractions.Fraction(2, 3)] * 2)
self.assertEqual(result['staves'][0]['tuplets'][0]['end'], notes[-1]['id'])
self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes))
self.assertFalse(notes[-1]['pitches'][0].get('tie_from'))
def test_no_chord_releases_harmony_without_reattacking_melody(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony(kind='none', offset=2) + tests.test_musicxml.xml_note('E', octave=5)
result = self.practice(content)
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony(kind='none', offset=2) + tests.test_musicxml.xml_note('E', octave=4)
result = self.section(content)
first, second = tests.test_musicxml.notes(result)
self.assertIn(len(first['pitches']), (3, 4))
self.assertEqual(len(second['pitches']), 1)
@@ -151,20 +151,20 @@ class GuitarPartTests(unittest.TestCase):
self.assertEqual(sum(generator.model.fraction(e['duration']) == 4 for e in timeline), 1)
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 for e in timeline))
def test_unplayable_harmonization_preserves_low_melody(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('C', octave=4)
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('C', octave=3)
with self.assertLogs('generator.guitar', level='WARNING'):
result = self.practice(content)
result = self.section(content)
note = tests.test_musicxml.notes(result)[0]
self.assertEqual(len(note['pitches']), 1)
self.assertEqual(note['pitches'][0]['tablature'], {'string': 5, 'fret': 3})
def test_song_melody_only_is_raised_an_octave(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4)))
before = copy.deepcopy(source)
for builder in (generator.practice.generate.practice_document, generator.arrange.generate.arrange_document):
for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document):
for part in ('electric', 'vocal'):
result = builder(source, part, rhythms=tests.rhythm_fixtures.library())
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']]
expected = 76 if part == 'electric' and builder is generator.arrange.generate.arrange_document else 64
expected = 76 if part == 'electric' else 64
self.assertEqual({generator.model.pitch_number(n['pitches'][0]) for n in notes}, {expected})
self.assertEqual(source, before)
def test_electric_stabs_are_capped_and_do_not_continue(self):
+5 -12
View File
@@ -10,7 +10,7 @@ import generator.model
import generator.musicxml
import generator.musicxml_writer
import generator.pitch
import generator.practice.generate
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.test_musicxml
@@ -58,9 +58,9 @@ class HarmonyTests(unittest.TestCase):
self.assertEqual(chord['symbol'], label)
self.assertTrue(chord['chord_optional'])
self.assertTrue(chord['parenthesized'])
practice = generator.practice.generate.practice_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())['staves'][0]['measures'][0]['harmonies'][0]
self.assertNotIn('chord_optional', practice)
self.assertEqual(generator.harmony.symbol(practice), label)
arranged = tests.arrangement_fixtures.section_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())['staves'][0]['measures'][0]['harmonies'][0]
self.assertNotIn('chord_optional', arranged)
self.assertEqual(generator.harmony.symbol(arranged), label)
source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0}
shifted = generator.pitch.concert_document(source)
result = shifted['staves'][0]['measures'][0]['harmonies'][0]
@@ -90,14 +90,7 @@ class HarmonyTests(unittest.TestCase):
self.assertEqual(generator.harmony.symbol(chord), 'Cm7')
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord_xml('7', 'dominant', extra='<degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree>') + tests.test_musicxml.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 = generator.musicxml.load_musicxml(pathlib.Path('data/practice/0001.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)
self.assertEqual(measures[1]['harmonies'][0]['symbol'], 'Bm9')
self.assertEqual(measures[3]['harmonies'][0]['symbol'], 'DmM7')
self.assertEqual(measures[0]['voices'][0]['notes'][0]['duration'], generator.model.rational(1))
def test_current_song_exports_import_without_edits(self):
for name, count, key in (('autumn-leaves', 33, 'F'), ('it-could-happen-to-you', 32, 'Eb')):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs') / f'{name}.xml')
self.assertEqual(len(source['staves'][0]['measures']), count)
+3 -13
View File
@@ -189,11 +189,6 @@ auto make_assets(vamp_zero::config const &config) -> void {
auto root = std::filesystem::path{config.runtime.assets_path};
write_score(root / "songs" / "first.json", root);
write_score(root / "songs" / "second.json", root);
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" / "0001" / (name + ".json"), root, std::string{part_name});
}
BOOST_REQUIRE(vamp_test::build_catalog(root, config.database.path));
}
@@ -359,16 +354,11 @@ BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalog_without_hardware_or_heartb
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
require_no_probe(config);
auto io = boost::asio::io_context{};
auto performance = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
auto practice = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
auto performance = boost::asio::co_spawn(io, vamp_zero::async_load_catalog(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
io.run();
auto songs = performance.get();
auto exercises = practice.get();
BOOST_REQUIRE(songs);
BOOST_REQUIRE(exercises);
BOOST_TEST(songs->parts.size() == 2U);
BOOST_REQUIRE_EQUAL(exercises->exercises.size(), 1U);
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;
@@ -380,7 +370,7 @@ BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalog_without_hardware_or_heartb
}
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/0001/electric.pages/2.png"}) {
for (std::char_t const *relative : {"songs/first.pages/2.png", "songs/second.pages/2.png"}) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
@@ -422,7 +412,7 @@ BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_
auto assets = std::filesystem::path{config.runtime.assets_path};
auto fixture = std::filesystem::path{VAMP_TEST_PLAYBACK_ASSETS_DIR};
std::filesystem::copy(fixture / "audio", assets / "audio", std::filesystem::copy_options::recursive);
auto path = assets / "practice/0001/acoustic.json";
auto path = assets / "songs/acoustic.json";
write_score(path, assets, "Acoustic");
auto score = boost::json::parse(read_file(path));
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
+1 -1
View File
@@ -91,7 +91,7 @@ class InstallZeroTests(unittest.TestCase):
def stale_state(self):
for name in ('etc/product-vamp/vamp.json', 'etc/product-vamp/obsolete', 'var/lib/product-vamp/vamp.db',
'var/lib/product-vamp/vamp.db-wal', 'var/lib/product-vamp/vamp.db-shm', 'run/product-vamp/heartbeat.json',
'usr/share/product-vamp/songs/removed.json', 'etc/systemd/system/product-vamp-zero.service.d/override.conf',
'usr/share/product-vamp/songs/removed.json', 'usr/share/product-vamp/practice/0001/electric.json', 'etc/systemd/system/product-vamp-zero.service.d/override.conf',
'run/systemd/system/product-vamp-ap.service.d/override.conf'):
path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True)
+1 -3
View File
@@ -59,7 +59,7 @@ class MusicModelTests(unittest.TestCase):
change = '<attributes><time><beats>6</beats><beat-type>8</beat-type></time></attributes>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter') + change + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')))
measure = source['staves'][0]['measures'][0]
events = generator.parts.generated_voice(measure, 'slash', None)
events = generator.parts.generated_voice(measure, 'slash')
self.assertEqual([generator.model.fraction(n['onset']) for n in events], [0, 1, fractions.Fraction(5, 2)])
self.assertEqual([generator.model.fraction(n['duration']) for n in events], [1, fractions.Fraction(3, 2), fractions.Fraction(3, 2)])
def test_harmony_carry_uses_last_event_and_never_future_event(self):
@@ -107,7 +107,6 @@ class MusicModelTests(unittest.TestCase):
root = pathlib.Path(temp)
data = root / 'data'
(data / 'songs').mkdir(parents=True)
(data / 'practice').mkdir()
(data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=5)), encoding='utf-8')
output = root / 'output'
output.mkdir()
@@ -132,7 +131,6 @@ class MusicModelTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as temp:
data, output = pathlib.Path(temp) / 'data', pathlib.Path(temp) / 'output'
(data / 'songs').mkdir(parents=True)
(data / 'practice').mkdir()
source = data / 'songs' / 'fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
+1 -1
View File
@@ -62,7 +62,7 @@ class MusicXMLTests(unittest.TestCase):
self.assertEqual(len(score['staves'][0]['measures']), 4)
self.assertEqual(len(score['staves'][0]['octave_shifts']), 1)
def test_catalog_imports(self):
sources = sorted(path for category in ('practice', 'songs') for path in (pathlib.Path('data') / category).glob('*.xml'))
sources = sorted(pathlib.Path('data/songs').glob('*.xml'))
self.assertTrue(sources)
for path in sources:
with self.subTest(path=path):
+33 -32
View File
@@ -16,7 +16,7 @@ import generator.musicxml
import generator.parts
import generator.pitch
import generator.playback
import generator.practice.generate
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.test_musicxml
@@ -33,7 +33,7 @@ class PartGenerationTests(unittest.TestCase):
self.assertEqual(cursor, generator.model.fraction(measure['duration']))
def test_dense_melody_keeps_rhythmic_accompaniment_without_sustaining_across_changes(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + xml_harmony('F', offset=3) + ''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2))
for result in (generator.practice.generate.practice_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library()), generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())):
for result in (tests.arrangement_fixtures.section_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library()), generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())):
generator.model.validate_document(result)
for measure in result['staves'][0]['measures']:
events = measure['voices'][0]['notes']
@@ -52,7 +52,7 @@ class PartGenerationTests(unittest.TestCase):
def test_harmony_carry_and_explicit_silence(self):
content = xml_harmony(offset=1) + xml_harmony(kind='none', offset=2) + xml_harmony('F', offset=3) + tests.test_musicxml.xml_note()
source = tests.test_musicxml.xml_score(content).replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note() + '</measure><measure number="3">' + xml_harmony(kind='none') + tests.test_musicxml.xml_note() + '</measure><measure number="4">' + tests.test_musicxml.xml_note() + '</measure></part>')
result = generator.practice.generate.practice_document(tests.test_musicxml.parse(source), 'acoustic', rhythms=tests.rhythm_fixtures.library())
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(source), 'acoustic', rhythms=tests.rhythm_fixtures.library())
for measure in result['staves'][0]['measures']:
self.assert_complete_measure(measure)
sounding = [n for n in tests.test_musicxml.notes(result) if n['pitches']]
@@ -80,9 +80,11 @@ class PartGenerationTests(unittest.TestCase):
self.assertTrue(all(n['_harmony']['root']['letter'] == 'f' for n in sounding))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + tests.test_musicxml.xml_note(duration=1, kind='quarter') + '<attributes><time><beats>6</beats><beat-type>8</beat-type></time></attributes>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')))
with self.assertRaisesRegex(ValueError, 'mid-measure meter'):
generator.practice.generate.practice_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library())
def test_exercise_006_has_accompaniment_in_every_measure(self):
result = generator.practice.generate.practice_document(generator.musicxml.load_musicxml(pathlib.Path('data/practice/0006.xml')), 'acoustic', rhythms=tests.rhythm_fixtures.library())
tests.arrangement_fixtures.section_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library())
def test_dense_melody_has_accompaniment_in_every_measure(self):
bar = xml_harmony() + ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(4))
xml = tests.test_musicxml.xml_score(bar).replace('</part>', ''.join(f'<measure number="{i}">{bar}</measure>' for i in range(2, 13)) + '</part>')
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(xml), 'acoustic', rhythms=tests.rhythm_fixtures.library())
self.assertEqual(len(result['staves'][0]['measures']), 12)
for measure in result['staves'][0]['measures']:
self.assertTrue(any(n['pitches'] for v in measure['voices'] for n in v['notes']), measure['id'])
@@ -97,22 +99,21 @@ class PartGenerationTests(unittest.TestCase):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + tests.test_musicxml.xml_note()))
source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': generator.model.rational(0), 'end': generator.model.rational(4), 'octaves': -1}]
original = copy.deepcopy(source)
for builder, wrapper, practice in ((generator.practice.generate._practice_document, generator.practice.generate.practice_document, True), (generator.arrange.generate._arrange_document, generator.arrange.generate.arrange_document, False)):
prepared = generator.parts.prepare_document(source, practice=practice)
before = copy.deepcopy(prepared)
plan = generator.accompaniment.plan_section(prepared['staves'][0]['measures'], tests.rhythm_fixtures.library(), electric=False) if practice else generator.arrange.generate.plan_arrangement(prepared, tests.rhythm_fixtures.library())
plan_before = copy.deepcopy(plan)
parts = [builder(prepared, part, plan) for part in generator.parts.PART_TYPES]
self.assertEqual(prepared, before)
self.assertEqual(plan, plan_before)
self.assertEqual(parts, [wrapper(source, part, rhythms=tests.rhythm_fixtures.library()) for part in generator.parts.PART_TYPES])
vocal = copy.deepcopy(parts[2])
parts[1]['metadata']['title'] = 'changed'
tests.test_musicxml.notes(parts[1])[0]['pitches'].clear()
if parts[1]['staves'][0]['octave_shifts']:
parts[1]['staves'][0]['octave_shifts'][0]['octaves'] = 9
self.assertEqual(parts[2], vocal)
self.assertEqual(prepared, before)
prepared = generator.parts.prepare_document(source)
before = copy.deepcopy(prepared)
plan = generator.arrange.generate.plan_arrangement(prepared, tests.rhythm_fixtures.library())
plan_before = copy.deepcopy(plan)
parts = [generator.arrange.generate._arrange_document(prepared, part, plan) for part in generator.parts.PART_TYPES]
self.assertEqual(prepared, before)
self.assertEqual(plan, plan_before)
self.assertEqual(parts, [generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library()) for part in generator.parts.PART_TYPES])
vocal = copy.deepcopy(parts[2])
parts[1]['metadata']['title'] = 'changed'
tests.test_musicxml.notes(parts[1])[0]['pitches'].clear()
if parts[1]['staves'][0]['octave_shifts']:
parts[1]['staves'][0]['octave_shifts'][0]['octaves'] = 9
self.assertEqual(parts[2], vocal)
self.assertEqual(prepared, before)
self.assertEqual(source, original)
def test_melody_written_pitch_rhythm_rests_and_cross_bar_ties_are_preserved(self):
content = ''
@@ -126,13 +127,13 @@ class PartGenerationTests(unittest.TestCase):
source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(root, encoding='unicode'))
before = copy.deepcopy(source)
expected = [n for m in source['staves'][0]['measures'] for n in m['voices'][0]['notes']]
for builder in (generator.practice.generate.practice_document, generator.arrange.generate.arrange_document):
for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document):
for part in ('electric', 'vocal'):
with self.subTest(builder=builder.__module__, part=part):
result = builder(source, part, rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
starts = [0] if builder is generator.practice.generate.practice_document else [i for i, m in enumerate(measures) if m.get('section_label') == ('Guitar Melody' if part == 'electric' else 'Vocal Melody')]
starts = [0] if builder is tests.arrangement_fixtures.section_document else [i for i, m in enumerate(measures) if m.get('section_label') == ('Guitar Melody' if part == 'electric' else 'Vocal Melody')]
self.assertTrue(starts)
for start in starts:
notes = [n for m in measures[start:start + 2] for n in m['voices'][0]['notes']]
@@ -142,16 +143,16 @@ class PartGenerationTests(unittest.TestCase):
self.assertEqual(generated[key], original[key])
self.assertEqual(len(generated['pitches']), len(original['pitches']))
for pitch, tone in zip(original['pitches'], generated['pitches']):
octave = 12 if part == 'electric' and builder is generator.arrange.generate.arrange_document else 0
octave = 12 if part == 'electric' else 0
self.assertEqual(generator.model.pitch_number(tone), generator.model.pitch_number(pitch) + octave)
self.assertEqual(tone['written'], pitch['written'])
for key in ('tie_from', 'tie_to'):
self.assertEqual(key in tone, key in pitch)
self.assertEqual(notes[0]['pitches'][0]['tie_to'], notes[1]['pitches'][0]['id'])
self.assertEqual(notes[1]['pitches'][0]['tie_to'], notes[2]['pitches'][0]['id'])
if builder is generator.practice.generate.practice_document:
if builder is tests.arrangement_fixtures.section_document:
events = generator.playback.compile_timeline(result)['events']
self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in events], [(0, 6, 49), (fractions.Fraction(13, 2), fractions.Fraction(3, 2), 51)])
self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in events], [(0, 6, 61 if part == 'electric' else 49), (fractions.Fraction(13, 2), fractions.Fraction(3, 2), 63 if part == 'electric' else 51)])
self.assertEqual(source, before)
def test_song_slug_collisions_fail_before_writes(self):
for names in (('one-two', 'one_two'), ('Song', 'song'), ('!!!', '???')):
@@ -171,8 +172,8 @@ class PartGenerationTests(unittest.TestCase):
def test_pipeline_parses_and_normalizes_each_source_once(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
for category, name in (('songs', 'song'), ('practice', '0001')):
(root / 'data' / category).mkdir(parents=True)
for category, name in (('songs', 'song'), ('songs', 'other')):
(root / 'data' / category).mkdir(parents=True, exist_ok=True)
(root / 'data' / category / (name + '.xml')).write_text(tests.test_musicxml.xml_score(xml_harmony() + tests.test_musicxml.xml_note()))
args = argparse.Namespace(data_dir=root / 'data', output_dir=root / 'output', static_assets_dir=pathlib.Path('package/assets'), check=False)
with (
@@ -186,11 +187,11 @@ class PartGenerationTests(unittest.TestCase):
):
generator.__main__.generate_assets(args)
load_library.assert_called_once_with(args.data_dir.resolve() / 'rhythms')
self.assertEqual(load.call_args_list, [unittest.mock.call(args.data_dir.resolve() / 'songs/song.xml'), unittest.mock.call(args.data_dir.resolve() / 'practice/0001.xml')])
self.assertEqual(load.call_args_list, [unittest.mock.call(args.data_dir.resolve() / 'songs/other.xml'), unittest.mock.call(args.data_dir.resolve() / 'songs/song.xml')])
self.assertEqual(normalize.call_count, 2)
original = {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()}
(root / 'data/practice/0002.xml').write_text('<broken')
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0002.xml'):
(root / 'data/songs/broken.xml').write_text('<broken')
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, 'broken.xml'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()})
self.assertEqual(len(list(args.output_dir.rglob('*.json'))), 6)
+2 -2
View File
@@ -23,8 +23,8 @@ auto playback_score() -> vamp_zero::score_document {
auto performance_state() -> vamp_zero::app_state {
auto catalog = std::make_shared<vamp_zero::catalog>();
catalog->performance.parts.push_back({.id = "part", .title = "Song", .part = "Vocal", .total_pages = 2U});
catalog->performance.groups.push_back({.title = "Song", .parts = {0U}});
catalog->parts.push_back({.id = "part", .title = "Song", .part = "Vocal", .total_pages = 2U});
catalog->groups.push_back({.title = "Song", .parts = {0U}});
auto state = vamp_zero::app_state{};
state.catalog = std::move(catalog);
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
+1 -1
View File
@@ -52,7 +52,7 @@ struct artwork_fixture {
}
auto state() const -> vamp_zero::app_state {
auto catalog = std::make_shared<vamp_zero::catalog>();
catalog->performance.parts.push_back({.id = "part", .title = "Title", .part = "Acoustic", .total_pages = 10000U, .title_image = identifier});
catalog->parts.push_back({.id = "part", .title = "Title", .part = "Acoustic", .total_pages = 10000U, .title_image = identifier});
auto value = vamp_zero::app_state{};
value.catalog = catalog;
value.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
-78
View File
@@ -1,78 +0,0 @@
import copy
import fractions
import pathlib
import unittest
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.types
import generator.model
import generator.playback
import generator.scat
import tests.test_musicxml
class ScatTests(unittest.TestCase):
def test_syllables_use_duration_pulse_phrase_and_previous_consonant(self):
self.assertEqual(generator.scat.syllable(fractions.Fraction(2), fractions.Fraction(1), False, True, ''), 'doo')
self.assertEqual(generator.scat.syllable(fractions.Fraction(1, 2), fractions.Fraction(1), True, False, ''), 'ba')
self.assertEqual(generator.scat.syllable(fractions.Fraction(1, 2), fractions.Fraction(1), False, False, ''), 'dit')
self.assertEqual(generator.scat.syllable(fractions.Fraction(1, 2), fractions.Fraction(1), False, False, 'dit'), 'da')
self.assertEqual(generator.scat.syllable(fractions.Fraction(1, 2), fractions.Fraction(1), True, True, ''), 'dat')
self.assertEqual(generator.scat.syllable(fractions.Fraction(1, 2), None, False, False, ''), 'ba')
def test_compound_additive_meter_and_pickups(self):
measure = {'meter': {'groups': [{'beats': [6], 'unit': 8}]}, 'duration': generator.model.rational(3)}
self.assertEqual(generator.scat.pulse(measure, fractions.Fraction(3, 2)), (fractions.Fraction(3, 2), True))
measure['meter']['groups'][0]['beats'] = [3, 2]
self.assertEqual(generator.scat.pulse(measure, fractions.Fraction(3, 2)), (fractions.Fraction(1), True))
measure.update(implicit=True, duration=generator.model.rational(fractions.Fraction(1, 2)))
self.assertEqual(generator.scat.pulse(measure, fractions.Fraction()), (fractions.Fraction(1), False))
def test_ties_sustain_one_syllable_and_stop_before_rests(self):
content = tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>') + tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="stop"/>')
content += '<note><rest/><duration>2</duration><type>half</type></note>'
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
original = copy.deepcopy(document)
before = generator.playback.compile_timeline(document)
generator.scat.add_scat(document)
self.assertEqual(generator.playback.compile_timeline(document), before)
self.assertEqual(tests.test_musicxml.notes(document)[0]['lyric'], 'doo')
self.assertEqual(tests.test_musicxml.notes(document)[0]['lyrics'][0]['extend_to'], generator.model.rational(2))
self.assertNotIn('lyric', tests.test_musicxml.notes(document)[1])
self.assertNotIn('lyrics', tests.test_musicxml.notes(document)[2])
again = copy.deepcopy(original)
generator.scat.add_scat(again)
self.assertEqual(document, again)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
layout = generator.engraving.layout.layout_score(document, generator.engraving.types.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 = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<tie type="start"/>'))
text = text.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '</measure></part>')
document = tests.test_musicxml.parse(text)
generator.scat.add_scat(document)
self.assertEqual(sum(bool(n.get('lyric')) for n in tests.test_musicxml.notes(document)), 1)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
for start, end in ((0, 4), (4, 8)):
system = generator.engraving.layout.make_system(generator.engraving.notation.ScoreIndex(document), start, end, fonts, generator.engraving.types.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 = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<tie type="start"/>'))
text = text.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/><tie type="start"/>') + '</measure><measure number="3">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '</measure></part>')
document = tests.test_musicxml.parse(text)
generator.scat.add_scat(document)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
index = generator.engraving.notation.ScoreIndex(document)
for start, end in ((0, 4), (4, 8), (8, 12), (0, 12)):
system = generator.engraving.layout.make_system(index, start, end, fonts, generator.engraving.types.Style())
self.assertEqual(sum(p.role == 'lyric-extension' for p in system.primitives), 1)
def test_straight_offbeat_attacks_and_cues(self):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2))
before = generator.playback.compile_timeline(document)
generator.scat.add_scat(document)
self.assertEqual([n['lyric'] for n in tests.test_musicxml.notes(document)], ['ba', 'dit', 'ba', 'dit', 'ba', 'dit', 'ba', 'dat'])
self.assertEqual(generator.playback.compile_timeline(document), before)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(prefix='<cue/>')))
generator.scat.add_scat(document)
self.assertNotIn('lyrics', tests.test_musicxml.notes(document)[0])
+11 -18
View File
@@ -26,7 +26,7 @@ import generator.musicxml_writer
import generator.pico_artwork
import generator.pitch
import generator.playback
import generator.practice.generate
import tests.arrangement_fixtures
import generator.score
import tests.musicxml_roundtrip
import tests.rhythm_fixtures
@@ -39,19 +39,12 @@ class ScoreBundleTests(unittest.TestCase):
root = pathlib.Path(temporary)
data, output = root / 'data', root / 'output'
(data / 'songs').mkdir(parents=True)
(data / 'practice').mkdir()
source = data / 'practice/0001.xml'
source = data / 'songs/fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
generator.__main__.generate_assets(args)
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
self.assertTrue(list(output.rglob('*.png')))
legacy_source = source.with_name('001.xml')
source.rename(legacy_source)
with self.assertRaisesRegex(ValueError, 'four-digit'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
legacy_source.rename(source)
(data / 'rhythms' / 'drafts').mkdir(parents=True)
for path in ('rhythms/0001.xml', 'rhythms/drafts/0002.xml'):
(data / path).write_text('<unfinished')
@@ -75,16 +68,16 @@ class ScoreBundleTests(unittest.TestCase):
generator.__main__.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(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
renamed = data / 'practice/0127.xml'
renamed = data / 'songs/renamed.xml'
source.rename(renamed)
source = renamed
legacy_output = output / 'practice/001'
legacy_output = output / 'obsolete'
legacy_output.mkdir()
(legacy_output / 'electric.json').write_text('{}')
generator.__main__.generate_assets(args)
self.assertFalse(legacy_output.exists())
self.assertFalse((output / 'practice/0001').exists())
self.assertEqual(len(list((output / 'practice/0127').glob('*.json'))), 3)
self.assertFalse(list((output / 'songs').glob('fixture*')))
self.assertEqual(len(list((output / 'songs').glob('renamed-*.json'))), 3)
source.unlink()
generator.__main__.generate_assets(args)
self.assertFalse(list(output.rglob('*.png')))
@@ -170,7 +163,7 @@ class ScoreBundleTests(unittest.TestCase):
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes='<clef><sign>TAB</sign></clef>'))
for part in ('vocal', 'acoustic', 'electric'):
with self.assertRaisesRegex(ValueError, 'pitched'):
generator.practice.generate.practice_document(source, part, rhythms=tests.rhythm_fixtures.library())
tests.arrangement_fixtures.section_document(source, part, rhythms=tests.rhythm_fixtures.library())
with self.assertRaisesRegex(ValueError, 'pitched'):
generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
def test_percussion_instrument_definitions_round_trip_and_are_audited(self):
@@ -192,7 +185,7 @@ class ScoreBundleTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
generator.model.validate_document(source)
def test_catalog_roundtrip_preserves_written_pitch_and_rhythm(self):
for path in sorted(path for category in ('practice', 'songs') for path in (pathlib.Path('data') / category).glob('*.xml')):
for path in sorted(pathlib.Path('data/songs').glob('*.xml')):
with self.subTest(path=path):
source = generator.musicxml.load_musicxml(path)
restored = self.round_trip(source)
@@ -281,12 +274,12 @@ class ScoreBundleTests(unittest.TestCase):
root = pathlib.Path(temporary)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
for index in range(3):
generator.score.write_json(root / 'practice' / f'{index}.json', document)
generator.score.write_json(root / 'songs' / f'{index}.json', document)
trusted = generator.engraving.pipeline.engrave_tree(root, pathlib.Path('package/assets/fonts/petaluma'))
generator.pico_artwork.generate_titles(root, pathlib.Path('package/assets/fonts/petaluma'))
with unittest.mock.patch('generator.engraving.validation.check_content', side_effect=AssertionError('already validated content')):
generator.score.validate_generated_tree(root, trusted)
page = root / 'practice/2.pages/0001.png'
page = root / 'songs/2.pages/0001.png'
original = page.read_bytes()
page.write_bytes(original + b'changed')
with self.assertRaisesRegex(ValueError, 'checksum'):
@@ -296,7 +289,7 @@ class ScoreBundleTests(unittest.TestCase):
generator.score.validate_generated_tree(root)
decode.assert_called_once()
PIL.Image.new('RGB', (976, 484), 'white').save(page)
path = root / 'practice/2.json'
path = root / 'songs/2.json'
bundle = json.loads(path.read_text())
bundle['presentation']['pages'][0]['sha256'] = hashlib.sha256(page.read_bytes()).hexdigest()
path.write_text(json.dumps(bundle))
+1 -1
View File
@@ -70,7 +70,7 @@ class PicoArtworkTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
paths = []
for category, part in (('songs', 'Acoustic'), ('songs', 'Vocal'), ('practice', 'Electric')):
for category, part in (('songs', 'Acoustic'), ('songs', 'Vocal'), ('songs', 'Electric')):
path = root / category / f'{part}.json'
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({'music': {'metadata': {'title': 'Evening Song'}, 'staves': [{'name': part}]},
+5 -21
View File
@@ -22,7 +22,7 @@
namespace {
auto performance(std::string id) -> vamp_zero::score_reference {
return {.category = vamp_zero::score_category::PERFORMANCE, .id = std::move(id)};
return {.id = std::move(id)};
}
auto drain(boost::asio::io_context &io) -> void {
@@ -147,7 +147,7 @@ BOOST_AUTO_TEST_CASE(score_loader_stale_error_does_not_replace_pending_selection
BOOST_CHECK_EQUAL(fixture.loader.snapshot().document->id, "available");
}
BOOST_AUTO_TEST_CASE(score_loader_catalog_identity_and_category_invalidate_selection) {
BOOST_AUTO_TEST_CASE(score_loader_catalog_identity_invalidates_same_score_selection) {
auto fixture = loader_fixture{};
fixture.loader.select(fixture.catalog, performance("same-id"));
drain(fixture.io);
@@ -160,13 +160,6 @@ BOOST_AUTO_TEST_CASE(score_loader_catalog_identity_and_category_invalidate_selec
fixture.scores->complete(1);
drain(fixture.io);
BOOST_REQUIRE(fixture.loader.snapshot().document);
fixture.loader.select(fixture.catalog, vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = "same-id"});
BOOST_CHECK(!fixture.loader.snapshot().document);
drain(fixture.io);
BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 3U);
BOOST_CHECK(fixture.scores->requests.back().category == vamp_zero::score_category::PRACTICE);
fixture.scores->complete(2);
drain(fixture.io);
}
BOOST_AUTO_TEST_CASE(score_loader_reuses_active_score_and_releases_on_menu_exit) {
@@ -316,10 +309,9 @@ 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_part) {
BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part) {
auto catalog = std::make_shared<vamp_zero::catalog>();
catalog->performance.parts = {{.id = "part-a"}, {.id = "part-b"}};
catalog->practice.exercises = {{.id = "scale", .parts = {{.id = "scale-c"}, {.id = "scale-d"}}}};
catalog->parts = {{.id = "part-a"}, {.id = "part-b"}};
auto state = vamp_zero::app_state{.catalog = catalog};
BOOST_CHECK(!vamp_zero::selected_score_reference(state));
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
@@ -327,15 +319,7 @@ BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_part
auto reference = vamp_zero::selected_score_reference(state);
BOOST_REQUIRE(reference);
BOOST_CHECK_EQUAL(reference->id, "part-b");
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_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_part_index = 0;
state.active_part_id = "missing";
BOOST_CHECK(!vamp_zero::selected_score_reference(state));
state.catalog.reset();
BOOST_CHECK(!vamp_zero::selected_score_reference(state));