#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { auto performance(std::string id) -> vamp_zero::score_reference { return {.category = vamp_zero::score_category::PERFORMANCE, .id = std::move(id)}; } auto drain(boost::asio::io_context &io) -> void { io.restart(); io.poll(); } struct controlled_scores { explicit controlled_scores(boost::asio::io_context &io) : io{io} {} auto load(vamp_zero::score_reference reference) -> boost::asio::awaitable> { requests.push_back(reference); auto timer = std::make_shared(io, std::chrono::steady_clock::time_point::max()); waits.push_back(timer); maximum_active = std::max(maximum_active, ++active); auto error = boost::system::error_code{}; co_await timer->async_wait(boost::asio::redirect_error(boost::asio::use_awaitable, error)); --active; if (throws) { throw std::runtime_error{"fixture failure"}; } if (fails) { co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "missing fixture score")); } co_return vamp_zero::score_document{.id = reference.id, .source_json = "fixture payload"}; } auto complete(std::size_t index) -> void { waits.at(index)->cancel(); } boost::asio::io_context &io; std::vector requests{}; std::vector> waits{}; std::size_t active{}; std::size_t maximum_active{}; std::bool_t fails{}; std::bool_t throws{}; }; struct loader_fixture { boost::asio::io_context io{}; std::shared_ptr scores{std::make_shared(io)}; std::shared_ptr catalog{std::make_shared()}; std::shared_ptr completions{std::make_shared()}; vamp_zero::score_loader loader{io.get_executor(), [scores = scores](vamp_zero::score_reference reference) { return scores->load(std::move(reference)); }, [completions = completions] { ++*completions; }}; }; } BOOST_AUTO_TEST_CASE(score_loader_starts_asynchronously_from_executor_handler) { auto fixture = loader_fixture{}; boost::asio::post(fixture.io, [&] { fixture.loader.select(fixture.catalog, performance("a")); BOOST_CHECK(fixture.scores->requests.empty()); BOOST_CHECK(fixture.loader.snapshot().loading); }); drain(fixture.io); BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 1U); BOOST_CHECK(!fixture.loader.snapshot().document); fixture.scores->complete(0); drain(fixture.io); auto loaded = fixture.loader.snapshot(); BOOST_REQUIRE(loaded.document); BOOST_CHECK_EQUAL(loaded.document->id, "a"); BOOST_CHECK(!loaded.loading); BOOST_CHECK(!loaded.error); BOOST_CHECK_EQUAL(*fixture.completions, 1U); } BOOST_AUTO_TEST_CASE(score_loader_coalesces_pending_and_discards_stale_results) { auto fixture = loader_fixture{}; fixture.loader.select(fixture.catalog, performance("a")); drain(fixture.io); fixture.loader.select(fixture.catalog, performance("b")); fixture.loader.select(fixture.catalog, performance("c")); drain(fixture.io); BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 1U); fixture.scores->complete(0); drain(fixture.io); BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 2U); BOOST_CHECK_EQUAL(fixture.scores->requests.back().id, "c"); BOOST_CHECK(!fixture.loader.snapshot().document); BOOST_CHECK(fixture.loader.snapshot().loading); BOOST_CHECK_EQUAL(*fixture.completions, 0U); fixture.scores->complete(1); drain(fixture.io); BOOST_REQUIRE(fixture.loader.snapshot().document); BOOST_CHECK_EQUAL(fixture.loader.snapshot().document->id, "c"); BOOST_CHECK_EQUAL(fixture.scores->maximum_active, 1U); } BOOST_AUTO_TEST_CASE(score_loader_coalesces_selections_before_executor_starts) { auto fixture = loader_fixture{}; fixture.loader.select(fixture.catalog, performance("a")); fixture.loader.select(fixture.catalog, performance("b")); fixture.loader.select(fixture.catalog, performance("c")); drain(fixture.io); BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 1U); BOOST_CHECK_EQUAL(fixture.scores->requests.front().id, "c"); fixture.scores->complete(0); drain(fixture.io); BOOST_REQUIRE(fixture.loader.snapshot().document); BOOST_CHECK_EQUAL(fixture.loader.snapshot().document->id, "c"); } BOOST_AUTO_TEST_CASE(score_loader_stale_error_does_not_replace_pending_selection) { auto fixture = loader_fixture{}; fixture.loader.select(fixture.catalog, performance("missing")); drain(fixture.io); fixture.loader.select(fixture.catalog, performance("available")); fixture.scores->fails = true; fixture.scores->complete(0); drain(fixture.io); BOOST_CHECK(!fixture.loader.snapshot().error); BOOST_CHECK(fixture.loader.snapshot().loading); BOOST_CHECK_EQUAL(*fixture.completions, 0U); fixture.scores->fails = false; fixture.scores->complete(1); drain(fixture.io); BOOST_REQUIRE(fixture.loader.snapshot().document); BOOST_CHECK_EQUAL(fixture.loader.snapshot().document->id, "available"); } BOOST_AUTO_TEST_CASE(score_loader_catalogue_identity_and_category_invalidate_selection) { auto fixture = loader_fixture{}; fixture.loader.select(fixture.catalog, performance("same-id")); drain(fixture.io); fixture.catalog = std::make_shared(); fixture.loader.select(fixture.catalog, performance("same-id")); fixture.scores->complete(0); drain(fixture.io); BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 2U); BOOST_CHECK(!fixture.loader.snapshot().document); fixture.scores->complete(1); drain(fixture.io); BOOST_REQUIRE(fixture.loader.snapshot().document); fixture.loader.select(fixture.catalog, vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = "same-id"}); BOOST_CHECK(!fixture.loader.snapshot().document); drain(fixture.io); BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 3U); BOOST_CHECK(fixture.scores->requests.back().category == vamp_zero::score_category::PRACTICE); fixture.scores->complete(2); drain(fixture.io); } BOOST_AUTO_TEST_CASE(score_loader_reuses_active_score_and_releases_on_menu_exit) { auto fixture = loader_fixture{}; fixture.loader.select(fixture.catalog, performance("a")); drain(fixture.io); fixture.scores->complete(0); drain(fixture.io); auto document = std::weak_ptr{fixture.loader.snapshot().document}; fixture.loader.select(fixture.catalog, performance("a")); drain(fixture.io); BOOST_CHECK_EQUAL(fixture.scores->requests.size(), 1U); BOOST_CHECK(document.lock() == fixture.loader.snapshot().document); fixture.loader.select(fixture.catalog, std::nullopt); BOOST_CHECK(document.expired()); BOOST_CHECK(!fixture.loader.snapshot().loading); BOOST_CHECK(!fixture.loader.snapshot().error); fixture.loader.select(fixture.catalog, performance("a")); drain(fixture.io); BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 2U); fixture.scores->complete(1); drain(fixture.io); } BOOST_AUTO_TEST_CASE(score_loader_exit_and_reentry_rejects_previous_generation) { auto fixture = loader_fixture{}; fixture.loader.select(fixture.catalog, performance("a")); drain(fixture.io); fixture.loader.select(fixture.catalog, std::nullopt); fixture.loader.select(fixture.catalog, performance("a")); fixture.scores->complete(0); drain(fixture.io); BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 2U); BOOST_CHECK(!fixture.loader.snapshot().document); BOOST_CHECK_EQUAL(*fixture.completions, 0U); fixture.loader.select(fixture.catalog, std::nullopt); fixture.scores->complete(1); drain(fixture.io); BOOST_CHECK(!fixture.loader.snapshot().document); BOOST_CHECK(!fixture.loader.snapshot().loading); BOOST_CHECK_EQUAL(*fixture.completions, 0U); } BOOST_AUTO_TEST_CASE(score_loader_errors_persist_without_repeated_loads_and_allow_reselection) { auto fixture = loader_fixture{}; fixture.scores->fails = true; fixture.loader.select(fixture.catalog, performance("missing")); drain(fixture.io); fixture.scores->complete(0); drain(fixture.io); auto loaded = fixture.loader.snapshot(); BOOST_REQUIRE(loaded.error); BOOST_CHECK(loaded.error->describe().find("missing fixture score") != std::string::npos); BOOST_CHECK(!loaded.loading); BOOST_CHECK(!loaded.document); fixture.loader.select(fixture.catalog, performance("missing")); drain(fixture.io); BOOST_CHECK_EQUAL(fixture.scores->requests.size(), 1U); fixture.loader.select(fixture.catalog, std::nullopt); fixture.scores->fails = false; fixture.loader.select(fixture.catalog, performance("missing")); drain(fixture.io); fixture.scores->complete(1); drain(fixture.io); BOOST_REQUIRE(fixture.loader.snapshot().document); BOOST_CHECK(!fixture.loader.snapshot().error); } BOOST_AUTO_TEST_CASE(score_loader_converts_coroutine_exceptions_to_error_state) { auto fixture = loader_fixture{}; fixture.scores->throws = true; fixture.loader.select(fixture.catalog, performance("a")); drain(fixture.io); fixture.scores->complete(0); drain(fixture.io); BOOST_CHECK(fixture.loader.snapshot().error.has_value()); BOOST_CHECK(!fixture.loader.snapshot().loading); BOOST_CHECK_EQUAL(*fixture.completions, 1U); } BOOST_AUTO_TEST_CASE(score_loader_stop_discards_inflight_and_pending_loads) { auto fixture = loader_fixture{}; fixture.loader.select(fixture.catalog, performance("a")); drain(fixture.io); fixture.loader.select(fixture.catalog, performance("b")); fixture.loader.stop(); fixture.scores->complete(0); fixture.loader.select(fixture.catalog, performance("c")); drain(fixture.io); BOOST_CHECK_EQUAL(fixture.scores->requests.size(), 1U); BOOST_CHECK_EQUAL(*fixture.completions, 0U); BOOST_CHECK(!fixture.loader.snapshot().document); BOOST_CHECK(!fixture.loader.snapshot().loading); } BOOST_AUTO_TEST_CASE(score_loader_stop_before_executor_runs_does_not_start_load) { auto fixture = loader_fixture{}; fixture.loader.select(fixture.catalog, performance("a")); fixture.loader.stop(); drain(fixture.io); BOOST_CHECK(fixture.scores->requests.empty()); BOOST_CHECK_EQUAL(*fixture.completions, 0U); } BOOST_AUTO_TEST_CASE(score_loader_destruction_during_load_does_not_call_consumer) { auto io = boost::asio::io_context{}; auto scores = std::make_shared(io); auto completions = std::size_t{}; { auto loader = vamp_zero::score_loader{io.get_executor(), [scores](vamp_zero::score_reference reference) { return scores->load(std::move(reference)); }, [&] { ++completions; }}; loader.select(std::make_shared(), performance("a")); drain(io); BOOST_REQUIRE_EQUAL(scores->requests.size(), 1U); } scores->complete(0); drain(io); BOOST_CHECK_EQUAL(completions, 0U); } BOOST_AUTO_TEST_CASE(score_loader_completion_can_overlap_stop_and_consumer_destruction) { auto io = boost::asio::io_context{}; auto entered = std::promise{}; auto entered_future = entered.get_future(); auto resume = std::promise{}; auto resume_future = resume.get_future().share(); auto consumer = std::make_shared>(0U); auto loader = std::make_unique(io.get_executor(), [](vamp_zero::score_reference reference) -> boost::asio::awaitable> { co_return vamp_zero::score_document{.id = reference.id}; }, [weak = std::weak_ptr{consumer}, &entered, resume_future] { entered.set_value(); resume_future.wait(); if (auto target = weak.lock()) { ++*target; } }); loader->select(std::make_shared(), performance("a")); auto worker = std::thread{[&] { io.run(); }}; auto entered_status = entered_future.wait_for(std::chrono::seconds{5}); if (entered_status == std::future_status::ready) { auto document = std::weak_ptr{loader->snapshot().document}; loader->stop(); BOOST_CHECK(document.expired()); loader.reset(); consumer.reset(); } resume.set_value(); worker.join(); BOOST_CHECK(entered_status == std::future_status::ready); } BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_variant) { auto catalog = std::make_shared(); catalog->performance.parts = {{.id = "part-a"}, {.id = "part-b"}}; catalog->practice.exercises = {{.id = "scale", .practice_type_id = "melody", .variants = {{.id = "scale-c"}, {.id = "scale-d"}}}}; auto state = vamp_zero::app_state{.catalog = catalog}; BOOST_CHECK(!vamp_zero::selected_score_reference(state)); state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE; state.active_part_id = "part-b"; auto reference = vamp_zero::selected_score_reference(state); BOOST_REQUIRE(reference); BOOST_CHECK_EQUAL(reference->id, "part-b"); BOOST_CHECK(reference->category == vamp_zero::score_category::PERFORMANCE); state.mode = vamp_zero::app_mode::PRACTICE_SCORE; state.active_practice_id = "scale"; state.selected_practice_key_index = 2; reference = vamp_zero::selected_score_reference(state); BOOST_REQUIRE(reference); BOOST_CHECK_EQUAL(reference->id, "scale-d"); BOOST_CHECK(reference->category == vamp_zero::score_category::PRACTICE); state.selected_practice_key_index = 0; BOOST_CHECK(!vamp_zero::selected_score_reference(state)); catalog->practice.exercises.front().practice_type_id = "guitar"; reference = vamp_zero::selected_score_reference(state); BOOST_REQUIRE(reference); BOOST_CHECK_EQUAL(reference->id, "scale-c"); state.catalog.reset(); BOOST_CHECK(!vamp_zero::selected_score_reference(state)); }