1527 lines
67 KiB
C++
1527 lines
67 KiB
C++
#include <test.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstddef>
|
|
#include <cstdio>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <future>
|
|
#include <initializer_list>
|
|
#include <limits>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <variant>
|
|
#include <vector>
|
|
|
|
#include <boost/asio/co_spawn.hpp>
|
|
#include <boost/asio/io_context.hpp>
|
|
#include <boost/asio/use_future.hpp>
|
|
#include <boost/json/object.hpp>
|
|
#include <boost/json/parse.hpp>
|
|
#include <boost/json/serialize.hpp>
|
|
#include <boost/json/array.hpp>
|
|
|
|
#include <noto.hpp>
|
|
|
|
#include <vamp_protocol/page_control.hpp>
|
|
#include <vamp_zero/config.hpp>
|
|
#include <vamp_pico/http_client.hpp>
|
|
#include <vamp_zero/database.hpp>
|
|
#include <vamp_zero/library.hpp>
|
|
#include <vamp_zero/parse.hpp>
|
|
#include <vamp_zero/page_control.hpp>
|
|
#include <vamp_zero/score.hpp>
|
|
#include <vamp_zero/types.hpp>
|
|
|
|
namespace {
|
|
|
|
constexpr auto layout_any_index = std::numeric_limits<std::size_t>::max();
|
|
|
|
auto pitch(std::char_t letter, std::string accidental, std::int32_t octave = 4) -> vamp_zero::score_pitch {
|
|
return {
|
|
.letter = letter,
|
|
.octave = octave,
|
|
.accidental = std::move(accidental),
|
|
};
|
|
}
|
|
|
|
auto note(std::initializer_list<vamp_zero::score_pitch> pitches,
|
|
std::bool_t tie_from_previous = false,
|
|
std::string notehead = {},
|
|
std::bool_t hidden = false) -> vamp_zero::score_note {
|
|
return {
|
|
.kind = "note",
|
|
.hidden = hidden,
|
|
.duration_ticks = 4096,
|
|
.display_ticks = 4096,
|
|
.notehead = std::move(notehead),
|
|
.tie_from_previous = tie_from_previous,
|
|
.pitches = std::vector<vamp_zero::score_pitch>{pitches},
|
|
};
|
|
}
|
|
|
|
auto note_with_ticks(std::initializer_list<vamp_zero::score_pitch> pitches, std::int64_t ticks) -> vamp_zero::score_note {
|
|
auto value = note(pitches);
|
|
value.duration_ticks = ticks;
|
|
value.display_ticks = ticks;
|
|
return value;
|
|
}
|
|
|
|
auto rest(std::initializer_list<vamp_zero::score_pitch> pitches) -> vamp_zero::score_note {
|
|
return {
|
|
.kind = "rest",
|
|
.duration_ticks = 4096,
|
|
.display_ticks = 4096,
|
|
.pitches = std::vector<vamp_zero::score_pitch>{pitches},
|
|
};
|
|
}
|
|
|
|
auto mark_name(vamp_zero::score_accidental_mark mark) -> std::string {
|
|
switch (mark) {
|
|
case vamp_zero::score_accidental_mark::NONE:
|
|
return "none";
|
|
case vamp_zero::score_accidental_mark::FLAT:
|
|
return "flat";
|
|
case vamp_zero::score_accidental_mark::NATURAL:
|
|
return "natural";
|
|
case vamp_zero::score_accidental_mark::SHARP:
|
|
return "sharp";
|
|
}
|
|
return {};
|
|
}
|
|
|
|
auto mark_names(std::vector<vamp_zero::score_accidental_mark> const &marks) -> std::vector<std::string> {
|
|
auto names = std::vector<std::string>{};
|
|
names.reserve(marks.size());
|
|
for (auto mark : marks) {
|
|
names.push_back(mark_name(mark));
|
|
}
|
|
return names;
|
|
}
|
|
|
|
void expect_marks(std::vector<vamp_zero::score_accidental_mark> const &marks, std::initializer_list<std::string_view> expected) {
|
|
auto actual_names = mark_names(marks);
|
|
BOOST_REQUIRE_EQUAL(actual_names.size(), expected.size());
|
|
auto index = std::size_t{};
|
|
for (auto name : expected) {
|
|
BOOST_TEST(actual_names[index] == std::string{name});
|
|
++index;
|
|
}
|
|
}
|
|
|
|
auto score_with_measure(vamp_zero::score_measure measure, std::string key_signature = "C", std::string time_signature = "4/4", std::string clef = "treble") -> vamp_zero::score_document {
|
|
if (measure.key_signature.empty()) {
|
|
measure.key_signature = key_signature;
|
|
}
|
|
if (measure.time_signature.empty()) {
|
|
measure.time_signature = time_signature;
|
|
}
|
|
return {
|
|
.title = "Layout Tune",
|
|
.composer = "Composer",
|
|
.key_signature = std::move(key_signature),
|
|
.time_signature = std::move(time_signature),
|
|
.clef = std::move(clef),
|
|
.part = "Lead",
|
|
.measures = {std::move(measure)},
|
|
};
|
|
}
|
|
|
|
auto score_with_notes(std::vector<vamp_zero::score_note> notes, std::string key_signature = "C", std::string time_signature = "4/4", std::string clef = "treble") -> vamp_zero::score_document {
|
|
auto measure = vamp_zero::score_measure{
|
|
.time_signature = time_signature,
|
|
.key_signature = key_signature,
|
|
.notes = std::move(notes),
|
|
};
|
|
return score_with_measure(std::move(measure), std::move(key_signature), std::move(time_signature), std::move(clef));
|
|
}
|
|
|
|
auto layout_items(vamp_zero::score_page_layout const &layout, vamp_zero::score_layout_item_role role, std::size_t system_index = layout_any_index, std::size_t measure_index = layout_any_index, std::size_t note_index = layout_any_index) -> std::vector<vamp_zero::score_layout_item const *> {
|
|
auto result = std::vector<vamp_zero::score_layout_item const *>{};
|
|
for (auto const &item : layout.items) {
|
|
if (item.role != role) {
|
|
continue;
|
|
}
|
|
if (system_index != layout_any_index && item.system_index != system_index) {
|
|
continue;
|
|
}
|
|
if (measure_index != layout_any_index && item.measure_index != measure_index) {
|
|
continue;
|
|
}
|
|
if (note_index != layout_any_index && item.note_index != note_index) {
|
|
continue;
|
|
}
|
|
result.push_back(&item);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
auto center_x(vamp_zero::score_layout_item const &item) -> std::float32_t {
|
|
return item.bounds.x + item.bounds.width * 0.5F;
|
|
}
|
|
|
|
auto center_y(vamp_zero::score_layout_item const &item) -> std::float32_t {
|
|
return item.bounds.y + item.bounds.height * 0.5F;
|
|
}
|
|
|
|
auto right_edge(vamp_zero::score_layout_item const &item) -> std::float32_t {
|
|
return item.bounds.x + item.bounds.width;
|
|
}
|
|
|
|
auto left_edge(vamp_zero::score_layout_item const &item) -> std::float32_t {
|
|
return item.bounds.x;
|
|
}
|
|
|
|
auto bottom_edge(vamp_zero::score_layout_item const &item) -> std::float32_t {
|
|
return item.bounds.y + item.bounds.height;
|
|
}
|
|
|
|
auto near(std::float32_t left, std::float32_t right) -> std::bool_t {
|
|
return std::abs(left - right) < 0.02F;
|
|
}
|
|
|
|
void expect_up_stem_connected(vamp_zero::score_layout_item const &head, vamp_zero::score_layout_item const &stem, std::float32_t staff_spacing) {
|
|
auto const overlap = right_edge(head) - stem.from.x;
|
|
BOOST_TEST(overlap >= staff_spacing * 0.12F);
|
|
BOOST_TEST(overlap <= staff_spacing * 0.45F);
|
|
BOOST_TEST(stem.from.x > left_edge(head));
|
|
BOOST_TEST(stem.from.y > head.bounds.y);
|
|
BOOST_TEST(stem.from.y < bottom_edge(head));
|
|
BOOST_TEST(stem.to.y < stem.from.y);
|
|
}
|
|
|
|
void expect_down_stem_connected(vamp_zero::score_layout_item const &head, vamp_zero::score_layout_item const &stem, std::float32_t staff_spacing) {
|
|
auto const overlap = stem.from.x - left_edge(head);
|
|
BOOST_TEST(overlap >= staff_spacing * 0.12F);
|
|
BOOST_TEST(overlap <= staff_spacing * 0.45F);
|
|
BOOST_TEST(stem.from.x < right_edge(head));
|
|
BOOST_TEST(stem.from.y > head.bounds.y);
|
|
BOOST_TEST(stem.from.y < bottom_edge(head));
|
|
BOOST_TEST(stem.to.y > stem.from.y);
|
|
}
|
|
|
|
auto test_database_config(std::filesystem::path const &path) -> vamp_zero::database_config {
|
|
return {
|
|
.path = path.string(),
|
|
};
|
|
}
|
|
|
|
auto run_ensure_database(vamp_zero::database_config config) -> std::expected<void, modulus::error> {
|
|
auto io = boost::asio::io_context{};
|
|
auto future = boost::asio::co_spawn(io, vamp_zero::async_ensure_database(io.get_executor(), std::move(config)), boost::asio::use_future);
|
|
io.run();
|
|
return future.get();
|
|
}
|
|
|
|
auto run_import(vamp_zero::database_config config, std::filesystem::path const &assets_path) -> std::expected<void, modulus::error> {
|
|
auto io = boost::asio::io_context{};
|
|
auto future = boost::asio::co_spawn(io, vamp_zero::async_import_catalogs(io.get_executor(), std::move(config), assets_path.string()), boost::asio::use_future);
|
|
io.run();
|
|
return future.get();
|
|
}
|
|
|
|
auto query_database(std::filesystem::path const &path,
|
|
std::string sql,
|
|
std::vector<noto::sqlite_value> params = {}) -> std::expected<noto::query_result, modulus::error> {
|
|
auto io = boost::asio::io_context{};
|
|
auto future = boost::asio::co_spawn(
|
|
io,
|
|
[executor = io.get_executor(), path = path.string(), sql = std::move(sql), params = std::move(params)]() mutable
|
|
-> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
|
auto config = noto::file_connection_config(path, "product-vamp-test");
|
|
config.foreign_keys = true;
|
|
auto connection = noto::connection{executor, std::move(config)};
|
|
auto connected = co_await connection.async_connect();
|
|
if (!connected) {
|
|
co_return std::unexpected(connected.error());
|
|
}
|
|
auto selected = co_await connection.async_execute(std::move(sql), std::move(params));
|
|
auto closed = co_await connection.async_close();
|
|
if (!selected) {
|
|
co_return std::unexpected(selected.error());
|
|
}
|
|
if (!closed) {
|
|
co_return std::unexpected(closed.error());
|
|
}
|
|
co_return selected;
|
|
},
|
|
boost::asio::use_future);
|
|
io.run();
|
|
return future.get();
|
|
}
|
|
|
|
auto text_value(noto::sqlite_value const &value) -> std::string {
|
|
auto text = std::get_if<std::string>(&value.value);
|
|
return text == nullptr ? std::string{} : *text;
|
|
}
|
|
|
|
auto integer_value(noto::sqlite_value const &value) -> std::int64_t {
|
|
auto integer = std::get_if<std::int64_t>(&value.value);
|
|
return integer == nullptr ? 0 : *integer;
|
|
}
|
|
|
|
auto has_value(noto::query_result const &result, std::size_t column, std::string_view expected) -> std::bool_t {
|
|
return std::any_of(result.rows.begin(), result.rows.end(), [&](auto const &row) {
|
|
return row.size() > column && text_value(row[column]) == expected;
|
|
});
|
|
}
|
|
|
|
auto score_asset_json(std::string title, std::string key = "C") -> std::string {
|
|
auto value = boost::json::parse(R"json({
|
|
"schema_version":2,
|
|
"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
|
|
"staves":[{"id":"staff-lead","name":"Lead","role":"lead","clef":{"sign":"G","line":2,"octave_change":0},
|
|
"measures":[{"id":"measure-1","duration":{"numerator":4,"denominator":1},"key_signature":"C","time_signature":"4/4",
|
|
"key":{"alterations":[0,0,0,0,0,0,0]},"meter":{"groups":[{"beats":[4],"unit":4}]},
|
|
"clef":{"sign":"G","line":2,"octave_change":0},
|
|
"voices":[{"id":"voice-1","notes":[{"id":"note-1","kind":"note",
|
|
"onset":{"numerator":0,"denominator":1},"duration":{"numerator":4,"denominator":1},
|
|
"written":{"type":"whole","dots":0},
|
|
"pitches":[{"id":"pitch-1","letter":"c","octave":4,"alter":0,
|
|
"duration":{"numerator":4,"denominator":1},"written":{"type":"whole","dots":0}}]}]}]}]}]
|
|
})json");
|
|
auto &root = value.as_object();
|
|
root["metadata"].as_object()["title"] = title;
|
|
root["metadata"].as_object()["key_signature"] = key;
|
|
root["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["key_signature"] = key;
|
|
return boost::json::serialize(value);
|
|
}
|
|
|
|
auto require_invalid_score(std::string_view json) -> void {
|
|
auto parsed = std::expected<vamp_zero::score_document, modulus::error>{};
|
|
BOOST_CHECK_NO_THROW(parsed = vamp_zero::parse_score_document(json, "invalid-score.json"));
|
|
BOOST_REQUIRE(!parsed.has_value());
|
|
BOOST_TEST(parsed.error().category() == "product-vamp");
|
|
BOOST_TEST(parsed.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
|
BOOST_TEST(parsed.error().message().starts_with("invalid-score.json: score notation: "));
|
|
}
|
|
|
|
auto navigation_state() -> vamp_zero::app_state {
|
|
auto part = vamp_zero::library_part{
|
|
.id = "part-lead",
|
|
.title = "Navigation Tune",
|
|
.composer = "Composer",
|
|
.key_signature = "C",
|
|
.part = "Lead",
|
|
.source_path = "navigation.json",
|
|
.source_json = score_asset_json("Navigation Tune"),
|
|
.measure_count = 1,
|
|
.total_pages = 2,
|
|
};
|
|
auto variant = vamp_zero::practice_exercise::score_variant{
|
|
.id = "exercise-c",
|
|
.key_signature = "C",
|
|
.source_path = "exercise.json",
|
|
.source_json = score_asset_json("Exercise"),
|
|
.measure_count = 1,
|
|
.total_pages = 1,
|
|
};
|
|
auto exercise = vamp_zero::practice_exercise{
|
|
.id = "exercise-1",
|
|
.practice_type_id = "scale",
|
|
.practice_type_title = "Scales",
|
|
.lesson_group_id = "lesson-1",
|
|
.lesson_group_title = "Major",
|
|
.title = "C Major",
|
|
.composer = "Composer",
|
|
.key_signature = "C",
|
|
.source_path = "exercise.json",
|
|
.source_json = score_asset_json("Exercise"),
|
|
.measure_count = 1,
|
|
.total_pages = 1,
|
|
.variants = {variant},
|
|
};
|
|
return vamp_zero::app_state{
|
|
.performance =
|
|
{
|
|
.parts = {part},
|
|
.groups = {{
|
|
.title = part.title,
|
|
.composer = part.composer,
|
|
.parts = {part},
|
|
}},
|
|
},
|
|
.practice =
|
|
{
|
|
.exercises = {exercise},
|
|
.types = {{
|
|
.id = "scale",
|
|
.title = "Scales",
|
|
.lesson_groups = {{
|
|
.id = "lesson-1",
|
|
.title = "Major",
|
|
.exercises = {exercise},
|
|
}},
|
|
}},
|
|
},
|
|
};
|
|
}
|
|
|
|
void write_text(std::filesystem::path const &path, std::string const &text) {
|
|
std::filesystem::create_directories(path.parent_path());
|
|
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
|
|
output << text;
|
|
}
|
|
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
|
|
auto defaults = vamp_zero::parse_config(boost::json::parse(R"json({})json"));
|
|
BOOST_REQUIRE(defaults);
|
|
BOOST_TEST(defaults->audio.enabled);
|
|
BOOST_TEST(!defaults->audio.track_enabled);
|
|
BOOST_TEST(!defaults->audio.metronome_enabled);
|
|
BOOST_TEST(defaults->audio.metronome_bpm == 120U);
|
|
BOOST_TEST(defaults->audio.metronome_min_bpm == 40U);
|
|
BOOST_TEST(defaults->audio.metronome_max_bpm == 240U);
|
|
BOOST_TEST(defaults->audio.metronome_step == 5U);
|
|
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
|
|
"audio": {
|
|
"enabled": false,
|
|
"track_enabled": true,
|
|
"metronome_enabled": true,
|
|
"metronome_bpm": 90,
|
|
"metronome_min_bpm": 50,
|
|
"metronome_max_bpm": 180,
|
|
"metronome_step": 10,
|
|
"beats_per_bar": 3,
|
|
"master_volume": 0.5,
|
|
"metronome_volume": 0.25
|
|
}
|
|
})json"));
|
|
BOOST_REQUIRE(parsed);
|
|
BOOST_TEST(!parsed->audio.enabled);
|
|
BOOST_TEST(parsed->audio.track_enabled);
|
|
BOOST_TEST(parsed->audio.metronome_enabled);
|
|
BOOST_TEST(parsed->audio.metronome_bpm == 90U);
|
|
BOOST_TEST(parsed->audio.metronome_min_bpm == 50U);
|
|
BOOST_TEST(parsed->audio.metronome_max_bpm == 180U);
|
|
BOOST_TEST(parsed->audio.metronome_step == 10U);
|
|
BOOST_TEST(parsed->audio.beats_per_bar == 3U);
|
|
BOOST_TEST(parsed->audio.master_volume == 0.5F);
|
|
BOOST_TEST(parsed->audio.metronome_volume == 0.25F);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) {
|
|
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
|
|
"audio": {
|
|
"metronome_bpm": 120,
|
|
"metronome_min_bpm": 160,
|
|
"metronome_max_bpm": 80
|
|
}
|
|
})json"));
|
|
BOOST_TEST(!parsed);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(main_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);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
BOOST_TEST(state.selected_main_menu_index == 2U);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_TEST(state.audio.track_enabled);
|
|
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::MAIN_MENU));
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
BOOST_TEST(state.selected_main_menu_index == 3U);
|
|
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));
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
BOOST_TEST(state.selected_main_menu_index == 4U);
|
|
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);
|
|
BOOST_TEST(state.audio.metronome_bpm == 125U);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
|
BOOST_TEST(state.audio.metronome_bpm == 120U);
|
|
for (auto index = std::int32_t{}; index < 40; ++index) {
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
|
}
|
|
BOOST_TEST(state.audio.metronome_bpm == 40U);
|
|
for (auto index = std::int32_t{}; index < 80; ++index) {
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
}
|
|
BOOST_TEST(state.audio.metronome_bpm == 240U);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_TEST(!state.audio.tempo_editing);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(audio_status_json_reports_menu_settings) {
|
|
auto state = navigation_state();
|
|
state.audio.track_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("track_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());
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) {
|
|
auto state = navigation_state();
|
|
state.audio.metronome_enabled = true;
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::PERFORMANCE_SONG_MENU));
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::PERFORMANCE_PART_MENU));
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
|
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::PERFORMANCE_SCORE));
|
|
BOOST_TEST(state.audio.metronome_enabled);
|
|
BOOST_TEST(!state.audio.tempo_editing);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) {
|
|
auto parsed = vamp_zero::parse_score_document(score_asset_json("Standard Tune", "Eb"), "standard-tune.json");
|
|
BOOST_REQUIRE(static_cast<std::bool_t>(parsed));
|
|
BOOST_TEST(parsed->title == "Standard Tune");
|
|
BOOST_TEST(parsed->composer == "Composer");
|
|
BOOST_TEST(parsed->key_signature == "Eb");
|
|
BOOST_TEST(parsed->time_signature == "4/4");
|
|
BOOST_TEST(parsed->clef == "G:2:0");
|
|
BOOST_TEST(parsed->part == "Lead");
|
|
BOOST_REQUIRE_EQUAL(parsed->measures.size(), 1U);
|
|
BOOST_TEST(parsed->measures.front().duration.numerator == 4);
|
|
BOOST_TEST(parsed->measures.front().notes.front().written.type == "whole");
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) {
|
|
auto count = std::size_t{};
|
|
for (auto const &entry : std::filesystem::recursive_directory_iterator{VAMP_TEST_ASSETS_DIR}) {
|
|
if (entry.path().extension() != ".json") {
|
|
continue;
|
|
}
|
|
auto input = std::ifstream{entry.path()};
|
|
auto json = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
|
auto parsed = vamp_zero::parse_score_document(json, entry.path().string());
|
|
BOOST_TEST_CONTEXT(entry.path().string()) {
|
|
BOOST_REQUIRE(parsed.has_value());
|
|
BOOST_TEST(vamp_zero::score_page_count(*parsed) > 0U);
|
|
}
|
|
++count;
|
|
}
|
|
BOOST_TEST(count > 0U);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_parser_rejects_old_schema_and_extra_voices) {
|
|
BOOST_TEST(!vamp_zero::parse_score_document(R"({"metadata":{"title":"Old"},"staves":[]})").has_value());
|
|
auto source = boost::json::parse(score_asset_json("Fixture"));
|
|
auto &voices =
|
|
source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array();
|
|
auto copy = voices[0];
|
|
voices.push_back(copy);
|
|
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_parser_rejects_invalid_rational_and_dangling_tie) {
|
|
auto source = boost::json::parse(score_asset_json("Fixture"));
|
|
auto &n = source.as_object()["staves"]
|
|
.as_array()[0]
|
|
.as_object()["measures"]
|
|
.as_array()[0]
|
|
.as_object()["voices"]
|
|
.as_array()[0]
|
|
.as_object()["notes"]
|
|
.as_array()[0]
|
|
.as_object();
|
|
n["duration"].as_object()["denominator"] = 0;
|
|
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
|
|
n["duration"].as_object()["denominator"] = 1;
|
|
n["pitches"].as_array()[0].as_object()["tie_to"] = "missing";
|
|
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_parser_returns_errors_for_malformed_json_and_root_types) {
|
|
for (auto json : {"", "{", "null", "true", "42", "[]", "{}", R"("score")", R"({"schema_version":2,})"}) {
|
|
BOOST_TEST_CONTEXT(json) {
|
|
require_invalid_score(json);
|
|
}
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_parser_preserves_optional_notation_with_checked_readers) {
|
|
auto source = boost::json::parse(score_asset_json("Detailed score"));
|
|
auto &staff = source.at_pointer("/staves/0").as_object();
|
|
staff["tuplets"] = boost::json::parse(R"([{
|
|
"id":"tuplet-1", "note_count":1, "space_count":1, "depth":0,
|
|
"start":"note-1", "end":"note-1", "bracket":"yes", "show_number":"both"
|
|
}])");
|
|
staff["octave_shifts"] = boost::json::parse(R"([{
|
|
"start":{"numerator":0,"denominator":1}, "end":{"numerator":4,"denominator":1}, "octaves":1
|
|
}])");
|
|
auto &measure = source.at_pointer("/staves/0/measures/0").as_object();
|
|
measure["harmonies"] = boost::json::parse(R"([{
|
|
"id":"harmony-1", "onset":{"numerator":2,"denominator":1}, "kind":"minor", "symbol":"Dm"
|
|
}])");
|
|
measure["attributes"] = boost::json::parse(R"([{
|
|
"onset":{"numerator":0,"denominator":1}, "clef":{"sign":"C","line":3,"octave_change":-1},
|
|
"key":{"alterations":[0,0,0,1,0,0,0]}, "meter":{"groups":[{"beats":[3,2,3],"unit":8}]}
|
|
}])");
|
|
measure["barlines"] = boost::json::parse(R"({"right":"light-heavy"})");
|
|
auto &event = source.at_pointer("/staves/0/measures/0/voices/0/notes/0").as_object();
|
|
event["tuplet_ids"] = boost::json::parse(R"(["tuplet-1"])");
|
|
event["beams"] = boost::json::parse(R"([{"level":1,"type":"forward hook"}])");
|
|
event["tremolo"] = boost::json::parse(R"({"marks":2,"type":"single"})");
|
|
event["rest_position"] = boost::json::parse(R"({"letter":"b","octave":4})");
|
|
event["lyric_notation"] = boost::json::parse(R"({"extend":"start"})");
|
|
event["written"].as_object()["ratio"] = boost::json::parse(R"({"numerator":1,"denominator":1})");
|
|
event["written"].as_object()["normal"] = boost::json::parse(R"({"type":"whole","dots":0})");
|
|
event["pitches"].as_array()[0].as_object()["accidental_display"] =
|
|
boost::json::parse(R"({"parentheses":true,"bracket":false})");
|
|
auto parsed = vamp_zero::parse_score_document(boost::json::serialize(source), "detailed-score.json");
|
|
BOOST_REQUIRE(parsed.has_value());
|
|
BOOST_TEST(parsed->id == "detailed-score");
|
|
BOOST_TEST(parsed->source_json == boost::json::serialize(source));
|
|
auto const &parsed_measure = parsed->measures.front();
|
|
auto const &parsed_event = parsed_measure.notes.front();
|
|
BOOST_REQUIRE_EQUAL(parsed_measure.tuplets.size(), 1U);
|
|
BOOST_TEST(parsed_measure.tuplets.front().show_number == "both");
|
|
BOOST_TEST(parsed_measure.right_barline == "light-heavy");
|
|
BOOST_TEST(parsed_measure.harmonies.front().symbol == "Dm");
|
|
BOOST_TEST(parsed_measure.harmonies.front().onset.numerator == 2);
|
|
BOOST_TEST(parsed_measure.attributes.front().time_signature == "3+2+3/8");
|
|
BOOST_REQUIRE_EQUAL(parsed_measure.attributes.front().beats.size(), 3U);
|
|
BOOST_TEST(parsed_measure.attributes.front().beats.front().numerator == 3);
|
|
BOOST_TEST(parsed_measure.attributes.front().beats.front().denominator == 2);
|
|
BOOST_TEST((*parsed_measure.attributes.front().key_alterations)[3] == 1);
|
|
BOOST_TEST(parsed_event.clef == "C:3:-1");
|
|
BOOST_TEST(parsed_event.display_octaves == 1);
|
|
BOOST_TEST(parsed_event.written.normal_type == "whole");
|
|
BOOST_TEST(parsed_event.beams.front().type == "forward hook");
|
|
BOOST_TEST(parsed_event.tremolo_marks == 2);
|
|
BOOST_TEST(parsed_event.rest_position->letter == 'b');
|
|
BOOST_TEST(parsed_event.lyric_extend == "start");
|
|
BOOST_TEST(parsed_event.pitches.front().explicit_accidental);
|
|
BOOST_TEST(parsed_event.pitches.front().accidental_parentheses);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_parser_checks_required_fields_before_access) {
|
|
auto paths = std::vector<std::string>{
|
|
"/metadata",
|
|
"/staves",
|
|
"/staves/0/clef",
|
|
"/staves/0/measures",
|
|
"/staves/0/measures/0/duration",
|
|
"/staves/0/measures/0/key",
|
|
"/staves/0/measures/0/clef",
|
|
"/staves/0/measures/0/meter",
|
|
"/staves/0/measures/0/meter/groups",
|
|
"/staves/0/measures/0/meter/groups/0/beats",
|
|
"/staves/0/measures/0/meter/groups/0/unit",
|
|
"/staves/0/measures/0/key/alterations",
|
|
"/staves/0/measures/0/voices",
|
|
"/staves/0/measures/0/voices/0/notes",
|
|
};
|
|
auto event = std::string{"/staves/0/measures/0/voices/0/notes/0/"};
|
|
for (auto suffix : {"onset",
|
|
"duration",
|
|
"written",
|
|
"pitches",
|
|
"duration/numerator",
|
|
"duration/denominator",
|
|
"pitches/0/alter",
|
|
"pitches/0/octave",
|
|
"pitches/0/duration",
|
|
"pitches/0/written"}) {
|
|
paths.push_back(event + suffix);
|
|
}
|
|
for (auto const &path : paths) {
|
|
BOOST_TEST_CONTEXT(path) {
|
|
auto source = boost::json::parse(score_asset_json("Fixture"));
|
|
auto slash = path.rfind('/');
|
|
source.at_pointer(path.substr(0, slash)).as_object().erase(path.substr(slash + 1));
|
|
require_invalid_score(boost::json::serialize(source));
|
|
}
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_parser_checks_json_types_at_each_level) {
|
|
auto paths = std::vector<std::string>{
|
|
"/schema_version",
|
|
"/metadata",
|
|
"/metadata/title",
|
|
"/metadata/composer",
|
|
"/metadata/key_signature",
|
|
"/metadata/time_signature",
|
|
"/staves",
|
|
"/staves/0",
|
|
"/staves/0/name",
|
|
"/staves/0/clef",
|
|
"/staves/0/clef/sign",
|
|
"/staves/0/clef/line",
|
|
"/staves/0/clef/octave_change",
|
|
"/staves/0/measures",
|
|
"/staves/0/measures/0",
|
|
"/staves/0/measures/0/duration",
|
|
"/staves/0/measures/0/key",
|
|
"/staves/0/measures/0/key/alterations",
|
|
"/staves/0/measures/0/key/alterations/0",
|
|
"/staves/0/measures/0/meter",
|
|
"/staves/0/measures/0/meter/groups",
|
|
"/staves/0/measures/0/meter/groups/0",
|
|
"/staves/0/measures/0/meter/groups/0/beats",
|
|
"/staves/0/measures/0/meter/groups/0/beats/0",
|
|
"/staves/0/measures/0/meter/groups/0/unit",
|
|
"/staves/0/measures/0/voices",
|
|
"/staves/0/measures/0/voices/0",
|
|
"/staves/0/measures/0/voices/0/notes",
|
|
"/staves/0/measures/0/voices/0/notes/0",
|
|
};
|
|
auto event = std::string{"/staves/0/measures/0/voices/0/notes/0/"};
|
|
for (auto suffix : {"id",
|
|
"kind",
|
|
"onset",
|
|
"duration",
|
|
"duration/numerator",
|
|
"duration/denominator",
|
|
"written",
|
|
"written/type",
|
|
"written/dots",
|
|
"pitches",
|
|
"pitches/0",
|
|
"pitches/0/id",
|
|
"pitches/0/letter",
|
|
"pitches/0/octave",
|
|
"pitches/0/alter",
|
|
"pitches/0/duration",
|
|
"pitches/0/written"}) {
|
|
paths.push_back(event + suffix);
|
|
}
|
|
for (auto const &path : paths) {
|
|
BOOST_TEST_CONTEXT(path) {
|
|
auto source = boost::json::parse(score_asset_json("Fixture"));
|
|
source.at_pointer(path) = nullptr;
|
|
require_invalid_score(boost::json::serialize(source));
|
|
}
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_parser_checks_optional_notation_types) {
|
|
auto source = boost::json::parse(score_asset_json("Fixture"));
|
|
auto event_path = std::string{"/staves/0/measures/0/voices/0/notes/0"};
|
|
auto invalid_fields = boost::json::parse(R"({
|
|
"hidden":"false", "cue":0, "measure_rest":[], "lyric":false, "notehead":7, "stem":{},
|
|
"tuplet_ids":[42], "beams":[{"level":"1","type":"begin"}],
|
|
"rest_position":{"letter":"b","octave":false}, "grace":{"slash":0},
|
|
"tremolo":{"marks":"1","type":"single"}, "lyric_notation":{"extend":true}
|
|
})");
|
|
for (auto const &field : invalid_fields.as_object()) {
|
|
BOOST_TEST_CONTEXT(field.key()) {
|
|
auto changed = source;
|
|
changed.at_pointer(event_path).as_object()[field.key()] = field.value();
|
|
require_invalid_score(boost::json::serialize(changed));
|
|
}
|
|
}
|
|
for (auto name : {"harmonies", "attributes", "barlines"}) {
|
|
auto changed = source;
|
|
changed.at_pointer("/staves/0/measures/0").as_object()[name] = false;
|
|
require_invalid_score(boost::json::serialize(changed));
|
|
}
|
|
for (auto name : {"tuplets", "octave_shifts"}) {
|
|
auto changed = source;
|
|
changed.at_pointer("/staves/0").as_object()[name] = false;
|
|
require_invalid_score(boost::json::serialize(changed));
|
|
}
|
|
for (auto field : {"ratio", "normal"}) {
|
|
auto changed = source;
|
|
changed.at_pointer(event_path + "/written").as_object()[field] = false;
|
|
require_invalid_score(boost::json::serialize(changed));
|
|
}
|
|
source.at_pointer(event_path + "/pitches/0").as_object()["accidental_display"] =
|
|
boost::json::parse(R"({"parentheses":"false"})");
|
|
require_invalid_score(boost::json::serialize(source));
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_parser_rejects_integer_overflow_without_throwing) {
|
|
for (auto number : {"18446744073709551615", "-9223372036854775808", "1.5", "true", R"("4")"}) {
|
|
for (auto field : {"numerator", "denominator"}) {
|
|
auto source = boost::json::parse(score_asset_json("Fixture"));
|
|
source.at_pointer("/staves/0/measures/0/duration").as_object()[field] = boost::json::parse(number);
|
|
require_invalid_score(boost::json::serialize(source));
|
|
}
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(integer_parsing_checks_range_sign_and_entire_input) {
|
|
BOOST_TEST(vamp_zero::parse_integer<std::uint16_t>("65535", "port").value_or(0) == 65535U);
|
|
BOOST_TEST(vamp_zero::parse_integer<std::int32_t>("-2147483648", "octave").value_or(0) == std::numeric_limits<std::int32_t>::min());
|
|
BOOST_TEST(vamp_zero::parse_integer<std::uint64_t>("18446744073709551615", "page").value_or(0) == std::numeric_limits<std::uint64_t>::max());
|
|
for (auto text : {"", "-1", "+1", " 1", "1 ", "123x", "1.5", "65536", "18446744073709551616"}) {
|
|
auto parsed = vamp_zero::parse_integer<std::uint16_t>(text, "port");
|
|
BOOST_REQUIRE(!parsed.has_value());
|
|
BOOST_TEST(parsed.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
|
BOOST_TEST(parsed.error().message().starts_with("port "));
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_does_not_throw_on_invalid_clef_octaves) {
|
|
for (auto clef : {"G:2:x", "G:2:", "G:2:1x", "G:2:2147483648", "G:2:-2147483648"}) {
|
|
auto score = score_with_notes({note({pitch('c', "n", 4)})}, "C", "4/4", clef);
|
|
BOOST_CHECK_NO_THROW(vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score, 0));
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(library_scans_report_filesystem_errors_instead_of_empty_success) {
|
|
auto root = std::filesystem::temp_directory_path() /
|
|
("product-vamp-scan-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
write_text(root / "songs", "not a directory");
|
|
write_text(root / "practice", "not a directory");
|
|
auto performance = vamp_zero::load_performance_library(root.string());
|
|
auto practice = vamp_zero::load_practice_library(root.string());
|
|
BOOST_REQUIRE(!performance.has_value());
|
|
BOOST_REQUIRE(!practice.has_value());
|
|
BOOST_TEST(performance.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
|
|
BOOST_TEST(practice.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
|
|
std::filesystem::remove(root / "songs");
|
|
std::filesystem::remove(root / "practice");
|
|
std::filesystem::create_directories(root / "songs");
|
|
std::filesystem::create_directories(root / "practice");
|
|
std::filesystem::create_symlink("loop.json", root / "songs" / "loop.json");
|
|
std::filesystem::create_symlink("loop.json", root / "practice" / "loop.json");
|
|
BOOST_CHECK_NO_THROW(performance = vamp_zero::load_performance_library(root.string()));
|
|
BOOST_CHECK_NO_THROW(practice = vamp_zero::load_practice_library(root.string()));
|
|
BOOST_REQUIRE(!performance.has_value());
|
|
BOOST_REQUIRE(!practice.has_value());
|
|
BOOST_TEST(performance.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
|
|
BOOST_TEST(practice.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
|
|
std::filesystem::remove_all(root);
|
|
practice = vamp_zero::load_practice_library(root.string());
|
|
BOOST_REQUIRE(practice.has_value());
|
|
BOOST_TEST(practice->exercises.empty());
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_supports_all_written_durations_and_multiple_dots) {
|
|
auto names = std::vector<std::string>{"maxima",
|
|
"long",
|
|
"breve",
|
|
"whole",
|
|
"half",
|
|
"quarter",
|
|
"eighth",
|
|
"16th",
|
|
"32nd",
|
|
"64th",
|
|
"128th",
|
|
"256th",
|
|
"512th",
|
|
"1024th"};
|
|
for (auto const &name : names) {
|
|
auto n = note({pitch('c', "n", 4)});
|
|
n.written = {.type = name, .dots = 2};
|
|
n.pitches.front().written = n.written;
|
|
auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score_with_notes({n}), 0);
|
|
auto dots = layout_items(layout, vamp_zero::score_layout_item_role::DOT, 0, 0, 0);
|
|
BOOST_TEST(dots.size() == 2U);
|
|
for (auto const &item : layout.items) {
|
|
if (item.kind == vamp_zero::score_layout_item_kind::GLYPH) {
|
|
BOOST_TEST(item.bounds.width > 0);
|
|
BOOST_TEST(item.bounds.height > 0);
|
|
}
|
|
}
|
|
n.kind = "rest";
|
|
n.pitches.clear();
|
|
layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score_with_notes({n}), 0);
|
|
auto rests = layout_items(layout, vamp_zero::score_layout_item_role::REST, 0, 0, 0);
|
|
BOOST_REQUIRE_EQUAL(rests.size(), 1U);
|
|
BOOST_TEST(rests[0]->bounds.width > 0);
|
|
BOOST_TEST(rests[0]->bounds.height > 0);
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_paginates_dense_measures_without_losing_events) {
|
|
auto notes = std::vector<vamp_zero::score_note>{};
|
|
for (auto i = std::int32_t{}; i < 128; ++i) {
|
|
auto n = note_with_ticks({pitch('c', "#", 4)}, 128);
|
|
n.written = {.type = "128th"};
|
|
n.duration = {1, 32};
|
|
n.onset = {i, 32};
|
|
notes.push_back(n);
|
|
}
|
|
auto score = score_with_notes(notes);
|
|
auto total = std::size_t{};
|
|
auto pages = vamp_zero::score_page_count(score);
|
|
BOOST_TEST(pages > 1U);
|
|
for (auto page = std::size_t{}; page < pages; ++page) {
|
|
auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score, page);
|
|
for (auto const &item : layout.items) {
|
|
if (item.role == vamp_zero::score_layout_item_role::NOTEHEAD) {
|
|
++total;
|
|
BOOST_TEST(item.bounds.x >= 0);
|
|
BOOST_TEST(item.bounds.x + item.bounds.width <= 976);
|
|
BOOST_TEST(item.bounds.y >= 0);
|
|
BOOST_TEST(item.bounds.y + item.bounds.height <= 484);
|
|
}
|
|
}
|
|
}
|
|
BOOST_TEST(total == notes.size());
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_uses_independent_harmony_onsets) {
|
|
auto source = boost::json::parse(score_asset_json("Fixture"));
|
|
auto &m = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object();
|
|
m["harmonies"] =
|
|
boost::json::parse(R"([{"id":"h1","onset":{"numerator":2,"denominator":1},"kind":"major","symbol":"F"}])");
|
|
auto score = vamp_zero::parse_score_document(boost::json::serialize(source));
|
|
BOOST_REQUIRE(score.has_value());
|
|
auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, *score, 0);
|
|
auto heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0, 0);
|
|
auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, 0, 0);
|
|
BOOST_REQUIRE_EQUAL(heads.size(), 1U);
|
|
BOOST_REQUIRE_EQUAL(chords.size(), 1U);
|
|
BOOST_TEST(chords[0]->origin.x > heads[0]->origin.x);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_keeps_section_measure_and_harmony_labels_separate) {
|
|
auto score = score_with_notes({note({pitch('e', "n", 5)})});
|
|
score.measures[0].section_label = "Melody";
|
|
score.measures[0].harmonies.push_back({"h1", {0, 1}, "Cmaj7", "major-seventh"});
|
|
for (auto bounds : {luma::rect{.x = 0, .y = 0, .width = 976, .height = 484},
|
|
luma::rect{.x = 0, .y = 0, .width = 480, .height = 320}}) {
|
|
auto layout = vamp_zero::layout_score_page(bounds, score, 0);
|
|
auto sections = layout_items(layout, vamp_zero::score_layout_item_role::SECTION_LABEL);
|
|
auto numbers = layout_items(layout, vamp_zero::score_layout_item_role::MEASURE_NUMBER);
|
|
auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL);
|
|
BOOST_REQUIRE_EQUAL(sections.size(), 1U);
|
|
BOOST_REQUIRE_EQUAL(numbers.size(), 1U);
|
|
BOOST_REQUIRE_EQUAL(chords.size(), 1U);
|
|
BOOST_TEST(sections[0]->bounds.y >= 0);
|
|
BOOST_TEST(bottom_edge(*sections[0]) < numbers[0]->bounds.y);
|
|
BOOST_TEST(bottom_edge(*numbers[0]) < chords[0]->bounds.y);
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_ties_only_the_linked_chord_tone) {
|
|
auto source = boost::json::parse(score_asset_json("Partial tie"));
|
|
auto &ns = source.as_object()["staves"]
|
|
.as_array()[0]
|
|
.as_object()["measures"]
|
|
.as_array()[0]
|
|
.as_object()["voices"]
|
|
.as_array()[0]
|
|
.as_object()["notes"]
|
|
.as_array();
|
|
auto first = ns[0].as_object();
|
|
first["id"] = "a";
|
|
first["duration"].as_object()["numerator"] = 2;
|
|
first["written"].as_object()["type"] = "half";
|
|
auto &p = first["pitches"].as_array()[0].as_object();
|
|
p["id"] = "a-c";
|
|
p["duration"] = first["duration"];
|
|
p["written"] = first["written"];
|
|
auto other = p;
|
|
other["id"] = "a-e";
|
|
other["letter"] = "e";
|
|
first["pitches"].as_array().push_back(other);
|
|
auto second = first;
|
|
second["id"] = "b";
|
|
second["onset"].as_object()["numerator"] = 2;
|
|
second["pitches"].as_array()[0].as_object()["id"] = "b-c";
|
|
second["pitches"].as_array()[0].as_object()["tie_from"] = "a-c";
|
|
second["pitches"].as_array()[1].as_object()["id"] = "b-g";
|
|
second["pitches"].as_array()[1].as_object()["letter"] = "g";
|
|
first["pitches"].as_array()[0].as_object()["tie_to"] = "b-c";
|
|
ns = {first, second};
|
|
auto parsed = vamp_zero::parse_score_document(boost::json::serialize(source));
|
|
BOOST_REQUIRE(parsed.has_value());
|
|
auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, *parsed, 0);
|
|
BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD).size() == 4U);
|
|
BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::TIE).size() == 1U);
|
|
ns[0].as_object()["pitches"].as_array()[1].as_object()["duration"].as_object()["numerator"] = 0;
|
|
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_clears_secondary_chord_stems_above_notation) {
|
|
auto n = note({pitch('c', "#", 5), pitch('e', "n", 5)});
|
|
n.written = {.type = "quarter", .dots = 2};
|
|
n.pitches[0].written = n.written;
|
|
n.pitches[1].written = {.type = "eighth"};
|
|
auto score = score_with_notes({n});
|
|
score.measures[0].harmonies.push_back({"h", {0, 1}, "Cdim", "diminished"});
|
|
auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score, 0);
|
|
auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL);
|
|
BOOST_REQUIRE_EQUAL(chords.size(), 1U);
|
|
for (auto role : {vamp_zero::score_layout_item_role::STEM, vamp_zero::score_layout_item_role::FLAG}) {
|
|
for (auto item : layout_items(layout, role)) {
|
|
BOOST_TEST(bottom_edge(*chords[0]) < item->bounds.y);
|
|
}
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(accidentals_are_suppressed_when_the_key_signature_already_covers_the_pitch) {
|
|
auto c_major = vamp_zero::score_measure{
|
|
.key_signature = "C",
|
|
.notes =
|
|
{
|
|
note({pitch('c', "n")}),
|
|
note({pitch('d', "n")}),
|
|
note({pitch('e', "n")}),
|
|
},
|
|
};
|
|
expect_marks(vamp_zero::notated_accidentals_for_measure(c_major, "C"), {"none", "none", "none"});
|
|
auto f_major = vamp_zero::score_measure{
|
|
.key_signature = "F",
|
|
.notes =
|
|
{
|
|
note({pitch('b', "b")}),
|
|
note({pitch('e', "n")}),
|
|
},
|
|
};
|
|
expect_marks(vamp_zero::notated_accidentals_for_measure(f_major, "C"), {"none", "none"});
|
|
auto d_major = vamp_zero::score_measure{
|
|
.key_signature = "D",
|
|
.notes =
|
|
{
|
|
note({pitch('f', "#")}),
|
|
note({pitch('c', "#")}),
|
|
},
|
|
};
|
|
expect_marks(vamp_zero::notated_accidentals_for_measure(d_major, "C"), {"none", "none"});
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(accidentals_follow_measure_state_after_the_first_printed_change) {
|
|
auto measure = vamp_zero::score_measure{
|
|
.key_signature = "F",
|
|
.notes =
|
|
{
|
|
note({pitch('b', "b")}),
|
|
note({pitch('b', "n")}),
|
|
note({pitch('b', "n")}),
|
|
note({pitch('b', "b")}),
|
|
note({pitch('b', "b")}),
|
|
},
|
|
};
|
|
expect_marks(vamp_zero::notated_accidentals_for_measure(measure, "C"), {"none", "natural", "none", "flat", "none"});
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(measure_state_resets_for_each_measure) {
|
|
auto first = vamp_zero::score_measure{
|
|
.key_signature = "C",
|
|
.notes =
|
|
{
|
|
note({pitch('f', "#")}),
|
|
note({pitch('f', "#")}),
|
|
},
|
|
};
|
|
expect_marks(vamp_zero::notated_accidentals_for_measure(first, "C"), {"sharp", "none"});
|
|
auto second = vamp_zero::score_measure{
|
|
.key_signature = "C",
|
|
.notes =
|
|
{
|
|
note({pitch('f', "#")}),
|
|
},
|
|
};
|
|
expect_marks(vamp_zero::notated_accidentals_for_measure(second, "C"), {"sharp"});
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(tied_rests_hidden_and_slash_notes_do_not_emit_accidentals) {
|
|
auto measure = vamp_zero::score_measure{
|
|
.key_signature = "C",
|
|
.notes =
|
|
{
|
|
note({pitch('f', "#")}, true),
|
|
rest({pitch('b', "b")}),
|
|
note({pitch('c', "#")}, false, "slash"),
|
|
note({pitch('d', "#")}, false, {}, true),
|
|
note({pitch('e', "b")}),
|
|
},
|
|
};
|
|
expect_marks(vamp_zero::notated_accidentals_for_measure(measure, "C"), {"none", "flat"});
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_accepts_crlf_payloads) {
|
|
auto state = vamp_pico::remote_display_state{};
|
|
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("active=1\r\ntitle=Autumn Leaves\r\npart=Vocal\r\nmode=score\r\npage=2\r\npages=11\r\n", state));
|
|
BOOST_TEST(state.active);
|
|
BOOST_TEST(std::strcmp(state.title, "Autumn Leaves") == 0);
|
|
BOOST_TEST(std::strcmp(state.part, "Vocal") == 0);
|
|
BOOST_TEST(std::strcmp(state.mode, "score") == 0);
|
|
BOOST_TEST(state.page == 2U);
|
|
BOOST_TEST(state.pages == 11U);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_rejects_bad_numbers_and_preserves_state) {
|
|
auto state = vamp_pico::remote_display_state{};
|
|
std::snprintf(state.title, sizeof(state.title), "Keep");
|
|
BOOST_TEST(!vamp_pico::parse_remote_display_body("page=1x\npages=2\n", state));
|
|
BOOST_TEST(std::strcmp(state.title, "Keep") == 0);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_truncates_long_fields_safely) {
|
|
auto state = vamp_pico::remote_display_state{};
|
|
auto body =
|
|
std::string{"title="} + std::string(140U, 'A') + "\npart=" + std::string(80U, 'B') + "\nmode=performance-score";
|
|
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(body, state));
|
|
BOOST_TEST(std::strlen(state.title) == sizeof(state.title) - 1U);
|
|
BOOST_TEST(std::strlen(state.part) == sizeof(state.part) - 1U);
|
|
BOOST_TEST(std::strcmp(state.mode, "performance-score") == 0);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(database_ensure_creates_current_schema_without_version_bookkeeping) {
|
|
auto root =
|
|
std::filesystem::temp_directory_path() /
|
|
("product-vamp-db-schema-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
auto database_path = root / "vamp.db";
|
|
std::filesystem::remove_all(root);
|
|
auto config = test_database_config(database_path);
|
|
BOOST_REQUIRE(run_ensure_database(config));
|
|
BOOST_REQUIRE(run_ensure_database(config));
|
|
auto tables = query_database(database_path, "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name");
|
|
BOOST_REQUIRE(tables);
|
|
BOOST_TEST(has_value(*tables, 0U, "songs"));
|
|
BOOST_TEST(has_value(*tables, 0U, "practice_exercises"));
|
|
BOOST_TEST(has_value(*tables, 0U, "page_state"));
|
|
BOOST_TEST(!has_value(*tables, 0U, "_noto_schema_migrations"));
|
|
auto version = query_database(database_path, "PRAGMA user_version");
|
|
BOOST_REQUIRE(version);
|
|
BOOST_REQUIRE_EQUAL(version->rows.size(), 1U);
|
|
BOOST_TEST(integer_value(version->rows[0][0]) == 0);
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(database_ensure_updates_legacy_schema_in_place) {
|
|
auto root =
|
|
std::filesystem::temp_directory_path() /
|
|
("product-vamp-db-legacy-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
auto database_path = root / "vamp.db";
|
|
std::filesystem::remove_all(root);
|
|
std::filesystem::create_directories(root);
|
|
BOOST_REQUIRE(query_database(database_path, R"sql(
|
|
CREATE TABLE songs (
|
|
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120),
|
|
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
|
composer TEXT NOT NULL DEFAULT '',
|
|
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
|
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
|
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms)
|
|
) STRICT
|
|
)sql"));
|
|
BOOST_REQUIRE(query_database(database_path, R"sql(
|
|
CREATE TABLE practice_exercises (
|
|
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160),
|
|
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
|
composer TEXT NOT NULL DEFAULT '',
|
|
key_signature TEXT NOT NULL DEFAULT 'C',
|
|
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
|
source_path TEXT NOT NULL DEFAULT '',
|
|
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
|
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms)
|
|
) STRICT
|
|
)sql"));
|
|
BOOST_REQUIRE(query_database(database_path, "CREATE TABLE _noto_schema_migrations(id TEXT PRIMARY KEY NOT NULL) STRICT"));
|
|
BOOST_REQUIRE(query_database(database_path, "PRAGMA user_version = 5"));
|
|
auto config = test_database_config(database_path);
|
|
BOOST_REQUIRE(run_ensure_database(config));
|
|
auto song_columns = query_database(database_path, "PRAGMA table_info(songs)");
|
|
BOOST_REQUIRE(song_columns);
|
|
BOOST_TEST(has_value(*song_columns, 1U, "part"));
|
|
BOOST_TEST(has_value(*song_columns, 1U, "source_path"));
|
|
BOOST_TEST(has_value(*song_columns, 1U, "key_signature"));
|
|
BOOST_TEST(has_value(*song_columns, 1U, "catalog_managed"));
|
|
auto practice_columns = query_database(database_path, "PRAGMA table_info(practice_exercises)");
|
|
BOOST_REQUIRE(practice_columns);
|
|
BOOST_TEST(has_value(*practice_columns, 1U, "practice_type_id"));
|
|
BOOST_TEST(has_value(*practice_columns, 1U, "practice_type_title"));
|
|
BOOST_TEST(has_value(*practice_columns, 1U, "lesson_group_id"));
|
|
BOOST_TEST(has_value(*practice_columns, 1U, "lesson_group_title"));
|
|
BOOST_TEST(has_value(*practice_columns, 1U, "catalog_managed"));
|
|
auto tables = query_database(database_path, "SELECT name FROM sqlite_schema WHERE type = 'table'");
|
|
BOOST_REQUIRE(tables);
|
|
BOOST_TEST(!has_value(*tables, 0U, "_noto_schema_migrations"));
|
|
auto version = query_database(database_path, "PRAGMA user_version");
|
|
BOOST_REQUIRE(version);
|
|
BOOST_REQUIRE_EQUAL(version->rows.size(), 1U);
|
|
BOOST_TEST(integer_value(version->rows[0][0]) == 0);
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_rows) {
|
|
auto root = std::filesystem::temp_directory_path() /
|
|
("product-vamp-db-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
auto assets = root / "assets";
|
|
auto database_path = root / "vamp.db";
|
|
auto song_path = assets / "songs" / "tune.json";
|
|
std::filesystem::remove_all(root);
|
|
write_text(song_path, score_asset_json("Catalog Tune"));
|
|
auto config = test_database_config(database_path);
|
|
BOOST_REQUIRE(run_ensure_database(config));
|
|
BOOST_REQUIRE(run_import(config, assets));
|
|
auto first = query_database(database_path, "SELECT title, created_at_ms, updated_at_ms, catalog_managed FROM songs WHERE id = 'tune'");
|
|
BOOST_REQUIRE(first);
|
|
BOOST_REQUIRE_EQUAL(first->rows.size(), 1U);
|
|
auto created = integer_value(first->rows[0][1]);
|
|
auto updated = integer_value(first->rows[0][2]);
|
|
BOOST_TEST(text_value(first->rows[0][0]) == "Catalog Tune");
|
|
BOOST_TEST(integer_value(first->rows[0][3]) == 1);
|
|
BOOST_REQUIRE(run_import(config, assets));
|
|
auto unchanged = query_database(database_path, "SELECT created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'");
|
|
BOOST_REQUIRE(unchanged);
|
|
BOOST_REQUIRE_EQUAL(unchanged->rows.size(), 1U);
|
|
BOOST_TEST(integer_value(unchanged->rows[0][0]) == created);
|
|
BOOST_TEST(integer_value(unchanged->rows[0][1]) == updated);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds{2});
|
|
write_text(song_path, score_asset_json("Catalog Tune Updated"));
|
|
BOOST_REQUIRE(run_import(config, assets));
|
|
auto changed =
|
|
query_database(database_path, "SELECT title, created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'");
|
|
BOOST_REQUIRE(changed);
|
|
BOOST_REQUIRE_EQUAL(changed->rows.size(), 1U);
|
|
BOOST_TEST(text_value(changed->rows[0][0]) == "Catalog Tune Updated");
|
|
BOOST_TEST(integer_value(changed->rows[0][1]) == created);
|
|
BOOST_TEST(integer_value(changed->rows[0][2]) >= updated);
|
|
auto inserted_custom = query_database(database_path,
|
|
"INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, "
|
|
"key_signature, catalog_managed) VALUES('custom', 'Custom Tune', 'Composer', ?1, 1, 1, 'Lead', 'custom.json', 'C', "
|
|
"0)",
|
|
{noto::sqlite_value{score_asset_json("Custom Tune")}});
|
|
BOOST_REQUIRE(inserted_custom);
|
|
write_text(assets / "songs" / "custom.json", score_asset_json("Catalog Collision"));
|
|
BOOST_REQUIRE(run_import(config, assets));
|
|
auto custom = query_database(database_path, "SELECT title, catalog_managed FROM songs WHERE id = 'custom'");
|
|
BOOST_REQUIRE(custom);
|
|
BOOST_REQUIRE_EQUAL(custom->rows.size(), 1U);
|
|
BOOST_TEST(text_value(custom->rows[0][0]) == "Custom Tune");
|
|
BOOST_TEST(integer_value(custom->rows[0][1]) == 0);
|
|
std::filesystem::remove(assets / "songs" / "custom.json");
|
|
std::filesystem::remove(song_path);
|
|
BOOST_REQUIRE(run_import(config, assets));
|
|
auto counts = query_database(database_path, "SELECT sum(CASE WHEN id = 'tune' THEN 1 ELSE 0 END), sum(CASE WHEN id = 'custom' THEN 1 ELSE 0 END) FROM songs");
|
|
BOOST_REQUIRE(counts);
|
|
BOOST_REQUIRE_EQUAL(counts->rows.size(), 1U);
|
|
BOOST_TEST(integer_value(counts->rows[0][0]) == 0);
|
|
BOOST_TEST(integer_value(counts->rows[0][1]) == 1);
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_orders_preamble_on_staff_baselines) {
|
|
auto score = score_with_notes({note({pitch('e', "n")})}, "F", "4/4", "treble");
|
|
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
|
auto clefs = layout_items(layout, vamp_zero::score_layout_item_role::CLEF, 0U);
|
|
auto keys = layout_items(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, 0U);
|
|
auto times = layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 0U);
|
|
auto staff_lines = layout_items(layout, vamp_zero::score_layout_item_role::STAFF_LINE, 0U);
|
|
BOOST_REQUIRE_EQUAL(clefs.size(), 1U);
|
|
BOOST_REQUIRE(!keys.empty());
|
|
BOOST_REQUIRE(!times.empty());
|
|
BOOST_REQUIRE_GE(staff_lines.size(), 5U);
|
|
BOOST_TEST(clefs.front()->bounds.x < keys.front()->bounds.x);
|
|
BOOST_TEST(keys.front()->bounds.x < times.front()->bounds.x);
|
|
BOOST_TEST(near(clefs.front()->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 4.0F));
|
|
BOOST_TEST(near(keys.front()->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 2.5F));
|
|
BOOST_REQUIRE_GE(times.size(), 2U);
|
|
BOOST_TEST(near(times[0]->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 2.0F));
|
|
BOOST_TEST(near(times[1]->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 4.0F));
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_key_changes) {
|
|
auto score = vamp_zero::score_document{
|
|
.title = "Key Change",
|
|
.composer = "Composer",
|
|
.key_signature = "C",
|
|
.time_signature = "4/4",
|
|
.clef = "treble",
|
|
.part = "Lead",
|
|
.measures =
|
|
{
|
|
{.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('c', "n")})}},
|
|
{.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('d', "n")})}},
|
|
{.time_signature = "4/4", .key_signature = "G", .notes = {note({pitch('f', "#")})}},
|
|
{.time_signature = "4/4", .key_signature = "G", .notes = {note({pitch('g', "n")})}},
|
|
},
|
|
};
|
|
auto ranges = vamp_zero::score_system_ranges(score);
|
|
BOOST_REQUIRE_EQUAL(ranges.size(), 2U);
|
|
BOOST_TEST(ranges[0].first_measure == 0U);
|
|
BOOST_TEST(ranges[0].measure_count == 2U);
|
|
BOOST_TEST(ranges[1].first_measure == 2U);
|
|
BOOST_TEST(ranges[1].measure_count == 2U);
|
|
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
|
BOOST_REQUIRE(!layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 1U, 2U, 0U).empty());
|
|
BOOST_TEST(!layout_items(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, 1U).empty());
|
|
BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 1U).empty());
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_time_changes) {
|
|
auto score = vamp_zero::score_document{
|
|
.title = "Time Change",
|
|
.composer = "Composer",
|
|
.key_signature = "C",
|
|
.time_signature = "4/4",
|
|
.clef = "treble",
|
|
.part = "Lead",
|
|
.measures =
|
|
{
|
|
{.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('c', "n")})}},
|
|
{.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('d', "n")})}},
|
|
{.time_signature = "3/4", .key_signature = "C", .notes = {note({pitch('e', "n")})}},
|
|
},
|
|
};
|
|
auto ranges = vamp_zero::score_system_ranges(score);
|
|
BOOST_REQUIRE_EQUAL(ranges.size(), 2U);
|
|
BOOST_TEST(ranges[1].first_measure == 2U);
|
|
BOOST_TEST(ranges[1].measure_count == 1U);
|
|
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
|
BOOST_REQUIRE(!layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 1U, 2U, 0U).empty());
|
|
BOOST_TEST(!layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 1U).empty());
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_page_count_uses_forced_notation_breaks) {
|
|
auto measures = std::vector<vamp_zero::score_measure>{};
|
|
for (auto index = std::size_t{}; index < 10U; ++index) {
|
|
measures.push_back({
|
|
.time_signature = "4/4",
|
|
.key_signature = (index / 2U) % 2U == 0U ? "C" : "G",
|
|
.notes = {note({pitch('c', "n")})},
|
|
});
|
|
}
|
|
auto score = vamp_zero::score_document{
|
|
.title = "Many Breaks",
|
|
.composer = "Composer",
|
|
.key_signature = "C",
|
|
.time_signature = "4/4",
|
|
.clef = "treble",
|
|
.part = "Lead",
|
|
.measures = std::move(measures),
|
|
};
|
|
BOOST_TEST(vamp_zero::score_page_count(score) == 3U);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_attaches_stems_to_notehead_anchors) {
|
|
auto up_score = score_with_notes({note({pitch('d', "n")})});
|
|
auto up_layout =
|
|
vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, up_score, 0U);
|
|
auto up_heads = layout_items(up_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U);
|
|
auto up_stems = layout_items(up_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U);
|
|
BOOST_REQUIRE_EQUAL(up_heads.size(), 1U);
|
|
BOOST_REQUIRE_EQUAL(up_stems.size(), 1U);
|
|
expect_up_stem_connected(*up_heads.front(), *up_stems.front(), up_layout.staff_spacing);
|
|
auto down_score = score_with_notes({note({pitch('c', "n", 6)})});
|
|
auto down_layout =
|
|
vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, down_score, 0U);
|
|
auto down_heads = layout_items(down_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U);
|
|
auto down_stems = layout_items(down_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U);
|
|
BOOST_REQUIRE_EQUAL(down_heads.size(), 1U);
|
|
BOOST_REQUIRE_EQUAL(down_stems.size(), 1U);
|
|
expect_down_stem_connected(*down_heads.front(), *down_stems.front(), down_layout.staff_spacing);
|
|
auto half_score = score_with_notes({note_with_ticks({pitch('d', "n")}, 8192)});
|
|
auto half_layout =
|
|
vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, half_score, 0U);
|
|
auto half_heads = layout_items(half_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U);
|
|
auto half_stems = layout_items(half_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U);
|
|
BOOST_REQUIRE_EQUAL(half_heads.size(), 1U);
|
|
BOOST_REQUIRE_EQUAL(half_stems.size(), 1U);
|
|
expect_up_stem_connected(*half_heads.front(), *half_stems.front(), half_layout.staff_spacing);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_displaces_chord_seconds_and_columns_accidentals) {
|
|
auto score = score_with_notes({note({pitch('c', "#"), pitch('d', "#")})});
|
|
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
|
auto heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U);
|
|
auto accidentals = layout_items(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, 0U, 0U, 0U);
|
|
BOOST_REQUIRE_EQUAL(heads.size(), 2U);
|
|
BOOST_REQUIRE_EQUAL(accidentals.size(), 2U);
|
|
auto const *lower_head = *std::max_element(
|
|
heads.begin(), heads.end(), [](auto const *left, auto const *right) { return center_y(*left) < center_y(*right); });
|
|
auto const *upper_head = *std::min_element(
|
|
heads.begin(), heads.end(), [](auto const *left, auto const *right) { return center_y(*left) < center_y(*right); });
|
|
BOOST_TEST(center_x(*upper_head) > center_x(*lower_head) + layout.staff_spacing * 0.30F);
|
|
auto const *leftmost_head = *std::min_element(
|
|
heads.begin(), heads.end(), [](auto const *left, auto const *right) { return left->bounds.x < right->bounds.x; });
|
|
auto const *leftmost_accidental = *std::min_element(accidentals.begin(),
|
|
accidentals.end(),
|
|
[](auto const *left, auto const *right) { return left->bounds.x < right->bounds.x; });
|
|
auto const *rightmost_accidental = *std::max_element(accidentals.begin(),
|
|
accidentals.end(),
|
|
[](auto const *left, auto const *right) { return left->bounds.x < right->bounds.x; });
|
|
for (auto const *accidental : accidentals) {
|
|
BOOST_TEST(accidental->bounds.x + accidental->bounds.width < leftmost_head->bounds.x);
|
|
}
|
|
BOOST_TEST(rightmost_accidental->bounds.x - leftmost_accidental->bounds.x > layout.staff_spacing * 0.50F);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_beams_eighth_tuplets_and_labels_them) {
|
|
auto first = note_with_ticks({pitch('c', "n")}, 2048);
|
|
auto second = note_with_ticks({pitch('d', "n")}, 2048);
|
|
auto third = note_with_ticks({pitch('e', "n")}, 2048);
|
|
first.tuplet_id = "triplet-1";
|
|
second.tuplet_id = "triplet-1";
|
|
third.tuplet_id = "triplet-1";
|
|
auto score = score_with_measure({
|
|
.time_signature = "4/4",
|
|
.key_signature = "C",
|
|
.notes = {std::move(first), std::move(second), std::move(third)},
|
|
.tuplets = {{
|
|
.id = "triplet-1",
|
|
.note_count = 3,
|
|
.space_count = 2,
|
|
}},
|
|
});
|
|
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
|
auto beams = layout_items(layout, vamp_zero::score_layout_item_role::BEAM, 0U, 0U);
|
|
auto flags = layout_items(layout, vamp_zero::score_layout_item_role::FLAG, 0U, 0U);
|
|
auto tuplets = layout_items(layout, vamp_zero::score_layout_item_role::TUPLET, 0U, 0U);
|
|
BOOST_REQUIRE_EQUAL(beams.size(), 1U);
|
|
BOOST_TEST(flags.empty());
|
|
BOOST_REQUIRE_EQUAL(tuplets.size(), 1U);
|
|
BOOST_TEST(tuplets.front()->text == "3");
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_keeps_dense_notation_inside_measure_guard) {
|
|
auto first = note_with_ticks({pitch('c', "#")}, 2048);
|
|
auto second = note_with_ticks({pitch('d', "#")}, 2048);
|
|
auto third = note_with_ticks({pitch('e', "n")}, 6144);
|
|
auto fourth = note_with_ticks({pitch('f', "#")}, 4096);
|
|
auto fifth = note_with_ticks({pitch('g', "#")}, 2048);
|
|
fourth.tie_to_next = true;
|
|
auto score = score_with_measure({
|
|
.time_signature = "4/4",
|
|
.key_signature = "C",
|
|
.notes = {std::move(first), std::move(second), std::move(third), std::move(fourth), std::move(fifth)},
|
|
});
|
|
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
|
auto barlines = layout_items(layout, vamp_zero::score_layout_item_role::FINAL_BARLINE, 0U, 0U);
|
|
BOOST_REQUIRE_EQUAL(barlines.size(), 1U);
|
|
auto guarded_right = barlines.front()->from.x - layout.staff_spacing;
|
|
auto roles = {
|
|
vamp_zero::score_layout_item_role::NOTEHEAD,
|
|
vamp_zero::score_layout_item_role::ACCIDENTAL,
|
|
vamp_zero::score_layout_item_role::STEM,
|
|
vamp_zero::score_layout_item_role::FLAG,
|
|
vamp_zero::score_layout_item_role::BEAM,
|
|
vamp_zero::score_layout_item_role::DOT,
|
|
vamp_zero::score_layout_item_role::TIE,
|
|
};
|
|
for (auto role : roles) {
|
|
for (auto const *item : layout_items(layout, role, 0U, 0U)) {
|
|
BOOST_TEST(right_edge(*item) <= guarded_right);
|
|
}
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_places_lyrics_below_low_vocal_notation) {
|
|
auto low = note({pitch('c', "n", 3)});
|
|
low.lyric = "low";
|
|
auto score = score_with_notes({std::move(low)});
|
|
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
|
auto lyrics = layout_items(layout, vamp_zero::score_layout_item_role::LYRIC, 0U, 0U, 0U);
|
|
BOOST_REQUIRE_EQUAL(lyrics.size(), 1U);
|
|
auto notation_bottom = std::numeric_limits<std::float32_t>::lowest();
|
|
auto roles = {
|
|
vamp_zero::score_layout_item_role::NOTEHEAD,
|
|
vamp_zero::score_layout_item_role::ACCIDENTAL,
|
|
vamp_zero::score_layout_item_role::STEM,
|
|
vamp_zero::score_layout_item_role::FLAG,
|
|
vamp_zero::score_layout_item_role::DOT,
|
|
vamp_zero::score_layout_item_role::STAFF_LINE,
|
|
};
|
|
for (auto role : roles) {
|
|
for (auto const *item : layout_items(layout, role, 0U, 0U, 0U)) {
|
|
notation_bottom = std::max(notation_bottom, bottom_edge(*item));
|
|
}
|
|
}
|
|
BOOST_TEST(lyrics.front()->bounds.y >= notation_bottom + layout.staff_spacing * 0.70F);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_ties_to_the_actual_next_measure_notehead) {
|
|
auto tied = note_with_ticks({pitch('b', "b")}, 16384);
|
|
tied.tie_to_next = true;
|
|
auto target = note_with_ticks({pitch('b', "b")}, 4096);
|
|
target.tie_from_previous = true;
|
|
auto score = score_with_measure(
|
|
{
|
|
.time_signature = "4/4",
|
|
.key_signature = "F",
|
|
.notes = {std::move(tied)},
|
|
},
|
|
"F");
|
|
score.measures.push_back({
|
|
.time_signature = "4/4",
|
|
.key_signature = "F",
|
|
.notes = {std::move(target)},
|
|
});
|
|
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
|
auto ties = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 0U, 0U, 0U);
|
|
auto start_heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U);
|
|
auto target_heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 1U, 0U);
|
|
BOOST_REQUIRE_EQUAL(ties.size(), 1U);
|
|
BOOST_REQUIRE_EQUAL(start_heads.size(), 1U);
|
|
BOOST_REQUIRE_EQUAL(target_heads.size(), 1U);
|
|
BOOST_TEST(ties.front()->from.x > right_edge(*start_heads.front()));
|
|
BOOST_TEST(ties.front()->to.x < target_heads.front()->bounds.x);
|
|
BOOST_TEST(ties.front()->to.x > right_edge(*start_heads.front()));
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_draws_tie_continuations_across_system_breaks) {
|
|
auto measures = std::vector<vamp_zero::score_measure>{};
|
|
for (auto index = std::size_t{}; index < 5U; ++index) {
|
|
auto value = note_with_ticks({pitch('a', "n")}, 16384);
|
|
if (index == 3U) {
|
|
value.tie_to_next = true;
|
|
}
|
|
if (index == 4U) {
|
|
value.tie_from_previous = true;
|
|
}
|
|
measures.push_back({
|
|
.time_signature = "4/4",
|
|
.key_signature = "C",
|
|
.notes = {std::move(value)},
|
|
});
|
|
}
|
|
auto score = vamp_zero::score_document{
|
|
.title = "Break Tie",
|
|
.composer = "Composer",
|
|
.key_signature = "C",
|
|
.time_signature = "4/4",
|
|
.clef = "treble",
|
|
.part = "Lead",
|
|
.measures = std::move(measures),
|
|
};
|
|
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
|
auto outgoing = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 0U, 3U, 0U);
|
|
auto incoming = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 1U, 4U, 0U);
|
|
BOOST_REQUIRE_EQUAL(outgoing.size(), 1U);
|
|
BOOST_REQUIRE_EQUAL(incoming.size(), 1U);
|
|
BOOST_TEST(outgoing.front()->to.x > outgoing.front()->from.x);
|
|
BOOST_TEST(incoming.front()->to.x > incoming.front()->from.x);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(score_layout_raises_chord_symbols_above_high_notation) {
|
|
auto high = note({pitch('c', "n", 6)});
|
|
high.chord_symbol = "Cmaj7";
|
|
auto score = score_with_notes({std::move(high)});
|
|
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
|
auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, 0U, 0U);
|
|
BOOST_REQUIRE_EQUAL(chords.size(), 1U);
|
|
auto top_y = std::numeric_limits<std::float32_t>::max();
|
|
auto roles = {
|
|
vamp_zero::score_layout_item_role::NOTEHEAD,
|
|
vamp_zero::score_layout_item_role::ACCIDENTAL,
|
|
vamp_zero::score_layout_item_role::STEM,
|
|
vamp_zero::score_layout_item_role::FLAG,
|
|
vamp_zero::score_layout_item_role::BEAM,
|
|
vamp_zero::score_layout_item_role::DOT,
|
|
vamp_zero::score_layout_item_role::TUPLET,
|
|
vamp_zero::score_layout_item_role::TUPLET_BRACKET,
|
|
vamp_zero::score_layout_item_role::TIE,
|
|
};
|
|
for (auto role : roles) {
|
|
for (auto const *item : layout_items(layout, role, 0U, 0U)) {
|
|
top_y = std::min(top_y, item->bounds.y);
|
|
}
|
|
}
|
|
BOOST_TEST(right_edge(*chords.front()) <= layout.bounds.x + layout.bounds.width);
|
|
BOOST_TEST(chords.front()->bounds.y + chords.front()->bounds.height <= top_y - layout.staff_spacing * 0.40F);
|
|
}
|