Files
product-vamp/tests/test_ui.cpp
T
khodowany d2f493c17b
ci / checks (push) Successful in 50m29s
Playback Mode Pages
2026-10-06 23:11:25 -05:00

244 lines
11 KiB
C++

#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>
#include <vamp_zero/notation.hpp>
#include <vamp_zero/performance.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/color.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, {vamp_style::background.r, vamp_style::background.g, vamp_style::background.b, 255})};
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);
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
auto source = vamp_zero::parse_notation_document(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"), "score.json");
BOOST_REQUIRE(source);
auto playback = vamp_zero::automatic(state.audio);
auto const &pages = playback ? source->playback_pages : source->pages;
auto const &page = pages.at(state.page_index);
auto path = std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs" / page.path;
auto sheet = LoadImage(path.c_str());
BOOST_REQUIRE(sheet.data);
ImageDraw(&context.image, sheet, {0, 0, 976, 484}, {24, 98, 976, 484}, WHITE);
UnloadImage(sheet);
if (playback) {
if (auto highlight = vamp_zero::measure_highlight(page, state.active_measure, {24, 98, 976, 484})) {
context.stroke_rect(*highlight, 1.5F, vamp_zero::to_luma(vamp_style::accent));
}
}
} else {
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 ink = 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);
ink += value.r > 128U && value.g > 128U && value.b > 128U ? 1U : 0U;
}
}
BOOST_TEST(ink > 200U);
BOOST_TEST(ink < 320U * 240U / 2U);
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;
preview(state, "backing_unavailable");
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 = score.at("presentation").at("pages").as_array().size(), .title_image = title, .song_id = "autumn_leaves", .playback_pages = score.at("presentation").at("playback_pages").as_array().size()});
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
catalog->audio_assets.push_back({.path = "backing/autumn_leaves_120.wav", .song_id = "autumn_leaves", .bpm = 120});
state.catalog = catalog;
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
state.audio.backing_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;
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");
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
state.active_part_id = "electric";
for (auto phase : {"Manual", "Preparing", "Count-in", "Playing", "Sustain"}) {
state.audio.phase = phase;
state.audio.backing_enabled = std::string_view{phase} != "Manual";
state.audio.metronome_enabled = state.audio.backing_enabled;
state.active_measure = std::string_view{phase} == "Playing" ? std::optional<std::size_t>{1} : std::nullopt;
preview(state, phase);
}
state.audio.phase = "Playing";
state.page_index = 1;
state.active_measure = score.at("presentation").at("playback_pages").as_array()[1].at("first_measure").to_number<std::size_t>();
preview(state, "transition");
state.page_index = catalog->parts[0].playback_pages - 1;
state.active_measure = score.at("music").at("staves").as_array()[0].at("measures").as_array().size();
state.audio.phase = "Sustain";
preview(state, "final_line");
state.audio.phase = "Error";
state.active_measure.reset();
state.audio.playback_error = "Recording unavailable";
preview(state, "audio_error");
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
catalog->groups[0].title = std::string(200U, 'M');
catalog->groups[0].composer = std::string(200U, 'W');
preview(state, "long_title");
}