Shared Catalog
ci / checks (push) Failing after 1h5m14s

This commit is contained in:
khodowany
2026-09-17 01:25:26 -05:00
parent 5941a4cd49
commit fa385205e2
23 changed files with 742 additions and 242 deletions
+2
View File
@@ -7,6 +7,7 @@ add_executable(product-vamp-test
test_health.cpp
test_pico.cpp
test_score_loader.cpp
test_touch_input.cpp
)
target_include_directories(product-vamp-test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
@@ -14,6 +15,7 @@ target_include_directories(product-vamp-test PRIVATE
)
target_link_libraries(product-vamp-test PRIVATE
vamp-zero-core
modulus::raylib
)
target_compile_features(product-vamp-test PRIVATE cxx_std_23)
target_compile_definitions(product-vamp-test PRIVATE
+17
View File
@@ -62,3 +62,20 @@ class CatalogueTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'output path collides'):
generate_practice_scores(source, output)
self.assertFalse(output.exists())
def test_runtime_id_collisions_preserve_existing_output(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
names = ('chord/one-two/three.xml', 'chord/one/two-three.xml')
for name in names:
path = source / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(xml_score(xml_note()), encoding='utf-8')
output.mkdir()
sentinel = output / 'existing.json'
sentinel.write_text('unchanged', encoding='utf-8')
with self.assertRaisesRegex(ValueError, 'practice exercise ID collision') as error:
generate_practice_scores(source, output)
for name in names:
self.assertIn(name, str(error.exception))
self.assertEqual(list(output.iterdir()), [sentinel])
self.assertEqual(sentinel.read_text(), 'unchanged')
+71
View File
@@ -562,3 +562,74 @@ BOOST_AUTO_TEST_CASE(catalogue_changed_score_json_invalidates_metadata_and_requi
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);
}
}
BOOST_AUTO_TEST_CASE(catalogue_rejects_ambiguous_paths_before_import_or_direct_load) {
for (auto key : {"c", "d"}) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
db.write("songs/tune.json", catalogue_bundle("Original"));
db.write("practice/chord/one-two/three/c.json", catalogue_bundle("Original"));
BOOST_REQUIRE(db.import_assets());
auto songs = db.rows("songs");
auto practice = db.rows("practice_exercises");
db.write("songs/tune.json", catalogue_bundle("Changed"));
auto second = std::string{"practice/chord/one/two-three/"} + key + ".json";
db.write(second, catalogue_bundle("Conflicting", key));
auto result = db.import_assets();
BOOST_REQUIRE(!result);
auto message = result.error().describe();
BOOST_TEST(message.find("chord-one-two-three") != std::string::npos);
BOOST_TEST(message.find("practice/chord/one-two/three/c.json") != std::string::npos);
BOOST_TEST(message.find(second) != std::string::npos);
BOOST_TEST(!vamp_zero::load_practice_library(db.assets.string()));
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
BOOST_TEST(db.rows("practice_exercises") == practice, boost::test_tools::per_element());
}
}
BOOST_AUTO_TEST_CASE(catalogue_changed_import_keeps_complete_cached_metadata) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
for (auto title : {"Original", "Changed"}) {
db.write("songs/tune.json", catalogue_bundle(title, "D", "Eb"));
db.write("practice/chord/lesson/exercise/d.json", catalogue_bundle(title, "D", "Eb"));
BOOST_REQUIRE(db.import_assets());
for (auto table : {"songs", "practice_exercises"}) {
auto result = db.query("SELECT measure_count, total_pages, authored_key_signature FROM " + std::string{table});
BOOST_REQUIRE_EQUAL(result.rows.size(), 1U);
BOOST_TEST(std::get<std::int64_t>(result.rows[0][0].value) == 3);
BOOST_TEST(std::get<std::int64_t>(result.rows[0][1].value) == 2);
BOOST_TEST(std::get<std::string>(result.rows[0][2].value) == "Eb");
}
}
}
BOOST_AUTO_TEST_CASE(catalogue_rejects_ambiguous_variant_ids_with_distinct_exercise_ids) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
db.write("practice/chord/lesson/exercise/c-d.json", catalogue_bundle("First"));
db.write("practice/chord/lesson/exercise-c/d.json", catalogue_bundle("Second"));
auto result = db.import_assets();
BOOST_REQUIRE(!result);
BOOST_TEST(result.error().describe().find("practice variant ID collision 'chord-lesson-exercise-c-d'") != std::string::npos);
BOOST_TEST(db.rows("practice_exercises").empty());
}
BOOST_AUTO_TEST_CASE(catalogue_backfill_uses_pending_indexes_and_preserves_large_mixed_catalogues) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
BOOST_REQUIRE(db.ensure());
for (auto table : {"songs", "practice_exercises"}) {
auto name = std::string{table};
db.query("WITH RECURSIVE sequence(n) AS (VALUES(1) UNION ALL SELECT n + 1 FROM sequence WHERE n < 4000) INSERT INTO " + name + "(id, title, source_json, created_at_ms, updated_at_ms, catalog_managed, measure_count, total_pages, authored_key_signature) SELECT 'score-' || n, 'Fixture', ?1, 1, 2, 0, CASE WHEN n % 2 = 0 THEN 3 END, 2, 'Eb' FROM sequence", {noto::sqlite_value{catalogue_bundle("Fixture", "D", "Eb")}});
auto plan = db.query("EXPLAIN QUERY PLAN SELECT id, source_path, source_json FROM " + name + " WHERE measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL LIMIT 1");
BOOST_REQUIRE_EQUAL(plan.rows.size(), 1U);
BOOST_TEST(std::get<std::string>(plan.rows[0][3].value).find("idx_" + name + "_pending_metadata") != std::string::npos);
}
BOOST_REQUIRE(db.performance());
BOOST_REQUIRE(db.practice());
for (auto table : {"songs", "practice_exercises"}) {
auto rows = db.query("SELECT count(*) FROM " + std::string{table} + " WHERE measure_count = 3 AND total_pages = 2 AND authored_key_signature = 'Eb' AND created_at_ms = 1 AND updated_at_ms = 2 AND catalog_managed = 0 AND source_json = ?1", {noto::sqlite_value{catalogue_bundle("Fixture", "D", "Eb")}});
BOOST_TEST(std::get<std::int64_t>(rows.rows[0][0].value) == 4000);
}
}
+80 -5
View File
@@ -18,6 +18,9 @@
#include <boost/asio/use_future.hpp>
#include <boost/json.hpp>
#include <openssl/sha.h>
#include <raylib.h>
#include <unistd.h>
#include <vamp_zero/app.hpp>
#include <vamp_zero/database.hpp>
@@ -118,10 +121,15 @@ auto config_file(std::filesystem::path const &root, vamp_zero::config const &con
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 fixture_page(std::int32_t width = 976, std::int32_t height = 484) -> std::string {
auto image = GenImageColor(width, height, WHITE);
auto size = std::int32_t{};
auto bytes = ExportImageToMemory(image, ".png", &size);
UnloadImage(image);
BOOST_REQUIRE(bytes != nullptr);
auto result = std::string{reinterpret_cast<std::char_t const *>(bytes), static_cast<std::size_t>(size)};
MemFree(bytes);
return result;
}
auto checksum(std::string const &bytes) -> std::string {
@@ -354,7 +362,7 @@ BOOST_AUTO_TEST_CASE(readiness_rejects_corrupt_checksums_and_escaping_pages) {
std::filesystem::create_symlink(root.path / "outside.png", page);
} else {
auto bytes = fixture_page();
bytes.back() = 0;
bytes.back() ^= 1;
write_file(page, bytes);
}
auto ready = run_once(root.path, config);
@@ -387,6 +395,73 @@ BOOST_AUTO_TEST_CASE(readiness_rejects_unusable_database_and_runtime_paths) {
BOOST_TEST(read_file(root.path / "blocked") == "regular file");
}
BOOST_AUTO_TEST_CASE(readiness_decodes_pages_even_when_checksums_match) {
for (auto kind : {"truncated", "missing pixels", "wrong dimensions", "oversized"}) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto bytes = fixture_page(std::string_view{kind} == "wrong dimensions" ? 10 : 976);
if (std::string_view{kind} == "truncated") {
bytes.resize(24U);
} else if (std::string_view{kind} == "missing pixels") {
bytes.resize(33U);
} else if (std::string_view{kind} == "oversized") {
bytes[16] = 127;
}
auto score_path = std::filesystem::path{config.runtime.assets_path} / "songs" / "first.json";
auto score = boost::json::parse(read_file(score_path));
auto &page = score.at("presentation").at("pages").as_array()[0].as_object();
write_file(score_path.parent_path() / std::string{page.at("path").as_string()}, bytes);
page["sha256"] = checksum(bytes);
write_file(score_path, boost::json::serialize(score));
auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(!ready, kind);
BOOST_TEST(ready.error().describe().find("first.pages/1.png") != std::string::npos);
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
}
}
BOOST_AUTO_TEST_CASE(readiness_rejects_actual_heartbeat_directories_and_symlinks) {
for (auto kind : {"directory", "symlink", "dangling symlink"}) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
std::filesystem::create_directories(std::filesystem::path{config.runtime.heartbeat_path}.parent_path());
if (std::string_view{kind} == "directory") {
std::filesystem::create_directory(config.runtime.heartbeat_path);
} else {
if (std::string_view{kind} == "symlink") {
write_file(root.path / "target", "untouched");
}
std::filesystem::create_symlink(root.path / "target", config.runtime.heartbeat_path);
}
auto ready = run_once(root.path, config);
BOOST_REQUIRE(!ready);
BOOST_TEST(ready.error().describe().find(config.runtime.heartbeat_path) != std::string::npos);
require_no_probe(config, true);
if (std::string_view{kind} == "symlink") {
BOOST_TEST(read_file(root.path / "target") == "untouched");
}
}
}
BOOST_AUTO_TEST_CASE(readiness_rejects_unwritable_parent_without_leaving_probes) {
if (::geteuid() == 0) {
BOOST_TEST_MESSAGE("Permission check requires an unprivileged test process");
return;
}
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto parent = std::filesystem::path{config.runtime.heartbeat_path}.parent_path();
std::filesystem::create_directory(parent);
std::filesystem::permissions(parent, std::filesystem::perms::owner_read | std::filesystem::perms::owner_exec);
auto ready = run_once(root.path, config);
std::filesystem::permissions(parent, std::filesystem::perms::owner_all);
BOOST_TEST(!ready);
require_no_probe(config);
}
BOOST_AUTO_TEST_CASE(readiness_rejects_occupied_bind_and_releases_successful_probe) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
+8 -8
View File
@@ -33,7 +33,7 @@ auto take_command(vamp_pico::request_scheduler &scheduler, std::uint64_t now_ms,
}
BOOST_AUTO_TEST_CASE(pico_gesture_single_tap_waits_for_double_tap_deadline) {
auto gesture = vamp_pico::gesture_state{};
auto gesture = vamp_protocol::gesture_state{};
BOOST_CHECK(!gesture.update(false, 0U));
BOOST_CHECK(!gesture.update(true, 10U));
BOOST_CHECK(!gesture.update(false, 50U));
@@ -43,7 +43,7 @@ BOOST_AUTO_TEST_CASE(pico_gesture_single_tap_waits_for_double_tap_deadline) {
}
BOOST_AUTO_TEST_CASE(pico_gesture_double_tap_includes_deadline_and_has_no_trailing_next) {
auto gesture = vamp_pico::gesture_state{};
auto gesture = vamp_protocol::gesture_state{};
BOOST_CHECK(!gesture.update(true, 0U));
BOOST_CHECK(!gesture.update(false, 40U));
BOOST_CHECK(!gesture.update(true, 180U));
@@ -52,7 +52,7 @@ BOOST_AUTO_TEST_CASE(pico_gesture_double_tap_includes_deadline_and_has_no_traili
}
BOOST_AUTO_TEST_CASE(pico_gesture_late_second_release_preserves_both_single_taps) {
auto gesture = vamp_pico::gesture_state{};
auto gesture = vamp_protocol::gesture_state{};
BOOST_CHECK(!gesture.update(true, 0U));
BOOST_CHECK(!gesture.update(false, 40U));
BOOST_CHECK(!gesture.update(true, 180U));
@@ -62,7 +62,7 @@ BOOST_AUTO_TEST_CASE(pico_gesture_late_second_release_preserves_both_single_taps
}
BOOST_AUTO_TEST_CASE(pico_gesture_expired_tap_is_emitted_when_next_press_starts) {
auto gesture = vamp_pico::gesture_state{};
auto gesture = vamp_protocol::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);
@@ -71,7 +71,7 @@ BOOST_AUTO_TEST_CASE(pico_gesture_expired_tap_is_emitted_when_next_press_starts)
}
BOOST_AUTO_TEST_CASE(pico_gesture_hold_emits_once_and_suppresses_release) {
auto gesture = vamp_pico::gesture_state{};
auto gesture = vamp_protocol::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);
@@ -84,7 +84,7 @@ BOOST_AUTO_TEST_CASE(pico_gesture_hold_emits_once_and_suppresses_release) {
}
BOOST_AUTO_TEST_CASE(pico_gesture_hold_consumes_pending_tap_even_if_release_is_next_sample) {
auto gesture = vamp_pico::gesture_state{};
auto gesture = vamp_protocol::gesture_state{};
BOOST_CHECK(!gesture.update(true, 0U));
BOOST_CHECK(!gesture.update(false, 40U));
BOOST_CHECK(!gesture.update(true, 180U));
@@ -185,7 +185,7 @@ BOOST_AUTO_TEST_CASE(pico_scheduler_extra_completion_does_not_delay_next_poll) {
}
BOOST_AUTO_TEST_CASE(pico_gestures_are_sampled_and_queued_during_slow_request) {
auto gesture = vamp_pico::gesture_state{};
auto gesture = vamp_protocol::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) {
@@ -212,7 +212,7 @@ BOOST_AUTO_TEST_CASE(pico_gestures_are_sampled_and_queued_during_slow_request) {
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{};
auto gesture = vamp_protocol::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);
+152
View File
@@ -0,0 +1,152 @@
#include <boost/test/unit_test.hpp>
#include <chrono>
#include <cstdint>
#include <string>
#include <vector>
#include <boost/asio/io_context.hpp>
#include <boost/asio/post.hpp>
#include <vamp_protocol/gesture.hpp>
#include <vamp_zero/touch_input.hpp>
namespace {
auto touch_event(luma::touch_phase phase, std::chrono::steady_clock::time_point time, std::int32_t id = 1) -> luma::touch_event {
return {.points = {{.id = id, .phase = phase}}, .time = time};
}
struct touch_sample {
std::bool_t pressed;
std::uint64_t milliseconds;
};
}
BOOST_AUTO_TEST_CASE(zero_and_pico_share_timestamped_gesture_transitions) {
auto sequences = std::vector<std::vector<touch_sample>>{
{{true, 0}, {false, 30}},
{{true, 0}, {false, 30}, {true, 200}, {false, 300}},
{{true, 0}, {false, 40}, {true, 180}, {false, 260}},
{{true, 0}, {false, 40}, {true, 180}, {false, 261}},
{{true, 0}, {false, 40}, {true, 261}, {false, 300}},
{{true, 50}, {true, 749}, {true, 750}, {true, 900}, {false, 910}},
{{true, 0}, {false, 40}, {true, 180}, {false, 880}},
{{true, 0}, {false, 699}},
{{true, 0}, {false, 700}},
};
auto expected = std::vector<std::vector<std::string>>{
{"next"},
{"next", "next"},
{"previous"},
{"next", "next"},
{"next", "next"},
{"select"},
{"select"},
{"next"},
{"select"},
};
for (auto index = std::size_t{}; index < sequences.size(); ++index) {
auto io = boost::asio::io_context{};
auto zero_commands = std::vector<std::string>{};
auto pico_commands = std::vector<std::string>{};
auto zero = vamp_zero::touch_input{io.get_executor(), [&](auto command) { zero_commands.emplace_back(vamp_protocol::page_command_name(command)); }};
auto pico = vamp_protocol::gesture_state{};
auto base = std::chrono::steady_clock::now() - std::chrono::seconds{10};
auto down = std::bool_t{false};
for (auto sample : sequences[index]) {
auto phase = sample.pressed ? (down ? luma::touch_phase::MOVE : luma::touch_phase::DOWN) : luma::touch_phase::UP;
BOOST_TEST(zero.handle(touch_event(phase, base + std::chrono::milliseconds{sample.milliseconds})));
if (auto command = pico.update(sample.pressed, sample.milliseconds)) {
pico_commands.emplace_back(vamp_protocol::page_command_name(*command));
}
down = sample.pressed;
}
if (auto command = pico.update(false, 2000U)) {
pico_commands.emplace_back(vamp_protocol::page_command_name(*command));
}
io.run();
BOOST_TEST(zero_commands == expected[index], boost::test_tools::per_element());
BOOST_TEST(pico_commands == expected[index], boost::test_tools::per_element());
}
}
BOOST_AUTO_TEST_CASE(shared_gesture_deadline_and_reset_discard_pending_actions) {
auto gesture = vamp_protocol::gesture_state{};
BOOST_TEST(!gesture.pending_deadline());
BOOST_TEST(!gesture.update(true, 0U));
BOOST_TEST(!gesture.update(false, 30U));
BOOST_REQUIRE(gesture.pending_deadline());
BOOST_TEST(*gesture.pending_deadline() == 251U);
BOOST_TEST(!gesture.update(false, 250U));
BOOST_TEST(!gesture.update(true, 250U));
BOOST_TEST(!gesture.pending_deadline());
gesture.reset();
BOOST_TEST(!gesture.pending_deadline());
BOOST_TEST(!gesture.update(false, 2000U));
}
BOOST_AUTO_TEST_CASE(zero_touch_timer_emits_a_single_tap_without_further_input) {
auto io = boost::asio::io_context{};
auto commands = std::vector<std::string>{};
auto touch = vamp_zero::touch_input{io.get_executor(), [&](auto command) { commands.emplace_back(vamp_protocol::page_command_name(command)); }};
auto now = std::chrono::steady_clock::now();
touch.handle(touch_event(luma::touch_phase::DOWN, now));
touch.handle(touch_event(luma::touch_phase::UP, now));
io.poll();
BOOST_TEST(commands.empty());
io.run();
BOOST_REQUIRE_EQUAL(commands.size(), 1U);
BOOST_TEST(commands.front() == "next");
}
BOOST_AUTO_TEST_CASE(zero_touch_filters_other_fingers_and_cancels_pending_actions) {
auto io = boost::asio::io_context{};
auto commands = std::vector<std::string>{};
auto touch = vamp_zero::touch_input{io.get_executor(), [&](auto command) { commands.emplace_back(vamp_protocol::page_command_name(command)); }};
auto base = std::chrono::steady_clock::now() - std::chrono::seconds{10};
BOOST_TEST(touch.handle(touch_event(luma::touch_phase::DOWN, base)));
BOOST_TEST(!touch.handle(touch_event(luma::touch_phase::DOWN, base, 2)));
BOOST_TEST(!touch.handle(touch_event(luma::touch_phase::UP, base, 2)));
BOOST_TEST(touch.handle(touch_event(luma::touch_phase::UP, base + std::chrono::milliseconds{30})));
BOOST_TEST(touch.handle(touch_event(luma::touch_phase::DOWN, base + std::chrono::milliseconds{200})));
BOOST_TEST(touch.handle(touch_event(luma::touch_phase::CANCEL, base + std::chrono::milliseconds{210})));
BOOST_TEST(!touch.handle(touch_event(luma::touch_phase::UP, base + std::chrono::milliseconds{220})));
io.run();
BOOST_TEST(commands.empty());
}
BOOST_AUTO_TEST_CASE(zero_touch_stale_timer_cannot_consume_newer_gesture) {
auto io = boost::asio::io_context{};
auto commands = std::vector<std::string>{};
auto touch = vamp_zero::touch_input{io.get_executor(), [&](auto command) { commands.emplace_back(vamp_protocol::page_command_name(command)); }};
auto base = std::chrono::steady_clock::now() - std::chrono::seconds{10};
touch.handle(touch_event(luma::touch_phase::DOWN, base));
touch.handle(touch_event(luma::touch_phase::UP, base + std::chrono::milliseconds{30}));
boost::asio::post(io, [&] {
touch.handle(touch_event(luma::touch_phase::DOWN, base + std::chrono::milliseconds{200}));
touch.handle(touch_event(luma::touch_phase::UP, base + std::chrono::milliseconds{220}));
});
io.run();
BOOST_REQUIRE_EQUAL(commands.size(), 1U);
BOOST_TEST(commands.front() == "previous");
}
BOOST_AUTO_TEST_CASE(zero_touch_stop_and_destruction_suppress_queued_commands) {
for (auto hold : {false, true}) {
auto io = boost::asio::io_context{};
auto commands = std::vector<std::string>{};
{
auto touch = vamp_zero::touch_input{io.get_executor(), [&](auto command) { commands.emplace_back(vamp_protocol::page_command_name(command)); }};
auto base = std::chrono::steady_clock::now() - std::chrono::seconds{10};
touch.handle(touch_event(luma::touch_phase::DOWN, base));
touch.handle(touch_event(luma::touch_phase::UP, base + std::chrono::milliseconds{hold ? 700 : 30}));
touch.stop();
BOOST_TEST(!touch.handle(touch_event(luma::touch_phase::DOWN, base)));
}
io.run();
BOOST_TEST(commands.empty());
}
}