+6
-2
@@ -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/<type>/<lesson_group>/<exercise>/<key>.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.
|
At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice/<type>/<lesson_group>/<exercise>/<key>.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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ add_executable(product-vamp-pico2w
|
|||||||
src/vamp_pico/remote_display.cpp
|
src/vamp_pico/remote_display.cpp
|
||||||
src/vamp_pico/touch.cpp
|
src/vamp_pico/touch.cpp
|
||||||
src/vamp_protocol/page_control.cpp
|
src/vamp_protocol/page_control.cpp
|
||||||
|
src/vamp_protocol/gesture.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(product-vamp-pico2w PRIVATE
|
target_include_directories(product-vamp-pico2w PRIVATE
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
|
|||||||
+4
-1
@@ -6,7 +6,7 @@ set(CMAKE_CXX_STANDARD 23)
|
|||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
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(Threads REQUIRED)
|
||||||
find_package(ligo CONFIG REQUIRED)
|
find_package(ligo CONFIG REQUIRED)
|
||||||
find_package(noto CONFIG REQUIRED)
|
find_package(noto CONFIG REQUIRED)
|
||||||
@@ -40,6 +40,7 @@ target_link_libraries(vamp-style PUBLIC
|
|||||||
)
|
)
|
||||||
|
|
||||||
set(VAMP_PROTOCOL_SOURCES
|
set(VAMP_PROTOCOL_SOURCES
|
||||||
|
src/vamp_protocol/gesture.cpp
|
||||||
src/vamp_protocol/page_control.cpp
|
src/vamp_protocol/page_control.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ set(VAMP_CORE_SOURCES
|
|||||||
src/vamp_zero/score.cpp
|
src/vamp_zero/score.cpp
|
||||||
src/vamp_zero/score_loader.cpp
|
src/vamp_zero/score_loader.cpp
|
||||||
src/vamp_zero/types.cpp
|
src/vamp_zero/types.cpp
|
||||||
|
src/vamp_zero/touch_input.cpp
|
||||||
src/vamp_zero/ui.cpp
|
src/vamp_zero/ui.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ target_link_libraries(vamp-zero-core PUBLIC
|
|||||||
noto::noto
|
noto::noto
|
||||||
luma::luma
|
luma::luma
|
||||||
sonus::sonus
|
sonus::sonus
|
||||||
|
PRIVATE modulus::raylib
|
||||||
)
|
)
|
||||||
|
|
||||||
add_executable(vamp-zero src/vamp_zero/main.cpp)
|
add_executable(vamp-zero src/vamp_zero/main.cpp)
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
|
|||||||
return 0
|
return 0
|
||||||
sources = sorted(source_dir.rglob('*.xml'))
|
sources = sorted(source_dir.rglob('*.xml'))
|
||||||
destinations: dict[Path, Path] = {}
|
destinations: dict[Path, Path] = {}
|
||||||
|
identities: dict[str, Path] = {}
|
||||||
for path in sources:
|
for path in sources:
|
||||||
parts = path.relative_to(source_dir).parts
|
parts = path.relative_to(source_dir).parts
|
||||||
if len(parts) != 3 or parts[0] not in ('chord', 'melody'):
|
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)
|
destination = Path(parts[0]) / parts[1] / slugify(path.stem)
|
||||||
if destination in destinations:
|
if destination in destinations:
|
||||||
raise ValueError(f'{path}: practice output path collides with {destinations[destination]}: {destination}')
|
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
|
destinations[destination] = path
|
||||||
count = 0
|
count = 0
|
||||||
for destination, path in destinations.items():
|
for destination, path in destinations.items():
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
#include <vamp_protocol/page_control.hpp>
|
#include <vamp_protocol/gesture.hpp>
|
||||||
|
|
||||||
|
|
||||||
namespace vamp_pico {
|
namespace vamp_pico {
|
||||||
@@ -21,17 +21,6 @@ struct remote_request {
|
|||||||
vamp_protocol::page_command command{vamp_protocol::page_command::NEXT};
|
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<vamp_protocol::page_command>;
|
|
||||||
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 {
|
enum class enqueue_result {
|
||||||
QUEUED,
|
QUEUED,
|
||||||
BUSY,
|
BUSY,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include <vamp_protocol/page_control.hpp>
|
||||||
|
|
||||||
|
|
||||||
|
namespace vamp_protocol {
|
||||||
|
|
||||||
|
class gesture_state {
|
||||||
|
public:
|
||||||
|
auto update(std::bool_t touching, std::uint64_t now_ms) -> std::optional<vamp_protocol::page_command>;
|
||||||
|
auto reset() -> void;
|
||||||
|
auto pending_deadline() const -> std::optional<std::uint64_t>;
|
||||||
|
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_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -30,5 +30,6 @@ private:
|
|||||||
|
|
||||||
auto score_page_count(vamp_zero::score_document const &score) -> std::size_t;
|
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<std::string, modulus::error>;
|
auto validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected<std::string, modulus::error>;
|
||||||
|
auto validate_score_page(vamp_zero::score_document const &score, std::size_t index) -> std::expected<void, modulus::error>;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <boost/asio/any_io_executor.hpp>
|
||||||
|
|
||||||
|
#include <luma.hpp>
|
||||||
|
|
||||||
|
#include <vamp_protocol/page_control.hpp>
|
||||||
|
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
class touch_input {
|
||||||
|
public:
|
||||||
|
touch_input(boost::asio::any_io_executor executor, std::function<void(vamp_protocol::page_command)> 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> state_;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,42 +1,6 @@
|
|||||||
#include <vamp_pico/input.hpp>
|
#include <vamp_pico/input.hpp>
|
||||||
|
|
||||||
|
|
||||||
auto vamp_pico::gesture_state::update(std::bool_t touching, std::uint64_t now_ms) -> std::optional<vamp_protocol::page_command> {
|
|
||||||
constexpr auto hold_ms = std::uint64_t{700};
|
|
||||||
constexpr auto double_tap_ms = std::uint64_t{220};
|
|
||||||
auto command = std::optional<vamp_protocol::page_command>{};
|
|
||||||
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 {
|
auto vamp_pico::request_scheduler::enqueue(vamp_protocol::page_command command) -> enqueue_result {
|
||||||
if (size_ == commands_.size()) {
|
if (size_ == commands_.size()) {
|
||||||
return enqueue_result::BUSY;
|
return enqueue_result::BUSY;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ auto main() -> int {
|
|||||||
}
|
}
|
||||||
auto remote = vamp_pico::remote_display_state{};
|
auto remote = vamp_pico::remote_display_state{};
|
||||||
auto status = link_status::RETRYING;
|
auto status = link_status::RETRYING;
|
||||||
auto gesture = vamp_pico::gesture_state{};
|
auto gesture = vamp_protocol::gesture_state{};
|
||||||
auto scheduler = vamp_pico::request_scheduler{};
|
auto scheduler = vamp_pico::request_scheduler{};
|
||||||
// Keep the owned request buffers off the Pico's small main stack.
|
// Keep the owned request buffers off the Pico's small main stack.
|
||||||
static auto client = vamp_pico::http_client{};
|
static auto client = vamp_pico::http_client{};
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#include <vamp_protocol/gesture.hpp>
|
||||||
|
|
||||||
|
|
||||||
|
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<vamp_protocol::page_command> {
|
||||||
|
auto command = std::optional<vamp_protocol::page_command>{};
|
||||||
|
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<std::uint64_t> {
|
||||||
|
if (!pending_tap_ || touching_) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return last_up_ms_ + double_tap_ms + 1U;
|
||||||
|
}
|
||||||
+68
-30
@@ -5,7 +5,6 @@
|
|||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <expected>
|
#include <expected>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <future>
|
#include <future>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
@@ -16,6 +15,8 @@
|
|||||||
#include <thread>
|
#include <thread>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
#include <boost/asio/co_spawn.hpp>
|
#include <boost/asio/co_spawn.hpp>
|
||||||
#include <boost/asio/io_context.hpp>
|
#include <boost/asio/io_context.hpp>
|
||||||
#include <boost/asio/post.hpp>
|
#include <boost/asio/post.hpp>
|
||||||
@@ -59,6 +60,30 @@ auto bind_port(vamp_zero::config const &config) -> std::uint16_t {
|
|||||||
return endpoint->port == 0 ? 8080 : endpoint->port;
|
return endpoint->port == 0 ? 8080 : endpoint->port;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto validate_runtime_destination(std::filesystem::path const &destination) -> std::expected<void, modulus::error> {
|
||||||
|
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<void, modulus::error> {
|
auto write_text_atomic(std::filesystem::path const &destination, std::string const &text) -> std::expected<void, modulus::error> {
|
||||||
auto error = std::error_code{};
|
auto error = std::error_code{};
|
||||||
if (!destination.parent_path().empty()) {
|
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()));
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create runtime directory: " + error.message()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
auto temporary = destination;
|
auto valid = validate_runtime_destination(destination);
|
||||||
temporary += ".tmp";
|
if (!valid) {
|
||||||
auto output = std::ofstream{temporary, std::ios::binary | std::ios::trunc};
|
return valid;
|
||||||
if (!output) {
|
|
||||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create runtime file: " + temporary.string()));
|
|
||||||
}
|
}
|
||||||
output << text;
|
auto pattern = destination.string() + ".XXXXXX";
|
||||||
output.close();
|
auto descriptor = ::mkstemp(pattern.data());
|
||||||
if (!output) {
|
if (descriptor < 0) {
|
||||||
std::filesystem::remove(temporary, error);
|
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()));
|
||||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to write runtime file: " + temporary.string()));
|
|
||||||
}
|
}
|
||||||
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<std::size_t>(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) {
|
if (error) {
|
||||||
auto message = error.message();
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to publish runtime file " + destination.string() + ": " + 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 {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -162,9 +198,9 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::data
|
|||||||
co_return std::unexpected(score.error());
|
co_return std::unexpected(score.error());
|
||||||
}
|
}
|
||||||
for (auto page = std::size_t{}; page < vamp_zero::score_page_count(*score); ++page) {
|
for (auto page = std::size_t{}; page < vamp_zero::score_page_count(*score); ++page) {
|
||||||
auto path = vamp_zero::validated_page_path(*score, page);
|
auto valid = vamp_zero::validate_score_page(*score, page);
|
||||||
if (!path) {
|
if (!valid) {
|
||||||
co_return std::unexpected(path.error());
|
co_return std::unexpected(valid.error());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
co_return std::expected<void, modulus::error>{};
|
co_return std::expected<void, modulus::error>{};
|
||||||
@@ -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<void, modulus::error> {
|
auto probe_runtime_directory(vamp_zero::config const &config) -> std::expected<void, modulus::error> {
|
||||||
|
auto valid = validate_runtime_destination(config.runtime.heartbeat_path);
|
||||||
|
if (!valid) {
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
auto directory = std::filesystem::path{config.runtime.heartbeat_path}.parent_path();
|
auto directory = std::filesystem::path{config.runtime.heartbeat_path}.parent_path();
|
||||||
if (directory.empty()) {
|
if (directory.empty()) {
|
||||||
directory = ".";
|
directory = ".";
|
||||||
@@ -201,24 +241,22 @@ auto probe_runtime_directory(vamp_zero::config const &config) -> std::expected<v
|
|||||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create runtime directory: " + error.message()));
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create runtime directory: " + error.message()));
|
||||||
}
|
}
|
||||||
auto pattern = (directory / ".vamp-readiness-XXXXXX").string();
|
auto pattern = (directory / ".vamp-readiness-XXXXXX").string();
|
||||||
if (::mkdtemp(pattern.data()) == nullptr) {
|
auto descriptor = ::mkstemp(pattern.data());
|
||||||
|
if (descriptor < 0) {
|
||||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE,
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE,
|
||||||
"failed to create runtime probe: " + std::error_code{errno, std::generic_category()}.message()));
|
"failed to create runtime probe: " + std::error_code{errno, std::generic_category()}.message()));
|
||||||
}
|
}
|
||||||
|
auto probe = runtime_temporary{pattern, descriptor};
|
||||||
struct cleanup {
|
auto closed = ::close(descriptor);
|
||||||
std::filesystem::path path;
|
probe.descriptor = -1;
|
||||||
~cleanup() {
|
if (closed != 0) {
|
||||||
auto ignored = std::error_code{};
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to close runtime probe: " + pattern));
|
||||||
std::filesystem::remove_all(path, ignored);
|
}
|
||||||
}
|
auto written = write_text_atomic(probe.path, "ready\n");
|
||||||
} probe{pattern};
|
|
||||||
|
|
||||||
auto written = write_text_atomic(probe.path / "probe", "ready\n");
|
|
||||||
if (!written) {
|
if (!written) {
|
||||||
return written;
|
return written;
|
||||||
}
|
}
|
||||||
std::filesystem::remove_all(probe.path, error);
|
std::filesystem::remove(probe.path, error);
|
||||||
if (error) {
|
if (error) {
|
||||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to remove runtime probe: " + error.message()));
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to remove runtime probe: " + error.message()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ CREATE TABLE IF NOT EXISTS practice_exercises (
|
|||||||
if (ensured) {
|
if (ensured) {
|
||||||
ensured = co_await execute_schema_statement(connection, "CREATE TRIGGER IF NOT EXISTS invalidate_" + std::string{table} + "_metadata AFTER UPDATE OF source_json ON " + table + " WHEN NEW.source_json != OLD.source_json BEGIN UPDATE " + table + " SET measure_count = NULL, total_pages = NULL, authored_key_signature = NULL WHERE id = NEW.id; END");
|
ensured = co_await execute_schema_statement(connection, "CREATE TRIGGER IF NOT EXISTS invalidate_" + std::string{table} + "_metadata AFTER UPDATE OF source_json ON " + table + " WHEN NEW.source_json != OLD.source_json BEGIN UPDATE " + table + " SET measure_count = NULL, total_pages = NULL, authored_key_signature = NULL WHERE id = NEW.id; END");
|
||||||
}
|
}
|
||||||
|
index_statements.push_back("CREATE INDEX IF NOT EXISTS idx_" + std::string{table} + "_pending_metadata ON " + table + "(id) WHERE measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL");
|
||||||
}
|
}
|
||||||
for (auto &statement : index_statements) {
|
for (auto &statement : index_statements) {
|
||||||
if (!ensured) {
|
if (!ensured) {
|
||||||
@@ -255,8 +256,10 @@ auto now_ms() -> std::int64_t {
|
|||||||
.count();
|
.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
auto backfill_metadata(noto::connection &connection, std::string const &table) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
auto backfill_metadata(boost::asio::any_io_executor executor, noto::connection &connection, std::string const &table) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||||
for (;;) {
|
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");
|
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) {
|
if (!row) {
|
||||||
co_return std::unexpected(row.error());
|
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<std::expected<void, modulus::error>> {
|
||||||
|
auto params = std::vector<noto::sqlite_value>{
|
||||||
|
noto::sqlite_value{static_cast<std::int64_t>(document.measure_count)},
|
||||||
|
noto::sqlite_value{static_cast<std::int64_t>(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<void, modulus::error>{};
|
||||||
|
}
|
||||||
|
|
||||||
auto count_value(noto::sqlite_value const &value) -> std::size_t {
|
auto count_value(noto::sqlite_value const &value) -> std::size_t {
|
||||||
auto count = std::get_if<std::int64_t>(&value.value);
|
auto count = std::get_if<std::int64_t>(&value.value);
|
||||||
return count == nullptr || *count <= 0 ? 0U : static_cast<std::size_t>(*count);
|
return count == nullptr || *count <= 0 ? 0U : static_cast<std::size_t>(*count);
|
||||||
@@ -394,6 +411,7 @@ auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vam
|
|||||||
if (!imported) {
|
if (!imported) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
co_await boost::asio::post(executor, boost::asio::use_awaitable);
|
||||||
auto document = vamp_zero::read_score_document(path);
|
auto document = vamp_zero::read_score_document(path);
|
||||||
if (!document) {
|
if (!document) {
|
||||||
imported = std::unexpected(document.error());
|
imported = std::unexpected(document.error());
|
||||||
@@ -445,6 +463,8 @@ WHERE songs.catalog_managed = 1
|
|||||||
std::move(params));
|
std::move(params));
|
||||||
if (!inserted) {
|
if (!inserted) {
|
||||||
imported = std::unexpected(inserted.error());
|
imported = std::unexpected(inserted.error());
|
||||||
|
} else {
|
||||||
|
imported = co_await restore_imported_metadata(connection, "songs", part.id, *document);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
auto practice_ids = std::vector<std::string>{};
|
auto practice_ids = std::vector<std::string>{};
|
||||||
@@ -453,6 +473,7 @@ WHERE songs.catalog_managed = 1
|
|||||||
if (!imported) {
|
if (!imported) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
co_await boost::asio::post(executor, boost::asio::use_awaitable);
|
||||||
auto document = vamp_zero::read_score_document(path);
|
auto document = vamp_zero::read_score_document(path);
|
||||||
if (!document) {
|
if (!document) {
|
||||||
imported = std::unexpected(document.error());
|
imported = std::unexpected(document.error());
|
||||||
@@ -526,6 +547,10 @@ WHERE practice_exercises.catalog_managed = 1
|
|||||||
imported = std::unexpected(inserted.error());
|
imported = std::unexpected(inserted.error());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
imported = co_await restore_imported_metadata(connection, "practice_exercises", variant.id, *document);
|
||||||
|
if (!imported) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (imported) {
|
if (imported) {
|
||||||
@@ -565,7 +590,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
|
|||||||
if (!connected) {
|
if (!connected) {
|
||||||
co_return std::unexpected(connected.error());
|
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) {
|
if (!filled) {
|
||||||
static_cast<void>(co_await connection.async_close());
|
static_cast<void>(co_await connection.async_close());
|
||||||
co_return std::unexpected(filled.error());
|
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) {
|
if (!connected) {
|
||||||
co_return std::unexpected(connected.error());
|
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) {
|
if (!filled) {
|
||||||
static_cast<void>(co_await connection.async_close());
|
static_cast<void>(co_await connection.async_close());
|
||||||
co_return std::unexpected(filled.error());
|
co_return std::unexpected(filled.error());
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iterator>
|
#include <iterator>
|
||||||
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <system_error>
|
#include <system_error>
|
||||||
@@ -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()));
|
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());
|
std::sort(paths.begin(), paths.end());
|
||||||
|
if (practice) {
|
||||||
|
auto exercises = std::map<std::string, std::filesystem::path>{};
|
||||||
|
auto variants = std::map<std::string, std::filesystem::path>{};
|
||||||
|
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;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-3
@@ -5,6 +5,7 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include <openssl/sha.h>
|
#include <openssl/sha.h>
|
||||||
|
#include <raylib.h>
|
||||||
|
|
||||||
#include <vamp_zero/errors.hpp>
|
#include <vamp_zero/errors.hpp>
|
||||||
#include <vamp_zero/score.hpp>
|
#include <vamp_zero/score.hpp>
|
||||||
@@ -46,8 +47,15 @@ auto vamp_zero::score_page_count(vamp_zero::score_document const &score) -> std:
|
|||||||
return score.pages.size();
|
return score.pages.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
auto vamp_zero::validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected<std::string, modulus::error> {
|
namespace {
|
||||||
auto fail = [&](std::string message) -> std::expected<std::string, modulus::error> {
|
|
||||||
|
struct page_file {
|
||||||
|
std::string path;
|
||||||
|
std::vector<std::uchar_t> bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto read_page(vamp_zero::score_document const &score, std::size_t index) -> std::expected<page_file, modulus::error> {
|
||||||
|
auto fail = [&](std::string message) -> std::expected<page_file, modulus::error> {
|
||||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, score.source_path + ": " + std::move(message) + "; regenerate score page assets"));
|
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()) {
|
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) {
|
if (hash != score.pages[index].sha256) {
|
||||||
return fail("score page checksum mismatch");
|
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<std::string, modulus::error> {
|
||||||
|
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<void, modulus::error> {
|
||||||
|
auto page = read_page(score, index);
|
||||||
|
if (!page) {
|
||||||
|
return std::unexpected(page.error());
|
||||||
|
}
|
||||||
|
auto fail = [&](std::string message) -> std::expected<void, modulus::error> {
|
||||||
|
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<std::uchar_t, 8>{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<std::uint32_t>(bytes[offset]) << 24U) | (static_cast<std::uint32_t>(bytes[offset + 1U]) << 16U) | (static_cast<std::uint32_t>(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<std::int32_t>(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 {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#include <vamp_zero/touch_input.hpp>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <boost/asio/post.hpp>
|
||||||
|
#include <boost/asio/steady_timer.hpp>
|
||||||
|
|
||||||
|
#include <vamp_protocol/gesture.hpp>
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
auto milliseconds(std::chrono::steady_clock::time_point time) -> std::uint64_t {
|
||||||
|
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::milliseconds>(time.time_since_epoch()).count());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct vamp_zero::touch_input::state : std::enable_shared_from_this<state> {
|
||||||
|
state(boost::asio::any_io_executor executor, std::function<void(vamp_protocol::page_command)> handler)
|
||||||
|
: timer{std::move(executor)}, handler{std::move(handler)} {}
|
||||||
|
auto send(std::optional<vamp_protocol::page_command> 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<void(vamp_protocol::page_command)> handler;
|
||||||
|
std::mutex mutex{};
|
||||||
|
vamp_protocol::gesture_state gesture{};
|
||||||
|
std::optional<std::int32_t> 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<void(vamp_protocol::page_command)> handler)
|
||||||
|
: state_{std::make_shared<state>(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();
|
||||||
|
}
|
||||||
+6
-141
@@ -1,20 +1,16 @@
|
|||||||
#include <vamp_zero/ui.hpp>
|
#include <vamp_zero/ui.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <expected>
|
#include <expected>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include <boost/asio/any_io_executor.hpp>
|
#include <boost/asio/any_io_executor.hpp>
|
||||||
#include <boost/asio/post.hpp>
|
|
||||||
#include <boost/asio/steady_timer.hpp>
|
|
||||||
|
|
||||||
#include <luma.hpp>
|
#include <luma.hpp>
|
||||||
|
|
||||||
@@ -27,27 +23,15 @@
|
|||||||
#include <vamp_zero/parse.hpp>
|
#include <vamp_zero/parse.hpp>
|
||||||
#include <vamp_zero/score.hpp>
|
#include <vamp_zero/score.hpp>
|
||||||
#include <vamp_zero/score_loader.hpp>
|
#include <vamp_zero/score_loader.hpp>
|
||||||
|
#include <vamp_zero/touch_input.hpp>
|
||||||
|
|
||||||
|
|
||||||
struct vamp_zero::ui_runtime : std::enable_shared_from_this<vamp_zero::ui_runtime> {
|
struct vamp_zero::ui_runtime {
|
||||||
ui_runtime(boost::asio::any_io_executor executor, std::function<void(vamp_protocol::page_command)> handler)
|
ui_runtime(boost::asio::any_io_executor executor, std::function<void(vamp_protocol::page_command)> handler)
|
||||||
: executor_{std::move(executor)}, tap_timer_{executor_}, command_handler_{std::move(handler)} {}
|
: touch_{std::move(executor), std::move(handler)} {}
|
||||||
auto handle_touch(luma::touch_event const &event) -> std::bool_t;
|
vamp_zero::touch_input touch_;
|
||||||
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<void(vamp_protocol::page_command)> command_handler_{};
|
|
||||||
std::mutex mutex_{};
|
std::mutex mutex_{};
|
||||||
vamp_zero::ui_state state_{};
|
vamp_zero::ui_state state_{};
|
||||||
std::mutex touch_mutex_{};
|
|
||||||
std::optional<std::int32_t> 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 {
|
namespace {
|
||||||
@@ -95,9 +79,6 @@ struct score_draw_cache {
|
|||||||
std::weak_ptr<vamp_zero::score_document const> document{};
|
std::weak_ptr<vamp_zero::score_document const> 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 bind_port(vamp_zero::config const &config) -> std::uint16_t {
|
||||||
auto const &bind = config.control.endpoint.bind;
|
auto const &bind = config.control.endpoint.bind;
|
||||||
auto colon = bind.rfind(':');
|
auto colon = bind.rfind(':');
|
||||||
@@ -561,7 +542,7 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
auto handle_touch(luma::touch_event const &event) -> std::bool_t override {
|
auto handle_touch(luma::touch_event const &event) -> std::bool_t override {
|
||||||
return runtime_->handle_touch(event);
|
return runtime_->touch_.handle(event);
|
||||||
}
|
}
|
||||||
private:
|
private:
|
||||||
std::shared_ptr<vamp_zero::ui_runtime> runtime_;
|
std::shared_ptr<vamp_zero::ui_runtime> runtime_;
|
||||||
@@ -583,117 +564,6 @@ vamp_zero::zero_ui::~zero_ui() {
|
|||||||
stop();
|
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<vamp_protocol::page_command>{};
|
|
||||||
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<void, modulus::error> {
|
auto vamp_zero::zero_ui::run() -> std::expected<void, modulus::error> {
|
||||||
if (auto status = application_->set_root(std::make_unique<vamp_root_control>(runtime_, score_loader_, *application_)); !status) {
|
if (auto status = application_->set_root(std::make_unique<vamp_root_control>(runtime_, score_loader_, *application_)); !status) {
|
||||||
return status;
|
return status;
|
||||||
@@ -704,12 +574,7 @@ auto vamp_zero::zero_ui::run() -> std::expected<void, modulus::error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto vamp_zero::zero_ui::stop() -> void {
|
auto vamp_zero::zero_ui::stop() -> void {
|
||||||
{
|
runtime_->touch_.stop();
|
||||||
auto lock = std::scoped_lock{runtime_->touch_mutex_};
|
|
||||||
runtime_->stopped_ = true;
|
|
||||||
runtime_->pending_tap_ = false;
|
|
||||||
runtime_->tap_timer_.cancel();
|
|
||||||
}
|
|
||||||
score_loader_->stop();
|
score_loader_->stop();
|
||||||
application_->request_stop();
|
application_->request_stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ add_executable(product-vamp-test
|
|||||||
test_health.cpp
|
test_health.cpp
|
||||||
test_pico.cpp
|
test_pico.cpp
|
||||||
test_score_loader.cpp
|
test_score_loader.cpp
|
||||||
|
test_touch_input.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(product-vamp-test PRIVATE
|
target_include_directories(product-vamp-test PRIVATE
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}
|
${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
@@ -14,6 +15,7 @@ target_include_directories(product-vamp-test PRIVATE
|
|||||||
)
|
)
|
||||||
target_link_libraries(product-vamp-test PRIVATE
|
target_link_libraries(product-vamp-test PRIVATE
|
||||||
vamp-zero-core
|
vamp-zero-core
|
||||||
|
modulus::raylib
|
||||||
)
|
)
|
||||||
target_compile_features(product-vamp-test PRIVATE cxx_std_23)
|
target_compile_features(product-vamp-test PRIVATE cxx_std_23)
|
||||||
target_compile_definitions(product-vamp-test PRIVATE
|
target_compile_definitions(product-vamp-test PRIVATE
|
||||||
|
|||||||
@@ -62,3 +62,20 @@ class CatalogueTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, 'output path collides'):
|
with self.assertRaisesRegex(ValueError, 'output path collides'):
|
||||||
generate_practice_scores(source, output)
|
generate_practice_scores(source, output)
|
||||||
self.assertFalse(output.exists())
|
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')
|
||||||
|
|||||||
@@ -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_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
@@ -18,6 +18,9 @@
|
|||||||
#include <boost/asio/use_future.hpp>
|
#include <boost/asio/use_future.hpp>
|
||||||
#include <boost/json.hpp>
|
#include <boost/json.hpp>
|
||||||
#include <openssl/sha.h>
|
#include <openssl/sha.h>
|
||||||
|
#include <raylib.h>
|
||||||
|
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
#include <vamp_zero/app.hpp>
|
#include <vamp_zero/app.hpp>
|
||||||
#include <vamp_zero/database.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();
|
return path.string();
|
||||||
}
|
}
|
||||||
|
|
||||||
auto fixture_page() -> std::string {
|
auto fixture_page(std::int32_t width = 976, std::int32_t height = 484) -> std::string {
|
||||||
auto bytes = std::array<std::uchar_t, 24>{137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13,
|
auto image = GenImageColor(width, height, WHITE);
|
||||||
73, 72, 68, 82, 0, 0, 3, 208, 0, 0, 1, 228};
|
auto size = std::int32_t{};
|
||||||
return {reinterpret_cast<std::char_t const *>(bytes.data()), bytes.size()};
|
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 {
|
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);
|
std::filesystem::create_symlink(root.path / "outside.png", page);
|
||||||
} else {
|
} else {
|
||||||
auto bytes = fixture_page();
|
auto bytes = fixture_page();
|
||||||
bytes.back() = 0;
|
bytes.back() ^= 1;
|
||||||
write_file(page, bytes);
|
write_file(page, bytes);
|
||||||
}
|
}
|
||||||
auto ready = run_once(root.path, config);
|
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_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) {
|
BOOST_AUTO_TEST_CASE(readiness_rejects_occupied_bind_and_releases_successful_probe) {
|
||||||
auto root = temporary_directory{};
|
auto root = temporary_directory{};
|
||||||
auto config = fixture_config(root.path);
|
auto config = fixture_config(root.path);
|
||||||
|
|||||||
+8
-8
@@ -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) {
|
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(false, 0U));
|
||||||
BOOST_CHECK(!gesture.update(true, 10U));
|
BOOST_CHECK(!gesture.update(true, 10U));
|
||||||
BOOST_CHECK(!gesture.update(false, 50U));
|
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) {
|
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(true, 0U));
|
||||||
BOOST_CHECK(!gesture.update(false, 40U));
|
BOOST_CHECK(!gesture.update(false, 40U));
|
||||||
BOOST_CHECK(!gesture.update(true, 180U));
|
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) {
|
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(true, 0U));
|
||||||
BOOST_CHECK(!gesture.update(false, 40U));
|
BOOST_CHECK(!gesture.update(false, 40U));
|
||||||
BOOST_CHECK(!gesture.update(true, 180U));
|
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) {
|
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(true, 0U));
|
||||||
BOOST_CHECK(!gesture.update(false, 40U));
|
BOOST_CHECK(!gesture.update(false, 40U));
|
||||||
check_command(gesture.update(true, 261U), vamp_protocol::page_command::NEXT);
|
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) {
|
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, 50U));
|
||||||
BOOST_CHECK(!gesture.update(true, 749U));
|
BOOST_CHECK(!gesture.update(true, 749U));
|
||||||
check_command(gesture.update(true, 750U), vamp_protocol::page_command::SELECT);
|
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) {
|
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(true, 0U));
|
||||||
BOOST_CHECK(!gesture.update(false, 40U));
|
BOOST_CHECK(!gesture.update(false, 40U));
|
||||||
BOOST_CHECK(!gesture.update(true, 180U));
|
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) {
|
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{};
|
auto scheduler = vamp_pico::request_scheduler{};
|
||||||
BOOST_REQUIRE(scheduler.next(0U));
|
BOOST_REQUIRE(scheduler.next(0U));
|
||||||
for (auto now_ms = std::uint64_t{}; now_ms < 1504U; now_ms += 8U) {
|
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) {
|
BOOST_AUTO_TEST_CASE(pico_timing_survives_32_bit_millisecond_boundary) {
|
||||||
constexpr auto start = std::uint64_t{0xffffffffU} - 100U;
|
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(true, start));
|
||||||
BOOST_CHECK(!gesture.update(false, start + 40U));
|
BOOST_CHECK(!gesture.update(false, start + 40U));
|
||||||
check_command(gesture.update(false, start + 261U), vamp_protocol::page_command::NEXT);
|
check_command(gesture.update(false, start + 261U), vamp_protocol::page_command::NEXT);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user