702 lines
32 KiB
C++
702 lines
32 KiB
C++
#include <test.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstddef>
|
|
#include <cstdio>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <future>
|
|
#include <initializer_list>
|
|
#include <limits>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <variant>
|
|
#include <vector>
|
|
|
|
#include <boost/asio/co_spawn.hpp>
|
|
#include <boost/asio/io_context.hpp>
|
|
#include <boost/asio/use_future.hpp>
|
|
#include <boost/json/object.hpp>
|
|
#include <boost/json/parse.hpp>
|
|
#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>
|
|
#include <vamp_zero/database.hpp>
|
|
#include <vamp_zero/library.hpp>
|
|
#include <vamp_zero/navigation.hpp>
|
|
#include <vamp_zero/parse.hpp>
|
|
#include <vamp_zero/page_control.hpp>
|
|
#include <vamp_zero/score.hpp>
|
|
#include <vamp_zero/types.hpp>
|
|
|
|
namespace {
|
|
|
|
auto test_database_config(std::filesystem::path const &path) -> vamp_zero::database_config {
|
|
return {
|
|
.path = path.string(),
|
|
};
|
|
}
|
|
|
|
auto run_ensure_database(vamp_zero::database_config config) -> std::expected<void, modulus::error> {
|
|
auto io = boost::asio::io_context{};
|
|
auto future = boost::asio::co_spawn(io, vamp_zero::async_ensure_database(io.get_executor(), std::move(config)), boost::asio::use_future);
|
|
io.run();
|
|
return future.get();
|
|
}
|
|
|
|
auto run_import(vamp_zero::database_config config, std::filesystem::path const &assets_path) -> std::expected<void, modulus::error> {
|
|
auto io = boost::asio::io_context{};
|
|
auto future = boost::asio::co_spawn(io, vamp_zero::async_import_catalogs(io.get_executor(), std::move(config), assets_path.string()), boost::asio::use_future);
|
|
io.run();
|
|
return future.get();
|
|
}
|
|
|
|
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 integer_value(noto::sqlite_value const &value) -> std::int64_t {
|
|
auto integer = std::get_if<std::int64_t>(&value.value);
|
|
return integer == nullptr ? 0 : *integer;
|
|
}
|
|
|
|
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 {
|
|
auto value = boost::json::parse(R"json({
|
|
"schema_version":3,
|
|
"music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
|
|
"staves":[{"id":"staff-lead","name":"Lead","measures":[{"id":"measure-1","voices":[]}]}]},
|
|
"presentation":{"width":976,"height":484,"pages":[{"path":"fixture.pages/0001.png",
|
|
"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]}
|
|
})json");
|
|
auto &metadata = value.as_object()["music"].as_object()["metadata"].as_object();
|
|
metadata["title"] = std::move(title);
|
|
metadata["key_signature"] = std::move(key);
|
|
return boost::json::serialize(value);
|
|
}
|
|
|
|
auto navigation_state() -> vamp_zero::app_state {
|
|
auto part = vamp_zero::library_part{
|
|
.id = "part-lead",
|
|
.title = "Navigation Tune",
|
|
.composer = "Composer",
|
|
.key_signature = "C",
|
|
.part = "Lead",
|
|
.source_path = "navigation.json",
|
|
.source_json = score_asset_json("Navigation Tune"),
|
|
.measure_count = 1,
|
|
.total_pages = 2,
|
|
};
|
|
auto variant = vamp_zero::practice_exercise::score_variant{
|
|
.id = "exercise-c",
|
|
.key_signature = "C",
|
|
.source_path = "exercise.json",
|
|
.source_json = score_asset_json("Exercise"),
|
|
.measure_count = 1,
|
|
.total_pages = 1,
|
|
};
|
|
auto exercise = vamp_zero::practice_exercise{
|
|
.id = "exercise-1",
|
|
.practice_type_id = "scale",
|
|
.practice_type_title = "Scales",
|
|
.lesson_group_id = "lesson-1",
|
|
.lesson_group_title = "Major",
|
|
.title = "C Major",
|
|
.composer = "Composer",
|
|
.key_signature = "C",
|
|
.source_path = "exercise.json",
|
|
.source_json = score_asset_json("Exercise"),
|
|
.measure_count = 1,
|
|
.total_pages = 1,
|
|
.variants = {variant},
|
|
};
|
|
return vamp_zero::app_state{
|
|
.performance =
|
|
{
|
|
.parts = {part},
|
|
.groups = {{
|
|
.title = part.title,
|
|
.composer = part.composer,
|
|
.parts = {part},
|
|
}},
|
|
},
|
|
.practice =
|
|
{
|
|
.exercises = {exercise},
|
|
.types = {{
|
|
.id = "scale",
|
|
.title = "Scales",
|
|
.lesson_groups = {{
|
|
.id = "lesson-1",
|
|
.title = "Major",
|
|
.exercises = {exercise},
|
|
}},
|
|
}},
|
|
},
|
|
};
|
|
}
|
|
|
|
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(product_vamp_audio_config_parses_defaults_and_values) {
|
|
auto defaults = vamp_zero::parse_config(boost::json::parse(R"json({})json"));
|
|
BOOST_REQUIRE(defaults);
|
|
BOOST_TEST(defaults->audio.enabled);
|
|
BOOST_TEST(!defaults->audio.track_enabled);
|
|
BOOST_TEST(!defaults->audio.metronome_enabled);
|
|
BOOST_TEST(defaults->audio.metronome_bpm == 120U);
|
|
BOOST_TEST(defaults->audio.metronome_min_bpm == 40U);
|
|
BOOST_TEST(defaults->audio.metronome_max_bpm == 240U);
|
|
BOOST_TEST(defaults->audio.metronome_step == 5U);
|
|
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
|
|
"audio": {
|
|
"enabled": false,
|
|
"track_enabled": true,
|
|
"metronome_enabled": true,
|
|
"metronome_bpm": 90,
|
|
"metronome_min_bpm": 50,
|
|
"metronome_max_bpm": 180,
|
|
"metronome_step": 10,
|
|
"beats_per_bar": 3,
|
|
"master_volume": 0.5,
|
|
"metronome_volume": 0.25
|
|
}
|
|
})json"));
|
|
BOOST_REQUIRE(parsed);
|
|
BOOST_TEST(!parsed->audio.enabled);
|
|
BOOST_TEST(parsed->audio.track_enabled);
|
|
BOOST_TEST(parsed->audio.metronome_enabled);
|
|
BOOST_TEST(parsed->audio.metronome_bpm == 90U);
|
|
BOOST_TEST(parsed->audio.metronome_min_bpm == 50U);
|
|
BOOST_TEST(parsed->audio.metronome_max_bpm == 180U);
|
|
BOOST_TEST(parsed->audio.metronome_step == 10U);
|
|
BOOST_TEST(parsed->audio.beats_per_bar == 3U);
|
|
BOOST_TEST(parsed->audio.master_volume == 0.5F);
|
|
BOOST_TEST(parsed->audio.metronome_volume == 0.25F);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) {
|
|
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
|
|
"audio": {
|
|
"metronome_bpm": 120,
|
|
"metronome_min_bpm": 160,
|
|
"metronome_max_bpm": 80
|
|
}
|
|
})json"));
|
|
BOOST_TEST(!parsed);
|
|
}
|
|
|
|
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);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_TEST(state.audio.track_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);
|
|
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);
|
|
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);
|
|
BOOST_TEST(state.audio.metronome_bpm == 125U);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
|
BOOST_TEST(state.audio.metronome_bpm == 120U);
|
|
for (auto index = std::int32_t{}; index < 40; ++index) {
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
|
}
|
|
BOOST_TEST(state.audio.metronome_bpm == 40U);
|
|
for (auto index = std::int32_t{}; index < 80; ++index) {
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
}
|
|
BOOST_TEST(state.audio.metronome_bpm == 240U);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_TEST(!state.audio.tempo_editing);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(audio_status_json_reports_menu_settings) {
|
|
auto state = navigation_state();
|
|
state.audio.track_enabled = true;
|
|
state.audio.metronome_enabled = true;
|
|
state.audio.metronome_bpm = 135;
|
|
state.audio.tempo_editing = true;
|
|
auto status = vamp_zero::app_status_json(state).as_object();
|
|
auto const &audio = status.at("audio").as_object();
|
|
BOOST_TEST(audio.at("track_enabled").as_bool());
|
|
BOOST_TEST(audio.at("metronome_enabled").as_bool());
|
|
BOOST_TEST(audio.at("metronome_bpm").as_uint64() == 135U);
|
|
BOOST_TEST(audio.at("metronome_min_bpm").as_uint64() == 40U);
|
|
BOOST_TEST(audio.at("metronome_max_bpm").as_uint64() == 240U);
|
|
BOOST_TEST(audio.at("metronome_step").as_uint64() == 5U);
|
|
BOOST_TEST(audio.at("tempo_editing").as_bool());
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) {
|
|
auto state = navigation_state();
|
|
state.audio.metronome_enabled = true;
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::PERFORMANCE_SONG_MENU));
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::PERFORMANCE_PART_MENU));
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::PERFORMANCE_SCORE));
|
|
BOOST_TEST(state.audio.metronome_enabled);
|
|
BOOST_TEST(!state.audio.tempo_editing);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) {
|
|
auto count = std::size_t{};
|
|
for (auto const &entry : std::filesystem::recursive_directory_iterator{VAMP_TEST_ASSETS_DIR}) {
|
|
if (entry.path().extension() != ".json") {
|
|
continue;
|
|
}
|
|
auto input = std::ifstream{entry.path()};
|
|
auto json = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
|
auto parsed = vamp_zero::parse_score_document(json, entry.path().string());
|
|
BOOST_TEST_CONTEXT(entry.path().string()) {
|
|
BOOST_REQUIRE(parsed.has_value());
|
|
BOOST_TEST(vamp_zero::score_page_count(*parsed) > 0U);
|
|
}
|
|
++count;
|
|
}
|
|
BOOST_TEST(count > 0U);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(chord_and_melody_practice_share_key_navigation_and_remote_display) {
|
|
for (auto type : {"chord", "melody"}) {
|
|
auto state = navigation_state();
|
|
auto &exercise = state.practice.exercises.front();
|
|
exercise.practice_type_id = type;
|
|
auto variant = exercise.variants.front();
|
|
variant.id = "exercise-d";
|
|
variant.key_signature = "D";
|
|
variant.source_path = "exercise-d.json";
|
|
variant.source_json = score_asset_json("Exercise", "D");
|
|
variant.total_pages = 3U;
|
|
exercise.variants.push_back(variant);
|
|
state.practice.types.front().id = type;
|
|
state.practice.types.front().lesson_groups.front().exercises = {exercise};
|
|
state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
|
|
state.selected_practice_type_index = 1U;
|
|
state.selected_practice_lesson_index = 1U;
|
|
state.selected_practice_exercise_index = 1U;
|
|
state.audio.metronome_enabled = true;
|
|
BOOST_TEST(vamp_zero::supports_key_selection(exercise));
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU);
|
|
BOOST_TEST(vamp_zero::practice_key_row_count(state) == 3U);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_SCORE);
|
|
BOOST_TEST(state.audio.metronome_enabled);
|
|
auto selected = vamp_zero::selected_practice_variant(state, state.practice.exercises.front());
|
|
BOOST_REQUIRE(selected != nullptr);
|
|
BOOST_TEST(selected->id == "exercise-d");
|
|
BOOST_TEST(selected->source_path == "exercise-d.json");
|
|
BOOST_TEST(vamp_zero::active_pages(state) == 3U);
|
|
auto remote = vamp_pico::remote_display_state{};
|
|
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
|
|
BOOST_TEST(remote.active);
|
|
BOOST_TEST(std::string{remote.part} == "Key: D");
|
|
BOOST_TEST(remote.pages == 3U);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
BOOST_TEST(state.page_index == 1U);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU);
|
|
BOOST_TEST(state.selected_practice_key_index == 2U);
|
|
BOOST_TEST(state.active_practice_id.empty());
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU);
|
|
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
|
|
auto root = std::filesystem::temp_directory_path() / ("product-vamp-keys-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
auto config = test_database_config(root / "vamp.db");
|
|
BOOST_REQUIRE(run_ensure_database(config));
|
|
for (auto iteration = std::uint32_t{}; iteration < 2U; ++iteration) {
|
|
BOOST_REQUIRE(run_import(config, VAMP_TEST_ASSETS_DIR));
|
|
auto io = boost::asio::io_context{};
|
|
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config), boost::asio::use_future);
|
|
io.run();
|
|
auto library = future.get();
|
|
BOOST_REQUIRE(library);
|
|
BOOST_TEST(library->exercises.size() == 4U);
|
|
for (auto const &exercise : library->exercises) {
|
|
BOOST_TEST(vamp_zero::supports_key_selection(exercise));
|
|
BOOST_REQUIRE_EQUAL(exercise.variants.size(), 12U);
|
|
auto keys = vamp_zero::practice_key_names();
|
|
for (auto index = std::size_t{}; index < keys.size(); ++index) {
|
|
BOOST_TEST(exercise.variants[index].key_signature == keys[index]);
|
|
BOOST_REQUIRE(vamp_zero::parse_score_document(exercise.variants[index].source_json, exercise.variants[index].source_path));
|
|
}
|
|
}
|
|
}
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(keyless_practice_variants_display_their_transposition_reference) {
|
|
auto source = boost::json::parse(score_asset_json("Keyless", "No key"));
|
|
auto &music = source.as_object()["music"].as_object();
|
|
music["metadata"].as_object()["variant_tonic"] = "D";
|
|
music["staves"].as_array().front().as_object()["arrangement_part_type"] = "practice";
|
|
auto document = vamp_zero::parse_score_document(boost::json::serialize(source), "d.json");
|
|
BOOST_REQUIRE(document);
|
|
BOOST_TEST(document->key_signature == "D (no key)");
|
|
auto retained = boost::json::parse(document->source_json);
|
|
BOOST_TEST(retained.as_object()["music"].as_object()["metadata"].as_object()["key_signature"].as_string() == "No key");
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(integer_parsing_checks_range_sign_and_entire_input) {
|
|
BOOST_TEST(vamp_zero::parse_integer<std::uint16_t>("65535", "port").value_or(0) == 65535U);
|
|
BOOST_TEST(vamp_zero::parse_integer<std::int32_t>("-2147483648", "octave").value_or(0) == std::numeric_limits<std::int32_t>::min());
|
|
BOOST_TEST(vamp_zero::parse_integer<std::uint64_t>("18446744073709551615", "page").value_or(0) == std::numeric_limits<std::uint64_t>::max());
|
|
for (auto text : {"", "-1", "+1", " 1", "1 ", "123x", "1.5", "65536", "18446744073709551616"}) {
|
|
auto parsed = vamp_zero::parse_integer<std::uint16_t>(text, "port");
|
|
BOOST_REQUIRE(!parsed.has_value());
|
|
BOOST_TEST(parsed.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
|
BOOST_TEST(parsed.error().message().starts_with("port "));
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(library_scans_report_filesystem_errors_instead_of_empty_success) {
|
|
auto root = std::filesystem::temp_directory_path() /
|
|
("product-vamp-scan-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
write_text(root / "songs", "not a directory");
|
|
write_text(root / "practice", "not a directory");
|
|
auto performance = vamp_zero::load_performance_library(root.string());
|
|
auto practice = vamp_zero::load_practice_library(root.string());
|
|
BOOST_REQUIRE(!performance.has_value());
|
|
BOOST_REQUIRE(!practice.has_value());
|
|
BOOST_TEST(performance.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
|
|
BOOST_TEST(practice.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
|
|
std::filesystem::remove(root / "songs");
|
|
std::filesystem::remove(root / "practice");
|
|
std::filesystem::create_directories(root / "songs");
|
|
std::filesystem::create_directories(root / "practice");
|
|
std::filesystem::create_symlink("loop.json", root / "songs" / "loop.json");
|
|
std::filesystem::create_symlink("loop.json", root / "practice" / "loop.json");
|
|
BOOST_CHECK_NO_THROW(performance = vamp_zero::load_performance_library(root.string()));
|
|
BOOST_CHECK_NO_THROW(practice = vamp_zero::load_practice_library(root.string()));
|
|
BOOST_REQUIRE(!performance.has_value());
|
|
BOOST_REQUIRE(!practice.has_value());
|
|
BOOST_TEST(performance.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
|
|
BOOST_TEST(practice.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
|
|
std::filesystem::remove_all(root);
|
|
practice = vamp_zero::load_practice_library(root.string());
|
|
BOOST_REQUIRE(practice.has_value());
|
|
BOOST_TEST(practice->exercises.empty());
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_accepts_crlf_payloads) {
|
|
auto state = vamp_pico::remote_display_state{};
|
|
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("active=1\r\ntitle=Autumn Leaves\r\npart=Vocal\r\nmode=score\r\npage=2\r\npages=11\r\n", state));
|
|
BOOST_TEST(state.active);
|
|
BOOST_TEST(std::strcmp(state.title, "Autumn Leaves") == 0);
|
|
BOOST_TEST(std::strcmp(state.part, "Vocal") == 0);
|
|
BOOST_TEST(std::strcmp(state.mode, "score") == 0);
|
|
BOOST_TEST(state.page == 2U);
|
|
BOOST_TEST(state.pages == 11U);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_rejects_bad_numbers_and_preserves_state) {
|
|
auto state = vamp_pico::remote_display_state{};
|
|
std::snprintf(state.title, sizeof(state.title), "Keep");
|
|
BOOST_TEST(!vamp_pico::parse_remote_display_body("page=1x\npages=2\n", state));
|
|
BOOST_TEST(std::strcmp(state.title, "Keep") == 0);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_truncates_long_fields_safely) {
|
|
auto state = vamp_pico::remote_display_state{};
|
|
auto body =
|
|
std::string{"title="} + std::string(140U, 'A') + "\npart=" + std::string(80U, 'B') + "\nmode=performance-score";
|
|
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(body, state));
|
|
BOOST_TEST(std::strlen(state.title) == sizeof(state.title) - 1U);
|
|
BOOST_TEST(std::strlen(state.part) == sizeof(state.part) - 1U);
|
|
BOOST_TEST(std::strcmp(state.mode, "performance-score") == 0);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(database_ensure_creates_current_schema_without_version_bookkeeping) {
|
|
auto root =
|
|
std::filesystem::temp_directory_path() /
|
|
("product-vamp-db-schema-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
auto database_path = root / "vamp.db";
|
|
std::filesystem::remove_all(root);
|
|
auto config = test_database_config(database_path);
|
|
BOOST_REQUIRE(run_ensure_database(config));
|
|
BOOST_REQUIRE(run_ensure_database(config));
|
|
auto tables = query_database(database_path, "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name");
|
|
BOOST_REQUIRE(tables);
|
|
BOOST_TEST(has_value(*tables, 0U, "songs"));
|
|
BOOST_TEST(has_value(*tables, 0U, "practice_exercises"));
|
|
BOOST_TEST(has_value(*tables, 0U, "page_state"));
|
|
BOOST_TEST(!has_value(*tables, 0U, "_noto_schema_migrations"));
|
|
auto version = query_database(database_path, "PRAGMA user_version");
|
|
BOOST_REQUIRE(version);
|
|
BOOST_REQUIRE_EQUAL(version->rows.size(), 1U);
|
|
BOOST_TEST(integer_value(version->rows[0][0]) == 0);
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(database_ensure_updates_legacy_schema_in_place) {
|
|
auto root =
|
|
std::filesystem::temp_directory_path() /
|
|
("product-vamp-db-legacy-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
auto database_path = root / "vamp.db";
|
|
std::filesystem::remove_all(root);
|
|
std::filesystem::create_directories(root);
|
|
BOOST_REQUIRE(query_database(database_path, R"sql(
|
|
CREATE TABLE songs (
|
|
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120),
|
|
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
|
composer TEXT NOT NULL DEFAULT '',
|
|
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
|
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
|
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms)
|
|
) STRICT
|
|
)sql"));
|
|
BOOST_REQUIRE(query_database(database_path, R"sql(
|
|
CREATE TABLE practice_exercises (
|
|
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160),
|
|
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
|
composer TEXT NOT NULL DEFAULT '',
|
|
key_signature TEXT NOT NULL DEFAULT 'C',
|
|
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
|
source_path TEXT NOT NULL DEFAULT '',
|
|
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
|
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms)
|
|
) STRICT
|
|
)sql"));
|
|
BOOST_REQUIRE(query_database(database_path, "CREATE TABLE _noto_schema_migrations(id TEXT PRIMARY KEY NOT NULL) STRICT"));
|
|
BOOST_REQUIRE(query_database(database_path, "PRAGMA user_version = 5"));
|
|
auto config = test_database_config(database_path);
|
|
BOOST_REQUIRE(run_ensure_database(config));
|
|
auto song_columns = query_database(database_path, "PRAGMA table_info(songs)");
|
|
BOOST_REQUIRE(song_columns);
|
|
BOOST_TEST(has_value(*song_columns, 1U, "part"));
|
|
BOOST_TEST(has_value(*song_columns, 1U, "source_path"));
|
|
BOOST_TEST(has_value(*song_columns, 1U, "key_signature"));
|
|
BOOST_TEST(has_value(*song_columns, 1U, "catalog_managed"));
|
|
auto practice_columns = query_database(database_path, "PRAGMA table_info(practice_exercises)");
|
|
BOOST_REQUIRE(practice_columns);
|
|
BOOST_TEST(has_value(*practice_columns, 1U, "practice_type_id"));
|
|
BOOST_TEST(has_value(*practice_columns, 1U, "practice_type_title"));
|
|
BOOST_TEST(has_value(*practice_columns, 1U, "lesson_group_id"));
|
|
BOOST_TEST(has_value(*practice_columns, 1U, "lesson_group_title"));
|
|
BOOST_TEST(has_value(*practice_columns, 1U, "catalog_managed"));
|
|
auto tables = query_database(database_path, "SELECT name FROM sqlite_schema WHERE type = 'table'");
|
|
BOOST_REQUIRE(tables);
|
|
BOOST_TEST(!has_value(*tables, 0U, "_noto_schema_migrations"));
|
|
auto version = query_database(database_path, "PRAGMA user_version");
|
|
BOOST_REQUIRE(version);
|
|
BOOST_REQUIRE_EQUAL(version->rows.size(), 1U);
|
|
BOOST_TEST(integer_value(version->rows[0][0]) == 0);
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_rows) {
|
|
auto root = std::filesystem::temp_directory_path() /
|
|
("product-vamp-db-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
auto assets = root / "assets";
|
|
auto database_path = root / "vamp.db";
|
|
auto song_path = assets / "songs" / "tune.json";
|
|
std::filesystem::remove_all(root);
|
|
write_text(song_path, score_asset_json("Catalog Tune"));
|
|
auto config = test_database_config(database_path);
|
|
BOOST_REQUIRE(run_ensure_database(config));
|
|
BOOST_REQUIRE(run_import(config, assets));
|
|
auto first = query_database(database_path, "SELECT title, created_at_ms, updated_at_ms, catalog_managed FROM songs WHERE id = 'tune'");
|
|
BOOST_REQUIRE(first);
|
|
BOOST_REQUIRE_EQUAL(first->rows.size(), 1U);
|
|
auto created = integer_value(first->rows[0][1]);
|
|
auto updated = integer_value(first->rows[0][2]);
|
|
BOOST_TEST(text_value(first->rows[0][0]) == "Catalog Tune");
|
|
BOOST_TEST(integer_value(first->rows[0][3]) == 1);
|
|
BOOST_REQUIRE(run_import(config, assets));
|
|
auto unchanged = query_database(database_path, "SELECT created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'");
|
|
BOOST_REQUIRE(unchanged);
|
|
BOOST_REQUIRE_EQUAL(unchanged->rows.size(), 1U);
|
|
BOOST_TEST(integer_value(unchanged->rows[0][0]) == created);
|
|
BOOST_TEST(integer_value(unchanged->rows[0][1]) == updated);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds{2});
|
|
write_text(song_path, score_asset_json("Catalog Tune Updated"));
|
|
BOOST_REQUIRE(run_import(config, assets));
|
|
auto changed =
|
|
query_database(database_path, "SELECT title, created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'");
|
|
BOOST_REQUIRE(changed);
|
|
BOOST_REQUIRE_EQUAL(changed->rows.size(), 1U);
|
|
BOOST_TEST(text_value(changed->rows[0][0]) == "Catalog Tune Updated");
|
|
BOOST_TEST(integer_value(changed->rows[0][1]) == created);
|
|
BOOST_TEST(integer_value(changed->rows[0][2]) >= updated);
|
|
auto inserted_custom = query_database(database_path,
|
|
"INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, "
|
|
"key_signature, catalog_managed) VALUES('custom', 'Custom Tune', 'Composer', ?1, 1, 1, 'Lead', 'custom.json', 'C', "
|
|
"0)",
|
|
{noto::sqlite_value{score_asset_json("Custom Tune")}});
|
|
BOOST_REQUIRE(inserted_custom);
|
|
write_text(assets / "songs" / "custom.json", score_asset_json("Catalog Collision"));
|
|
BOOST_REQUIRE(run_import(config, assets));
|
|
auto custom = query_database(database_path, "SELECT title, catalog_managed FROM songs WHERE id = 'custom'");
|
|
BOOST_REQUIRE(custom);
|
|
BOOST_REQUIRE_EQUAL(custom->rows.size(), 1U);
|
|
BOOST_TEST(text_value(custom->rows[0][0]) == "Custom Tune");
|
|
BOOST_TEST(integer_value(custom->rows[0][1]) == 0);
|
|
std::filesystem::remove(assets / "songs" / "custom.json");
|
|
std::filesystem::remove(song_path);
|
|
BOOST_REQUIRE(run_import(config, assets));
|
|
auto counts = query_database(database_path, "SELECT sum(CASE WHEN id = 'tune' THEN 1 ELSE 0 END), sum(CASE WHEN id = 'custom' THEN 1 ELSE 0 END) FROM songs");
|
|
BOOST_REQUIRE(counts);
|
|
BOOST_REQUIRE_EQUAL(counts->rows.size(), 1U);
|
|
BOOST_TEST(integer_value(counts->rows[0][0]) == 0);
|
|
BOOST_TEST(integer_value(counts->rows[0][1]) == 1);
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_bundle_preserves_complete_music_without_runtime_flattening) {
|
|
auto source = score_asset_json("Fixture");
|
|
auto parsed = vamp_zero::parse_score_document(source, "fixture.json");
|
|
BOOST_REQUIRE(parsed);
|
|
BOOST_TEST(parsed->title == "Fixture");
|
|
BOOST_TEST(parsed->measure_count == 1U);
|
|
BOOST_TEST(parsed->source_json == source);
|
|
BOOST_TEST(vamp_zero::score_page_count(*parsed) == 1U);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_bundle_rejects_legacy_schema_malformed_manifest_and_unsafe_paths) {
|
|
for (auto text : {"", "null", "[]", "{}", "{", R"({"schema_version":2})"}) {
|
|
BOOST_CHECK_NO_THROW(BOOST_TEST(!vamp_zero::parse_score_document(text)));
|
|
}
|
|
for (auto path : {"../page.png", "/page.png", "pages/../../page.png", "pages/./page.png", "page.jpg", ""}) {
|
|
auto value = boost::json::parse(score_asset_json("Fixture"));
|
|
value.at_pointer("/presentation/pages/0").as_object()["path"] = path;
|
|
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(value)));
|
|
}
|
|
for (auto pointer : {"/music", "/music/metadata", "/music/staves", "/presentation", "/presentation/pages",
|
|
"/presentation/width", "/presentation/pages/0/path", "/presentation/pages/0/sha256"}) {
|
|
auto value = boost::json::parse(score_asset_json("Fixture"));
|
|
value.at_pointer(pointer) = nullptr;
|
|
BOOST_CHECK_NO_THROW(BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(value))));
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_page_validation_reports_missing_assets) {
|
|
auto parsed = vamp_zero::parse_score_document(score_asset_json("Fixture"), "/nonexistent/fixture.json");
|
|
BOOST_REQUIRE(parsed);
|
|
BOOST_TEST(!vamp_zero::validated_page_path(*parsed, 0));
|
|
BOOST_TEST(!vamp_zero::validated_page_path(*parsed, 1));
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_page_validation_checks_integrity_and_symlink_boundaries) {
|
|
auto root = std::filesystem::temp_directory_path() / ("vamp-page-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
auto assets = root / "assets";
|
|
std::filesystem::create_directories(assets);
|
|
auto page = assets / "page.png";
|
|
std::filesystem::copy_file(std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "tests/fixtures/engraving.png", page);
|
|
auto parsed = vamp_zero::parse_score_document(score_asset_json("Fixture"), (assets / "score.json").string());
|
|
BOOST_REQUIRE(parsed);
|
|
parsed->pages[0] = {.path = "page.png", .sha256 = "3bb4d7bd173db4517afed83f512b636f56f053a60e2b08db09106e0698f40284"};
|
|
BOOST_REQUIRE(vamp_zero::validated_page_path(*parsed, 0));
|
|
{
|
|
auto output = std::ofstream{page, std::ios::binary | std::ios::app};
|
|
output << "corrupt";
|
|
}
|
|
BOOST_TEST(!vamp_zero::validated_page_path(*parsed, 0));
|
|
std::filesystem::rename(page, root / "outside.png");
|
|
std::filesystem::create_symlink(root / "outside.png", page);
|
|
BOOST_TEST(!vamp_zero::validated_page_path(*parsed, 0));
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_page_cache_retains_at_most_two_textures) {
|
|
class page_texture final : public luma::texture {
|
|
public:
|
|
auto info() const -> luma::texture_info override {
|
|
return {.width = 976, .height = 484};
|
|
}
|
|
};
|
|
|
|
auto calls = std::size_t{};
|
|
auto weak = std::vector<std::weak_ptr<luma::texture>>{};
|
|
auto cache = vamp_zero::score_page_cache{[&](std::string const &) -> std::expected<std::shared_ptr<luma::texture>, modulus::error> {
|
|
++calls;
|
|
auto texture = std::make_shared<page_texture>();
|
|
weak.push_back(texture);
|
|
return texture;
|
|
}};
|
|
BOOST_REQUIRE(cache.get("one"));
|
|
BOOST_REQUIRE(cache.get("two"));
|
|
BOOST_REQUIRE(cache.get("one"));
|
|
BOOST_TEST(calls == 2U);
|
|
BOOST_REQUIRE(cache.get("three"));
|
|
BOOST_TEST(cache.size() == 2U);
|
|
BOOST_TEST(weak[1].expired());
|
|
BOOST_TEST(!weak[0].expired());
|
|
cache.clear();
|
|
BOOST_TEST(weak[0].expired());
|
|
BOOST_TEST(weak[2].expired());
|
|
}
|