From fa385205e2636045d0f7df19f737e646653184cc Mon Sep 17 00:00:00 2001 From: khodowany Date: Thu, 17 Sep 2026 01:25:26 -0500 Subject: [PATCH] Shared Catalog --- TECHNICAL_GUIDE.md | 8 +- cmake/pico.cmake | 1 + cmake/zero.cmake | 5 +- generator/practice/generate.py | 5 + include/vamp_pico/input.hpp | 13 +-- include/vamp_protocol/gesture.hpp | 24 +++++ include/vamp_zero/score.hpp | 1 + include/vamp_zero/touch_input.hpp | 28 ++++++ src/vamp_pico/input.cpp | 36 ------- src/vamp_pico/main.cpp | 2 +- src/vamp_protocol/gesture.cpp | 54 +++++++++++ src/vamp_zero/app.cpp | 98 +++++++++++++------ src/vamp_zero/database.cpp | 31 +++++- src/vamp_zero/library.cpp | 22 +++++ src/vamp_zero/score.cpp | 52 +++++++++- src/vamp_zero/touch_input.cpp | 114 ++++++++++++++++++++++ src/vamp_zero/ui.cpp | 147 ++--------------------------- tests/CMakeLists.txt | 2 + tests/test_catalogue.py | 17 ++++ tests/test_catalogue_runtime.cpp | 71 ++++++++++++++ tests/test_health.cpp | 85 ++++++++++++++++- tests/test_pico.cpp | 16 ++-- tests/test_touch_input.cpp | 152 ++++++++++++++++++++++++++++++ 23 files changed, 742 insertions(+), 242 deletions(-) create mode 100644 include/vamp_protocol/gesture.hpp create mode 100644 include/vamp_zero/touch_input.hpp create mode 100644 src/vamp_protocol/gesture.cpp create mode 100644 src/vamp_zero/touch_input.cpp create mode 100644 tests/test_touch_input.cpp diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 8d5c3b8..03a47d4 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -33,12 +33,16 @@ On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts wi At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice////.json`, ensures the current SQLite schema with Noto, imports both catalogs, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody and chord practice both offer key selection by choosing a pre-generated score variant. The score view displays build-time engraved PNG pages. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host, not on the Pi. The view retains at most two page textures. -Runtime catalogues contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups and practice lessons reference canonical records by index. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Imports process one file at a time in a transaction; failed imports roll back, and unmanaged rows remain untouched. Legacy metadata is backfilled one score at a time. Selecting a score loads only that bundle asynchronously, discarding stale results after navigation; the view keeps at most two page textures. +Runtime catalogues contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups and practice lessons reference canonical records by index. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Imports process one file at a time in a transaction; failed imports roll back, and unmanaged rows remain untouched. Legacy metadata is backfilled one score at a time from an indexed pending set. External score-JSON changes invalidate cached metadata; imports restore their already-validated metadata within the transaction. Selecting a score loads only that bundle asynchronously, discarding stale results after navigation; the view keeps at most two page textures. -`--once` (also `runtime.exit_after_startup`) is a headless readiness check: it imports and validates the catalogue and referenced page assets, checks runtime-directory writes and the configured server bind, and exits without opening audio/display devices or publishing a readiness heartbeat. `--health-check` instead probes the running service's read-only `/api/health` endpoint using the configured timeout. A stopped/unresponsive service or an invalid response fails the check; it does not create or repair files. `--check-config` remains configuration-only. +Practice IDs retain their existing hyphen-joined format. Generation and runtime scanning reject distinct paths that produce the same exercise or variant ID, reporting both paths before replacing assets or importing rows. For example, `chord/one-two/three/c.json` and `chord/one/two-three/c.json` cannot coexist; rename one lesson or exercise to give it a distinct ID. + +`--once` (also `runtime.exit_after_startup`) is a headless readiness check: it imports and validates the catalogue and referenced page assets, checks runtime-directory writes and the configured server bind, and exits without opening audio/display devices or publishing a readiness heartbeat. Page checks include containment, checksums, CPU-only PNG decoding, and exact 976x484 dimensions. The configured heartbeat destination must be absent or a regular file, never a directory, symlink, or special file. Runtime writes and readiness probes use exclusive sibling temporary files; readiness leaves an existing heartbeat unchanged and removes its probes. `--health-check` instead probes the running service's read-only `/api/health` endpoint using the configured timeout. A stopped/unresponsive service or an invalid response fails the check; it does not create or repair files. `--check-config` remains configuration-only. Pico networking progresses without blocking touch sampling. Navigation commands use a bounded 16-entry FIFO ahead of background status polls; a full queue reports busy. Commands are not automatically retried because a timed-out request may already have executed. Background polls wait one second after completion before trying again. +Zero and Pico share one gesture state machine: a second release within 220 ms of the first release produces previous, a hold of at least 700 ms produces select once, and other taps produce next. A late second release preserves both single taps; holding consumes a pending first tap. Zero schedules pending-tap deadlines without polling and discards cancelled or obsolete timer callbacks. + ## Score Generation The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody and chord practice sources are emitted in all 12 keys at build time. Generated JSON keeps notation metadata while removing generator-only optional chord markers. The catalogue produces three bundles per song and twelve per practice exercise; source counts are discovered, not fixed in code. diff --git a/cmake/pico.cmake b/cmake/pico.cmake index 9c078aa..df34e3b 100644 --- a/cmake/pico.cmake +++ b/cmake/pico.cmake @@ -84,6 +84,7 @@ add_executable(product-vamp-pico2w src/vamp_pico/remote_display.cpp src/vamp_pico/touch.cpp src/vamp_protocol/page_control.cpp + src/vamp_protocol/gesture.cpp ) target_include_directories(product-vamp-pico2w PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include diff --git a/cmake/zero.cmake b/cmake/zero.cmake index 65f7bbd..291bc1a 100644 --- a/cmake/zero.cmake +++ b/cmake/zero.cmake @@ -6,7 +6,7 @@ set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -find_package(modulus CONFIG REQUIRED COMPONENTS boost_json openssl) +find_package(modulus CONFIG REQUIRED COMPONENTS boost_json openssl raylib) find_package(Threads REQUIRED) find_package(ligo CONFIG REQUIRED) find_package(noto CONFIG REQUIRED) @@ -40,6 +40,7 @@ target_link_libraries(vamp-style PUBLIC ) set(VAMP_PROTOCOL_SOURCES + src/vamp_protocol/gesture.cpp src/vamp_protocol/page_control.cpp ) @@ -68,6 +69,7 @@ set(VAMP_CORE_SOURCES src/vamp_zero/score.cpp src/vamp_zero/score_loader.cpp src/vamp_zero/types.cpp + src/vamp_zero/touch_input.cpp src/vamp_zero/ui.cpp ) @@ -92,6 +94,7 @@ target_link_libraries(vamp-zero-core PUBLIC noto::noto luma::luma sonus::sonus + PRIVATE modulus::raylib ) add_executable(vamp-zero src/vamp_zero/main.cpp) diff --git a/generator/practice/generate.py b/generator/practice/generate.py index 0c9b2a3..4f0bc3b 100644 --- a/generator/practice/generate.py +++ b/generator/practice/generate.py @@ -67,6 +67,7 @@ def generate_practice_scores(source_dir: Path, output_dir: Path) -> int: return 0 sources = sorted(source_dir.rglob('*.xml')) destinations: dict[Path, Path] = {} + identities: dict[str, Path] = {} for path in sources: parts = path.relative_to(source_dir).parts if len(parts) != 3 or parts[0] not in ('chord', 'melody'): @@ -74,6 +75,10 @@ def generate_practice_scores(source_dir: Path, output_dir: Path) -> int: destination = Path(parts[0]) / parts[1] / slugify(path.stem) if destination in destinations: raise ValueError(f'{path}: practice output path collides with {destinations[destination]}: {destination}') + identity = '-'.join(destination.parts) + if identity in identities: + raise ValueError(f'{path}: practice exercise ID collision with {identities[identity]}: {identity}') + identities[identity] = path destinations[destination] = path count = 0 for destination, path in destinations.items(): diff --git a/include/vamp_pico/input.hpp b/include/vamp_pico/input.hpp index a769196..c4358a7 100644 --- a/include/vamp_pico/input.hpp +++ b/include/vamp_pico/input.hpp @@ -5,7 +5,7 @@ #include #include -#include +#include namespace vamp_pico { @@ -21,17 +21,6 @@ struct remote_request { vamp_protocol::page_command command{vamp_protocol::page_command::NEXT}; }; -class gesture_state { -public: - auto update(std::bool_t touching, std::uint64_t now_ms) -> std::optional; -private: - std::bool_t touching_{}; - std::bool_t hold_sent_{}; - std::bool_t pending_tap_{}; - std::uint64_t down_ms_{}; - std::uint64_t last_up_ms_{}; -}; - enum class enqueue_result { QUEUED, BUSY, diff --git a/include/vamp_protocol/gesture.hpp b/include/vamp_protocol/gesture.hpp new file mode 100644 index 0000000..d91ea2d --- /dev/null +++ b/include/vamp_protocol/gesture.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +#include + + +namespace vamp_protocol { + +class gesture_state { +public: + auto update(std::bool_t touching, std::uint64_t now_ms) -> std::optional; + auto reset() -> void; + auto pending_deadline() const -> std::optional; +private: + std::bool_t touching_{}; + std::bool_t hold_sent_{}; + std::bool_t pending_tap_{}; + std::uint64_t down_ms_{}; + std::uint64_t last_up_ms_{}; +}; + +} diff --git a/include/vamp_zero/score.hpp b/include/vamp_zero/score.hpp index 8e67daa..cdcb878 100644 --- a/include/vamp_zero/score.hpp +++ b/include/vamp_zero/score.hpp @@ -30,5 +30,6 @@ private: auto score_page_count(vamp_zero::score_document const &score) -> std::size_t; auto validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected; +auto validate_score_page(vamp_zero::score_document const &score, std::size_t index) -> std::expected; } diff --git a/include/vamp_zero/touch_input.hpp b/include/vamp_zero/touch_input.hpp new file mode 100644 index 0000000..06d6e87 --- /dev/null +++ b/include/vamp_zero/touch_input.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include + +#include + +#include + + +namespace vamp_zero { + +class touch_input { +public: + touch_input(boost::asio::any_io_executor executor, std::function handler); + ~touch_input(); + touch_input(touch_input const &) = delete; + auto operator=(touch_input const &) -> touch_input & = delete; + auto handle(luma::touch_event const &event) -> std::bool_t; + auto stop() -> void; +private: + struct state; + std::shared_ptr state_; +}; + +} diff --git a/src/vamp_pico/input.cpp b/src/vamp_pico/input.cpp index e91c786..5748293 100644 --- a/src/vamp_pico/input.cpp +++ b/src/vamp_pico/input.cpp @@ -1,42 +1,6 @@ #include -auto vamp_pico::gesture_state::update(std::bool_t touching, std::uint64_t now_ms) -> std::optional { - constexpr auto hold_ms = std::uint64_t{700}; - constexpr auto double_tap_ms = std::uint64_t{220}; - auto command = std::optional{}; - if (pending_tap_ && !touching_ && now_ms - last_up_ms_ > double_tap_ms) { - pending_tap_ = false; - command = vamp_protocol::page_command::NEXT; - } - if (touching && !touching_) { - touching_ = true; - hold_sent_ = false; - down_ms_ = now_ms; - } - if (touching_ && !hold_sent_ && now_ms - down_ms_ >= hold_ms) { - hold_sent_ = true; - pending_tap_ = false; - command = vamp_protocol::page_command::SELECT; - } - if (!touching && touching_) { - touching_ = false; - if (!hold_sent_) { - if (pending_tap_ && now_ms - last_up_ms_ <= double_tap_ms) { - pending_tap_ = false; - command = vamp_protocol::page_command::PREVIOUS; - } else { - if (pending_tap_) { - command = vamp_protocol::page_command::NEXT; - } - pending_tap_ = true; - last_up_ms_ = now_ms; - } - } - } - return command; -} - auto vamp_pico::request_scheduler::enqueue(vamp_protocol::page_command command) -> enqueue_result { if (size_ == commands_.size()) { return enqueue_result::BUSY; diff --git a/src/vamp_pico/main.cpp b/src/vamp_pico/main.cpp index a5c0ff8..a5d7ad2 100644 --- a/src/vamp_pico/main.cpp +++ b/src/vamp_pico/main.cpp @@ -64,7 +64,7 @@ auto main() -> int { } auto remote = vamp_pico::remote_display_state{}; auto status = link_status::RETRYING; - auto gesture = vamp_pico::gesture_state{}; + auto gesture = vamp_protocol::gesture_state{}; auto scheduler = vamp_pico::request_scheduler{}; // Keep the owned request buffers off the Pico's small main stack. static auto client = vamp_pico::http_client{}; diff --git a/src/vamp_protocol/gesture.cpp b/src/vamp_protocol/gesture.cpp new file mode 100644 index 0000000..3b537e2 --- /dev/null +++ b/src/vamp_protocol/gesture.cpp @@ -0,0 +1,54 @@ +#include + + +namespace { + +constexpr auto hold_ms = std::uint64_t{700}; +constexpr auto double_tap_ms = std::uint64_t{220}; + +} + +auto vamp_protocol::gesture_state::update(std::bool_t touching, std::uint64_t now_ms) -> std::optional { + auto command = std::optional{}; + if (pending_tap_ && !touching_ && now_ms - last_up_ms_ > double_tap_ms) { + pending_tap_ = false; + command = vamp_protocol::page_command::NEXT; + } + if (touching && !touching_) { + touching_ = true; + hold_sent_ = false; + down_ms_ = now_ms; + } + if (touching_ && !hold_sent_ && now_ms - down_ms_ >= hold_ms) { + hold_sent_ = true; + pending_tap_ = false; + command = vamp_protocol::page_command::SELECT; + } + if (!touching && touching_) { + touching_ = false; + if (!hold_sent_) { + if (pending_tap_ && now_ms - last_up_ms_ <= double_tap_ms) { + pending_tap_ = false; + command = vamp_protocol::page_command::PREVIOUS; + } else { + if (pending_tap_) { + command = vamp_protocol::page_command::NEXT; + } + pending_tap_ = true; + last_up_ms_ = now_ms; + } + } + } + return command; +} + +auto vamp_protocol::gesture_state::reset() -> void { + *this = {}; +} + +auto vamp_protocol::gesture_state::pending_deadline() const -> std::optional { + if (!pending_tap_ || touching_) { + return std::nullopt; + } + return last_up_ms_ + double_tap_ms + 1U; +} diff --git a/src/vamp_zero/app.cpp b/src/vamp_zero/app.cpp index b94495f..5379dbb 100644 --- a/src/vamp_zero/app.cpp +++ b/src/vamp_zero/app.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include #include @@ -16,6 +15,8 @@ #include #include +#include + #include #include #include @@ -59,6 +60,30 @@ auto bind_port(vamp_zero::config const &config) -> std::uint16_t { return endpoint->port == 0 ? 8080 : endpoint->port; } +auto validate_runtime_destination(std::filesystem::path const &destination) -> std::expected { + auto error = std::error_code{}; + auto status = std::filesystem::symlink_status(destination, error); + if (error && error != std::errc::no_such_file_or_directory) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to inspect runtime file " + destination.string() + ": " + error.message())); + } + if (status.type() != std::filesystem::file_type::not_found && !std::filesystem::is_regular_file(status)) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "runtime destination must be absent or a regular file: " + destination.string())); + } + return {}; +} + +struct runtime_temporary { + std::filesystem::path path; + std::int32_t descriptor{-1}; + ~runtime_temporary() { + if (descriptor >= 0) { + ::close(descriptor); + } + auto ignored = std::error_code{}; + std::filesystem::remove(path, ignored); + } +}; + auto write_text_atomic(std::filesystem::path const &destination, std::string const &text) -> std::expected { auto error = std::error_code{}; if (!destination.parent_path().empty()) { @@ -67,23 +92,34 @@ auto write_text_atomic(std::filesystem::path const &destination, std::string con return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create runtime directory: " + error.message())); } } - auto temporary = destination; - temporary += ".tmp"; - auto output = std::ofstream{temporary, std::ios::binary | std::ios::trunc}; - if (!output) { - return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create runtime file: " + temporary.string())); + auto valid = validate_runtime_destination(destination); + if (!valid) { + return valid; } - output << text; - output.close(); - if (!output) { - std::filesystem::remove(temporary, error); - return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to write runtime file: " + temporary.string())); + auto pattern = destination.string() + ".XXXXXX"; + auto descriptor = ::mkstemp(pattern.data()); + if (descriptor < 0) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create runtime file for " + destination.string() + ": " + std::error_code{errno, std::generic_category()}.message())); } - std::filesystem::rename(temporary, destination, error); + auto temporary = runtime_temporary{pattern, descriptor}; + for (auto offset = std::size_t{}; offset < text.size();) { + auto written = ::write(descriptor, text.data() + offset, text.size() - offset); + if (written < 0 && errno == EINTR) { + continue; + } + if (written <= 0) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to write runtime file: " + temporary.path.string())); + } + offset += static_cast(written); + } + auto closed = ::close(descriptor); + temporary.descriptor = -1; + if (closed != 0) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to close runtime file: " + temporary.path.string())); + } + std::filesystem::rename(temporary.path, destination, error); if (error) { - auto message = error.message(); - std::filesystem::remove(temporary, error); - return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to publish runtime file: " + message)); + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to publish runtime file " + destination.string() + ": " + error.message())); } return {}; } @@ -162,9 +198,9 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::data co_return std::unexpected(score.error()); } for (auto page = std::size_t{}; page < vamp_zero::score_page_count(*score); ++page) { - auto path = vamp_zero::validated_page_path(*score, page); - if (!path) { - co_return std::unexpected(path.error()); + auto valid = vamp_zero::validate_score_page(*score, page); + if (!valid) { + co_return std::unexpected(valid.error()); } } co_return std::expected{}; @@ -191,6 +227,10 @@ auto validate_catalogue(boost::asio::any_io_executor executor, vamp_zero::databa } auto probe_runtime_directory(vamp_zero::config const &config) -> std::expected { + auto valid = validate_runtime_destination(config.runtime.heartbeat_path); + if (!valid) { + return valid; + } auto directory = std::filesystem::path{config.runtime.heartbeat_path}.parent_path(); if (directory.empty()) { directory = "."; @@ -201,24 +241,22 @@ auto probe_runtime_directory(vamp_zero::config const &config) -> std::expected std::int64_t { .count(); } -auto backfill_metadata(noto::connection &connection, std::string const &table) -> boost::asio::awaitable> { +auto backfill_metadata(boost::asio::any_io_executor executor, noto::connection &connection, std::string const &table) -> boost::asio::awaitable> { for (;;) { + // Bound immediate coroutine completion chains even for very large catalogues. + co_await boost::asio::post(executor, boost::asio::use_awaitable); auto row = co_await connection.async_execute("SELECT id, source_path, source_json FROM " + table + " WHERE measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL LIMIT 1"); if (!row) { co_return std::unexpected(row.error()); @@ -283,6 +286,20 @@ auto backfill_metadata(noto::connection &connection, std::string const &table) - } } +auto restore_imported_metadata(noto::connection &connection, std::string const &table, std::string const &id, vamp_zero::score_document const &document) -> boost::asio::awaitable> { + auto params = std::vector{ + noto::sqlite_value{static_cast(document.measure_count)}, + noto::sqlite_value{static_cast(document.pages.size())}, + noto::sqlite_value{document.authored_key_signature}, + noto::sqlite_value{id}, + }; + auto updated = co_await connection.async_execute("UPDATE " + table + " SET measure_count = ?1, total_pages = ?2, authored_key_signature = ?3 WHERE id = ?4 AND catalog_managed = 1 AND (measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL)", std::move(params)); + if (!updated) { + co_return std::unexpected(updated.error()); + } + co_return std::expected{}; +} + auto count_value(noto::sqlite_value const &value) -> std::size_t { auto count = std::get_if(&value.value); return count == nullptr || *count <= 0 ? 0U : static_cast(*count); @@ -394,6 +411,7 @@ auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vam if (!imported) { break; } + co_await boost::asio::post(executor, boost::asio::use_awaitable); auto document = vamp_zero::read_score_document(path); if (!document) { imported = std::unexpected(document.error()); @@ -445,6 +463,8 @@ WHERE songs.catalog_managed = 1 std::move(params)); if (!inserted) { imported = std::unexpected(inserted.error()); + } else { + imported = co_await restore_imported_metadata(connection, "songs", part.id, *document); } } auto practice_ids = std::vector{}; @@ -453,6 +473,7 @@ WHERE songs.catalog_managed = 1 if (!imported) { break; } + co_await boost::asio::post(executor, boost::asio::use_awaitable); auto document = vamp_zero::read_score_document(path); if (!document) { imported = std::unexpected(document.error()); @@ -526,6 +547,10 @@ WHERE practice_exercises.catalog_managed = 1 imported = std::unexpected(inserted.error()); break; } + imported = co_await restore_imported_metadata(connection, "practice_exercises", variant.id, *document); + if (!imported) { + break; + } } } if (imported) { @@ -565,7 +590,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec if (!connected) { co_return std::unexpected(connected.error()); } - auto filled = co_await backfill_metadata(connection, "songs"); + auto filled = co_await backfill_metadata(executor, connection, "songs"); if (!filled) { static_cast(co_await connection.async_close()); co_return std::unexpected(filled.error()); @@ -621,7 +646,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo if (!connected) { co_return std::unexpected(connected.error()); } - auto filled = co_await backfill_metadata(connection, "practice_exercises"); + auto filled = co_await backfill_metadata(executor, connection, "practice_exercises"); if (!filled) { static_cast(co_await connection.async_close()); co_return std::unexpected(filled.error()); diff --git a/src/vamp_zero/library.cpp b/src/vamp_zero/library.cpp index cf99a4a..2c01504 100644 --- a/src/vamp_zero/library.cpp +++ b/src/vamp_zero/library.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -96,6 +97,27 @@ auto vamp_zero::score_paths(std::string const &assets_path, vamp_zero::score_cat return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan score assets: " + error.message())); } std::sort(paths.begin(), paths.end()); + if (practice) { + auto exercises = std::map{}; + auto variants = std::map{}; + for (auto const &path : paths) { + auto relative = path.lexically_relative(root); + auto exercise_path = relative.parent_path(); + auto id = std::string{}; + for (auto const &part : exercise_path) { + id += (id.empty() ? "" : "-") + part.string(); + } + auto [exercise, added] = exercises.emplace(id, relative); + if (!added && exercise->second.parent_path() != exercise_path) { + return std::unexpected(invalid("practice exercise ID collision '" + id + "': " + (root / exercise->second).string() + " and " + path.string())); + } + auto variant_id = id + "-" + path.stem().string(); + auto [variant, unique] = variants.emplace(variant_id, relative); + if (!unique) { + return std::unexpected(invalid("practice variant ID collision '" + variant_id + "': " + (root / variant->second).string() + " and " + path.string())); + } + } + } return paths; } diff --git a/src/vamp_zero/score.cpp b/src/vamp_zero/score.cpp index a43b5be..c2473d7 100644 --- a/src/vamp_zero/score.cpp +++ b/src/vamp_zero/score.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -46,8 +47,15 @@ auto vamp_zero::score_page_count(vamp_zero::score_document const &score) -> std: return score.pages.size(); } -auto vamp_zero::validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected { - auto fail = [&](std::string message) -> std::expected { +namespace { + +struct page_file { + std::string path; + std::vector bytes; +}; + +auto read_page(vamp_zero::score_document const &score, std::size_t index) -> std::expected { + auto fail = [&](std::string message) -> std::expected { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, score.source_path + ": " + std::move(message) + "; regenerate score page assets")); }; if (index >= score.pages.size()) { @@ -90,5 +98,43 @@ auto vamp_zero::validated_page_path(vamp_zero::score_document const &score, std: if (hash != score.pages[index].sha256) { return fail("score page checksum mismatch"); } - return path.string(); + return page_file{path.string(), std::move(bytes)}; +} + +} + +auto vamp_zero::validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected { + auto page = read_page(score, index); + if (!page) { + return std::unexpected(page.error()); + } + return std::move(page->path); +} + +auto vamp_zero::validate_score_page(vamp_zero::score_document const &score, std::size_t index) -> std::expected { + auto page = read_page(score, index); + if (!page) { + return std::unexpected(page.error()); + } + auto fail = [&](std::string message) -> std::expected { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, page->path + ": " + message + "; regenerate score page assets")); + }; + auto const &bytes = page->bytes; + constexpr auto ihdr = std::array{0, 0, 0, 13, 'I', 'H', 'D', 'R'}; + if (bytes.size() < 33U || !std::equal(ihdr.begin(), ihdr.end(), bytes.begin() + 8)) { + return fail("invalid PNG header"); + } + auto dimension = [&](std::size_t offset) -> std::uint32_t { + return (static_cast(bytes[offset]) << 24U) | (static_cast(bytes[offset + 1U]) << 16U) | (static_cast(bytes[offset + 2U]) << 8U) | bytes[offset + 3U]; + }; + if (dimension(16U) != 976U || dimension(20U) != 484U) { + return fail("invalid score page dimensions"); + } + auto image = LoadImageFromMemory(".png", bytes.data(), static_cast(bytes.size())); + auto valid = image.data != nullptr && image.width == 976 && image.height == 484; + UnloadImage(image); + if (!valid) { + return fail("failed to decode score page PNG"); + } + return {}; } diff --git a/src/vamp_zero/touch_input.cpp b/src/vamp_zero/touch_input.cpp new file mode 100644 index 0000000..7939adb --- /dev/null +++ b/src/vamp_zero/touch_input.cpp @@ -0,0 +1,114 @@ +#include + +#include +#include +#include +#include + +#include +#include + +#include + + +namespace { + +auto milliseconds(std::chrono::steady_clock::time_point time) -> std::uint64_t { + return static_cast(std::chrono::duration_cast(time.time_since_epoch()).count()); +} + +} + +struct vamp_zero::touch_input::state : std::enable_shared_from_this { + state(boost::asio::any_io_executor executor, std::function handler) + : timer{std::move(executor)}, handler{std::move(handler)} {} + auto send(std::optional command) -> void { + if (!command) { + return; + } + boost::asio::post(timer.get_executor(), [weak = weak_from_this(), command = *command] { + auto self = weak.lock(); + if (!self) { + return; + } + { + auto lock = std::scoped_lock{self->mutex}; + if (self->stopped) { + return; + } + } + self->handler(command); + }); + } + auto schedule() -> void { + auto generation = ++timer_generation; + timer.cancel(); + auto deadline = gesture.pending_deadline(); + if (!deadline) { + return; + } + timer.expires_at(std::chrono::steady_clock::time_point{std::chrono::milliseconds{*deadline}}); + timer.async_wait([weak = weak_from_this(), generation](boost::system::error_code const &error) { + auto self = weak.lock(); + if (error || !self) { + return; + } + auto lock = std::scoped_lock{self->mutex}; + if (self->stopped || self->timer_generation != generation) { + return; + } + self->send(self->gesture.update(false, milliseconds(std::chrono::steady_clock::now()))); + self->schedule(); + }); + } + boost::asio::steady_timer timer; + std::function handler; + std::mutex mutex{}; + vamp_protocol::gesture_state gesture{}; + std::optional active_touch{}; + std::uint64_t timer_generation{}; + std::bool_t stopped{}; +}; + +vamp_zero::touch_input::touch_input(boost::asio::any_io_executor executor, std::function handler) + : state_{std::make_shared(std::move(executor), std::move(handler))} {} + +vamp_zero::touch_input::~touch_input() { + stop(); +} + +auto vamp_zero::touch_input::handle(luma::touch_event const &event) -> std::bool_t { + auto lock = std::scoped_lock{state_->mutex}; + if (state_->stopped || !state_->handler) { + return false; + } + auto consumed = std::bool_t{false}; + for (auto const &point : event.points) { + if (point.phase == luma::touch_phase::DOWN && !state_->active_touch) { + state_->active_touch = point.id; + } else if (!state_->active_touch || point.id != *state_->active_touch) { + continue; + } + consumed = true; + if (point.phase == luma::touch_phase::CANCEL) { + state_->active_touch.reset(); + state_->gesture.reset(); + } else { + auto touching = point.phase != luma::touch_phase::UP; + if (!touching) { + state_->active_touch.reset(); + } + state_->send(state_->gesture.update(touching, milliseconds(event.time))); + } + state_->schedule(); + } + return consumed; +} + +auto vamp_zero::touch_input::stop() -> void { + auto lock = std::scoped_lock{state_->mutex}; + state_->stopped = true; + state_->active_touch.reset(); + state_->gesture.reset(); + state_->schedule(); +} diff --git a/src/vamp_zero/ui.cpp b/src/vamp_zero/ui.cpp index 269205b..f8e35ec 100644 --- a/src/vamp_zero/ui.cpp +++ b/src/vamp_zero/ui.cpp @@ -1,20 +1,16 @@ #include #include -#include #include #include #include #include #include -#include #include #include #include #include -#include -#include #include @@ -27,27 +23,15 @@ #include #include #include +#include -struct vamp_zero::ui_runtime : std::enable_shared_from_this { +struct vamp_zero::ui_runtime { ui_runtime(boost::asio::any_io_executor executor, std::function handler) - : executor_{std::move(executor)}, tap_timer_{executor_}, command_handler_{std::move(handler)} {} - auto handle_touch(luma::touch_event const &event) -> std::bool_t; - auto send_touch_command(vamp_protocol::page_command command) -> void; - auto arm_pending_tap_locked() -> void; - auto cancel_pending_tap_locked() -> void; - boost::asio::any_io_executor executor_; - boost::asio::steady_timer tap_timer_; - std::function command_handler_{}; + : touch_{std::move(executor), std::move(handler)} {} + vamp_zero::touch_input touch_; std::mutex mutex_{}; vamp_zero::ui_state state_{}; - std::mutex touch_mutex_{}; - std::optional active_touch_id_{}; - std::chrono::steady_clock::time_point touch_down_time_{}; - std::chrono::steady_clock::time_point last_tap_time_{}; - std::bool_t hold_sent_{}; - std::bool_t pending_tap_{}; - std::bool_t stopped_{}; }; namespace { @@ -95,9 +79,6 @@ struct score_draw_cache { std::weak_ptr document{}; }; -constexpr auto touch_hold_duration = std::chrono::milliseconds{700}; -constexpr auto touch_double_tap_duration = std::chrono::milliseconds{220}; - auto bind_port(vamp_zero::config const &config) -> std::uint16_t { auto const &bind = config.control.endpoint.bind; auto colon = bind.rfind(':'); @@ -561,7 +542,7 @@ public: } } auto handle_touch(luma::touch_event const &event) -> std::bool_t override { - return runtime_->handle_touch(event); + return runtime_->touch_.handle(event); } private: std::shared_ptr runtime_; @@ -583,117 +564,6 @@ vamp_zero::zero_ui::~zero_ui() { stop(); } -auto vamp_zero::ui_runtime::send_touch_command(vamp_protocol::page_command command) -> void { - if (!command_handler_) { - return; - } - boost::asio::post(executor_, [weak = weak_from_this(), command] { - auto self = weak.lock(); - if (!self) { - return; - } - { - auto lock = std::scoped_lock{self->touch_mutex_}; - if (self->stopped_) { - return; - } - } - self->command_handler_(command); - }); -} - -auto vamp_zero::ui_runtime::cancel_pending_tap_locked() -> void { - pending_tap_ = false; -} - -auto vamp_zero::ui_runtime::arm_pending_tap_locked() -> void { - tap_timer_.expires_after(touch_double_tap_duration); - tap_timer_.async_wait([weak = weak_from_this()](boost::system::error_code const &error) { - auto self = weak.lock(); - if (error || !self) { - return; - } - auto send_next = std::bool_t{false}; - { - auto lock = std::scoped_lock{self->touch_mutex_}; - send_next = self->pending_tap_ && !self->stopped_; - self->pending_tap_ = false; - } - if (send_next) { - self->send_touch_command(vamp_protocol::page_command::NEXT); - } - }); -} - -auto vamp_zero::ui_runtime::handle_touch(luma::touch_event const &event) -> std::bool_t { - if (!command_handler_ || event.points.empty()) { - return false; - } - auto consumed = std::bool_t{false}; - auto commands = std::vector{}; - auto arm_tap = std::bool_t{false}; - { - auto lock = std::scoped_lock{touch_mutex_}; - if (stopped_) { - return false; - } - for (auto const &point : event.points) { - if (point.phase == luma::touch_phase::DOWN && !active_touch_id_) { - active_touch_id_ = point.id; - touch_down_time_ = event.time; - hold_sent_ = false; - tap_timer_.cancel(); - consumed = true; - continue; - } - if (!active_touch_id_ || point.id != *active_touch_id_) { - continue; - } - consumed = true; - if (point.phase == luma::touch_phase::CANCEL) { - active_touch_id_.reset(); - hold_sent_ = false; - cancel_pending_tap_locked(); - tap_timer_.cancel(); - continue; - } - if (point.phase == luma::touch_phase::MOVE) { - if (!hold_sent_ && event.time - touch_down_time_ >= touch_hold_duration) { - hold_sent_ = true; - cancel_pending_tap_locked(); - tap_timer_.cancel(); - commands.push_back(vamp_protocol::page_command::SELECT); - } - continue; - } - if (point.phase == luma::touch_phase::UP) { - active_touch_id_.reset(); - if (!hold_sent_ && event.time - touch_down_time_ >= touch_hold_duration) { - commands.push_back(vamp_protocol::page_command::SELECT); - } else if (!hold_sent_) { - if (pending_tap_ && event.time - last_tap_time_ <= touch_double_tap_duration) { - cancel_pending_tap_locked(); - tap_timer_.cancel(); - commands.push_back(vamp_protocol::page_command::PREVIOUS); - } else { - pending_tap_ = true; - last_tap_time_ = event.time; - arm_tap = true; - } - } - hold_sent_ = false; - } - } - if (arm_tap) { - arm_pending_tap_locked(); - } - } - for (auto command : commands) { - send_touch_command(command); - } - return consumed; -} - auto vamp_zero::zero_ui::run() -> std::expected { if (auto status = application_->set_root(std::make_unique(runtime_, score_loader_, *application_)); !status) { return status; @@ -704,12 +574,7 @@ auto vamp_zero::zero_ui::run() -> std::expected { } auto vamp_zero::zero_ui::stop() -> void { - { - auto lock = std::scoped_lock{runtime_->touch_mutex_}; - runtime_->stopped_ = true; - runtime_->pending_tap_ = false; - runtime_->tap_timer_.cancel(); - } + runtime_->touch_.stop(); score_loader_->stop(); application_->request_stop(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5c6a870..22f7163 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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 diff --git a/tests/test_catalogue.py b/tests/test_catalogue.py index 17b24d3..8f59276 100644 --- a/tests/test_catalogue.py +++ b/tests/test_catalogue.py @@ -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') diff --git a/tests/test_catalogue_runtime.cpp b/tests/test_catalogue_runtime.cpp index 41b19bc..19aee7d 100644 --- a/tests/test_catalogue_runtime.cpp +++ b/tests/test_catalogue_runtime.cpp @@ -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(result.rows[0][0].value) == 3); + BOOST_TEST(std::get(result.rows[0][1].value) == 2); + BOOST_TEST(std::get(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(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(rows.rows[0][0].value) == 4000); + } +} diff --git a/tests/test_health.cpp b/tests/test_health.cpp index 45c2c88..4f2b95a 100644 --- a/tests/test_health.cpp +++ b/tests/test_health.cpp @@ -18,6 +18,9 @@ #include #include #include +#include + +#include #include #include @@ -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{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(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(bytes), static_cast(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); diff --git a/tests/test_pico.cpp b/tests/test_pico.cpp index 1c87446..f215a8d 100644 --- a/tests/test_pico.cpp +++ b/tests/test_pico.cpp @@ -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); diff --git a/tests/test_touch_input.cpp b/tests/test_touch_input.cpp new file mode 100644 index 0000000..ab7d9f3 --- /dev/null +++ b/tests/test_touch_input.cpp @@ -0,0 +1,152 @@ +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include + + +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>{ + {{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>{ + {"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{}; + auto pico_commands = std::vector{}; + 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{}; + 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{}; + 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{}; + 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{}; + { + 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()); + } +}