Shared Catalog

This commit is contained in:
khodowany
2026-09-17 00:44:19 -05:00
parent aa1273b07d
commit 5941a4cd49
44 changed files with 3613 additions and 733 deletions
+9
View File
@@ -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
View File
@@ -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;
+17
View File
@@ -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;
+35
View File
@@ -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
View File
@@ -0,0 +1,4 @@
#pragma once
auto cyw43_arch_lwip_begin() -> void;
auto cyw43_arch_lwip_end() -> void;
+15 -37
View File
@@ -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");
}
+564
View File
@@ -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);
}
}
+411
View File
@@ -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);
}
+172
View File
@@ -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)
+697
View File
@@ -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);
}
+346
View File
@@ -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));
}