#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); 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,"title_image":"0000000000000000000000000000000000000000000000000000000000000000","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; vamp_test::add_timing(value); 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_catalog, config, assets.string()); } auto score(std::string id) const -> std::expected { return run_catalog_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{std::move(id)}, assets.string()); } }; } BOOST_AUTO_TEST_CASE(catalog_snapshots_heartbeat_and_navigation_share_immutable_metadata) { 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 catalog = std::make_shared(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, }; 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->parts[1]); auto heartbeat = control.heartbeat(); BOOST_TEST(heartbeat.catalog.get() == catalog.get()); BOOST_TEST(heartbeat.input_revision == before.input_revision); 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->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.pico_connected); BOOST_TEST(before.input_revision == 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()); } } BOOST_AUTO_TEST_CASE(catalog_performance_groups_use_title_and_composer_with_part_indices) { 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); 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_individual_score_lookup_is_parameterized_and_reports_missing_or_invalid) { auto db = catalog_database{}; auto song = catalog_bundle("Song", "F"); db.write("songs/shared.json", song); 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(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(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 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{}; for (auto const &part : result->parts) { ids.push_back(part.id); } auto expected = std::vector{"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) { 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{.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, title_image) " "VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2, '0000000000000000000000000000000000000000000000000000000000000000')"); auto songs = db.rows("songs"); for (auto attempt = std::uint32_t{}; attempt < 2U; ++attempt) { auto performance = db.performance(); BOOST_REQUIRE(performance); 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_TEST(db.rows("songs") == songs, boost::test_tools::per_element()); auto song = db.score("tune"); BOOST_REQUIRE(!song); BOOST_TEST(song.error().code() == static_cast(vamp_zero::error_code::INVALID_ARGUMENT)); } 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(); BOOST_REQUIRE(songs); BOOST_TEST(songs->parts.empty()); 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(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")); BOOST_REQUIRE(db.build()); std::filesystem::remove(db.assets / "songs/tune.json"); 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 score = db.score("tune"); BOOST_REQUIRE(songs); 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((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("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("tune")); }