UI
This commit is contained in:
+14
-1
@@ -2,14 +2,17 @@ add_executable(product-vamp-test
|
||||
../src/vamp_pico/http_client.cpp
|
||||
../src/vamp_pico/input.cpp
|
||||
../src/vamp_pico/remote_display.cpp
|
||||
../src/vamp_pico/screen.cpp
|
||||
test.cpp
|
||||
test_catalog_runtime.cpp
|
||||
test_health.cpp
|
||||
test_menu.cpp
|
||||
test_pico.cpp
|
||||
test_portal_artwork.cpp
|
||||
test_control_artwork.cpp
|
||||
test_playback.cpp
|
||||
test_score_loader.cpp
|
||||
test_touch_input.cpp
|
||||
test_ui.cpp
|
||||
)
|
||||
target_include_directories(product-vamp-test PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
@@ -27,7 +30,17 @@ target_compile_definitions(product-vamp-test PRIVATE
|
||||
VAMP_TEST_PYTHON="${Python3_EXECUTABLE}"
|
||||
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
|
||||
VAMP_TEST_PLAYBACK_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/playback-assets"
|
||||
VAMP_TEST_UI_DIR="${CMAKE_CURRENT_BINARY_DIR}/ui-previews"
|
||||
)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/ui-previews/static.p4
|
||||
COMMAND ${Python3_EXECUTABLE} -m generator.pico_artwork --static-output ${CMAKE_CURRENT_BINARY_DIR}/ui-previews/static.p4
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
DEPENDS ${CMAKE_SOURCE_DIR}/generator/pico_artwork.py ${CMAKE_SOURCE_DIR}/package/assets/fonts/petaluma/PetalumaScript.otf
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(vamp-ui-test-assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/ui-previews/static.p4)
|
||||
add_dependencies(product-vamp-test vamp-ui-test-assets)
|
||||
file(GLOB_RECURSE VAMP_PLAYBACK_TEST_INPUTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/generator/*.py)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/playback-assets/score.json
|
||||
|
||||
+21
-21
@@ -34,7 +34,7 @@
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/parse.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/control.hpp>
|
||||
#include <vamp_zero/app.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
#include <vamp_zero/types.hpp>
|
||||
@@ -92,9 +92,6 @@ BOOST_AUTO_TEST_CASE(build_identification_uses_git_without_an_application_releas
|
||||
auto once = std::string{"--once"};
|
||||
args.push_back(once.data());
|
||||
BOOST_TEST(!vamp_zero::parse_command_line(3, args.data()));
|
||||
auto status = vamp_zero::portal_status_json({}, {}).as_object();
|
||||
BOOST_TEST(status.at("git_sha").as_string() == VAMP_GIT_SHA);
|
||||
BOOST_TEST(!status.contains("version"));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(runtime_config_uses_headless_and_ignores_unknown_fields) {
|
||||
@@ -155,20 +152,23 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) {
|
||||
BOOST_TEST(!parsed);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) {
|
||||
BOOST_AUTO_TEST_CASE(settings_menu_toggles_audio_settings_and_edits_tempo) {
|
||||
auto state = navigation_state();
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_main_menu_index == 1U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
|
||||
BOOST_TEST(state.selected_settings_index == 1U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(state.audio.playback_enabled);
|
||||
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::MAIN_MENU));
|
||||
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::SETTINGS_MENU));
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_main_menu_index == 2U);
|
||||
BOOST_TEST(state.selected_settings_index == 2U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(state.audio.metronome_enabled);
|
||||
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::MAIN_MENU));
|
||||
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::SETTINGS_MENU));
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_main_menu_index == 3U);
|
||||
BOOST_TEST(state.selected_settings_index == 3U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(state.audio.tempo_editing);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
@@ -187,21 +187,22 @@ BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) {
|
||||
BOOST_TEST(!state.audio.tempo_editing);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(audio_status_json_reports_menu_settings) {
|
||||
BOOST_AUTO_TEST_CASE(remote_display_reports_settings_without_a_catalog_dump) {
|
||||
auto state = navigation_state();
|
||||
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
|
||||
state.selected_settings_index = 3U;
|
||||
state.audio.playback_enabled = true;
|
||||
state.audio.metronome_enabled = true;
|
||||
state.audio.metronome_bpm = 135;
|
||||
state.audio.tempo_editing = true;
|
||||
auto status = vamp_zero::app_status_json(state).as_object();
|
||||
auto const &audio = status.at("audio").as_object();
|
||||
BOOST_TEST(audio.at("playback_enabled").as_bool());
|
||||
BOOST_TEST(audio.at("metronome_enabled").as_bool());
|
||||
BOOST_TEST(audio.at("metronome_bpm").as_uint64() == 135U);
|
||||
BOOST_TEST(audio.at("metronome_min_bpm").as_uint64() == 40U);
|
||||
BOOST_TEST(audio.at("metronome_max_bpm").as_uint64() == 240U);
|
||||
BOOST_TEST(audio.at("metronome_step").as_uint64() == 5U);
|
||||
BOOST_TEST(audio.at("tempo_editing").as_bool());
|
||||
auto remote = vamp_pico::remote_display_state{};
|
||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
|
||||
BOOST_TEST(std::string{remote.focus} == "Tempo");
|
||||
BOOST_TEST(std::string{remote.mode} == "settings_menu");
|
||||
BOOST_TEST(remote.playback);
|
||||
BOOST_TEST(remote.metronome);
|
||||
BOOST_TEST(remote.bpm == 135U);
|
||||
BOOST_TEST(remote.editing);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) {
|
||||
@@ -240,7 +241,7 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalog) {
|
||||
BOOST_AUTO_TEST_CASE(empty_catalog_navigation_and_main_menu_wrap) {
|
||||
auto state = vamp_zero::app_state{};
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
||||
BOOST_TEST(state.selected_main_menu_index == 3U);
|
||||
BOOST_TEST(state.selected_main_menu_index == 1U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_main_menu_index == 0U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
@@ -250,7 +251,6 @@ BOOST_AUTO_TEST_CASE(empty_catalog_navigation_and_main_menu_wrap) {
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::MAIN_MENU);
|
||||
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
|
||||
BOOST_TEST(!vamp_zero::app_status_json(state).as_object().contains("practice"));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display) {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/control.hpp>
|
||||
|
||||
#include "catalog_fixture.hpp"
|
||||
|
||||
@@ -156,7 +156,7 @@ BOOST_AUTO_TEST_CASE(catalog_snapshots_heartbeat_and_navigation_share_immutable_
|
||||
BOOST_TEST(vamp_zero::selected_part(before) == &catalog->parts[1]);
|
||||
auto heartbeat = control.heartbeat();
|
||||
BOOST_TEST(heartbeat.catalog.get() == catalog.get());
|
||||
BOOST_TEST(heartbeat.heartbeat_count == 1U);
|
||||
BOOST_TEST(heartbeat.input_revision == before.input_revision);
|
||||
BOOST_TEST(heartbeat.pico_connected);
|
||||
auto playing = control.apply(vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(vamp_zero::to_string(playing.mode) == "performance_score");
|
||||
@@ -165,8 +165,8 @@ BOOST_AUTO_TEST_CASE(catalog_snapshots_heartbeat_and_navigation_share_immutable_
|
||||
BOOST_TEST(control.apply(vamp_protocol::page_command::PREVIOUS).page_index == 0U);
|
||||
control.apply(vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(control.snapshot().catalog.get() == catalog.get());
|
||||
BOOST_TEST(before.heartbeat_count == 0U);
|
||||
BOOST_TEST(before.select_count == 0U);
|
||||
BOOST_TEST(!before.pico_connected);
|
||||
BOOST_TEST(before.input_revision == 0U);
|
||||
BOOST_TEST(before.active_part_id.empty());
|
||||
BOOST_TEST(original.catalog.get() == catalog.get());
|
||||
BOOST_REQUIRE(!observed.empty());
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <vamp_pico/http_client.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/control.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -72,7 +72,7 @@ auto call(ligo::service_router const &router, std::string method, std::string ro
|
||||
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(portal_title_route_serves_registered_binary_and_remote_identity, artwork_fixture) {
|
||||
BOOST_FIXTURE_TEST_CASE(control_title_route_serves_registered_binary_and_remote_identity, artwork_fixture) {
|
||||
auto control = vamp_zero::control_state{config(), state()};
|
||||
auto router = vamp_zero::make_router(control);
|
||||
auto status = call(router, "GET", vamp_protocol::remote_display_path);
|
||||
@@ -92,7 +92,7 @@ BOOST_FIXTURE_TEST_CASE(portal_title_route_serves_registered_binary_and_remote_i
|
||||
BOOST_CHECK(!call(router, "GET", vamp_protocol::remote_title_path, boost::json::object{{"id", identifier}}));
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(portal_title_route_rejects_arbitrary_unregistered_and_corrupt_assets, artwork_fixture) {
|
||||
BOOST_FIXTURE_TEST_CASE(control_title_route_rejects_arbitrary_unregistered_and_corrupt_assets, artwork_fixture) {
|
||||
auto control = vamp_zero::control_state{config(), state()};
|
||||
auto router = vamp_zero::make_router(control);
|
||||
for (auto const &id : {std::string{"../title"}, std::string(64U, 'f'), std::string(64U, 'A'), identifier + "a"}) {
|
||||
@@ -115,24 +115,20 @@ BOOST_FIXTURE_TEST_CASE(portal_title_route_rejects_arbitrary_unregistered_and_co
|
||||
BOOST_CHECK(!call(router, "POST", vamp_protocol::remote_title_path, boost::json::object{{"id", identifier}}));
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(portal_title_validation_rejects_symlink_outside_title_directory, artwork_fixture) {
|
||||
BOOST_FIXTURE_TEST_CASE(control_title_validation_rejects_symlink_outside_title_directory, artwork_fixture) {
|
||||
auto other = root / "other.mask";
|
||||
std::filesystem::rename(path(), other);
|
||||
std::filesystem::create_symlink(other, path());
|
||||
BOOST_CHECK(!vamp_zero::validated_title_path(root.string(), identifier));
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(portal_uses_and_serves_only_the_packaged_petaluma_font, artwork_fixture) {
|
||||
std::filesystem::create_directories(root / "fonts/petaluma");
|
||||
std::filesystem::copy_file(std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "package/assets/fonts/petaluma/PetalumaScript.otf", root / "fonts/petaluma/PetalumaScript.otf");
|
||||
BOOST_FIXTURE_TEST_CASE(control_has_no_browser_remote_or_catalog_dump, artwork_fixture) {
|
||||
auto control = vamp_zero::control_state{config(), {}};
|
||||
auto router = vamp_zero::make_router(control);
|
||||
auto html = call(router, "GET", "/");
|
||||
BOOST_REQUIRE(html);
|
||||
BOOST_CHECK(html->body.find("font-family:PetalumaScript") != std::string::npos);
|
||||
auto font = call(router, "GET", "/assets/PetalumaScript.otf");
|
||||
BOOST_REQUIRE(font);
|
||||
BOOST_TEST(font->content_type == "font/otf");
|
||||
BOOST_TEST(font->body.substr(0U, 4U) == "OTTO");
|
||||
BOOST_CHECK(!call(router, "GET", "/"));
|
||||
BOOST_CHECK(!call(router, "GET", "/api/status"));
|
||||
BOOST_CHECK(!call(router, "GET", "/assets/PetalumaScript.otf"));
|
||||
BOOST_CHECK(!call(router, "GET", "/assets/other.otf"));
|
||||
BOOST_REQUIRE(call(router, "GET", "/api/health"));
|
||||
BOOST_REQUIRE(call(router, "GET", vamp_protocol::remote_display_path));
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
#include <vamp_zero/app.hpp>
|
||||
#include <vamp_zero/database.hpp>
|
||||
#include <vamp_zero/health.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/control.hpp>
|
||||
|
||||
#include "catalog_fixture.hpp"
|
||||
|
||||
@@ -310,17 +310,17 @@ BOOST_AUTO_TEST_CASE(health_command_never_mutates_database_assets_or_runtime) {
|
||||
require_no_probe(config, true);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(portal_health_route_reports_readiness_without_changing_control_state) {
|
||||
BOOST_AUTO_TEST_CASE(control_health_route_reports_readiness_without_changing_control_state) {
|
||||
auto state = vamp_zero::control_state{vamp_zero::config{}, vamp_zero::app_state{}};
|
||||
auto service = http_service{vamp_zero::make_router(state)};
|
||||
BOOST_TEST(vamp_zero::check_health(service.config()).has_value());
|
||||
auto snapshot = state.snapshot();
|
||||
BOOST_TEST(snapshot.heartbeat_count == 0U);
|
||||
BOOST_TEST(snapshot.input_revision == 0U);
|
||||
BOOST_TEST(!snapshot.pico_connected);
|
||||
BOOST_TEST(snapshot.last_command == "startup");
|
||||
BOOST_CHECK(snapshot.mode == vamp_zero::app_mode::MAIN_MENU);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(portal_remote_commands_refresh_connection_without_background_polls) {
|
||||
BOOST_AUTO_TEST_CASE(control_remote_commands_refresh_connection_without_background_polls) {
|
||||
for (auto const *route : {vamp_protocol::remote_next_path, vamp_protocol::remote_previous_path, vamp_protocol::remote_select_path}) {
|
||||
auto state = vamp_zero::control_state{vamp_zero::config{}, vamp_zero::app_state{}};
|
||||
auto catalog = state.snapshot().catalog;
|
||||
@@ -331,7 +331,7 @@ BOOST_AUTO_TEST_CASE(portal_remote_commands_refresh_connection_without_backgroun
|
||||
BOOST_REQUIRE(response.get());
|
||||
auto snapshot = state.snapshot();
|
||||
BOOST_TEST(snapshot.pico_connected);
|
||||
BOOST_TEST(snapshot.heartbeat_count == 1U);
|
||||
BOOST_TEST(snapshot.input_revision == 1U);
|
||||
BOOST_TEST(snapshot.catalog.get() == catalog.get());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
|
||||
#include <vamp_pico/http_client.hpp>
|
||||
#include <vamp_zero/control.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/menu_view.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto menu_bounds = luma::rect{0.0F, 80.0F, 1024.0F, 520.0F};
|
||||
|
||||
auto action(vamp_zero::app_state const &state, std::size_t row, vamp_zero::menu_action_kind kind = vamp_zero::menu_action_kind::ACTIVATE) -> vamp_zero::menu_action {
|
||||
return {kind, row, state.input_revision, state.catalog};
|
||||
}
|
||||
|
||||
auto point_event(luma::touch_phase phase, luma::point point, std::chrono::steady_clock::time_point time, std::int32_t id = 1) -> luma::touch_event {
|
||||
return {.points = {{.id = id, .position = point, .phase = phase}}, .time = time};
|
||||
}
|
||||
|
||||
auto row_center(vamp_zero::app_state const &state, std::size_t row, vamp_zero::menu_action_kind kind = vamp_zero::menu_action_kind::ACTIVATE) -> luma::point {
|
||||
auto targets = vamp_zero::menu_layout(menu_bounds, state);
|
||||
auto found = std::find_if(targets.begin(), targets.end(), [&](auto const &target) { return target.action.row == row && target.action.kind == kind; });
|
||||
BOOST_REQUIRE(found != targets.end());
|
||||
return {found->bounds.x + found->bounds.width / 2.0F, found->bounds.y + found->bounds.height / 2.0F};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(menu_actions_share_navigation_and_preserve_settings_focus) {
|
||||
auto state = vamp_zero::app_state{};
|
||||
BOOST_TEST(vamp_zero::menu_count(state) == 2U);
|
||||
BOOST_TEST(vamp_zero::menu_row(state, 0U).label == "Performance");
|
||||
BOOST_TEST(vamp_zero::menu_row(state, 1U).label == "Settings");
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 1U));
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
|
||||
BOOST_TEST(state.selected_settings_index == 1U);
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 1U));
|
||||
BOOST_TEST(state.audio.playback_enabled);
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 2U));
|
||||
BOOST_TEST(state.audio.metronome_enabled);
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 3U));
|
||||
BOOST_TEST(state.audio.tempo_editing);
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 0U));
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::MAIN_MENU);
|
||||
BOOST_TEST(!state.audio.tempo_editing);
|
||||
state = vamp_zero::apply_app_command(state, vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
|
||||
BOOST_TEST(state.selected_settings_index == 3U);
|
||||
BOOST_TEST(!state.audio.tempo_editing);
|
||||
BOOST_TEST(state.audio.playback_enabled);
|
||||
BOOST_TEST(state.audio.metronome_enabled);
|
||||
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(menu_touch_activates_once_on_release_and_stepper_has_priority) {
|
||||
auto state = vamp_zero::app_state{};
|
||||
auto touch = vamp_zero::menu_touch{};
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto point = row_center(state, 1U);
|
||||
BOOST_CHECK(!touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state));
|
||||
auto tapped = touch.handle(point_event(luma::touch_phase::UP, point, now + std::chrono::milliseconds{40}), menu_bounds, state);
|
||||
BOOST_REQUIRE(tapped);
|
||||
state = vamp_zero::apply_menu_action(state, *tapped);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
|
||||
BOOST_TEST(!state.audio.playback_enabled);
|
||||
BOOST_CHECK(!touch.handle(point_event(luma::touch_phase::UP, point, now), menu_bounds, state));
|
||||
point = row_center(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP);
|
||||
touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state);
|
||||
tapped = touch.handle(point_event(luma::touch_phase::UP, point, now + std::chrono::milliseconds{40}), menu_bounds, state);
|
||||
BOOST_REQUIRE(tapped);
|
||||
BOOST_CHECK(tapped->kind == vamp_zero::menu_action_kind::TEMPO_UP);
|
||||
state = vamp_zero::apply_menu_action(state, *tapped);
|
||||
BOOST_TEST(state.audio.metronome_bpm == 125U);
|
||||
BOOST_TEST(!state.audio.tempo_editing);
|
||||
for (auto i = 0U; i < 100U; ++i) {
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_DOWN));
|
||||
}
|
||||
BOOST_TEST(state.audio.metronome_bpm == state.audio.metronome_min_bpm);
|
||||
for (auto i = 0U; i < 100U; ++i) {
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP));
|
||||
}
|
||||
BOOST_TEST(state.audio.metronome_bpm == state.audio.metronome_max_bpm);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(menu_touch_cancels_drag_hold_multitouch_and_stale_press) {
|
||||
for (auto scenario = 0U; scenario < 5U; ++scenario) {
|
||||
auto state = vamp_zero::app_state{};
|
||||
auto touch = vamp_zero::menu_touch{};
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto point = row_center(state, 1U);
|
||||
touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state);
|
||||
if (scenario == 0U) {
|
||||
touch.handle(point_event(luma::touch_phase::MOVE, {point.x + 30.0F, point.y}, now), menu_bounds, state);
|
||||
} else if (scenario == 1U) {
|
||||
now += std::chrono::seconds{1};
|
||||
} else if (scenario == 2U) {
|
||||
touch.handle(point_event(luma::touch_phase::DOWN, point, now, 2), menu_bounds, state);
|
||||
touch.handle(point_event(luma::touch_phase::UP, point, now, 2), menu_bounds, state);
|
||||
} else if (scenario == 3U) {
|
||||
state = vamp_zero::apply_app_command(state, vamp_protocol::page_command::NEXT);
|
||||
} else {
|
||||
touch.handle(point_event(luma::touch_phase::CANCEL, point, now), menu_bounds, state);
|
||||
}
|
||||
BOOST_CHECK(!touch.handle(point_event(luma::touch_phase::UP, point, now + std::chrono::milliseconds{40}), menu_bounds, state));
|
||||
touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state);
|
||||
BOOST_REQUIRE(touch.handle(point_event(luma::touch_phase::UP, point, now + std::chrono::milliseconds{40}), menu_bounds, state));
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(control_rejects_delayed_actions_after_remote_navigation_or_catalog_change) {
|
||||
auto state = vamp_zero::app_state{};
|
||||
auto control = vamp_zero::control_state{{}, state};
|
||||
auto tapped = action(state, 1U);
|
||||
control.heartbeat();
|
||||
control.apply(tapped);
|
||||
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::SETTINGS_MENU);
|
||||
control.apply(tapped);
|
||||
BOOST_TEST(!control.snapshot().audio.playback_enabled);
|
||||
auto settings = control.snapshot();
|
||||
tapped = action(settings, 1U);
|
||||
control.apply(vamp_protocol::page_command::NEXT);
|
||||
auto before = control.snapshot();
|
||||
control.apply(tapped);
|
||||
BOOST_TEST(control.snapshot().input_revision == before.input_revision);
|
||||
BOOST_TEST(!control.snapshot().audio.playback_enabled);
|
||||
tapped = action(before, 1U);
|
||||
tapped.catalog = std::make_shared<vamp_zero::catalog const>();
|
||||
control.apply(tapped);
|
||||
BOOST_TEST(control.snapshot().input_revision == before.input_revision);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(menu_scroll_reaches_every_song_and_remote_shows_menu_titles) {
|
||||
auto catalog = vamp_zero::catalog{};
|
||||
for (auto i = 0U; i < 20U; ++i) {
|
||||
catalog.parts.push_back({.id = std::to_string(i), .title = "Song " + std::to_string(i), .part = "Electric", .total_pages = 3U, .title_image = std::string(64U, 'a')});
|
||||
catalog.groups.push_back({catalog.parts.back().title, "Composer", {i}});
|
||||
}
|
||||
auto state = vamp_zero::app_state{.catalog = std::make_shared<vamp_zero::catalog const>(std::move(catalog)), .mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU};
|
||||
for (auto index = 1U; index <= 20U; ++index) {
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 0U, vamp_zero::menu_action_kind::NEXT));
|
||||
BOOST_TEST(state.selected_song_index == index);
|
||||
auto targets = vamp_zero::menu_layout(menu_bounds, state);
|
||||
BOOST_TEST(std::any_of(targets.begin(), targets.end(), [&](auto const &target) { return target.action.kind == vamp_zero::menu_action_kind::ACTIVATE && target.action.row == index; }));
|
||||
auto remote = vamp_pico::remote_display_state{};
|
||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
|
||||
BOOST_TEST(!remote.active);
|
||||
BOOST_TEST(std::string{remote.title} == "Song " + std::to_string(index - 1U));
|
||||
BOOST_TEST(std::string{remote.title_image.data()} == std::string(64U, 'a'));
|
||||
}
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 20U));
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 1U));
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
BOOST_TEST(state.active_part_id == "19");
|
||||
BOOST_TEST(vamp_zero::menu_layout(menu_bounds, state).empty());
|
||||
state = vamp_zero::apply_app_command(state, vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
|
||||
BOOST_TEST(state.selected_part_index == 1U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_settings_parser_rejects_invalid_values_without_mutating_state) {
|
||||
auto state = vamp_pico::remote_display_state{};
|
||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("mode=settings_menu\nfocus=Playback\nplayback=1\nbpm=135\n", state));
|
||||
auto before = state;
|
||||
for (auto text : {"bpm=0\n", "bpm=401\n", "bpm=-1\n", "editing=yes\n", "playback=2\n", "metronome=true\n", "focus=abcdefghijklmnopqrstuvwxyz0123456789\n"}) {
|
||||
BOOST_CHECK(!vamp_pico::parse_remote_display_body(text, state));
|
||||
BOOST_CHECK(state == before);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
#include <vamp_zero/audio.hpp>
|
||||
#include <vamp_zero/config.hpp>
|
||||
#include <vamp_zero/playback.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/control.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
@@ -79,7 +79,7 @@ BOOST_AUTO_TEST_CASE(playback_completion_is_session_guarded) {
|
||||
state.finish_playback(42U);
|
||||
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
|
||||
BOOST_TEST(state.snapshot().selected_part_index == 1U);
|
||||
BOOST_TEST(state.snapshot().select_count == 0U);
|
||||
BOOST_TEST(state.snapshot().score_session == 42U);
|
||||
state.apply(vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(state.snapshot().score_session == 43U);
|
||||
state.finish_playback(42U);
|
||||
|
||||
@@ -39,9 +39,9 @@ class PicoArtworkTests(unittest.TestCase):
|
||||
output = pathlib.Path(directory) / 'generated' / 'static.p4'
|
||||
subprocess.run([sys.executable, '-m', 'generator.pico_artwork', '--static-output', str(output)], check=True)
|
||||
atlas = output.read_bytes()
|
||||
self.assertLess(len(atlas), 24000)
|
||||
self.assertLess(len(atlas), 40000)
|
||||
magic, count, reserved = struct.unpack_from('<4sHH', atlas)
|
||||
self.assertEqual((magic, count, reserved), (b'VPSA', 24, 0))
|
||||
self.assertEqual((magic, count, reserved), (b'VPSA', len(generator.pico_artwork.SPRITES), 0))
|
||||
digits = []
|
||||
for index in range(count):
|
||||
width, height, offset = struct.unpack_from('<HHI', atlas, 8 + index * 8)
|
||||
@@ -54,7 +54,7 @@ class PicoArtworkTests(unittest.TestCase):
|
||||
levels = [value for byte in coverage for value in (byte >> 4, byte & 15)][:width * height]
|
||||
self.assertGreater(sum(value > 0 for value in levels), 25)
|
||||
self.assertTrue(any(0 < value < 15 for value in levels))
|
||||
if index >= 13:
|
||||
if index >= len(generator.pico_artwork.LABELS):
|
||||
self.assertEqual(width, 22)
|
||||
digits.append(coverage)
|
||||
ink_rows = {pixel // width for pixel, value in enumerate(levels) if value}
|
||||
|
||||
@@ -150,3 +150,21 @@ BOOST_AUTO_TEST_CASE(zero_touch_stop_and_destruction_suppress_queued_commands) {
|
||||
BOOST_TEST(commands.empty());
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(zero_touch_reset_discards_old_score_gestures_but_accepts_new_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 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{700}));
|
||||
touch.reset();
|
||||
io.run();
|
||||
BOOST_TEST(commands.empty());
|
||||
io.restart();
|
||||
touch.handle(touch_event(luma::touch_phase::DOWN, base));
|
||||
touch.handle(touch_event(luma::touch_phase::UP, base + std::chrono::milliseconds{30}));
|
||||
io.run();
|
||||
BOOST_REQUIRE_EQUAL(commands.size(), 1U);
|
||||
BOOST_TEST(commands.front() == "next");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include <boost/json/parse.hpp>
|
||||
#include <boost/json/object.hpp>
|
||||
|
||||
#include <raylib.h>
|
||||
|
||||
#include <vamp_pico/screen.hpp>
|
||||
#include <vamp_zero/menu_view.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
auto read_bytes(std::filesystem::path const &path) -> std::string {
|
||||
auto input = std::ifstream{path, std::ios::binary};
|
||||
BOOST_REQUIRE(input.good());
|
||||
return {std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
||||
}
|
||||
|
||||
auto color(luma::color value) -> Color {
|
||||
return {value.r, value.g, value.b, value.a};
|
||||
}
|
||||
|
||||
auto rectangle(luma::rect value) -> Rectangle {
|
||||
return {value.x, value.y, value.width, value.height};
|
||||
}
|
||||
|
||||
// Uses the same raylib glyph rasterizer and metrics as Luma, without a GPU/window.
|
||||
|
||||
class image_context final : public luma::draw_context {
|
||||
public:
|
||||
image_context() = default;
|
||||
~image_context() override {
|
||||
UnloadImage(image);
|
||||
for (auto const &[size, font] : fonts_) {
|
||||
UnloadFontData(font.glyphs, font.glyphCount);
|
||||
MemFree(font.recs);
|
||||
}
|
||||
}
|
||||
auto fill_rect(luma::rect bounds, luma::color value) -> void override {
|
||||
ImageDrawRectangleRec(&image, rectangle(bounds), color(value));
|
||||
}
|
||||
auto stroke_rect(luma::rect bounds, std::float32_t thickness, luma::color value) -> void override {
|
||||
ImageDrawRectangleLines(&image, rectangle(bounds), static_cast<std::int32_t>(thickness), color(value));
|
||||
}
|
||||
auto stroke_line(luma::point from, luma::point to, std::float32_t thickness, luma::color value) -> void override {
|
||||
ImageDrawLineEx(&image, {from.x, from.y}, {to.x, to.y}, static_cast<std::int32_t>(thickness), color(value));
|
||||
}
|
||||
auto fill_ellipse(luma::rect bounds, luma::color value) -> void override {
|
||||
BOOST_TEST(bounds.width == bounds.height);
|
||||
ImageDrawCircle(&image, static_cast<std::int32_t>(bounds.x + bounds.width / 2.0F), static_cast<std::int32_t>(bounds.y + bounds.height / 2.0F), static_cast<std::int32_t>(bounds.width / 2.0F), color(value));
|
||||
}
|
||||
auto stroke_ellipse(luma::rect, std::float32_t, luma::color) -> void override {
|
||||
BOOST_FAIL("unexpected ellipse outline");
|
||||
}
|
||||
auto draw_texture(luma::texture const &, luma::rect, luma::rect, luma::color) -> void override {
|
||||
BOOST_FAIL("menu must not load score textures");
|
||||
}
|
||||
auto draw_text(std::string_view text, luma::point origin, luma::text_style const &style, luma::color value) -> void override {
|
||||
BOOST_TEST(style.font == vamp_style::ui_font_path);
|
||||
auto measured = measure_text(text, style);
|
||||
BOOST_REQUIRE(clip_.has_value());
|
||||
BOOST_TEST(measured.width <= clip_->width);
|
||||
BOOST_TEST(origin.y >= clip_->y);
|
||||
BOOST_TEST(origin.y + measured.height <= clip_->y + clip_->height);
|
||||
auto face = font(style.size);
|
||||
auto owned = std::string{text};
|
||||
auto x = origin.x;
|
||||
for (auto i = std::size_t{}; i < owned.size();) {
|
||||
auto bytes = std::int32_t{};
|
||||
auto codepoint = GetCodepointNext(owned.c_str() + i, &bytes);
|
||||
auto index = GetGlyphIndex(face, codepoint);
|
||||
auto const &glyph = face.glyphs[index];
|
||||
if (codepoint != ' ') {
|
||||
ImageDraw(&image, glyph.image, {0.0F, 0.0F, face.recs[index].width, face.recs[index].height}, {x + static_cast<std::float32_t>(glyph.offsetX), origin.y + static_cast<std::float32_t>(glyph.offsetY), face.recs[index].width, face.recs[index].height}, color(value));
|
||||
}
|
||||
x += (glyph.advanceX == 0 ? face.recs[index].width : static_cast<std::float32_t>(glyph.advanceX)) + 1.0F;
|
||||
i += static_cast<std::size_t>(bytes);
|
||||
}
|
||||
}
|
||||
auto push_clip(luma::rect bounds) -> void override {
|
||||
BOOST_TEST(bounds.x >= 0.0F);
|
||||
BOOST_TEST(bounds.y >= 0.0F);
|
||||
BOOST_TEST(bounds.x + bounds.width <= 1024.0F);
|
||||
BOOST_TEST(bounds.y + bounds.height <= 600.0F);
|
||||
clip_ = bounds;
|
||||
}
|
||||
auto pop_clip() -> void override {
|
||||
clip_.reset();
|
||||
}
|
||||
auto measure_text(std::string_view text, luma::text_style const &style) const -> luma::size override {
|
||||
auto size = MeasureTextEx(font(style.size), std::string{text}.c_str(), style.size, 1.0F);
|
||||
return {size.x, size.y};
|
||||
}
|
||||
Image image{GenImageColor(1024, 600, WHITE)};
|
||||
private:
|
||||
auto font(std::float32_t size) const -> Font {
|
||||
auto key = static_cast<std::int32_t>(std::ceil(size));
|
||||
auto found = fonts_.find(key);
|
||||
if (found != fonts_.end()) {
|
||||
return found->second;
|
||||
}
|
||||
auto bytes = read_bytes(std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "package/assets/fonts/petaluma/PetalumaScript.otf");
|
||||
auto face = Font{};
|
||||
face.baseSize = key;
|
||||
face.glyphs = LoadFontData(reinterpret_cast<std::uchar_t const *>(bytes.data()), static_cast<std::int32_t>(bytes.size()), key, nullptr, 95, FONT_DEFAULT, &face.glyphCount);
|
||||
BOOST_REQUIRE(face.glyphs != nullptr);
|
||||
auto atlas = GenImageFontAtlas(face.glyphs, &face.recs, face.glyphCount, key, 4, 0);
|
||||
BOOST_REQUIRE(atlas.data != nullptr);
|
||||
for (auto i = std::int32_t{}; i < face.glyphCount; ++i) {
|
||||
UnloadImage(face.glyphs[i].image);
|
||||
face.glyphs[i].image = ImageFromImage(atlas, face.recs[i]);
|
||||
}
|
||||
UnloadImage(atlas);
|
||||
face.texture.id = 1U;
|
||||
fonts_.emplace(key, face);
|
||||
return face;
|
||||
}
|
||||
mutable std::map<std::int32_t, Font> fonts_{};
|
||||
std::optional<luma::rect> clip_{};
|
||||
};
|
||||
|
||||
auto preview(vamp_zero::app_state const &state, std::string const &name, vamp_pico::link_status link = vamp_pico::link_status::CONNECTED, std::bool_t busy = false) -> void {
|
||||
auto directory = std::filesystem::path{VAMP_TEST_UI_DIR};
|
||||
std::filesystem::create_directories(directory);
|
||||
auto context = image_context{};
|
||||
vamp_zero::draw_header(context, {0.0F, 0.0F, 1024.0F, 600.0F}, state);
|
||||
vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state);
|
||||
BOOST_REQUIRE(ExportImage(context.image, (directory / ("zero-" + name + ".png")).c_str()));
|
||||
auto remote = vamp_pico::remote_display_state{};
|
||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
|
||||
auto title = vamp_pico::title_cache{};
|
||||
if (remote.title_image[0] != 0) {
|
||||
title.select(remote.title_image.data());
|
||||
BOOST_REQUIRE(title.accept(remote.title_image.data(), read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "pico/titles" / (std::string{remote.title_image.data()} + ".mask"))));
|
||||
}
|
||||
auto atlas = read_bytes(directory / "static.p4");
|
||||
auto image = GenImageColor(320, 240, WHITE);
|
||||
auto row = std::array<std::uint16_t, vamp_pico::display_width>{};
|
||||
auto dark = std::size_t{};
|
||||
for (auto y = 0U; y < 240U; ++y) {
|
||||
vamp_pico::render_scanline(row, y, remote, link, busy, title, atlas);
|
||||
for (auto x = 0U; x < 320U; ++x) {
|
||||
auto pixel = row[x];
|
||||
auto value = Color{static_cast<std::uint8_t>((pixel >> 11U) * 255U / 31U), static_cast<std::uint8_t>(((pixel >> 5U) & 63U) * 255U / 63U), static_cast<std::uint8_t>((pixel & 31U) * 255U / 31U), 255};
|
||||
ImageDrawPixel(&image, x, y, value);
|
||||
dark += value.r < 128U && value.g < 128U && value.b < 128U ? 1U : 0U;
|
||||
}
|
||||
}
|
||||
BOOST_TEST(dark > 200U);
|
||||
BOOST_REQUIRE(ExportImage(image, (directory / ("pico-" + name + ".png")).c_str()));
|
||||
UnloadImage(image);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays) {
|
||||
auto state = vamp_zero::app_state{};
|
||||
preview(state, "main");
|
||||
preview(state, "offline", vamp_pico::link_status::OFFLINE);
|
||||
preview(state, "busy", vamp_pico::link_status::CONNECTED, true);
|
||||
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
|
||||
state.pico_connected = true;
|
||||
state.audio.playback_enabled = true;
|
||||
state.audio.metronome_enabled = true;
|
||||
preview(state, "settings");
|
||||
state.selected_settings_index = 3U;
|
||||
state.audio.tempo_editing = true;
|
||||
preview(state, "tempo");
|
||||
state.audio.tempo_editing = false;
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
||||
preview(state, "empty");
|
||||
auto catalog = std::make_shared<vamp_zero::catalog>();
|
||||
auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn-leaves-electric.json"));
|
||||
auto title = std::string{score.at("presentation").at("title_image").as_string()};
|
||||
catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = 32U, .title_image = title});
|
||||
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
|
||||
state.catalog = catalog;
|
||||
state.selected_song_index = 1U;
|
||||
preview(state, "songs");
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||
state.selected_part_index = 1U;
|
||||
preview(state, "parts");
|
||||
catalog->groups[0].title = std::string(200U, 'M');
|
||||
catalog->groups[0].composer = std::string(200U, 'W');
|
||||
preview(state, "long-title");
|
||||
}
|
||||
Reference in New Issue
Block a user