#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 { 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 { 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 params = {}) -> std::expected { 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> { 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(&value.value); return text == nullptr ? std::string{} : *text; } auto integer_value(noto::sqlite_value const &value) -> std::int64_t { auto integer = std::get_if(&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", .measure_count = 1, .total_pages = 2, }; auto variant = vamp_zero::practice_exercise::score_variant{ .id = "exercise-c", .key_signature = "C", .source_path = "exercise.json", .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", .authored_key_signature = "C", .variants = {variant}, }; auto practice = vamp_zero::group_practice_exercises({exercise}); return vamp_zero::app_state{ .catalog = std::make_shared(vamp_zero::catalogue{ .performance = vamp_zero::group_library_parts({part}), .practice = std::move(*practice), }), }; } 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(state.mode) == static_cast(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(state.mode) == static_cast(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(state.mode) == static_cast(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(state.mode) == static_cast(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(state.mode) == static_cast(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{input}, std::istreambuf_iterator{}}; 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 catalog = std::make_shared(*state.catalog); auto &exercise = catalog->practice.exercises.front(); exercise.practice_type_id = type; auto variant = exercise.variants.front(); variant.id = "exercise-d"; variant.key_signature = "D"; variant.source_path = "exercise-d.json"; variant.total_pages = 3U; exercise.variants.push_back(variant); catalog->practice.types.front().id = type; state.catalog = catalog; state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; state.selected_practice_type_index = 1U; state.selected_practice_lesson_index = 1U; state.selected_practice_exercise_index = 1U; state.audio.metronome_enabled = true; BOOST_TEST(vamp_zero::supports_key_selection(exercise)); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU); BOOST_TEST(vamp_zero::practice_key_row_count(state) == 3U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_SCORE); BOOST_TEST(state.audio.metronome_enabled); auto selected = vamp_zero::selected_practice_variant(state, state.catalog->practice.exercises.front()); BOOST_REQUIRE(selected != nullptr); BOOST_TEST(selected->id == "exercise-d"); BOOST_TEST(selected->source_path == "exercise-d.json"); BOOST_TEST(vamp_zero::active_pages(state) == 3U); auto remote = vamp_pico::remote_display_state{}; BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote)); BOOST_TEST(remote.active); BOOST_TEST(std::string{remote.part} == "Key: D"); BOOST_TEST(remote.pages == 3U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); BOOST_TEST(state.page_index == 1U); 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"); auto expected_exercises = std::size_t{}; for (auto const &entry : std::filesystem::recursive_directory_iterator{std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "data/practice"}) { if (entry.is_regular_file() && entry.path().extension() == ".xml") { ++expected_exercises; } } BOOST_REQUIRE(expected_exercises > 0U); BOOST_REQUIRE(run_ensure_database(config)); for (auto iteration = std::uint32_t{}; iteration < 2U; ++iteration) { BOOST_REQUIRE(run_import(config, VAMP_TEST_ASSETS_DIR)); 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_MESSAGE(library.has_value(), (library ? "" : library.error().describe())); BOOST_TEST(library->exercises.size() == expected_exercises); for (auto const &exercise : library->exercises) { BOOST_TEST(vamp_zero::supports_key_selection(exercise)); BOOST_REQUIRE_EQUAL(exercise.variants.size(), 12U); auto keys = vamp_zero::practice_key_names(); for (auto index = std::size_t{}; index < keys.size(); ++index) { auto const &key = exercise.variants[index].key_signature; BOOST_TEST(key.substr(0, key.find(' ')) == keys[index]); BOOST_TEST(exercise.variants[index].total_pages > 0U); BOOST_TEST(exercise.variants[index].measure_count > 0U); } } } std::filesystem::remove_all(root); } BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_refresh_after_rename) { auto root = std::filesystem::temp_directory_path() / ("product-vamp-paths-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); auto assets = root / "assets"; auto config = test_database_config(root / "vamp.db"); std::filesystem::create_directories(assets / "songs"); for (auto name : {"chord/001_lesson/010-cycles", "chord/001_lesson/002-cycles", "chord/002_lesson/001-cycles", "melody/001_lesson/127-cycles"}) { write_text(assets / "practice" / name / "c.json", score_asset_json("Shared Title")); } auto library = vamp_zero::load_practice_library(assets.string()); BOOST_REQUIRE(library); BOOST_REQUIRE_EQUAL(library->exercises.size(), 4U); BOOST_REQUIRE_EQUAL(library->types.size(), 2U); BOOST_TEST(library->types.front().id == "chord"); BOOST_REQUIRE_EQUAL(library->types.front().lesson_groups.size(), 2U); auto const &lesson = library->types.front().lesson_groups.front(); BOOST_TEST(lesson.id == "001_lesson"); BOOST_REQUIRE_EQUAL(lesson.exercises.size(), 2U); BOOST_TEST(library->exercises[lesson.exercises[0]].id == "chord-001_lesson-002-cycles"); BOOST_TEST(library->exercises[lesson.exercises[1]].id == "chord-001_lesson-010-cycles"); for (auto const &exercise : library->exercises) { BOOST_TEST(exercise.title == "Shared Title"); } BOOST_REQUIRE(run_ensure_database(config)); BOOST_REQUIRE(run_import(config, assets)); std::filesystem::rename(assets / "practice/chord/001_lesson", assets / "practice/chord/003_renamed_lesson"); std::filesystem::rename(assets / "practice/chord/003_renamed_lesson/002-cycles", assets / "practice/chord/003_renamed_lesson/099-renamed"); BOOST_REQUIRE(run_import(config, assets)); auto rows = query_database(root / "vamp.db", "SELECT id FROM practice_exercises ORDER BY id"); BOOST_REQUIRE(rows); BOOST_REQUIRE_EQUAL(rows->rows.size(), 4U); BOOST_TEST(!has_value(*rows, 0U, "chord-001_lesson-002-cycles-c")); BOOST_TEST(!has_value(*rows, 0U, "chord-001_lesson-010-cycles-c")); BOOST_TEST(has_value(*rows, 0U, "chord-003_renamed_lesson-099-renamed-c")); BOOST_TEST(has_value(*rows, 0U, "chord-003_renamed_lesson-010-cycles-c")); BOOST_TEST(has_value(*rows, 0U, "chord-002_lesson-001-cycles-c")); BOOST_TEST(has_value(*rows, 0U, "melody-001_lesson-127-cycles-c")); std::filesystem::remove_all(root); } BOOST_AUTO_TEST_CASE(catalogue_queries_handle_empty_results_and_invalid_stored_scores) { auto root = std::filesystem::temp_directory_path() / ("product-vamp-query-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); auto config = test_database_config(root / "vamp.db"); BOOST_REQUIRE(run_ensure_database(config)); for (auto invalid_rows : {false, true}) { if (invalid_rows) { for (auto table : {"songs", "practice_exercises"}) { BOOST_REQUIRE(query_database(root / "vamp.db", std::string{"INSERT INTO "} + table + "(id, title, source_json, created_at_ms, updated_at_ms) VALUES('invalid', 'Invalid', '{}', 0, 0)")); } } auto io = boost::asio::io_context{}; auto performance_future = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config), boost::asio::use_future); auto practice_future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config), boost::asio::use_future); io.run(); auto performance = performance_future.get(); auto practice = practice_future.get(); if (invalid_rows) { BOOST_REQUIRE(!performance); BOOST_REQUIRE(!practice); auto invalid_score = vamp_zero::parse_score_document("{}", ""); BOOST_REQUIRE(!invalid_score); BOOST_TEST(performance.error().message() == invalid_score.error().message()); BOOST_TEST(practice.error().message() == invalid_score.error().message()); } else { BOOST_REQUIRE(performance); BOOST_REQUIRE(practice); BOOST_TEST(performance->parts.empty()); BOOST_TEST(practice->exercises.empty()); } } std::filesystem::remove_all(root); } BOOST_AUTO_TEST_CASE(keyless_practice_variants_display_their_transposition_reference) { auto source = boost::json::parse(score_asset_json("Keyless", "No key")); auto &music = source.as_object()["music"].as_object(); 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("65535", "port").value_or(0) == 65535U); BOOST_TEST(vamp_zero::parse_integer("-2147483648", "octave").value_or(0) == std::numeric_limits::min()); BOOST_TEST(vamp_zero::parse_integer("18446744073709551615", "page").value_or(0) == std::numeric_limits::max()); for (auto text : {"", "-1", "+1", " 1", "1 ", "123x", "1.5", "65536", "18446744073709551616"}) { auto parsed = vamp_zero::parse_integer(text, "port"); BOOST_REQUIRE(!parsed.has_value()); BOOST_TEST(parsed.error().code() == static_cast(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(vamp_zero::error_code::UNAVAILABLE)); BOOST_TEST(practice.error().code() == static_cast(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(vamp_zero::error_code::UNAVAILABLE)); BOOST_TEST(practice.error().code() == static_cast(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>{}; auto cache = vamp_zero::score_page_cache{[&](std::string const &) -> std::expected, modulus::error> { ++calls; auto texture = std::make_shared(); 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()); }