Shared Catalog
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
add_executable(product-vamp-test
|
||||
../src/vamp_pico/http_client.cpp
|
||||
../src/vamp_pico/input.cpp
|
||||
../src/vamp_pico/remote_display.cpp
|
||||
test.cpp
|
||||
test_catalogue_runtime.cpp
|
||||
test_health.cpp
|
||||
test_pico.cpp
|
||||
test_score_loader.cpp
|
||||
)
|
||||
target_include_directories(product-vamp-test PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/fixtures/pico_sdk
|
||||
)
|
||||
target_link_libraries(product-vamp-test PRIVATE
|
||||
vamp-zero-core
|
||||
)
|
||||
target_compile_features(product-vamp-test PRIVATE cxx_std_23)
|
||||
target_compile_definitions(product-vamp-test PRIVATE
|
||||
VAMP_ZERO_HOST="192.168.42.1"
|
||||
VAMP_ZERO_PORT=8080
|
||||
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
|
||||
)
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <modulus/types.hpp>
|
||||
|
||||
|
||||
struct ip_addr_t {
|
||||
std::uint32_t addr{};
|
||||
};
|
||||
|
||||
constexpr auto IPADDR_TYPE_V4 = std::uint8_t{0};
|
||||
|
||||
auto ipaddr_aton(std::char_t const *text, ip_addr_t *address) -> std::int32_t;
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <modulus/types.hpp>
|
||||
|
||||
|
||||
struct pbuf {
|
||||
pbuf *next{};
|
||||
void const *payload{};
|
||||
std::uint16_t tot_len{};
|
||||
std::uint16_t len{};
|
||||
std::bool_t freed{};
|
||||
};
|
||||
|
||||
auto pbuf_copy_partial(pbuf const *buffer, void *destination, std::uint16_t length, std::uint16_t offset) -> std::uint16_t;
|
||||
auto pbuf_free(pbuf *buffer) -> std::uint8_t;
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <lwip/ip_addr.h>
|
||||
#include <lwip/pbuf.h>
|
||||
|
||||
constexpr auto ERR_OK = std::int8_t{0};
|
||||
constexpr auto ERR_MEM = std::int8_t{-1};
|
||||
constexpr auto ERR_ABRT = std::int8_t{-13};
|
||||
constexpr auto ERR_RST = std::int8_t{-14};
|
||||
constexpr auto TCP_WRITE_FLAG_COPY = std::uint8_t{1};
|
||||
|
||||
struct tcp_pcb {
|
||||
void *argument{};
|
||||
std::int8_t (*receive)(void *, tcp_pcb *, pbuf *, std::int8_t){};
|
||||
std::int8_t (*sent)(void *, tcp_pcb *, std::uint16_t){};
|
||||
std::int8_t (*poll)(void *, tcp_pcb *){};
|
||||
void (*error)(void *, std::int8_t){};
|
||||
std::int8_t (*connected)(void *, tcp_pcb *, std::int8_t){};
|
||||
std::bool_t alive{};
|
||||
};
|
||||
|
||||
auto tcp_new_ip_type(std::uint8_t type) -> tcp_pcb *;
|
||||
auto tcp_arg(tcp_pcb *pcb, void *argument) -> void;
|
||||
auto tcp_recv(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *, pbuf *, std::int8_t)) -> void;
|
||||
auto tcp_sent(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *, std::uint16_t)) -> void;
|
||||
auto tcp_poll(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *), std::uint8_t interval) -> void;
|
||||
auto tcp_err(tcp_pcb *pcb, void (*callback)(void *, std::int8_t)) -> void;
|
||||
auto tcp_connect(tcp_pcb *pcb, ip_addr_t const *address, std::uint16_t port, std::int8_t (*callback)(void *, tcp_pcb *, std::int8_t)) -> std::int8_t;
|
||||
auto tcp_write(tcp_pcb *pcb, void const *data, std::uint16_t size, std::uint8_t flags) -> std::int8_t;
|
||||
auto tcp_output(tcp_pcb *pcb) -> std::int8_t;
|
||||
auto tcp_recved(tcp_pcb *pcb, std::uint16_t size) -> void;
|
||||
auto tcp_close(tcp_pcb *pcb) -> std::int8_t;
|
||||
auto tcp_abort(tcp_pcb *pcb) -> void;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
|
||||
auto cyw43_arch_lwip_begin() -> void;
|
||||
auto cyw43_arch_lwip_end() -> void;
|
||||
+15
-37
@@ -130,7 +130,6 @@ auto navigation_state() -> vamp_zero::app_state {
|
||||
.key_signature = "C",
|
||||
.part = "Lead",
|
||||
.source_path = "navigation.json",
|
||||
.source_json = score_asset_json("Navigation Tune"),
|
||||
.measure_count = 1,
|
||||
.total_pages = 2,
|
||||
};
|
||||
@@ -138,7 +137,6 @@ auto navigation_state() -> vamp_zero::app_state {
|
||||
.id = "exercise-c",
|
||||
.key_signature = "C",
|
||||
.source_path = "exercise.json",
|
||||
.source_json = score_asset_json("Exercise"),
|
||||
.measure_count = 1,
|
||||
.total_pages = 1,
|
||||
};
|
||||
@@ -150,36 +148,15 @@ auto navigation_state() -> vamp_zero::app_state {
|
||||
.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,
|
||||
.authored_key_signature = "C",
|
||||
.variants = {variant},
|
||||
};
|
||||
auto practice = vamp_zero::group_practice_exercises({exercise});
|
||||
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},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
.catalog = std::make_shared<vamp_zero::catalogue const>(vamp_zero::catalogue{
|
||||
.performance = vamp_zero::group_library_parts({part}),
|
||||
.practice = std::move(*practice),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -325,17 +302,17 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) {
|
||||
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();
|
||||
auto catalog = std::make_shared<vamp_zero::catalogue>(*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.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};
|
||||
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;
|
||||
@@ -350,7 +327,7 @@ BOOST_AUTO_TEST_CASE(chord_and_melody_practice_share_key_navigation_and_remote_d
|
||||
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());
|
||||
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");
|
||||
@@ -400,7 +377,8 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
|
||||
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_REQUIRE(vamp_zero::parse_score_document(exercise.variants[index].source_json, exercise.variants[index].source_path));
|
||||
BOOST_TEST(exercise.variants[index].total_pages > 0U);
|
||||
BOOST_TEST(exercise.variants[index].measure_count > 0U);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,8 +402,8 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
|
||||
auto const &lesson = library->types.front().lesson_groups.front();
|
||||
BOOST_TEST(lesson.id == "001_lesson");
|
||||
BOOST_REQUIRE_EQUAL(lesson.exercises.size(), 2U);
|
||||
BOOST_TEST(lesson.exercises[0].id == "chord-001_lesson-002-cycles");
|
||||
BOOST_TEST(lesson.exercises[1].id == "chord-001_lesson-010-cycles");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#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.hpp>
|
||||
|
||||
#include <noto.hpp>
|
||||
|
||||
#include <vamp_zero/database.hpp>
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
template <typename T>
|
||||
concept retains_score_json = requires(T value) { value.source_json; };
|
||||
|
||||
static_assert(!retains_score_json<vamp_zero::library_part>);
|
||||
static_assert(!retains_score_json<vamp_zero::practice_exercise>);
|
||||
static_assert(!retains_score_json<vamp_zero::practice_exercise::score_variant>);
|
||||
|
||||
struct catalogue_directory {
|
||||
std::filesystem::path path;
|
||||
catalogue_directory() {
|
||||
auto pattern = (std::filesystem::temp_directory_path() / "vamp-catalogue-XXXXXX").string();
|
||||
BOOST_REQUIRE(::mkdtemp(pattern.data()) != nullptr);
|
||||
path = pattern;
|
||||
}
|
||||
~catalogue_directory() {
|
||||
auto ignored = std::error_code{};
|
||||
std::filesystem::remove_all(path, ignored);
|
||||
}
|
||||
};
|
||||
|
||||
template <typename Function, typename... Arguments>
|
||||
auto run_catalogue_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 catalogue_query(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string sql, std::vector<noto::sqlite_value> params)
|
||||
-> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
auto connection = noto::connection{executor, noto::file_connection_config(config.path, "vamp-catalogue-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 catalogue_bundle(std::string_view title, std::string_view key = "C", std::optional<std::string_view> authored = std::nullopt) -> 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":[{"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;
|
||||
if (authored) {
|
||||
metadata["authored_key_signature"] = *authored;
|
||||
}
|
||||
return boost::json::serialize(value);
|
||||
}
|
||||
|
||||
struct catalogue_database {
|
||||
catalogue_directory directory;
|
||||
std::filesystem::path assets{directory.path / "assets"};
|
||||
vamp_zero::database_config config{.path = (directory.path / "catalogue.db").string()};
|
||||
catalogue_database() {
|
||||
std::filesystem::create_directories(assets / "songs");
|
||||
}
|
||||
auto query(std::string sql, std::vector<noto::sqlite_value> params = {}) const -> noto::query_result {
|
||||
auto result = run_catalogue_operation(catalogue_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<std::string> {
|
||||
auto result = query("SELECT json_array(id, title, composer, key_signature, source_path, source_json, catalog_managed, "
|
||||
"measure_count, total_pages, authored_key_signature, created_at_ms, updated_at_ms) FROM " +
|
||||
table + where + " ORDER BY id");
|
||||
auto rows = std::vector<std::string>{};
|
||||
for (auto const &row : result.rows) {
|
||||
rows.push_back(std::get<std::string>(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 ensure() const -> std::expected<void, modulus::error> {
|
||||
return run_catalogue_operation(vamp_zero::async_ensure_database, config);
|
||||
}
|
||||
auto import_assets() const -> std::expected<void, modulus::error> {
|
||||
return run_catalogue_operation(vamp_zero::async_import_catalogs, config, assets.string());
|
||||
}
|
||||
auto performance() const -> std::expected<vamp_zero::library_state, modulus::error> {
|
||||
return run_catalogue_operation(vamp_zero::async_load_performance_library, config);
|
||||
}
|
||||
auto practice() const -> std::expected<vamp_zero::practice_library, modulus::error> {
|
||||
return run_catalogue_operation(vamp_zero::async_load_practice_library, config);
|
||||
}
|
||||
auto score(vamp_zero::score_category category, std::string id) const -> std::expected<vamp_zero::score_document, modulus::error> {
|
||||
return run_catalogue_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{category, std::move(id)});
|
||||
}
|
||||
};
|
||||
|
||||
auto catalogue_exercise(std::string key, std::string authored = "Eb", std::string id = "exercise", std::string lesson = "lesson") -> vamp_zero::practice_exercise {
|
||||
auto variant_id = id + "-" + key;
|
||||
return {
|
||||
.id = std::move(id),
|
||||
.practice_type_id = "melody",
|
||||
.practice_type_title = "Melody Practice",
|
||||
.lesson_group_id = std::move(lesson),
|
||||
.lesson_group_title = "Lesson",
|
||||
.title = "Exercise",
|
||||
.composer = "Composer",
|
||||
.authored_key_signature = std::move(authored),
|
||||
.variants = {{.id = std::move(variant_id), .key_signature = std::move(key), .measure_count = 3, .total_pages = 2}},
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_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({catalogue_exercise("D", "Eb", "chosen", "b"), catalogue_exercise("C", "Eb", "other", "a")});
|
||||
BOOST_REQUIRE(practice);
|
||||
auto catalog = std::make_shared<vamp_zero::catalogue const>(vamp_zero::catalogue{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_type_index = 1,
|
||||
.selected_practice_lesson_index = 2,
|
||||
.selected_practice_exercise_index = 1,
|
||||
};
|
||||
auto control = vamp_zero::control_state{{}, original};
|
||||
auto observed = std::vector<vamp_zero::app_state>{};
|
||||
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 keys = practice_control.apply(vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(vamp_zero::to_string(keys.mode) == "practice_key_menu");
|
||||
auto selected = practice_control.apply(vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(selected.catalog.get() == catalog.get());
|
||||
BOOST_TEST(vamp_zero::selected_practice_variant(selected, catalog->practice.exercises[1]) == &catalog->practice.exercises[1].variants[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(keys.active_practice_id.empty());
|
||||
BOOST_TEST(keys.selected_practice_key_index == 0U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_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<std::uint32_t>(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(catalogue_practice_groups_require_authored_key_agreement_and_index_exercises) {
|
||||
for (auto authored : {"Eb", "C dorian", "No key", ""}) {
|
||||
auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise("D", authored), catalogue_exercise("C", authored), catalogue_exercise("F", "F", "second", "second")});
|
||||
BOOST_REQUIRE(grouped);
|
||||
BOOST_REQUIRE_EQUAL(grouped->exercises.size(), 2U);
|
||||
auto const &exercise = grouped->exercises[0];
|
||||
BOOST_TEST(exercise.authored_key_signature == authored);
|
||||
BOOST_REQUIRE_EQUAL(exercise.variants.size(), 2U);
|
||||
BOOST_TEST(exercise.variants[0].key_signature == "C");
|
||||
BOOST_TEST(exercise.variants[1].key_signature == "D");
|
||||
BOOST_REQUIRE_EQUAL(grouped->types.size(), 1U);
|
||||
auto const &lessons = grouped->types[0].lesson_groups;
|
||||
BOOST_REQUIRE_EQUAL(lessons.size(), 2U);
|
||||
for (auto index = std::size_t{}; index < lessons.size(); ++index) {
|
||||
BOOST_REQUIRE_EQUAL(lessons[index].exercises.size(), 1U);
|
||||
BOOST_TEST(lessons[index].exercises[0] == index);
|
||||
BOOST_TEST(grouped->exercises[lessons[index].exercises[0]].lesson_group_id == lessons[index].id);
|
||||
}
|
||||
}
|
||||
for (auto conflict : {"F", ""}) {
|
||||
auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise("C"), catalogue_exercise("D", conflict)});
|
||||
BOOST_REQUIRE(!grouped);
|
||||
BOOST_TEST(grouped.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
BOOST_TEST(grouped.error().message().find("authored key") != std::string_view::npos);
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_authored_key_metadata_is_optional_but_must_be_a_nonempty_string) {
|
||||
auto legacy = vamp_zero::parse_score_document(catalogue_bundle("Legacy", "D"), "legacy.json");
|
||||
BOOST_REQUIRE(legacy);
|
||||
BOOST_TEST(legacy->authored_key_signature.empty());
|
||||
for (auto authored : {"Eb", "C minor", "C dorian", "No key"}) {
|
||||
auto document = vamp_zero::parse_score_document(catalogue_bundle("Exercise", "D", authored), "/assets/practice/melody/lesson/exercise/d.json");
|
||||
BOOST_REQUIRE(document);
|
||||
BOOST_TEST(document->authored_key_signature == authored);
|
||||
auto metadata = vamp_zero::practice_metadata(*document, "/assets");
|
||||
BOOST_REQUIRE(metadata);
|
||||
BOOST_TEST(metadata->authored_key_signature == authored);
|
||||
BOOST_TEST(metadata->variants[0].key_signature == "D");
|
||||
}
|
||||
for (auto invalid : {boost::json::value{}, boost::json::value{false}, boost::json::value{42}, boost::json::value{""}, boost::json::value{boost::json::array{}}}) {
|
||||
auto value = boost::json::parse(catalogue_bundle("Invalid"));
|
||||
value.at("music").at("metadata").as_object()["authored_key_signature"] = invalid;
|
||||
auto document = vamp_zero::parse_score_document(boost::json::serialize(value), "invalid.json");
|
||||
BOOST_REQUIRE(!document);
|
||||
BOOST_TEST(document.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
BOOST_TEST(document.error().message().find("authored_key_signature") != std::string_view::npos);
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_streamed_import_rolls_back_and_preserves_unmanaged_rows) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
db.write("songs/a-managed.json", catalogue_bundle("Original"));
|
||||
db.write("songs/custom.json", catalogue_bundle("Custom Song"));
|
||||
db.write("songs/stale.json", catalogue_bundle("Stale"));
|
||||
db.write("practice/melody/lesson/exercise/c.json", catalogue_bundle("Exercise", "C", "Eb"));
|
||||
db.write("practice/melody/lesson/exercise/d.json", catalogue_bundle("Exercise", "D", "Eb"));
|
||||
db.write("practice/melody/lesson/custom/c.json", catalogue_bundle("Custom Exercise", "C", "Eb"));
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
db.query("UPDATE songs SET catalog_managed = 0 WHERE id = 'custom'");
|
||||
db.query("UPDATE practice_exercises SET catalog_managed = 0 WHERE id = 'melody-lesson-custom-c'");
|
||||
auto songs = db.rows("songs");
|
||||
auto practice = db.rows("practice_exercises");
|
||||
auto custom_songs = db.rows("songs", " WHERE catalog_managed = 0");
|
||||
auto custom_practice = db.rows("practice_exercises", " WHERE catalog_managed = 0");
|
||||
BOOST_REQUIRE_EQUAL(custom_songs.size(), 1U);
|
||||
BOOST_REQUIRE_EQUAL(custom_practice.size(), 1U);
|
||||
db.write("songs/a-managed.json", catalogue_bundle("Updated"));
|
||||
db.write("songs/b-new.json", catalogue_bundle("New"));
|
||||
db.write("songs/custom.json", catalogue_bundle("Collision"));
|
||||
db.write("practice/melody/lesson/custom/c.json", catalogue_bundle("Collision", "C", "Eb"));
|
||||
db.write("practice/melody/lesson/exercise/c.json", catalogue_bundle("Updated Exercise", "C", "Eb"));
|
||||
std::filesystem::remove(db.assets / "songs/stale.json");
|
||||
for (auto invalid : {std::string{"{}"}, catalogue_bundle("Exercise", "D", "F")}) {
|
||||
db.write("practice/melody/lesson/exercise/d.json", invalid);
|
||||
auto imported = db.import_assets();
|
||||
BOOST_REQUIRE(!imported);
|
||||
BOOST_TEST(imported.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
|
||||
BOOST_TEST(db.rows("practice_exercises") == practice, boost::test_tools::per_element());
|
||||
}
|
||||
db.write("practice/melody/lesson/exercise/d.json", catalogue_bundle("Updated Exercise", "D", "Eb"));
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
auto updated = db.query("SELECT title FROM songs WHERE id = 'a-managed'");
|
||||
BOOST_REQUIRE_EQUAL(updated.rows.size(), 1U);
|
||||
BOOST_TEST(std::get<std::string>(updated.rows[0][0].value) == "Updated");
|
||||
BOOST_TEST(db.query("SELECT id FROM songs WHERE id = 'b-new'").rows.size() == 1U);
|
||||
BOOST_TEST(db.query("SELECT id FROM songs WHERE id = 'stale'").rows.empty());
|
||||
BOOST_TEST(db.rows("songs", " WHERE catalog_managed = 0") == custom_songs, boost::test_tools::per_element());
|
||||
BOOST_TEST(db.rows("practice_exercises", " WHERE catalog_managed = 0") == custom_practice, boost::test_tools::per_element());
|
||||
std::filesystem::remove_all(db.assets);
|
||||
std::filesystem::create_directories(db.assets / "songs");
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
BOOST_TEST(db.rows("songs") == custom_songs, boost::test_tools::per_element());
|
||||
BOOST_TEST(db.rows("practice_exercises") == custom_practice, boost::test_tools::per_element());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_legacy_metadata_backfill_retains_custom_rows_and_score_json) {
|
||||
auto db = catalogue_database{};
|
||||
db.query(R"sql(CREATE TABLE songs (
|
||||
id TEXT PRIMARY KEY, title TEXT NOT NULL, composer TEXT NOT NULL, source_json TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, part TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL, key_signature TEXT NOT NULL, catalog_managed INTEGER NOT NULL
|
||||
) STRICT)sql");
|
||||
db.query(R"sql(CREATE TABLE practice_exercises (
|
||||
id TEXT PRIMARY KEY, practice_type_id TEXT NOT NULL, practice_type_title TEXT NOT NULL,
|
||||
lesson_group_id TEXT NOT NULL, lesson_group_title TEXT NOT NULL, title TEXT NOT NULL, composer TEXT NOT NULL,
|
||||
key_signature TEXT NOT NULL, source_json TEXT NOT NULL, source_path TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, catalog_managed INTEGER NOT NULL
|
||||
) STRICT)sql");
|
||||
auto song = catalogue_bundle("Embedded Song", "D");
|
||||
auto exercise = catalogue_bundle("Embedded Exercise", "D", "Eb");
|
||||
db.query("INSERT INTO songs VALUES('legacy-song', 'Custom Song', 'Composer', ?1, 11, 17, 'Lead', 'legacy.json', 'D', 0)", {noto::sqlite_value{song}});
|
||||
db.query("INSERT INTO practice_exercises VALUES('legacy-practice', 'melody', 'Melody Practice', 'lesson', 'Lesson', "
|
||||
"'Custom Exercise', 'Composer', 'D', ?1, 'practice/melody/lesson/exercise/d.json', 11, 17, 0)",
|
||||
{noto::sqlite_value{exercise}});
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
BOOST_TEST(db.query("SELECT id FROM " + std::string{table} + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
|
||||
}
|
||||
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].id == "legacy-song");
|
||||
BOOST_TEST(performance->parts[0].title == "Custom Song");
|
||||
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 == "Custom Exercise");
|
||||
BOOST_TEST(practice->exercises[0].authored_key_signature == "Eb");
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 1U);
|
||||
BOOST_TEST(practice->exercises[0].variants[0].id == "legacy-practice");
|
||||
BOOST_TEST(practice->exercises[0].variants[0].measure_count == 3U);
|
||||
BOOST_TEST(practice->exercises[0].variants[0].total_pages == 2U);
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
auto row = db.query("SELECT source_json, catalog_managed, created_at_ms, updated_at_ms, measure_count, total_pages, authored_key_signature FROM " + std::string{table});
|
||||
BOOST_REQUIRE_EQUAL(row.rows.size(), 1U);
|
||||
auto const &values = row.rows[0];
|
||||
auto is_song = std::string_view{table} == "songs";
|
||||
BOOST_TEST(std::get<std::string>(values[0].value) == (is_song ? song : exercise));
|
||||
BOOST_TEST(std::get<std::int64_t>(values[1].value) == 0);
|
||||
BOOST_TEST(std::get<std::int64_t>(values[2].value) == 11);
|
||||
BOOST_TEST(std::get<std::int64_t>(values[3].value) == 17);
|
||||
BOOST_TEST(std::get<std::int64_t>(values[4].value) == 3);
|
||||
BOOST_TEST(std::get<std::int64_t>(values[5].value) == 2);
|
||||
BOOST_TEST(std::get<std::string>(values[6].value) == (is_song ? "" : "Eb"));
|
||||
}
|
||||
auto songs = db.rows("songs");
|
||||
auto exercises = db.rows("practice_exercises");
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
BOOST_REQUIRE(db.performance());
|
||||
BOOST_REQUIRE(db.practice());
|
||||
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
|
||||
BOOST_TEST(db.rows("practice_exercises") == exercises, boost::test_tools::per_element());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports_missing_or_invalid) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
auto song = catalogue_bundle("Song", "F");
|
||||
auto exercise = catalogue_bundle("Exercise", "D", "Eb");
|
||||
db.write("songs/shared.json", song);
|
||||
db.write("practice/melody/lesson/exercise/d.json", exercise);
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
db.query("UPDATE practice_exercises SET id = 'shared'");
|
||||
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->authored_key_signature == (is_song ? "" : "Eb"));
|
||||
BOOST_TEST(document->source_json == (is_song ? song : exercise));
|
||||
BOOST_TEST(document->source_path == (db.assets / (is_song ? "songs/shared.json" : "practice/melody/lesson/exercise/d.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<std::int32_t>(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<std::int32_t>(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(catalogue_sequential_score_loads_keep_executor_stack_bounded) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
db.write("songs/repeated.json", catalogue_bundle("Repeated"));
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
auto repeated_loads = [](boost::asio::any_io_executor executor, vamp_zero::database_config config)
|
||||
-> boost::asio::awaitable<std::expected<std::uint32_t, modulus::error>> {
|
||||
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);
|
||||
if (!score) {
|
||||
co_return std::unexpected(score.error());
|
||||
}
|
||||
}
|
||||
co_return 4096U;
|
||||
};
|
||||
auto result = run_catalogue_operation(repeated_loads, db.config);
|
||||
BOOST_REQUIRE_MESSAGE(result, (result ? "" : result.error().describe()));
|
||||
BOOST_TEST(*result == 4096U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score_json) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
// Complete cached metadata must not require parsing or retaining the score body.
|
||||
db.query("INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, "
|
||||
"key_signature, measure_count, total_pages, authored_key_signature) "
|
||||
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 1, 1, 'Lead', 'songs/tune.json', 'F', 3, 2, '')");
|
||||
for (auto key : {"C", "D"}) {
|
||||
db.query("INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, "
|
||||
"title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms, measure_count, total_pages, authored_key_signature) "
|
||||
"VALUES(?1, 'melody', 'Melody Practice', 'lesson', 'Lesson', 'Metadata Exercise', 'Composer', ?2, '{}', ?3, 1, 1, 3, 2, 'Eb')",
|
||||
{noto::sqlite_value{std::string{"variant-"} + key}, noto::sqlite_value{std::string{key}},
|
||||
noto::sqlite_value{std::string{"practice/melody/lesson/exercise/"} + key + ".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].authored_key_signature == "Eb");
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 2U);
|
||||
BOOST_TEST(practice->exercises[0].variants[0].key_signature == "C");
|
||||
BOOST_TEST(practice->exercises[0].variants[1].key_signature == "D");
|
||||
for (auto const &variant : practice->exercises[0].variants) {
|
||||
BOOST_TEST(variant.measure_count == 3U);
|
||||
BOOST_TEST(variant.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, "variant-D");
|
||||
BOOST_REQUIRE(!song);
|
||||
BOOST_REQUIRE(!exercise);
|
||||
BOOST_TEST(song.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
BOOST_TEST(exercise.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
db.query("UPDATE practice_exercises SET authored_key_signature = 'F' WHERE id = 'variant-D'");
|
||||
auto disagreement = db.practice();
|
||||
BOOST_REQUIRE(!disagreement);
|
||||
BOOST_TEST(disagreement.error().message().find("authored key") != std::string_view::npos);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_changed_score_json_invalidates_metadata_and_requires_successful_backfill) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
auto original = catalogue_bundle("Original", "D", "Eb");
|
||||
db.write("songs/tune.json", original);
|
||||
db.write("practice/melody/lesson/exercise/d.json", original);
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
auto changed = boost::json::parse(catalogue_bundle("Changed", "D", "F"));
|
||||
changed.at("music").at("staves").as_array()[0].at("measures").as_array().pop_back();
|
||||
changed.at("presentation").at("pages").as_array().pop_back();
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
auto name = std::string{table};
|
||||
auto before = db.rows(name);
|
||||
db.query("UPDATE " + name + " SET source_json = ?1", {noto::sqlite_value{original}});
|
||||
BOOST_TEST(db.rows(name) == before, boost::test_tools::per_element());
|
||||
db.query("UPDATE " + name + " SET source_json = ?1", {noto::sqlite_value{boost::json::serialize(changed)}});
|
||||
BOOST_TEST(db.query("SELECT id FROM " + name + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
|
||||
}
|
||||
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].measure_count == 2U);
|
||||
BOOST_TEST(performance->parts[0].total_pages == 1U);
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises.size(), 1U);
|
||||
BOOST_TEST(practice->exercises[0].authored_key_signature == "F");
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 1U);
|
||||
BOOST_TEST(practice->exercises[0].variants[0].measure_count == 2U);
|
||||
BOOST_TEST(practice->exercises[0].variants[0].total_pages == 1U);
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
db.query("UPDATE " + std::string{table} + " SET source_json = '{}'");
|
||||
}
|
||||
performance = db.performance();
|
||||
practice = db.practice();
|
||||
BOOST_REQUIRE(!performance);
|
||||
BOOST_REQUIRE(!practice);
|
||||
BOOST_TEST(performance.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
BOOST_TEST(practice.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
BOOST_TEST(db.query("SELECT id FROM " + std::string{table} + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <future>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/asio/co_spawn.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
#include <boost/asio/use_future.hpp>
|
||||
#include <boost/json.hpp>
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <vamp_zero/app.hpp>
|
||||
#include <vamp_zero/database.hpp>
|
||||
#include <vamp_zero/health.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
struct temporary_directory {
|
||||
std::filesystem::path path;
|
||||
temporary_directory() {
|
||||
auto pattern = (std::filesystem::temp_directory_path() / "vamp-health-XXXXXX").string();
|
||||
BOOST_REQUIRE(::mkdtemp(pattern.data()) != nullptr);
|
||||
path = pattern;
|
||||
}
|
||||
~temporary_directory() {
|
||||
auto ignored = std::error_code{};
|
||||
std::filesystem::remove_all(path, ignored);
|
||||
}
|
||||
};
|
||||
|
||||
auto write_file(std::filesystem::path const &path, std::string const &bytes) -> void {
|
||||
std::filesystem::create_directories(path.parent_path());
|
||||
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
|
||||
output << bytes;
|
||||
output.close();
|
||||
BOOST_REQUIRE(output.good());
|
||||
}
|
||||
|
||||
auto read_file(std::filesystem::path const &path) -> std::string {
|
||||
auto input = std::ifstream{path, std::ios::binary};
|
||||
BOOST_REQUIRE(input.good());
|
||||
return {std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
||||
}
|
||||
|
||||
auto healthy_payload() -> boost::json::value {
|
||||
return boost::json::object{{"device", "zero"}, {"status", "ready"}, {"product", "product-vamp"}};
|
||||
}
|
||||
|
||||
auto health_router(boost::json::value payload) -> ligo::service_router {
|
||||
auto router = ligo::service_router{};
|
||||
router.add_route("GET", "/api/health", [payload = std::move(payload)](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> { co_return payload; });
|
||||
return router;
|
||||
}
|
||||
|
||||
struct http_service {
|
||||
boost::asio::io_context io;
|
||||
ligo::http::server server;
|
||||
std::thread thread;
|
||||
explicit http_service(ligo::service_router router, std::string host = "127.0.0.1")
|
||||
: server{io, {.host = std::move(host)}, std::move(router), {.timeout = std::chrono::milliseconds{250}}} {
|
||||
auto started = server.start();
|
||||
BOOST_REQUIRE_MESSAGE(started, (started ? "" : started.error().describe()));
|
||||
thread = std::thread{[this] { io.run(); }};
|
||||
}
|
||||
~http_service() {
|
||||
server.stop();
|
||||
io.stop();
|
||||
thread.join();
|
||||
}
|
||||
auto config(std::string host = "127.0.0.1") const -> vamp_zero::config {
|
||||
auto result = vamp_zero::config{};
|
||||
result.control.endpoint.bind = host + ":" + std::to_string(server.selected_port());
|
||||
result.control.endpoint.timeout = std::chrono::milliseconds{250};
|
||||
result.control.zero_ip = "192.0.2.1";
|
||||
result.control.endpoint.connect = "192.0.2.2:1";
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
auto unused_port() -> std::uint16_t {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto acceptor = boost::asio::ip::tcp::acceptor{io, {boost::asio::ip::make_address("127.0.0.1"), 0}};
|
||||
return acceptor.local_endpoint().port();
|
||||
}
|
||||
|
||||
auto fixture_config(std::filesystem::path const &root) -> vamp_zero::config {
|
||||
auto config = vamp_zero::config{};
|
||||
config.runtime.assets_path = (root / "assets").string();
|
||||
config.runtime.heartbeat_path = (root / "run" / "heartbeat.json").string();
|
||||
config.database.path = (root / "db" / "vamp.db").string();
|
||||
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
|
||||
config.control.endpoint.timeout = std::chrono::milliseconds{250};
|
||||
return config;
|
||||
}
|
||||
|
||||
auto config_file(std::filesystem::path const &root, vamp_zero::config const &config) -> std::string {
|
||||
auto json = boost::json::object{
|
||||
{"modulus", modulus::to_json(config.modulus)},
|
||||
{"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"hardware", "real"}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}},
|
||||
{"database", {{"path", config.database.path}}},
|
||||
{"control", {{"endpoint", ligo::to_json(config.control.endpoint)}, {"zero_ip", config.control.zero_ip}}},
|
||||
{"audio", {{"enabled", true}, {"track_enabled", true}, {"metronome_enabled", true}}},
|
||||
};
|
||||
auto path = root / "config.json";
|
||||
write_file(path, boost::json::serialize(json));
|
||||
return path.string();
|
||||
}
|
||||
|
||||
auto fixture_page() -> std::string {
|
||||
auto bytes = std::array<std::uchar_t, 24>{137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13,
|
||||
73, 72, 68, 82, 0, 0, 3, 208, 0, 0, 1, 228};
|
||||
return {reinterpret_cast<std::char_t const *>(bytes.data()), bytes.size()};
|
||||
}
|
||||
|
||||
auto checksum(std::string const &bytes) -> std::string {
|
||||
auto digest = std::array<std::uchar_t, SHA256_DIGEST_LENGTH>{};
|
||||
SHA256(reinterpret_cast<std::uchar_t const *>(bytes.data()), bytes.size(), digest.data());
|
||||
auto result = std::string{};
|
||||
constexpr auto digits = std::string_view{"0123456789abcdef"};
|
||||
for (std::uchar_t byte : digest) {
|
||||
result.push_back(digits[byte >> 4U]);
|
||||
result.push_back(digits[byte & 15U]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
auto write_score(std::filesystem::path const &path, std::string key = "C") -> void {
|
||||
auto score = boost::json::parse(R"({"schema_version":3,"music":{"metadata":{"title":"Fixture","composer":"Composer",
|
||||
"key_signature":"C","time_signature":"4/4"},"staves":[{"name":"Lead","measures":[{"voices":[]}]}]},
|
||||
"presentation":{"width":976,"height":484,"pages":[]}})");
|
||||
score.at("music").at("metadata").as_object()["key_signature"] = key;
|
||||
auto &pages = score.at("presentation").at("pages").as_array();
|
||||
for (auto index = std::uint32_t{1}; index <= 2U; ++index) {
|
||||
auto relative = path.stem().string() + ".pages/" + std::to_string(index) + ".png";
|
||||
auto bytes = fixture_page();
|
||||
write_file(path.parent_path() / relative, bytes);
|
||||
pages.push_back(boost::json::object{{"path", relative}, {"sha256", checksum(bytes)}});
|
||||
}
|
||||
write_file(path, boost::json::serialize(score));
|
||||
}
|
||||
|
||||
auto make_assets(vamp_zero::config const &config) -> void {
|
||||
auto root = std::filesystem::path{config.runtime.assets_path};
|
||||
write_score(root / "songs" / "first.json");
|
||||
write_score(root / "songs" / "second.json");
|
||||
write_score(root / "practice" / "melody" / "lesson" / "exercise" / "c.json");
|
||||
write_score(root / "practice" / "melody" / "lesson" / "exercise" / "d.json", "D");
|
||||
}
|
||||
|
||||
auto run_once(std::filesystem::path const &root, vamp_zero::config const &config) -> std::expected<void, modulus::error> {
|
||||
return vamp_zero::run({.config_path = config_file(root, config), .exit_after_startup = true});
|
||||
}
|
||||
|
||||
auto require_no_probe(vamp_zero::config const &config, std::bool_t heartbeat_exists = false) -> void {
|
||||
auto directory = std::filesystem::path{config.runtime.heartbeat_path}.parent_path();
|
||||
if (!std::filesystem::exists(directory)) {
|
||||
return;
|
||||
}
|
||||
auto count = std::uint32_t{};
|
||||
for (auto const &entry : std::filesystem::directory_iterator{directory}) {
|
||||
BOOST_TEST(entry.path().filename().string() == "heartbeat.json");
|
||||
++count;
|
||||
}
|
||||
BOOST_TEST(count == (heartbeat_exists ? 1U : 0U));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(health_uses_configured_bind_and_normalizes_wildcards) {
|
||||
auto service = http_service{health_router(healthy_payload())};
|
||||
BOOST_TEST(vamp_zero::check_health(service.config()).has_value());
|
||||
BOOST_TEST(vamp_zero::check_health(service.config("0.0.0.0")).has_value());
|
||||
auto other = http_service{health_router(healthy_payload()), "127.0.0.2"};
|
||||
BOOST_TEST(vamp_zero::check_health(other.config("127.0.0.2")).has_value());
|
||||
auto ipv6 = http_service{health_router(healthy_payload()), "::1"};
|
||||
BOOST_TEST(vamp_zero::check_health(ipv6.config("[::]")).has_value());
|
||||
BOOST_TEST(vamp_zero::check_health(ipv6.config("[0:0:0:0:0:0:0:0]")).has_value());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(health_rejects_wrong_device_product_status_and_payload_types) {
|
||||
for (std::char_t const *field : {"product", "device", "status"}) {
|
||||
for (auto value : {boost::json::value{"wrong"}, boost::json::value{42}, boost::json::value{}}) {
|
||||
auto payload = healthy_payload();
|
||||
payload.as_object()[field] = value;
|
||||
auto service = http_service{health_router(std::move(payload))};
|
||||
BOOST_TEST(!vamp_zero::check_health(service.config()));
|
||||
}
|
||||
auto payload = healthy_payload();
|
||||
payload.as_object().erase(field);
|
||||
auto service = http_service{health_router(std::move(payload))};
|
||||
BOOST_TEST(!vamp_zero::check_health(service.config()));
|
||||
}
|
||||
for (auto payload : {boost::json::value{}, boost::json::value{true}, boost::json::value{boost::json::array{}}}) {
|
||||
auto service = http_service{health_router(std::move(payload))};
|
||||
BOOST_TEST(!vamp_zero::check_health(service.config()));
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(health_rejects_malformed_or_failed_envelopes_and_missing_routes) {
|
||||
for (std::char_t const *body : {"not JSON", "{}", "[]", "{\"ok\":true}", "{\"ok\":false}",
|
||||
"{\"device\":\"zero\",\"product\":\"product-vamp\",\"status\":\"ready\"}"}) {
|
||||
auto router = ligo::service_router{};
|
||||
router.add_response_route("GET", "/api/health", [body](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
||||
co_return ligo::text_response("application/json", body);
|
||||
});
|
||||
auto service = http_service{std::move(router)};
|
||||
BOOST_TEST(!vamp_zero::check_health(service.config()));
|
||||
}
|
||||
auto service = http_service{ligo::service_router{}};
|
||||
BOOST_TEST(!vamp_zero::check_health(service.config()));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(health_fails_when_stopped_and_obeys_timeout) {
|
||||
auto config = vamp_zero::config{};
|
||||
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
|
||||
config.control.endpoint.timeout = std::chrono::milliseconds{50};
|
||||
BOOST_TEST(!vamp_zero::check_health(config));
|
||||
auto io = boost::asio::io_context{};
|
||||
auto stalled = boost::asio::ip::tcp::acceptor{io, {boost::asio::ip::make_address("127.0.0.1"), 0}};
|
||||
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(stalled.local_endpoint().port());
|
||||
auto started = std::chrono::steady_clock::now();
|
||||
BOOST_TEST(!vamp_zero::check_health(config));
|
||||
auto elapsed = std::chrono::steady_clock::now() - started;
|
||||
BOOST_TEST((elapsed < std::chrono::seconds{2}));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(health_command_never_mutates_database_assets_or_runtime) {
|
||||
auto root = temporary_directory{};
|
||||
auto service = http_service{health_router(healthy_payload())};
|
||||
auto config = fixture_config(root.path);
|
||||
config.control = service.config().control;
|
||||
config.runtime.exit_after_startup = true;
|
||||
auto log_level = modulus::default_logger().level();
|
||||
auto error_config = modulus::default_error_config();
|
||||
config.modulus.logging.level = log_level == modulus::log_level::DEBUG ? modulus::log_level::ERROR : modulus::log_level::DEBUG;
|
||||
config.modulus.errors.include_location = !error_config.include_location;
|
||||
auto command = vamp_zero::command_line{.config_path = config_file(root.path, config), .health_check = true};
|
||||
BOOST_TEST(vamp_zero::run(command).has_value());
|
||||
BOOST_TEST((modulus::default_logger().level() == log_level));
|
||||
BOOST_TEST(modulus::default_error_config().include_location == error_config.include_location);
|
||||
BOOST_TEST(!std::filesystem::exists(root.path / "db"));
|
||||
BOOST_TEST(!std::filesystem::exists(root.path / "assets"));
|
||||
BOOST_TEST(!std::filesystem::exists(root.path / "run"));
|
||||
write_file(config.database.path, "not a database");
|
||||
write_file(std::filesystem::path{config.runtime.assets_path} / "songs" / "invalid.json", "not JSON");
|
||||
write_file(config.runtime.heartbeat_path, "stale heartbeat");
|
||||
auto database_time = std::filesystem::last_write_time(config.database.path);
|
||||
auto heartbeat_time = std::filesystem::last_write_time(config.runtime.heartbeat_path);
|
||||
BOOST_TEST(vamp_zero::run(command).has_value());
|
||||
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
|
||||
command.config_path = config_file(root.path, config);
|
||||
BOOST_TEST(!vamp_zero::run(command));
|
||||
BOOST_TEST((modulus::default_logger().level() == log_level));
|
||||
BOOST_TEST(modulus::default_error_config().include_location == error_config.include_location);
|
||||
BOOST_TEST(read_file(config.database.path) == "not a database");
|
||||
BOOST_TEST(read_file(std::filesystem::path{config.runtime.assets_path} / "songs" / "invalid.json") == "not JSON");
|
||||
BOOST_TEST(read_file(config.runtime.heartbeat_path) == "stale heartbeat");
|
||||
BOOST_TEST((std::filesystem::last_write_time(config.database.path) == database_time));
|
||||
BOOST_TEST((std::filesystem::last_write_time(config.runtime.heartbeat_path) == heartbeat_time));
|
||||
BOOST_TEST(!std::filesystem::exists(config.database.path + "-wal"));
|
||||
require_no_probe(config, true);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(portal_health_route_reports_readiness_without_changing_control_state) {
|
||||
auto state = vamp_zero::control_state{vamp_zero::config{}, vamp_zero::app_state{}};
|
||||
auto service = http_service{vamp_zero::make_router(state)};
|
||||
BOOST_TEST(vamp_zero::check_health(service.config()).has_value());
|
||||
auto snapshot = state.snapshot();
|
||||
BOOST_TEST(snapshot.heartbeat_count == 0U);
|
||||
BOOST_TEST(!snapshot.pico_connected);
|
||||
BOOST_TEST(snapshot.last_command == "startup");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(portal_remote_commands_refresh_connection_without_background_polls) {
|
||||
for (auto const *route : {vamp_protocol::remote_next_path, vamp_protocol::remote_previous_path, vamp_protocol::remote_select_path}) {
|
||||
auto state = vamp_zero::control_state{vamp_zero::config{}, vamp_zero::app_state{}};
|
||||
auto catalog = state.snapshot().catalog;
|
||||
auto router = vamp_zero::make_router(state);
|
||||
auto io = boost::asio::io_context{};
|
||||
auto response = boost::asio::co_spawn(io, router.call_response({.method = "POST", .route = route}, {}), boost::asio::use_future);
|
||||
io.run();
|
||||
BOOST_REQUIRE(response.get());
|
||||
auto snapshot = state.snapshot();
|
||||
BOOST_TEST(snapshot.pico_connected);
|
||||
BOOST_TEST(snapshot.heartbeat_count == 1U);
|
||||
BOOST_TEST(snapshot.catalog.get() == catalog.get());
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_imports_catalogue_without_hardware_or_heartbeat) {
|
||||
auto root = temporary_directory{};
|
||||
auto config = fixture_config(root.path);
|
||||
make_assets(config);
|
||||
auto ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
|
||||
BOOST_TEST(std::filesystem::exists(config.database.path));
|
||||
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
|
||||
require_no_probe(config);
|
||||
auto io = boost::asio::io_context{};
|
||||
auto performance = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database), boost::asio::use_future);
|
||||
auto practice = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database), boost::asio::use_future);
|
||||
io.run();
|
||||
auto songs = performance.get();
|
||||
auto exercises = practice.get();
|
||||
BOOST_REQUIRE(songs);
|
||||
BOOST_REQUIRE(exercises);
|
||||
BOOST_TEST(songs->parts.size() == 2U);
|
||||
BOOST_REQUIRE_EQUAL(exercises->exercises.size(), 1U);
|
||||
BOOST_TEST(exercises->exercises.front().variants.size() == 2U);
|
||||
write_file(config.runtime.heartbeat_path, "existing heartbeat");
|
||||
auto heartbeat_time = std::filesystem::last_write_time(config.runtime.heartbeat_path);
|
||||
config.runtime.exit_after_startup = true;
|
||||
ready = vamp_zero::run({.config_path = config_file(root.path, config)});
|
||||
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
|
||||
BOOST_TEST(read_file(config.runtime.heartbeat_path) == "existing heartbeat");
|
||||
BOOST_TEST((std::filesystem::last_write_time(config.runtime.heartbeat_path) == heartbeat_time));
|
||||
require_no_probe(config, true);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_validates_later_pages_parts_and_practice_variants) {
|
||||
for (std::char_t const *relative : {"songs/first.pages/2.png", "songs/second.pages/2.png", "practice/melody/lesson/exercise/d.pages/2.png"}) {
|
||||
auto root = temporary_directory{};
|
||||
auto config = fixture_config(root.path);
|
||||
make_assets(config);
|
||||
std::filesystem::remove(std::filesystem::path{config.runtime.assets_path} / relative);
|
||||
auto ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE(!ready);
|
||||
BOOST_TEST(std::string{ready.error().message()}.find("page is missing") != std::string::npos);
|
||||
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_rejects_corrupt_checksums_and_escaping_pages) {
|
||||
for (std::bool_t escape : {false, true}) {
|
||||
auto root = temporary_directory{};
|
||||
auto config = fixture_config(root.path);
|
||||
make_assets(config);
|
||||
auto page = std::filesystem::path{config.runtime.assets_path} / "songs" / "second.pages" / "2.png";
|
||||
if (escape) {
|
||||
write_file(root.path / "outside.png", fixture_page());
|
||||
std::filesystem::remove(page);
|
||||
std::filesystem::create_symlink(root.path / "outside.png", page);
|
||||
} else {
|
||||
auto bytes = fixture_page();
|
||||
bytes.back() = 0;
|
||||
write_file(page, bytes);
|
||||
}
|
||||
auto ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE(!ready);
|
||||
auto expected = std::string_view{escape ? "escapes its asset directory" : "checksum mismatch"};
|
||||
BOOST_TEST(std::string{ready.error().message()}.find(expected) != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_rejects_missing_or_malformed_catalogues) {
|
||||
auto root = temporary_directory{};
|
||||
auto config = fixture_config(root.path);
|
||||
BOOST_TEST(!run_once(root.path, config));
|
||||
make_assets(config);
|
||||
write_file(std::filesystem::path{config.runtime.assets_path} / "songs" / "first.json", "not JSON");
|
||||
BOOST_TEST(!run_once(root.path, config));
|
||||
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_rejects_unusable_database_and_runtime_paths) {
|
||||
auto root = temporary_directory{};
|
||||
auto config = fixture_config(root.path);
|
||||
make_assets(config);
|
||||
write_file(root.path / "blocked", "regular file");
|
||||
config.database.path = (root.path / "blocked" / "vamp.db").string();
|
||||
BOOST_TEST(!run_once(root.path, config));
|
||||
config.database.path = (root.path / "db" / "vamp.db").string();
|
||||
config.runtime.heartbeat_path = (root.path / "blocked" / "heartbeat.json").string();
|
||||
BOOST_TEST(!run_once(root.path, config));
|
||||
BOOST_TEST(read_file(root.path / "blocked") == "regular file");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_rejects_occupied_bind_and_releases_successful_probe) {
|
||||
auto root = temporary_directory{};
|
||||
auto config = fixture_config(root.path);
|
||||
make_assets(config);
|
||||
auto service = http_service{health_router(healthy_payload())};
|
||||
config.control = service.config().control;
|
||||
write_file(config.runtime.heartbeat_path, "existing heartbeat");
|
||||
BOOST_TEST(!run_once(root.path, config));
|
||||
BOOST_TEST(read_file(config.runtime.heartbeat_path) == "existing heartbeat");
|
||||
require_no_probe(config, true);
|
||||
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
|
||||
auto ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
|
||||
auto io = boost::asio::io_context{};
|
||||
auto endpoint = ligo::http::server_endpoint_from_config(config.control.endpoint);
|
||||
BOOST_REQUIRE(endpoint);
|
||||
auto server = ligo::http::server{io, *endpoint, {}};
|
||||
BOOST_TEST(server.start().has_value());
|
||||
server.stop();
|
||||
io.run();
|
||||
require_no_probe(config, true);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import copy
|
||||
from fractions import Fraction
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from generator.engraving import Fonts, Style, engrave_tree, layout_score
|
||||
from generator.engraving.attributes import staff_attributes
|
||||
from generator.engraving.notation import ScoreIndex
|
||||
from generator.model import key_from_fifths, parse_pitch_name, rational, validate_document
|
||||
from generator.practice.generate import generate_practice_scores, key_file_name, practice_document
|
||||
from generator.score import load_json, write_json
|
||||
from generator.transposition import TARGET_KEYS, concert_document, shift_key, transpose_document
|
||||
from tests.test_musicxml import parse, xml_note, xml_score
|
||||
|
||||
FONT_ROOT = Path('package/assets/fonts/petaluma')
|
||||
|
||||
def key_change(old, new, local=False, attributes=''):
|
||||
initial = f'<key><fifths>{old}</fifths></key>' + attributes
|
||||
change = f'<attributes><key><cancel>{old}</cancel><fifths>{new}</fifths></key></attributes>'
|
||||
if local:
|
||||
return parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half'), initial))
|
||||
root = ElementTree.fromstring(xml_score(xml_note(), initial))
|
||||
root.find('part').append(ElementTree.fromstring('<measure number="2">' + change + xml_note() + '</measure>'))
|
||||
return parse(ElementTree.tostring(root, encoding='unicode'))
|
||||
|
||||
class KeyCancellationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.fonts = Fonts(FONT_ROOT)
|
||||
def test_sharp_flat_and_cross_sign_changes_keep_their_placement(self):
|
||||
cases = [(1, 0, 'A', 3, 2, -54), (-2, -1, 'Ab', -4, -3, -36),
|
||||
(-1, -2, 'G', 1, 0, -48), (1, 2, 'F', -1, 0, -24)]
|
||||
for old, new, target, expected_old, expected_new, natural_y in cases:
|
||||
for local in (False, True):
|
||||
with self.subTest(old=old, new=new, target=target, local=local):
|
||||
source = key_change(old, new, local)
|
||||
before = copy.deepcopy(source)
|
||||
result = transpose_document(source, target)
|
||||
validate_document(result)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual(measures[0]['key'], key_from_fifths(expected_old))
|
||||
change = measures[0]['attributes'][0] if local else measures[1]
|
||||
self.assertEqual(change['key'], key_from_fifths(expected_new) | {'cancel': expected_old})
|
||||
self.assertEqual(len(measures), 1 if local else 2)
|
||||
if local:
|
||||
self.assertEqual(len(measures[0]['attributes']), 1)
|
||||
self.assertEqual(change['onset'], rational(2))
|
||||
else:
|
||||
self.assertTrue(all(not m['attributes'] for m in measures))
|
||||
self.assertEqual(change['number'], '2')
|
||||
time = Fraction(2 if local else 4)
|
||||
index = ScoreIndex(result)
|
||||
glyphs = staff_attributes(index, index.state(time), time, self.fonts, 12, 'change', ('key',))
|
||||
naturals = [p for p in glyphs if p.value == 'accidentalNatural']
|
||||
self.assertEqual(len(naturals), 1)
|
||||
self.assertEqual(naturals[0].points[0][1], natural_y)
|
||||
self.assertEqual([p.value for p in glyphs[1:]], ['accidentalSharp' if expected_new > 0 else 'accidentalFlat'] * abs(expected_new))
|
||||
layout = layout_score(result, Style(), self.fonts)
|
||||
reference = 'measure-1:attributes:0' if local else 'measure-2:initial'
|
||||
drawn = [p for page in layout.pages for p in page.primitives if p.role == 'accidental' and p.sources == (reference,)]
|
||||
self.assertEqual([p.value for p in drawn], [p.value for p in glyphs])
|
||||
self.assertEqual([p.points[0][1] - drawn[0].points[0][1] for p in drawn], [p.points[0][1] - glyphs[0].points[0][1] for p in glyphs])
|
||||
self.assertEqual(source, before)
|
||||
def test_metadata_and_initial_signature_transpose_cancellation(self):
|
||||
source = parse(xml_score(xml_note(), '<key><cancel>1</cancel><fifths>0</fifths></key>'))
|
||||
result = transpose_document(source, 'D', octave_shift=1)
|
||||
expected = key_from_fifths(2) | {'cancel': 3}
|
||||
self.assertEqual(result['metadata']['key'], expected)
|
||||
self.assertEqual(result['staves'][0]['measures'][0]['key'], expected)
|
||||
def test_zero_and_modal_cancellations_reference_the_old_signature(self):
|
||||
for mode in ('major', 'minor', 'dorian', 'none'):
|
||||
with self.subTest(mode=mode):
|
||||
key = key_from_fifths(-1, mode) | {'cancel': 0}
|
||||
result = shift_key(key, 1, 2)
|
||||
self.assertEqual(result['cancel'], 2)
|
||||
self.assertEqual(result['mode'], mode)
|
||||
self.assertEqual(result['alterations'], key_from_fifths(1)['alterations'])
|
||||
def test_unchanged_accidentals_do_not_receive_naturals(self):
|
||||
for old, new, target in [(1, 0, 'F'), (-1, 0, 'G'), (1, 1, 'A')]:
|
||||
with self.subTest(old=old, new=new, target=target):
|
||||
result = transpose_document(key_change(old, new), target)
|
||||
index = ScoreIndex(result)
|
||||
glyphs = staff_attributes(index, index.state(Fraction(4)), Fraction(4), self.fonts, 12, 'change', ('key',))
|
||||
self.assertNotIn('accidentalNatural', [p.value for p in glyphs])
|
||||
def test_unrepresentable_cancellation_fails_without_mutating_source(self):
|
||||
for fifths, steps, semitones in [(14, 1, 2), (-14, -1, -2), (0, 0, Fraction(1, 2))]:
|
||||
key = key_from_fifths(0) | {'cancel': fifths}
|
||||
before = copy.deepcopy(key)
|
||||
with self.subTest(fifths=fifths, semitones=semitones), self.assertRaisesRegex(ValueError, 'key cancellation.*represented'):
|
||||
shift_key(key, steps, semitones)
|
||||
self.assertEqual(key, before)
|
||||
for local in (False, True):
|
||||
source = key_change(0, 0, local)
|
||||
measures = source['staves'][0]['measures']
|
||||
change = measures[0]['attributes'][0] if local else measures[1]
|
||||
change['key']['cancel'] = 14
|
||||
before = copy.deepcopy(source)
|
||||
with self.subTest(local=local), self.assertRaisesRegex(ValueError, 'key cancellation.*represented'):
|
||||
transpose_document(source, 'D')
|
||||
self.assertEqual(source, before)
|
||||
def test_concert_transposition_uses_the_same_interval_for_cancellation(self):
|
||||
attributes = '<transpose><diatonic>1</diatonic><chromatic>2</chromatic></transpose>'
|
||||
for local in (False, True):
|
||||
with self.subTest(local=local):
|
||||
source = key_change(1, 0, local, attributes)
|
||||
result = concert_document(source)
|
||||
self.assertEqual(result, concert_document(result))
|
||||
measures = result['staves'][0]['measures']
|
||||
change = measures[0]['attributes'][0] if local else measures[1]
|
||||
self.assertEqual(change['key'], key_from_fifths(2) | {'cancel': 3})
|
||||
|
||||
class AuthoredKeyMetadataTests(unittest.TestCase):
|
||||
def test_eb_authored_key_survives_every_generated_practice_variant(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
source, output = Path(temp) / 'source', Path(temp) / 'output'
|
||||
text = xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>')
|
||||
for practice_type in ('chord', 'melody'):
|
||||
path = source / practice_type / 'lesson' / 'exercise.xml'
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(text, encoding='utf-8')
|
||||
self.assertEqual(generate_practice_scores(source, output), 24)
|
||||
for practice_type in ('chord', 'melody'):
|
||||
for key in TARGET_KEYS:
|
||||
with self.subTest(practice_type=practice_type, key=key):
|
||||
result = load_json(output / practice_type / 'lesson' / 'exercise' / key_file_name(key))
|
||||
self.assertEqual(result['schema_version'], 3)
|
||||
self.assertEqual(result['metadata']['authored_key_signature'], 'Eb')
|
||||
self.assertEqual(result['metadata']['key_signature'], key)
|
||||
self.assertEqual(result['metadata']['variant_tonic'], key)
|
||||
self.assertEqual((source / practice_type / 'lesson' / 'exercise.xml').read_text(encoding='utf-8'), text)
|
||||
def test_modes_and_keyless_authored_labels_survive_variants(self):
|
||||
for mode, fifths, label in [('minor', -3, 'C minor'), ('dorian', -2, 'C dorian'), ('mixolydian', -1, 'C mixolydian'), ('none', 0, 'No key')]:
|
||||
source = parse(xml_score(xml_note(), f'<key><fifths>{fifths}</fifths><mode>{mode}</mode></key>'))
|
||||
if mode == 'none':
|
||||
source['metadata']['transpose_origin'] = parse_pitch_name('C')
|
||||
before = copy.deepcopy(source)
|
||||
base = practice_document(source)
|
||||
self.assertEqual(base['metadata']['authored_key_signature'], label)
|
||||
for key in TARGET_KEYS:
|
||||
with self.subTest(mode=mode, key=key):
|
||||
result = transpose_document(base, key)
|
||||
validate_document(result)
|
||||
self.assertEqual(result['metadata']['authored_key_signature'], label)
|
||||
self.assertEqual(result['metadata']['key_signature'], 'No key' if mode == 'none' else f'{key} {mode}')
|
||||
self.assertEqual(result['metadata']['variant_tonic'], key)
|
||||
self.assertEqual(source, before)
|
||||
def test_existing_authored_key_is_retained_when_practice_is_rebuilt(self):
|
||||
source = parse(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
|
||||
before = copy.deepcopy(source)
|
||||
variant = transpose_document(practice_document(source), 'D')
|
||||
result = transpose_document(practice_document(variant), 'F')
|
||||
self.assertEqual(result['metadata']['authored_key_signature'], 'Eb')
|
||||
self.assertEqual(result['metadata']['key_signature'], 'F')
|
||||
self.assertEqual(source, before)
|
||||
def test_schema3_bundles_accept_optional_authored_key(self):
|
||||
source = parse(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
|
||||
self.assertNotIn('authored_key_signature', source['metadata'])
|
||||
for authored in (False, True):
|
||||
with self.subTest(authored=authored), tempfile.TemporaryDirectory() as temp:
|
||||
result = transpose_document(practice_document(source) if authored else source, 'D')
|
||||
root = Path(temp)
|
||||
path = root / 'variant.json'
|
||||
write_json(path, result)
|
||||
engrave_tree(root, FONT_ROOT)
|
||||
bundle = json.loads(path.read_text(encoding='utf-8'))
|
||||
self.assertEqual(bundle['schema_version'], 3)
|
||||
self.assertEqual(bundle['music']['metadata']['key_signature'], 'D')
|
||||
self.assertEqual(bundle['music']['metadata'].get('authored_key_signature'), 'Eb' if authored else None)
|
||||
self.assertEqual(load_json(path), result)
|
||||
@@ -0,0 +1,697 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
|
||||
#include <lwip/tcp.h>
|
||||
#include <pico/cyw43_arch.h>
|
||||
|
||||
#include <vamp_pico/http_client.hpp>
|
||||
#include <vamp_pico/input.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
auto check_command(std::optional<vamp_protocol::page_command> command, vamp_protocol::page_command expected) -> void {
|
||||
BOOST_REQUIRE(command.has_value());
|
||||
BOOST_CHECK(*command == expected);
|
||||
}
|
||||
|
||||
auto take_command(vamp_pico::request_scheduler &scheduler, std::uint64_t now_ms, vamp_protocol::page_command expected) -> void {
|
||||
auto request = scheduler.next(now_ms);
|
||||
BOOST_REQUIRE(request.has_value());
|
||||
BOOST_CHECK(request->kind == vamp_pico::request_kind::COMMAND);
|
||||
BOOST_CHECK(request->command == expected);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_gesture_single_tap_waits_for_double_tap_deadline) {
|
||||
auto gesture = vamp_pico::gesture_state{};
|
||||
BOOST_CHECK(!gesture.update(false, 0U));
|
||||
BOOST_CHECK(!gesture.update(true, 10U));
|
||||
BOOST_CHECK(!gesture.update(false, 50U));
|
||||
BOOST_CHECK(!gesture.update(false, 270U));
|
||||
check_command(gesture.update(false, 271U), vamp_protocol::page_command::NEXT);
|
||||
BOOST_CHECK(!gesture.update(false, 900U));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_gesture_double_tap_includes_deadline_and_has_no_trailing_next) {
|
||||
auto gesture = vamp_pico::gesture_state{};
|
||||
BOOST_CHECK(!gesture.update(true, 0U));
|
||||
BOOST_CHECK(!gesture.update(false, 40U));
|
||||
BOOST_CHECK(!gesture.update(true, 180U));
|
||||
check_command(gesture.update(false, 260U), vamp_protocol::page_command::PREVIOUS);
|
||||
BOOST_CHECK(!gesture.update(false, 1000U));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_gesture_late_second_release_preserves_both_single_taps) {
|
||||
auto gesture = vamp_pico::gesture_state{};
|
||||
BOOST_CHECK(!gesture.update(true, 0U));
|
||||
BOOST_CHECK(!gesture.update(false, 40U));
|
||||
BOOST_CHECK(!gesture.update(true, 180U));
|
||||
check_command(gesture.update(false, 261U), vamp_protocol::page_command::NEXT);
|
||||
BOOST_CHECK(!gesture.update(false, 481U));
|
||||
check_command(gesture.update(false, 482U), vamp_protocol::page_command::NEXT);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_gesture_expired_tap_is_emitted_when_next_press_starts) {
|
||||
auto gesture = vamp_pico::gesture_state{};
|
||||
BOOST_CHECK(!gesture.update(true, 0U));
|
||||
BOOST_CHECK(!gesture.update(false, 40U));
|
||||
check_command(gesture.update(true, 261U), vamp_protocol::page_command::NEXT);
|
||||
BOOST_CHECK(!gesture.update(false, 300U));
|
||||
check_command(gesture.update(false, 521U), vamp_protocol::page_command::NEXT);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_gesture_hold_emits_once_and_suppresses_release) {
|
||||
auto gesture = vamp_pico::gesture_state{};
|
||||
BOOST_CHECK(!gesture.update(true, 50U));
|
||||
BOOST_CHECK(!gesture.update(true, 749U));
|
||||
check_command(gesture.update(true, 750U), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(!gesture.update(true, 2000U));
|
||||
BOOST_CHECK(!gesture.update(false, 2010U));
|
||||
BOOST_CHECK(!gesture.update(false, 2300U));
|
||||
BOOST_CHECK(!gesture.update(true, 2400U));
|
||||
BOOST_CHECK(!gesture.update(false, 2450U));
|
||||
check_command(gesture.update(false, 2671U), vamp_protocol::page_command::NEXT);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_gesture_hold_consumes_pending_tap_even_if_release_is_next_sample) {
|
||||
auto gesture = vamp_pico::gesture_state{};
|
||||
BOOST_CHECK(!gesture.update(true, 0U));
|
||||
BOOST_CHECK(!gesture.update(false, 40U));
|
||||
BOOST_CHECK(!gesture.update(true, 180U));
|
||||
BOOST_CHECK(!gesture.update(true, 879U));
|
||||
check_command(gesture.update(false, 880U), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(!gesture.update(false, 1200U));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_scheduler_initial_poll_and_one_second_from_completion) {
|
||||
auto scheduler = vamp_pico::request_scheduler{};
|
||||
auto request = scheduler.next(500U);
|
||||
BOOST_REQUIRE(request.has_value());
|
||||
BOOST_CHECK(request->kind == vamp_pico::request_kind::DISPLAY);
|
||||
BOOST_CHECK(!scheduler.next(100000U));
|
||||
scheduler.complete(100000U);
|
||||
BOOST_CHECK(!scheduler.next(100999U));
|
||||
request = scheduler.next(101000U);
|
||||
BOOST_REQUIRE(request.has_value());
|
||||
BOOST_CHECK(request->kind == vamp_pico::request_kind::DISPLAY);
|
||||
BOOST_CHECK(!scheduler.next(200000U));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_scheduler_commands_take_priority_and_never_retry) {
|
||||
auto scheduler = vamp_pico::request_scheduler{};
|
||||
BOOST_CHECK(scheduler.enqueue(vamp_protocol::page_command::SELECT) == vamp_pico::enqueue_result::QUEUED);
|
||||
take_command(scheduler, 0U, vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(!scheduler.next(700U));
|
||||
scheduler.complete(700U);
|
||||
BOOST_TEST(scheduler.queued() == 0U);
|
||||
BOOST_CHECK(!scheduler.next(1699U));
|
||||
BOOST_CHECK(scheduler.enqueue(vamp_protocol::page_command::NEXT) == vamp_pico::enqueue_result::QUEUED);
|
||||
take_command(scheduler, 1700U, vamp_protocol::page_command::NEXT);
|
||||
scheduler.complete(1900U);
|
||||
BOOST_CHECK(!scheduler.next(2899U));
|
||||
auto request = scheduler.next(2900U);
|
||||
BOOST_REQUIRE(request.has_value());
|
||||
BOOST_CHECK(request->kind == vamp_pico::request_kind::DISPLAY);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_scheduler_fifo_overflow_is_explicit_and_does_not_replace_commands) {
|
||||
auto scheduler = vamp_pico::request_scheduler{};
|
||||
BOOST_REQUIRE(scheduler.next(0U));
|
||||
constexpr auto commands = std::array{
|
||||
vamp_protocol::page_command::NEXT,
|
||||
vamp_protocol::page_command::PREVIOUS,
|
||||
vamp_protocol::page_command::SELECT,
|
||||
};
|
||||
for (auto index = std::size_t{}; index < 16U; ++index) {
|
||||
BOOST_CHECK(scheduler.enqueue(commands[index % commands.size()]) == vamp_pico::enqueue_result::QUEUED);
|
||||
}
|
||||
BOOST_TEST(scheduler.queued() == 16U);
|
||||
BOOST_CHECK(scheduler.enqueue(vamp_protocol::page_command::SELECT) == vamp_pico::enqueue_result::BUSY);
|
||||
BOOST_TEST(scheduler.queued() == 16U);
|
||||
BOOST_CHECK(!scheduler.next(1000U));
|
||||
scheduler.complete(1000U);
|
||||
for (auto index = std::size_t{}; index < 8U; ++index) {
|
||||
take_command(scheduler, 1000U + index, commands[index % commands.size()]);
|
||||
scheduler.complete(1000U + index);
|
||||
}
|
||||
for (auto index = std::size_t{16}; index < 24U; ++index) {
|
||||
BOOST_CHECK(scheduler.enqueue(commands[index % commands.size()]) == vamp_pico::enqueue_result::QUEUED);
|
||||
}
|
||||
BOOST_TEST(scheduler.queued() == 16U);
|
||||
BOOST_CHECK(scheduler.enqueue(vamp_protocol::page_command::NEXT) == vamp_pico::enqueue_result::BUSY);
|
||||
for (auto index = std::size_t{8}; index < 24U; ++index) {
|
||||
take_command(scheduler, 1000U + index, commands[index % commands.size()]);
|
||||
scheduler.complete(1000U + index);
|
||||
}
|
||||
BOOST_TEST(scheduler.queued() == 0U);
|
||||
BOOST_CHECK(!scheduler.next(2022U));
|
||||
auto request = scheduler.next(2023U);
|
||||
BOOST_REQUIRE(request.has_value());
|
||||
BOOST_CHECK(request->kind == vamp_pico::request_kind::DISPLAY);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_scheduler_duplicate_commands_are_not_coalesced) {
|
||||
auto scheduler = vamp_pico::request_scheduler{};
|
||||
for (auto index = 0U; index < 16U; ++index) {
|
||||
BOOST_CHECK(scheduler.enqueue(vamp_protocol::page_command::NEXT) == vamp_pico::enqueue_result::QUEUED);
|
||||
}
|
||||
for (auto index = 0U; index < 16U; ++index) {
|
||||
take_command(scheduler, index, vamp_protocol::page_command::NEXT);
|
||||
BOOST_CHECK(!scheduler.next(index));
|
||||
scheduler.complete(index);
|
||||
}
|
||||
BOOST_TEST(scheduler.queued() == 0U);
|
||||
BOOST_CHECK(!scheduler.next(16U));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_scheduler_extra_completion_does_not_delay_next_poll) {
|
||||
auto scheduler = vamp_pico::request_scheduler{};
|
||||
scheduler.complete(0U);
|
||||
BOOST_REQUIRE(scheduler.next(0U));
|
||||
scheduler.complete(500U);
|
||||
scheduler.complete(1200U);
|
||||
BOOST_CHECK(!scheduler.next(1499U));
|
||||
BOOST_REQUIRE(scheduler.next(1500U));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_gestures_are_sampled_and_queued_during_slow_request) {
|
||||
auto gesture = vamp_pico::gesture_state{};
|
||||
auto scheduler = vamp_pico::request_scheduler{};
|
||||
BOOST_REQUIRE(scheduler.next(0U));
|
||||
for (auto now_ms = std::uint64_t{}; now_ms < 1504U; now_ms += 8U) {
|
||||
auto touching = (now_ms >= 16U && now_ms < 48U) || (now_ms >= 120U && now_ms < 152U) ||
|
||||
(now_ms >= 240U && now_ms < 1008U) || (now_ms >= 1104U && now_ms < 1144U);
|
||||
if (auto command = gesture.update(touching, now_ms); command) {
|
||||
BOOST_CHECK(scheduler.enqueue(*command) == vamp_pico::enqueue_result::QUEUED);
|
||||
}
|
||||
BOOST_CHECK(!scheduler.next(now_ms));
|
||||
}
|
||||
BOOST_TEST(scheduler.queued() == 3U);
|
||||
scheduler.complete(1504U);
|
||||
take_command(scheduler, 1504U, vamp_protocol::page_command::PREVIOUS);
|
||||
scheduler.complete(1505U);
|
||||
take_command(scheduler, 1505U, vamp_protocol::page_command::SELECT);
|
||||
scheduler.complete(1506U);
|
||||
take_command(scheduler, 1506U, vamp_protocol::page_command::NEXT);
|
||||
scheduler.complete(1507U);
|
||||
BOOST_CHECK(!scheduler.next(2506U));
|
||||
auto request = scheduler.next(2507U);
|
||||
BOOST_REQUIRE(request.has_value());
|
||||
BOOST_CHECK(request->kind == vamp_pico::request_kind::DISPLAY);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_timing_survives_32_bit_millisecond_boundary) {
|
||||
constexpr auto start = std::uint64_t{0xffffffffU} - 100U;
|
||||
auto gesture = vamp_pico::gesture_state{};
|
||||
BOOST_CHECK(!gesture.update(true, start));
|
||||
BOOST_CHECK(!gesture.update(false, start + 40U));
|
||||
check_command(gesture.update(false, start + 261U), vamp_protocol::page_command::NEXT);
|
||||
auto scheduler = vamp_pico::request_scheduler{};
|
||||
BOOST_REQUIRE(scheduler.next(start));
|
||||
scheduler.complete(start + 500U);
|
||||
BOOST_CHECK(!scheduler.next(start + 1499U));
|
||||
BOOST_REQUIRE(scheduler.next(start + 1500U));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_http_client_has_stable_ownership) {
|
||||
BOOST_TEST(!std::is_copy_constructible_v<vamp_pico::http_client>);
|
||||
BOOST_TEST(!std::is_copy_assignable_v<vamp_pico::http_client>);
|
||||
BOOST_TEST(!std::is_move_constructible_v<vamp_pico::http_client>);
|
||||
BOOST_TEST(!std::is_move_assignable_v<vamp_pico::http_client>);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct fake_network {
|
||||
std::array<tcp_pcb, 64> pcbs{};
|
||||
std::size_t allocated{};
|
||||
std::size_t writes{};
|
||||
std::size_t closes{};
|
||||
std::size_t aborts{};
|
||||
std::size_t acknowledged{};
|
||||
std::size_t freed{};
|
||||
std::size_t lock_depth{};
|
||||
std::bool_t address_valid{true};
|
||||
std::bool_t allocate_failure{};
|
||||
std::int8_t connect_error{ERR_OK};
|
||||
std::int8_t write_error{ERR_OK};
|
||||
std::int8_t output_error{ERR_OK};
|
||||
std::int8_t close_error{ERR_OK};
|
||||
std::string written{};
|
||||
std::char_t const *written_data{};
|
||||
std::uint16_t written_size{};
|
||||
};
|
||||
|
||||
auto network = fake_network{};
|
||||
|
||||
auto check_locked(tcp_pcb const *pcb = nullptr) -> void {
|
||||
BOOST_CHECK(network.lock_depth > 0U);
|
||||
if (pcb != nullptr) {
|
||||
BOOST_CHECK(pcb->alive);
|
||||
}
|
||||
}
|
||||
|
||||
auto check_detached(tcp_pcb const *pcb) -> void {
|
||||
BOOST_CHECK(pcb->argument == nullptr);
|
||||
BOOST_CHECK(pcb->receive == nullptr);
|
||||
BOOST_CHECK(pcb->sent == nullptr);
|
||||
BOOST_CHECK(pcb->poll == nullptr);
|
||||
BOOST_CHECK(pcb->error == nullptr);
|
||||
}
|
||||
|
||||
struct fake_lwip_fixture {
|
||||
fake_lwip_fixture() {
|
||||
network = fake_network{};
|
||||
}
|
||||
~fake_lwip_fixture() {
|
||||
BOOST_CHECK(network.lock_depth == 0U);
|
||||
for (auto index = std::size_t{}; index < network.allocated; ++index) {
|
||||
BOOST_CHECK(!network.pcbs[index].alive);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct callback_lock {
|
||||
callback_lock() {
|
||||
cyw43_arch_lwip_begin();
|
||||
}
|
||||
~callback_lock() {
|
||||
cyw43_arch_lwip_end();
|
||||
}
|
||||
};
|
||||
|
||||
auto deliver_connected(tcp_pcb &pcb, std::int8_t error = ERR_OK) -> std::int8_t {
|
||||
auto lock = callback_lock{};
|
||||
if (!pcb.alive || pcb.connected == nullptr) {
|
||||
return ERR_OK;
|
||||
}
|
||||
return pcb.connected(pcb.argument, &pcb, error);
|
||||
}
|
||||
|
||||
auto deliver_data(tcp_pcb &pcb, std::string_view data, std::int8_t error = ERR_OK) -> std::int8_t {
|
||||
auto lock = callback_lock{};
|
||||
if (!pcb.alive || pcb.receive == nullptr) {
|
||||
return ERR_OK;
|
||||
}
|
||||
auto half = static_cast<std::uint16_t>(data.size() / 2U);
|
||||
auto tail_size = static_cast<std::uint16_t>(data.size() - half);
|
||||
auto tail = pbuf{nullptr, data.data() + half, tail_size, tail_size};
|
||||
auto head = pbuf{&tail, data.data(), static_cast<std::uint16_t>(data.size()), half};
|
||||
auto result = pcb.receive(pcb.argument, &pcb, &head, error);
|
||||
BOOST_CHECK(head.freed);
|
||||
BOOST_CHECK(tail.freed);
|
||||
return result;
|
||||
}
|
||||
|
||||
auto deliver_eof(tcp_pcb &pcb) -> std::int8_t {
|
||||
auto lock = callback_lock{};
|
||||
if (!pcb.alive || pcb.receive == nullptr) {
|
||||
return ERR_OK;
|
||||
}
|
||||
return pcb.receive(pcb.argument, &pcb, nullptr, ERR_OK);
|
||||
}
|
||||
|
||||
auto deliver_error(tcp_pcb &pcb) -> void {
|
||||
auto lock = callback_lock{};
|
||||
if (!pcb.alive || pcb.error == nullptr) {
|
||||
return;
|
||||
}
|
||||
auto callback = pcb.error;
|
||||
auto argument = pcb.argument;
|
||||
pcb.alive = false;
|
||||
callback(argument, ERR_RST);
|
||||
}
|
||||
|
||||
constexpr auto display_response = std::string_view{
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n"
|
||||
"active=1\ntitle=Owned response\npart=Piano\nmode=score\npage=2\npages=3\n"};
|
||||
|
||||
auto require_failure(vamp_pico::http_client &client, std::uint64_t now_ms) -> void {
|
||||
BOOST_CHECK(client.poll(now_ms) == vamp_pico::http_status::READY);
|
||||
BOOST_CHECK(client.start({}, now_ms) == vamp_pico::http_start_result::BUSY);
|
||||
auto result = client.take_result();
|
||||
BOOST_REQUIRE(result.has_value());
|
||||
BOOST_CHECK(!result->success);
|
||||
BOOST_CHECK(!client.take_result());
|
||||
BOOST_CHECK(client.poll(now_ms) == vamp_pico::http_status::IDLE);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto cyw43_arch_lwip_begin() -> void {
|
||||
++network.lock_depth;
|
||||
}
|
||||
|
||||
auto cyw43_arch_lwip_end() -> void {
|
||||
BOOST_CHECK(network.lock_depth > 0U);
|
||||
--network.lock_depth;
|
||||
}
|
||||
|
||||
auto ipaddr_aton(std::char_t const *, ip_addr_t *address) -> std::int32_t {
|
||||
check_locked();
|
||||
address->addr = 1U;
|
||||
return network.address_valid ? 1 : 0;
|
||||
}
|
||||
|
||||
auto tcp_new_ip_type(std::uint8_t type) -> tcp_pcb * {
|
||||
check_locked();
|
||||
BOOST_CHECK(type == IPADDR_TYPE_V4);
|
||||
if (network.allocate_failure) {
|
||||
return nullptr;
|
||||
}
|
||||
BOOST_REQUIRE(network.allocated < network.pcbs.size());
|
||||
auto &pcb = network.pcbs[network.allocated++];
|
||||
pcb.alive = true;
|
||||
return &pcb;
|
||||
}
|
||||
|
||||
auto tcp_arg(tcp_pcb *pcb, void *argument) -> void {
|
||||
check_locked(pcb);
|
||||
pcb->argument = argument;
|
||||
}
|
||||
|
||||
auto tcp_recv(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *, pbuf *, std::int8_t)) -> void {
|
||||
check_locked(pcb);
|
||||
pcb->receive = callback;
|
||||
}
|
||||
|
||||
auto tcp_sent(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *, std::uint16_t)) -> void {
|
||||
check_locked(pcb);
|
||||
pcb->sent = callback;
|
||||
}
|
||||
|
||||
auto tcp_poll(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *), std::uint8_t) -> void {
|
||||
check_locked(pcb);
|
||||
pcb->poll = callback;
|
||||
}
|
||||
|
||||
auto tcp_err(tcp_pcb *pcb, void (*callback)(void *, std::int8_t)) -> void {
|
||||
check_locked(pcb);
|
||||
pcb->error = callback;
|
||||
}
|
||||
|
||||
auto tcp_connect(tcp_pcb *pcb, ip_addr_t const *, std::uint16_t, std::int8_t (*callback)(void *, tcp_pcb *, std::int8_t)) -> std::int8_t {
|
||||
check_locked(pcb);
|
||||
pcb->connected = callback;
|
||||
return network.connect_error;
|
||||
}
|
||||
|
||||
auto tcp_write(tcp_pcb *pcb, void const *data, std::uint16_t size, std::uint8_t flags) -> std::int8_t {
|
||||
check_locked(pcb);
|
||||
BOOST_CHECK(flags == TCP_WRITE_FLAG_COPY);
|
||||
network.written_data = static_cast<std::char_t const *>(data);
|
||||
network.written_size = size;
|
||||
network.written.assign(network.written_data, size);
|
||||
++network.writes;
|
||||
return network.write_error;
|
||||
}
|
||||
|
||||
auto tcp_output(tcp_pcb *pcb) -> std::int8_t {
|
||||
check_locked(pcb);
|
||||
return network.output_error;
|
||||
}
|
||||
|
||||
auto tcp_recved(tcp_pcb *pcb, std::uint16_t size) -> void {
|
||||
check_locked(pcb);
|
||||
network.acknowledged += size;
|
||||
}
|
||||
|
||||
auto tcp_close(tcp_pcb *pcb) -> std::int8_t {
|
||||
check_locked(pcb);
|
||||
check_detached(pcb);
|
||||
++network.closes;
|
||||
if (network.close_error == ERR_OK) {
|
||||
pcb->alive = false;
|
||||
}
|
||||
return network.close_error;
|
||||
}
|
||||
|
||||
auto tcp_abort(tcp_pcb *pcb) -> void {
|
||||
check_locked(pcb);
|
||||
check_detached(pcb);
|
||||
pcb->alive = false;
|
||||
++network.aborts;
|
||||
}
|
||||
|
||||
auto pbuf_copy_partial(pbuf const *buffer, void *destination, std::uint16_t length, std::uint16_t offset) -> std::uint16_t {
|
||||
check_locked();
|
||||
auto copied = std::uint16_t{};
|
||||
auto *output = static_cast<std::char_t *>(destination);
|
||||
while (buffer != nullptr && copied < length) {
|
||||
if (offset < buffer->len) {
|
||||
auto size = std::min<std::uint16_t>(length - copied, buffer->len - offset);
|
||||
std::memcpy(output + copied, static_cast<std::char_t const *>(buffer->payload) + offset, size);
|
||||
copied += size;
|
||||
offset = 0U;
|
||||
} else {
|
||||
offset -= buffer->len;
|
||||
}
|
||||
buffer = buffer->next;
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
auto pbuf_free(pbuf *buffer) -> std::uint8_t {
|
||||
check_locked();
|
||||
auto count = std::uint8_t{};
|
||||
while (buffer != nullptr) {
|
||||
BOOST_CHECK(!buffer->freed);
|
||||
buffer->freed = true;
|
||||
++count;
|
||||
++network.freed;
|
||||
buffer = buffer->next;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(pico_http_owns_fragmented_buffers_and_unconsumed_result, fake_lwip_fixture) {
|
||||
auto client = vamp_pico::http_client{};
|
||||
BOOST_CHECK(client.poll(0U) == vamp_pico::http_status::IDLE);
|
||||
BOOST_CHECK(!client.take_result());
|
||||
auto request = vamp_pico::remote_request{vamp_pico::request_kind::COMMAND, vamp_protocol::page_command::SELECT};
|
||||
BOOST_CHECK(client.start(request, 0U) == vamp_pico::http_start_result::STARTED);
|
||||
request.command = vamp_protocol::page_command::PREVIOUS;
|
||||
auto &pcb = network.pcbs[0];
|
||||
BOOST_CHECK(client.poll(10U) == vamp_pico::http_status::IN_FLIGHT);
|
||||
BOOST_TEST(network.writes == 0U);
|
||||
BOOST_CHECK(deliver_connected(pcb) == ERR_OK);
|
||||
BOOST_TEST(network.writes == 1U);
|
||||
BOOST_CHECK(network.written.find("POST /api/remote/select HTTP/1.1\r\n") == 0U);
|
||||
BOOST_CHECK(network.written.find("Content-Length: 2\r\n") != std::string::npos);
|
||||
BOOST_CHECK(network.written.find("\r\n\r\n{}") != std::string::npos);
|
||||
BOOST_CHECK(client.start(request, 20U) == vamp_pico::http_start_result::BUSY);
|
||||
BOOST_CHECK(std::string_view(network.written_data, network.written_size) == network.written);
|
||||
auto response = std::string{display_response};
|
||||
BOOST_CHECK(deliver_data(pcb, std::string_view{response}.substr(0U, 9U)) == ERR_OK);
|
||||
BOOST_CHECK(deliver_data(pcb, std::string_view{response}.substr(9U, 48U)) == ERR_OK);
|
||||
BOOST_CHECK(!client.take_result());
|
||||
BOOST_CHECK(deliver_data(pcb, std::string_view{response}.substr(57U)) == ERR_OK);
|
||||
std::fill(response.begin(), response.end(), 'x');
|
||||
BOOST_CHECK(deliver_eof(pcb) == ERR_OK);
|
||||
check_detached(&pcb);
|
||||
BOOST_CHECK(client.poll(30U) == vamp_pico::http_status::READY);
|
||||
BOOST_CHECK(client.start(request, 30U) == vamp_pico::http_start_result::BUSY);
|
||||
auto result = client.take_result();
|
||||
BOOST_REQUIRE(result.has_value());
|
||||
BOOST_CHECK(result->success);
|
||||
BOOST_CHECK(result->request.command == vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(std::string{result->display.title} == "Owned response");
|
||||
BOOST_TEST(result->display.page == 2U);
|
||||
BOOST_TEST(result->display.pages == 3U);
|
||||
BOOST_TEST(network.acknowledged == display_response.size());
|
||||
BOOST_CHECK(!client.take_result());
|
||||
BOOST_CHECK(client.start({}, 40U) == vamp_pico::http_start_result::STARTED);
|
||||
BOOST_TEST(std::string{result->display.title} == "Owned response");
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(pico_http_cancel_detaches_callbacks_before_reuse, fake_lwip_fixture) {
|
||||
auto client = vamp_pico::http_client{};
|
||||
for (auto connected : {false, true}) {
|
||||
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
|
||||
auto &old = network.pcbs[network.allocated - 1U];
|
||||
if (connected) {
|
||||
BOOST_CHECK(deliver_connected(old) == ERR_OK);
|
||||
BOOST_CHECK(deliver_data(old, "HTTP/1.1 200 OK\r\n") == ERR_OK);
|
||||
}
|
||||
client.cancel();
|
||||
check_detached(&old);
|
||||
BOOST_CHECK(!old.alive);
|
||||
BOOST_CHECK(!client.take_result());
|
||||
BOOST_CHECK(client.poll(1000U) == vamp_pico::http_status::IDLE);
|
||||
BOOST_CHECK(client.start({}, 1000U) == vamp_pico::http_start_result::STARTED);
|
||||
BOOST_CHECK(deliver_connected(old) == ERR_OK);
|
||||
BOOST_CHECK(deliver_data(old, display_response) == ERR_OK);
|
||||
BOOST_CHECK(deliver_eof(old) == ERR_OK);
|
||||
deliver_error(old);
|
||||
BOOST_CHECK(client.poll(1001U) == vamp_pico::http_status::IN_FLIGHT);
|
||||
BOOST_CHECK(!client.take_result());
|
||||
client.cancel();
|
||||
client.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(pico_http_timeout_detaches_callbacks_and_never_retries, fake_lwip_fixture) {
|
||||
auto client = vamp_pico::http_client{};
|
||||
BOOST_CHECK(client.start({vamp_pico::request_kind::COMMAND, vamp_protocol::page_command::NEXT}, 100U) == vamp_pico::http_start_result::STARTED);
|
||||
auto &old = network.pcbs[0];
|
||||
BOOST_CHECK(deliver_connected(old) == ERR_OK);
|
||||
BOOST_CHECK(deliver_data(old, "HTTP/1.1 200 OK\r\n") == ERR_OK);
|
||||
BOOST_CHECK(client.poll(799U) == vamp_pico::http_status::IN_FLIGHT);
|
||||
require_failure(client, 800U);
|
||||
check_detached(&old);
|
||||
BOOST_TEST(network.writes == 1U);
|
||||
BOOST_TEST(network.aborts == 1U);
|
||||
BOOST_CHECK(client.start({}, 900U) == vamp_pico::http_start_result::STARTED);
|
||||
BOOST_CHECK(deliver_eof(old) == ERR_OK);
|
||||
deliver_error(old);
|
||||
BOOST_CHECK(client.poll(1899U) == vamp_pico::http_status::IN_FLIGHT);
|
||||
require_failure(client, 1900U);
|
||||
BOOST_TEST(network.writes == 1U);
|
||||
BOOST_TEST(network.allocated == 2U);
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(pico_http_destructor_aborts_and_detaches, fake_lwip_fixture) {
|
||||
{
|
||||
auto client = vamp_pico::http_client{};
|
||||
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
|
||||
BOOST_CHECK(deliver_connected(network.pcbs[0]) == ERR_OK);
|
||||
}
|
||||
check_detached(&network.pcbs[0]);
|
||||
BOOST_TEST(network.aborts == 1U);
|
||||
BOOST_CHECK(deliver_data(network.pcbs[0], display_response) == ERR_OK);
|
||||
BOOST_CHECK(deliver_eof(network.pcbs[0]) == ERR_OK);
|
||||
deliver_error(network.pcbs[0]);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_http_immediate_failures_are_consumed_once) {
|
||||
for (auto failure = 0U; failure < 3U; ++failure) {
|
||||
auto fixture = fake_lwip_fixture{};
|
||||
auto client = vamp_pico::http_client{};
|
||||
network.address_valid = failure != 0U;
|
||||
network.allocate_failure = failure == 1U;
|
||||
network.connect_error = failure == 2U ? ERR_MEM : ERR_OK;
|
||||
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
|
||||
require_failure(client, 0U);
|
||||
BOOST_TEST(network.writes == 0U);
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_http_connected_write_output_and_receive_failures_abort) {
|
||||
for (auto failure = 0U; failure < 4U; ++failure) {
|
||||
auto fixture = fake_lwip_fixture{};
|
||||
auto client = vamp_pico::http_client{};
|
||||
network.write_error = failure == 1U ? ERR_MEM : ERR_OK;
|
||||
network.output_error = failure == 2U ? ERR_MEM : ERR_OK;
|
||||
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
|
||||
auto &pcb = network.pcbs[0];
|
||||
auto connected = deliver_connected(pcb, failure == 0U ? ERR_RST : ERR_OK);
|
||||
if (failure == 3U) {
|
||||
BOOST_CHECK(connected == ERR_OK);
|
||||
BOOST_CHECK(deliver_data(pcb, display_response, ERR_RST) == ERR_ABRT);
|
||||
} else {
|
||||
BOOST_CHECK(connected == ERR_ABRT);
|
||||
}
|
||||
require_failure(client, 10U);
|
||||
check_detached(&pcb);
|
||||
BOOST_TEST(network.aborts == 1U);
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(pico_http_error_callback_does_not_touch_freed_pcb, fake_lwip_fixture) {
|
||||
auto client = vamp_pico::http_client{};
|
||||
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
|
||||
deliver_error(network.pcbs[0]);
|
||||
require_failure(client, 1U);
|
||||
BOOST_TEST(network.aborts == 0U);
|
||||
BOOST_TEST(network.closes == 0U);
|
||||
BOOST_CHECK(client.start({}, 2U) == vamp_pico::http_start_result::STARTED);
|
||||
BOOST_CHECK(deliver_connected(network.pcbs[1]) == ERR_OK);
|
||||
deliver_error(network.pcbs[1]);
|
||||
require_failure(client, 3U);
|
||||
BOOST_TEST(network.aborts == 0U);
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(pico_http_close_failure_aborts_with_correct_callback_result, fake_lwip_fixture) {
|
||||
auto client = vamp_pico::http_client{};
|
||||
network.close_error = ERR_MEM;
|
||||
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
|
||||
auto &pcb = network.pcbs[0];
|
||||
BOOST_CHECK(deliver_connected(pcb) == ERR_OK);
|
||||
BOOST_CHECK(deliver_data(pcb, display_response) == ERR_OK);
|
||||
BOOST_CHECK(deliver_eof(pcb) == ERR_ABRT);
|
||||
auto result = client.take_result();
|
||||
BOOST_REQUIRE(result.has_value());
|
||||
BOOST_CHECK(result->success);
|
||||
BOOST_TEST(network.closes == 1U);
|
||||
BOOST_TEST(network.aborts == 1U);
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(pico_http_malformed_headers_status_and_body_fail, fake_lwip_fixture) {
|
||||
auto client = vamp_pico::http_client{};
|
||||
for (auto response : {
|
||||
"",
|
||||
"HTTP/1.1 2",
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n",
|
||||
"HTTP/1.1 500 Error\r\n\r\nactive=1\n",
|
||||
"HTTP/1.1 2x0 OK\r\n\r\nactive=1\n",
|
||||
"HTTP/1.1 20x OK\r\n\r\nactive=1\n",
|
||||
"HTTP/1.1 2000 OK\r\n\r\nactive=1\n",
|
||||
"HTTP/2.0 200 OK\r\n\r\nactive=1\n",
|
||||
"HTTP/1.1 200 OK\r\n\r\nactive=1\npage=invalid\n",
|
||||
}) {
|
||||
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
|
||||
auto &pcb = network.pcbs[network.allocated - 1U];
|
||||
BOOST_CHECK(deliver_connected(pcb) == ERR_OK);
|
||||
BOOST_CHECK(deliver_data(pcb, response) == ERR_OK);
|
||||
deliver_eof(pcb);
|
||||
require_failure(client, 1U);
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(pico_http_http10_and_heartbeat_are_supported, fake_lwip_fixture) {
|
||||
auto client = vamp_pico::http_client{};
|
||||
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
|
||||
auto &display = network.pcbs[0];
|
||||
BOOST_CHECK(deliver_connected(display) == ERR_OK);
|
||||
BOOST_CHECK(network.written.find("GET /api/remote/display HTTP/1.1\r\n") == 0U);
|
||||
BOOST_CHECK(deliver_data(display, "HTTP/1.0 200 OK\r\n\r\nactive=1\ntitle=HTTP10\n") == ERR_OK);
|
||||
BOOST_CHECK(deliver_eof(display) == ERR_OK);
|
||||
auto result = client.take_result();
|
||||
BOOST_REQUIRE(result.has_value());
|
||||
BOOST_CHECK(result->success);
|
||||
BOOST_TEST(std::string{result->display.title} == "HTTP10");
|
||||
BOOST_CHECK(client.start({vamp_pico::request_kind::HEARTBEAT}, 10U) == vamp_pico::http_start_result::STARTED);
|
||||
auto &heartbeat = network.pcbs[1];
|
||||
BOOST_CHECK(deliver_connected(heartbeat) == ERR_OK);
|
||||
BOOST_CHECK(network.written.find("POST /api/remote/heartbeat HTTP/1.1\r\n") == 0U);
|
||||
BOOST_CHECK(deliver_data(heartbeat, "HTTP/1.1 204 No Content\r\n\r\n") == ERR_OK);
|
||||
BOOST_CHECK(deliver_eof(heartbeat) == ERR_OK);
|
||||
result = client.take_result();
|
||||
BOOST_REQUIRE(result.has_value());
|
||||
BOOST_CHECK(result->success);
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(pico_http_oversized_response_is_rejected_without_truncation, fake_lwip_fixture) {
|
||||
auto client = vamp_pico::http_client{};
|
||||
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
|
||||
auto &pcb = network.pcbs[0];
|
||||
BOOST_CHECK(deliver_connected(pcb) == ERR_OK);
|
||||
auto response = std::string{display_response};
|
||||
response.resize(1024U, 'x');
|
||||
BOOST_CHECK(deliver_data(pcb, response) == ERR_OK);
|
||||
BOOST_CHECK(deliver_data(pcb, "x") == ERR_ABRT);
|
||||
require_failure(client, 1U);
|
||||
check_detached(&pcb);
|
||||
BOOST_TEST(network.acknowledged == 1025U);
|
||||
BOOST_TEST(network.aborts == 1U);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <boost/asio/redirect_error.hpp>
|
||||
#include <boost/asio/steady_timer.hpp>
|
||||
#include <boost/asio/use_awaitable.hpp>
|
||||
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/score_loader.hpp>
|
||||
|
||||
|
||||
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<std::expected<vamp_zero::score_document, modulus::error>> {
|
||||
requests.push_back(reference);
|
||||
auto timer = std::make_shared<boost::asio::steady_timer>(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<vamp_zero::score_reference> requests{};
|
||||
std::vector<std::shared_ptr<boost::asio::steady_timer>> 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<controlled_scores> scores{std::make_shared<controlled_scores>(io)};
|
||||
std::shared_ptr<vamp_zero::catalogue const> catalog{std::make_shared<vamp_zero::catalogue const>()};
|
||||
std::shared_ptr<std::size_t> completions{std::make_shared<std::size_t>()};
|
||||
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<vamp_zero::catalogue const>();
|
||||
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<controlled_scores>(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<vamp_zero::catalogue const>(), 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<void>{};
|
||||
auto entered_future = entered.get_future();
|
||||
auto resume = std::promise<void>{};
|
||||
auto resume_future = resume.get_future().share();
|
||||
auto consumer = std::make_shared<std::atomic<std::size_t>>(0U);
|
||||
auto loader = std::make_unique<vamp_zero::score_loader>(io.get_executor(), [](vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> { 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<vamp_zero::catalogue const>(), 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<vamp_zero::catalogue>();
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user