#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 "catalog_fixture.hpp" namespace { template concept retains_score_json = requires(T value) { value.source_json; }; static_assert(!retains_score_json); static_assert(!retains_score_json); static_assert(!retains_score_json); struct catalog_directory { std::filesystem::path path; catalog_directory() { auto pattern = (std::filesystem::temp_directory_path() / "vamp-catalog-XXXXXX").string(); BOOST_REQUIRE(::mkdtemp(pattern.data()) != nullptr); path = pattern; } ~catalog_directory() { auto ignored = std::error_code{}; std::filesystem::remove_all(path, ignored); } }; template auto run_catalog_operation(Function function, Arguments... arguments) { auto io = boost::asio::io_context{}; auto future = boost::asio::co_spawn(io, function(io.get_executor(), std::move(arguments)...), boost::asio::use_future); io.run(); return future.get(); } auto catalog_query(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string sql, std::vector params) -> boost::asio::awaitable> { auto connection = noto::connection{executor, noto::file_connection_config(config.path, "vamp-catalog-test")}; auto connected = co_await connection.async_connect(); if (!connected) { co_return std::unexpected(connected.error()); } auto result = co_await connection.async_execute(std::move(sql), std::move(params)); auto closed = co_await connection.async_close(); if (!result) { co_return std::unexpected(result.error()); } if (!closed) { co_return std::unexpected(closed.error()); } co_return result; } auto catalog_bundle(std::string_view title, std::string_view key = "C", std::string_view part = "Lead") -> std::string { auto value = boost::json::parse(R"json({ "music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"}, "staves":[{"name":"Lead","measures":[{"voices":[]},{"voices":[]},{"voices":[]}]}]}, "presentation":{"width":976,"height":484,"pages":[ {"path":"fixture.pages/1.png","sha256":"0000000000000000000000000000000000000000000000000000000000000000"}, {"path":"fixture.pages/2.png","sha256":"1111111111111111111111111111111111111111111111111111111111111111"}]} })json"); auto &metadata = value.at("music").at("metadata").as_object(); metadata["title"] = title; metadata["key_signature"] = key; value.at("music").at("staves").as_array().front().as_object()["name"] = part; return boost::json::serialize(value); } struct catalog_database { catalog_directory directory; std::filesystem::path assets{directory.path / "assets"}; vamp_zero::database_config config{.path = (directory.path / "catalog.db").string()}; catalog_database() { std::filesystem::create_directories(assets / "songs"); } auto query(std::string sql, std::vector params = {}) const -> noto::query_result { auto result = run_catalog_operation(catalog_query, config, std::move(sql), std::move(params)); BOOST_REQUIRE_MESSAGE(result, (result ? "" : result.error().describe())); return std::move(*result); } auto rows(std::string const &table, std::string const &where = "") const -> std::vector { auto result = query("SELECT json_array(id, title, composer, key_signature, source_path, source_json, " "measure_count, total_pages) FROM " + table + where + " ORDER BY id"); auto rows = std::vector{}; for (auto const &row : result.rows) { rows.push_back(std::get(row[0].value)); } return rows; } auto write(std::filesystem::path const &relative, std::string const &text) const -> void { auto path = assets / relative; std::filesystem::create_directories(path.parent_path()); auto output = std::ofstream{path, std::ios::binary | std::ios::trunc}; output << text; output.close(); BOOST_REQUIRE(output.good()); } auto build() const -> std::bool_t { return vamp_test::build_catalog(assets, config.path); } auto performance() const -> std::expected { return run_catalog_operation(vamp_zero::async_load_performance_library, config, assets.string()); } auto practice() const -> std::expected { 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 { return run_catalog_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{category, std::move(id)}, assets.string()); } }; auto catalog_exercise(std::string authored = "Eb", std::string id = "001") -> 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({ {.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", "002"), catalog_exercise("Eb", "001")}); BOOST_REQUIRE(practice); auto catalog = std::make_shared(vamp_zero::catalog{std::move(performance), std::move(*practice)}); 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{}; 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]); 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(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); BOOST_TEST(control.snapshot().catalog.get() == catalog.get()); BOOST_TEST(before.heartbeat_count == 0U); BOOST_TEST(before.select_count == 0U); BOOST_TEST(before.active_part_id.empty()); BOOST_TEST(original.catalog.get() == catalog.get()); BOOST_REQUIRE(!observed.empty()); 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"}, {.id = "other-title", .title = "Other Title", .composer = "Ada", .part = "Lead"}, {.id = "guitar", .title = "Shared Title", .composer = "Ada", .part = "Guitar"}, }); BOOST_REQUIRE_EQUAL(library.parts.size(), 4U); BOOST_REQUIRE_EQUAL(library.groups.size(), 3U); BOOST_TEST(library.groups[0].title == "Other Title"); BOOST_TEST(library.groups[1].title == "Shared Title"); BOOST_TEST(library.groups[1].composer == "Ada"); BOOST_TEST(library.groups[2].composer == "Bea"); BOOST_REQUIRE_EQUAL(library.groups[1].parts.size(), 2U); auto seen = std::vector(library.parts.size()); for (auto const &group : library.groups) { for (auto index : group.parts) { BOOST_REQUIRE_LT(index, library.parts.size()); auto const &part = library.parts[index]; BOOST_TEST(part.title == group.title); BOOST_TEST(part.composer == group.composer); ++seen[index]; } } for (auto count : seen) { BOOST_TEST(count == 1U); } BOOST_TEST(library.parts[library.groups[1].parts[0]].id == "guitar"); 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", "002"), first, fragment}); BOOST_REQUIRE(grouped); BOOST_REQUIRE_EQUAL(grouped->exercises.size(), 2U); auto const &exercise = grouped->exercises[0]; BOOST_TEST(exercise.id == "001"); BOOST_TEST(exercise.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(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) { 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/001"} / vamp_test::part_filename(part), catalog_bundle("Exercise", "D", part)); } 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/001/electric.json")).string()); BOOST_TEST(document->measure_count == 3U); BOOST_REQUIRE_EQUAL(document->pages.size(), 2U); BOOST_TEST(document->pages[1].path == "fixture.pages/2.png"); for (auto id : {"missing", "shared' OR 1=1 --"}) { auto missing = db.score(category, id); BOOST_REQUIRE(!missing); BOOST_TEST(missing.error().code() == static_cast(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(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}}); } } BOOST_AUTO_TEST_CASE(catalog_sequential_score_loads_keep_executor_stack_bounded) { auto db = catalog_database{}; db.write("songs/repeated.json", catalog_bundle("Repeated")); BOOST_REQUIRE(db.build()); auto repeated_loads = [](boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets) -> boost::asio::awaitable> { auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .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) { co_return std::unexpected(score.error()); } } co_return 4096U; }; auto result = run_catalog_operation(repeated_loads, db.config, db.assets.string()); BOOST_REQUIRE_MESSAGE(result, (result ? "" : result.error().describe())); BOOST_TEST(*result == 4096U); } BOOST_AUTO_TEST_CASE(catalog_reload_uses_cached_metadata_without_parsing_score_json) { auto db = catalog_database{}; BOOST_REQUIRE(db.build()); // Complete cached metadata must not require parsing or retaining the score body. db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, " "key_signature, measure_count, total_pages) " "VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2)"); for (auto part_name : vamp_zero::practice_part_names()) { db.query("INSERT INTO practice_exercises(id, exercise_id, " "title, composer, key_signature, source_json, source_path, measure_count, total_pages, part) " "VALUES(?1, '001', 'Metadata Exercise', 'Composer', 'Eb', '{}', ?3, 3, 2, ?2)", {noto::sqlite_value{std::string{"practice_part-"} + std::string{part_name}}, noto::sqlite_value{std::string{part_name}}, noto::sqlite_value{std::string{"practice/001/"} + std::string{part_name} + ".json"}}); } auto songs = db.rows("songs"); auto exercises = db.rows("practice_exercises"); 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"); BOOST_REQUIRE(!song); BOOST_REQUIRE(!exercise); BOOST_TEST(song.error().code() == static_cast(vamp_zero::error_code::INVALID_ARGUMENT)); BOOST_TEST(exercise.error().code() == static_cast(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) { auto db = catalog_database{}; auto validate = [&] { return run_catalog_operation(vamp_zero::async_validate_database, db.config, true); }; BOOST_TEST(!validate()); BOOST_TEST(!std::filesystem::exists(db.config.path)); 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"}) { db.query(sql); auto snapshot_sql = "SELECT json_group_array(sql) FROM (SELECT sql FROM sqlite_schema ORDER BY name)"; auto before = std::get(db.query(snapshot_sql).rows[0][0].value); auto result = validate(); BOOST_REQUIRE(!result); BOOST_TEST(result.error().message().find("reinstall") != std::string_view::npos); BOOST_TEST(std::get(db.query(snapshot_sql).rows[0][0].value) == before); BOOST_REQUIRE(db.build()); } db.query("PRAGMA application_id = 0"); BOOST_TEST(!validate()); auto output = std::ofstream{db.config.path, std::ios::trunc}; output << "not a database"; output.close(); BOOST_TEST(!validate()); BOOST_TEST(!std::filesystem::exists(db.config.path + "-wal")); BOOST_TEST(!std::filesystem::exists(db.config.path + "-journal")); } BOOST_AUTO_TEST_CASE(catalog_validation_checks_column_types_constraints_and_table_kind) { auto db = catalog_database{}; auto validate = [&] { return run_catalog_operation(vamp_zero::async_validate_database, db.config, false); }; for (auto change : {std::pair{"title TEXT NOT NULL", "title BLOB NOT NULL"}, {"title TEXT NOT NULL", "title TEXT"}, {"PRIMARY KEY", ""}, {" STRICT", ""}}) { BOOST_REQUIRE(db.build()); auto sql = std::get(db.query("SELECT sql FROM sqlite_schema WHERE name = 'songs'").rows[0][0].value); auto position = sql.find(change.first); BOOST_REQUIRE(position != std::string::npos); sql.replace(position, std::string_view{change.first}.size(), change.second); db.query("DROP TABLE songs"); db.query(sql); auto timestamp = std::filesystem::last_write_time(db.config.path); BOOST_TEST(!validate()); BOOST_TEST((std::filesystem::last_write_time(db.config.path) == timestamp)); } BOOST_REQUIRE(db.build()); db.query("ALTER TABLE songs RENAME TO underlying_songs"); db.query("CREATE VIEW songs AS SELECT * FROM underlying_songs"); BOOST_TEST(!validate()); } 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/001"} / 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; auto timestamp = std::filesystem::last_write_time(db.config.path); 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"); 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")); BOOST_TEST(!std::filesystem::exists(db.config.path + "-journal")); } BOOST_AUTO_TEST_CASE(catalog_rejects_paths_outside_the_asset_root) { auto db = catalog_database{}; db.write("songs/tune.json", catalog_bundle("Fixture")); BOOST_REQUIRE(db.build()); for (auto path : {"/tmp/tune.json", "../tune.json", "songs/../../tune.json", "songs/./tune.json", "songs\\tune.json", ""}) { if (std::string_view{path}.empty()) { continue; } 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")); } 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")); }