@@ -23,6 +23,7 @@ target_compile_definitions(product-vamp-test PRIVATE
|
||||
VAMP_ZERO_HOST="192.168.42.1"
|
||||
VAMP_ZERO_PORT=8080
|
||||
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <cerrno>
|
||||
#include <filesystem>
|
||||
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <modulus/types.hpp>
|
||||
|
||||
|
||||
namespace vamp_test {
|
||||
|
||||
inline auto build_catalogue(std::filesystem::path const &assets, std::filesystem::path const &database) -> std::bool_t {
|
||||
auto source = assets.string();
|
||||
auto destination = database.string();
|
||||
auto child = ::fork();
|
||||
BOOST_REQUIRE(child >= 0);
|
||||
if (child == 0) {
|
||||
::execl(VAMP_TEST_PYTHON, VAMP_TEST_PYTHON, VAMP_TEST_SOURCE_DIR "/generator/catalogue.py", source.c_str(), destination.c_str(), nullptr);
|
||||
::_exit(127);
|
||||
}
|
||||
auto status = std::int32_t{};
|
||||
auto waited = ::waitpid(child, &status, 0);
|
||||
while (waited < 0 && errno == EINTR) {
|
||||
waited = ::waitpid(child, &status, 0);
|
||||
}
|
||||
BOOST_REQUIRE(waited == child);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
}
|
||||
+26
-224
@@ -41,6 +41,8 @@
|
||||
#include <vamp_zero/score.hpp>
|
||||
#include <vamp_zero/types.hpp>
|
||||
|
||||
#include "catalogue_fixture.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
auto test_database_config(std::filesystem::path const &path) -> vamp_zero::database_config {
|
||||
@@ -49,20 +51,6 @@ auto test_database_config(std::filesystem::path const &path) -> vamp_zero::datab
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -98,11 +86,6 @@ auto text_value(noto::sqlite_value const &value) -> std::string {
|
||||
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;
|
||||
@@ -368,11 +351,10 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
|
||||
}
|
||||
}
|
||||
BOOST_REQUIRE(expected_exercises > 0U);
|
||||
BOOST_REQUIRE(run_ensure_database(config));
|
||||
for (auto iteration = std::uint32_t{}; iteration < 2U; ++iteration) {
|
||||
BOOST_REQUIRE(run_import(config, VAMP_TEST_ASSETS_DIR));
|
||||
config.path = (std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "catalogue.db").string();
|
||||
auto io = boost::asio::io_context{};
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config), boost::asio::use_future);
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config, VAMP_TEST_ASSETS_DIR), boost::asio::use_future);
|
||||
io.run();
|
||||
auto library = future.get();
|
||||
BOOST_REQUIRE_MESSAGE(library.has_value(), (library ? "" : library.error().describe()));
|
||||
@@ -386,6 +368,21 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
|
||||
BOOST_TEST(key.substr(0, key.find(' ')) == keys[index]);
|
||||
BOOST_TEST(exercise.variants[index].total_pages > 0U);
|
||||
BOOST_TEST(exercise.variants[index].measure_count > 0U);
|
||||
if (iteration == 0U) {
|
||||
auto const &variant = exercise.variants[index];
|
||||
io.restart();
|
||||
auto loaded = boost::asio::co_spawn(io, vamp_zero::async_load_score(io.get_executor(), config, {vamp_zero::score_category::PRACTICE, variant.id}, VAMP_TEST_ASSETS_DIR), boost::asio::use_future);
|
||||
io.run();
|
||||
auto score = loaded.get();
|
||||
BOOST_REQUIRE_MESSAGE(score, (score ? "" : score.error().describe()));
|
||||
BOOST_TEST(score->title == exercise.title);
|
||||
BOOST_TEST(score->composer == exercise.composer);
|
||||
BOOST_TEST(score->key_signature == variant.key_signature);
|
||||
BOOST_TEST(score->authored_key_signature == exercise.authored_key_signature);
|
||||
BOOST_TEST(score->measure_count == variant.measure_count);
|
||||
BOOST_TEST(score->pages.size() == variant.total_pages);
|
||||
BOOST_TEST(score->source_path == variant.source_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,7 +397,11 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
|
||||
for (auto name : {"chord/001_lesson/010-cycles", "chord/001_lesson/002-cycles", "chord/002_lesson/001-cycles", "melody/001_lesson/127-cycles"}) {
|
||||
write_text(assets / "practice" / name / "c.json", score_asset_json("Shared Title"));
|
||||
}
|
||||
auto library = vamp_zero::load_practice_library(assets.string());
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path));
|
||||
auto io = boost::asio::io_context{};
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config, assets.string()), boost::asio::use_future);
|
||||
io.run();
|
||||
auto library = future.get();
|
||||
BOOST_REQUIRE(library);
|
||||
BOOST_REQUIRE_EQUAL(library->exercises.size(), 4U);
|
||||
BOOST_REQUIRE_EQUAL(library->types.size(), 2U);
|
||||
@@ -414,11 +415,10 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
|
||||
for (auto const &exercise : library->exercises) {
|
||||
BOOST_TEST(exercise.title == "Shared Title");
|
||||
}
|
||||
BOOST_REQUIRE(run_ensure_database(config));
|
||||
BOOST_REQUIRE(run_import(config, assets));
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path));
|
||||
std::filesystem::rename(assets / "practice/chord/001_lesson", assets / "practice/chord/003_renamed_lesson");
|
||||
std::filesystem::rename(assets / "practice/chord/003_renamed_lesson/002-cycles", assets / "practice/chord/003_renamed_lesson/099-renamed");
|
||||
BOOST_REQUIRE(run_import(config, assets));
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path));
|
||||
auto rows = query_database(root / "vamp.db", "SELECT id FROM practice_exercises ORDER BY id");
|
||||
BOOST_REQUIRE(rows);
|
||||
BOOST_REQUIRE_EQUAL(rows->rows.size(), 4U);
|
||||
@@ -431,39 +431,6 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_queries_handle_empty_results_and_invalid_stored_scores) {
|
||||
auto root = std::filesystem::temp_directory_path() / ("product-vamp-query-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
auto config = test_database_config(root / "vamp.db");
|
||||
BOOST_REQUIRE(run_ensure_database(config));
|
||||
for (auto invalid_rows : {false, true}) {
|
||||
if (invalid_rows) {
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
BOOST_REQUIRE(query_database(root / "vamp.db", std::string{"INSERT INTO "} + table + "(id, title, source_json, created_at_ms, updated_at_ms) VALUES('invalid', 'Invalid', '{}', 0, 0)"));
|
||||
}
|
||||
}
|
||||
auto io = boost::asio::io_context{};
|
||||
auto performance_future = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config), boost::asio::use_future);
|
||||
auto practice_future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config), boost::asio::use_future);
|
||||
io.run();
|
||||
auto performance = performance_future.get();
|
||||
auto practice = practice_future.get();
|
||||
if (invalid_rows) {
|
||||
BOOST_REQUIRE(!performance);
|
||||
BOOST_REQUIRE(!practice);
|
||||
auto invalid_score = vamp_zero::parse_score_document("{}", "");
|
||||
BOOST_REQUIRE(!invalid_score);
|
||||
BOOST_TEST(performance.error().message() == invalid_score.error().message());
|
||||
BOOST_TEST(practice.error().message() == invalid_score.error().message());
|
||||
} else {
|
||||
BOOST_REQUIRE(performance);
|
||||
BOOST_REQUIRE(practice);
|
||||
BOOST_TEST(performance->parts.empty());
|
||||
BOOST_TEST(practice->exercises.empty());
|
||||
}
|
||||
}
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(keyless_practice_variants_display_their_transposition_reference) {
|
||||
auto source = boost::json::parse(score_asset_json("Keyless", "No key"));
|
||||
auto &music = source.as_object()["music"].as_object();
|
||||
@@ -488,35 +455,6 @@ BOOST_AUTO_TEST_CASE(integer_parsing_checks_range_sign_and_entire_input) {
|
||||
}
|
||||
}
|
||||
|
||||
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(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));
|
||||
@@ -545,142 +483,6 @@ BOOST_AUTO_TEST_CASE(pico_remote_display_parser_truncates_long_fields_safely) {
|
||||
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_bundle_preserves_complete_music_without_runtime_flattening) {
|
||||
auto source = score_asset_json("Fixture");
|
||||
auto parsed = vamp_zero::parse_score_document(source, "fixture.json");
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
from contextlib import closing
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from generator.catalogue import APPLICATION_ID, SCHEMA_VERSION, build_catalogue
|
||||
|
||||
def bundle(title='Fixture', key='C', authored='Eb'):
|
||||
return {
|
||||
'schema_version': 3,
|
||||
'music': {'metadata': {'title': title, 'composer': 'Composer', 'key_signature': key, 'time_signature': '4/4', 'authored_key_signature': authored},
|
||||
'staves': [{'name': 'Lead', 'arrangement_part_type': 'practice', 'measures': [{'voices': []}]}]},
|
||||
'presentation': {'width': 976, 'height': 484, 'pages': [{'path': 'fixture.pages/1.png', 'sha256': '0' * 64}]},
|
||||
}
|
||||
|
||||
def write_score(root, relative, document=None):
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(document or bundle()), encoding='utf-8')
|
||||
return path
|
||||
|
||||
class CatalogueTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
(self.root / 'songs').mkdir()
|
||||
def rows(self, table):
|
||||
with closing(sqlite3.connect(self.root / 'catalogue.db')) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
return [dict(row) for row in connection.execute(f'SELECT * FROM {table} ORDER BY id')]
|
||||
def test_metadata_relative_paths_and_complete_documents(self):
|
||||
document = bundle('Song', 'F')
|
||||
write_score(self.root, 'songs/tune.json', document)
|
||||
for key in ('c', 'd'):
|
||||
write_score(self.root, f'practice/chord/001_lesson/exercise/{key}.json', bundle('Exercise', key.upper()))
|
||||
database = build_catalogue(self.root)
|
||||
song = self.rows('songs')[0]
|
||||
self.assertEqual(json.loads(song['source_json']), document)
|
||||
self.assertEqual((song['id'], song['part'], song['source_path']), ('tune', 'Lead', 'songs/tune.json'))
|
||||
self.assertEqual((song['measure_count'], song['total_pages']), (1, 1))
|
||||
practice = self.rows('practice_exercises')
|
||||
self.assertEqual([row['id'] for row in practice], ['chord-001_lesson-exercise-c', 'chord-001_lesson-exercise-d'])
|
||||
self.assertEqual(practice[0]['practice_type_title'], 'Chord Practice')
|
||||
self.assertEqual(practice[0]['lesson_group_title'], '001 Lesson')
|
||||
self.assertEqual(practice[0]['authored_key_signature'], 'Eb')
|
||||
with closing(sqlite3.connect(database)) as connection:
|
||||
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], APPLICATION_ID)
|
||||
self.assertEqual(connection.execute('PRAGMA user_version').fetchone()[0], SCHEMA_VERSION)
|
||||
self.assertEqual(connection.execute('PRAGMA integrity_check').fetchall(), [('ok',)])
|
||||
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('practice_exercises',), ('songs',)])
|
||||
self.assertFalse(Path(str(database) + '-wal').exists())
|
||||
self.assertFalse(Path(str(database) + '-journal').exists())
|
||||
def test_rebuild_is_deterministic_relocatable_and_replaces_deleted_rows(self):
|
||||
score = write_score(self.root, 'songs/tune.json')
|
||||
database = build_catalogue(self.root)
|
||||
previous = database.read_bytes()
|
||||
build_catalogue(self.root)
|
||||
self.assertEqual(database.read_bytes(), previous)
|
||||
with tempfile.TemporaryDirectory() as other:
|
||||
root = Path(other)
|
||||
write_score(root, 'songs/tune.json')
|
||||
self.assertEqual(build_catalogue(root).read_bytes(), previous)
|
||||
score.rename(score.with_name('renamed.json'))
|
||||
build_catalogue(self.root)
|
||||
self.assertEqual([row['id'] for row in self.rows('songs')], ['renamed'])
|
||||
score.with_name('renamed.json').unlink()
|
||||
build_catalogue(self.root)
|
||||
self.assertEqual(self.rows('songs'), [])
|
||||
def test_invalid_input_preserves_existing_database(self):
|
||||
score = write_score(self.root, 'songs/tune.json')
|
||||
database = build_catalogue(self.root)
|
||||
previous = database.read_bytes()
|
||||
for document in ({}, bundle(title=''), bundle(authored='')):
|
||||
score.write_text(json.dumps(document))
|
||||
with self.assertRaises(ValueError):
|
||||
build_catalogue(self.root)
|
||||
self.assertEqual(database.read_bytes(), previous)
|
||||
self.assertFalse(list(self.root.glob('.vamp-catalogue-*')))
|
||||
def test_path_collisions_and_authored_key_disagreement_fail(self):
|
||||
write_score(self.root, 'practice/chord/one-two/three/c.json')
|
||||
conflicting = write_score(self.root, 'practice/chord/one/two-three/c.json')
|
||||
with self.assertRaisesRegex(ValueError, 'ID collision'):
|
||||
build_catalogue(self.root)
|
||||
conflicting.unlink()
|
||||
write_score(self.root, 'practice/chord/one-two/three/d.json', bundle(key='D', authored='F'))
|
||||
with self.assertRaisesRegex(ValueError, 'authored key'):
|
||||
build_catalogue(self.root)
|
||||
def test_keyless_display_metadata_keeps_original_music(self):
|
||||
document = bundle(key='No key', authored='No key')
|
||||
document['music']['metadata']['variant_tonic'] = 'D'
|
||||
write_score(self.root, 'practice/chord/lesson/exercise/d.json', document)
|
||||
build_catalogue(self.root)
|
||||
row = self.rows('practice_exercises')[0]
|
||||
self.assertEqual(row['key_signature'], 'D (no key)')
|
||||
self.assertEqual(json.loads(row['source_json']), document)
|
||||
def test_invalid_layout_and_escaping_source_fail(self):
|
||||
with self.assertRaisesRegex(ValueError, 'directory not found'):
|
||||
build_catalogue(self.root / 'missing')
|
||||
self.assertFalse((self.root / 'missing').exists())
|
||||
score = write_score(self.root, 'songs/nested/tune.json')
|
||||
with self.assertRaisesRegex(ValueError, 'unexpected songs'):
|
||||
build_catalogue(self.root)
|
||||
score.unlink()
|
||||
(self.root / 'songs/escape.json').symlink_to('/etc/passwd')
|
||||
with self.assertRaises(ValueError):
|
||||
build_catalogue(self.root)
|
||||
def test_runtime_asset_packaging_excludes_score_json(self):
|
||||
import subprocess
|
||||
write_score(self.root, 'songs/tune.json')
|
||||
build_catalogue(self.root)
|
||||
(self.root / 'songs/tune.png').write_bytes(b'page')
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
destination = Path(temporary)
|
||||
subprocess.run(['cmake', f'-DSOURCE={self.root}', f'-DDESTINATION={destination}', '-P', 'cmake/runtime_assets.cmake'], check=True, capture_output=True)
|
||||
self.assertTrue((destination / 'catalogue.db').exists())
|
||||
self.assertTrue((destination / 'songs/tune.png').exists())
|
||||
self.assertFalse(list(destination.rglob('*.json')))
|
||||
@@ -29,6 +29,8 @@
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
|
||||
#include "catalogue_fixture.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
template <typename T>
|
||||
@@ -108,8 +110,8 @@ struct catalogue_database {
|
||||
return std::move(*result);
|
||||
}
|
||||
auto rows(std::string const &table, std::string const &where = "") const -> std::vector<std::string> {
|
||||
auto result = query("SELECT json_array(id, title, composer, key_signature, source_path, source_json, catalog_managed, "
|
||||
"measure_count, total_pages, authored_key_signature, created_at_ms, updated_at_ms) FROM " +
|
||||
auto result = query("SELECT json_array(id, title, composer, key_signature, source_path, source_json, "
|
||||
"measure_count, total_pages, authored_key_signature) FROM " +
|
||||
table + where + " ORDER BY id");
|
||||
auto rows = std::vector<std::string>{};
|
||||
for (auto const &row : result.rows) {
|
||||
@@ -125,20 +127,17 @@ struct catalogue_database {
|
||||
output.close();
|
||||
BOOST_REQUIRE(output.good());
|
||||
}
|
||||
auto ensure() const -> std::expected<void, modulus::error> {
|
||||
return run_catalogue_operation(vamp_zero::async_ensure_database, config);
|
||||
}
|
||||
auto import_assets() const -> std::expected<void, modulus::error> {
|
||||
return run_catalogue_operation(vamp_zero::async_import_catalogs, config, assets.string());
|
||||
auto build() const -> std::bool_t {
|
||||
return vamp_test::build_catalogue(assets, config.path);
|
||||
}
|
||||
auto performance() const -> std::expected<vamp_zero::library_state, modulus::error> {
|
||||
return run_catalogue_operation(vamp_zero::async_load_performance_library, config);
|
||||
return run_catalogue_operation(vamp_zero::async_load_performance_library, config, assets.string());
|
||||
}
|
||||
auto practice() const -> std::expected<vamp_zero::practice_library, modulus::error> {
|
||||
return run_catalogue_operation(vamp_zero::async_load_practice_library, config);
|
||||
return run_catalogue_operation(vamp_zero::async_load_practice_library, config, assets.string());
|
||||
}
|
||||
auto score(vamp_zero::score_category category, std::string id) const -> std::expected<vamp_zero::score_document, modulus::error> {
|
||||
return run_catalogue_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{category, std::move(id)});
|
||||
return run_catalogue_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{category, std::move(id)}, assets.string());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -285,10 +284,6 @@ BOOST_AUTO_TEST_CASE(catalogue_authored_key_metadata_is_optional_but_must_be_a_n
|
||||
auto document = vamp_zero::parse_score_document(catalogue_bundle("Exercise", "D", authored), "/assets/practice/melody/lesson/exercise/d.json");
|
||||
BOOST_REQUIRE(document);
|
||||
BOOST_TEST(document->authored_key_signature == authored);
|
||||
auto metadata = vamp_zero::practice_metadata(*document, "/assets");
|
||||
BOOST_REQUIRE(metadata);
|
||||
BOOST_TEST(metadata->authored_key_signature == authored);
|
||||
BOOST_TEST(metadata->variants[0].key_signature == "D");
|
||||
}
|
||||
for (auto invalid : {boost::json::value{}, boost::json::value{false}, boost::json::value{42}, boost::json::value{""}, boost::json::value{boost::json::array{}}}) {
|
||||
auto value = boost::json::parse(catalogue_bundle("Invalid"));
|
||||
@@ -300,123 +295,13 @@ BOOST_AUTO_TEST_CASE(catalogue_authored_key_metadata_is_optional_but_must_be_a_n
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_streamed_import_rolls_back_and_preserves_unmanaged_rows) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
db.write("songs/a-managed.json", catalogue_bundle("Original"));
|
||||
db.write("songs/custom.json", catalogue_bundle("Custom Song"));
|
||||
db.write("songs/stale.json", catalogue_bundle("Stale"));
|
||||
db.write("practice/melody/lesson/exercise/c.json", catalogue_bundle("Exercise", "C", "Eb"));
|
||||
db.write("practice/melody/lesson/exercise/d.json", catalogue_bundle("Exercise", "D", "Eb"));
|
||||
db.write("practice/melody/lesson/custom/c.json", catalogue_bundle("Custom Exercise", "C", "Eb"));
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
db.query("UPDATE songs SET catalog_managed = 0 WHERE id = 'custom'");
|
||||
db.query("UPDATE practice_exercises SET catalog_managed = 0 WHERE id = 'melody-lesson-custom-c'");
|
||||
auto songs = db.rows("songs");
|
||||
auto practice = db.rows("practice_exercises");
|
||||
auto custom_songs = db.rows("songs", " WHERE catalog_managed = 0");
|
||||
auto custom_practice = db.rows("practice_exercises", " WHERE catalog_managed = 0");
|
||||
BOOST_REQUIRE_EQUAL(custom_songs.size(), 1U);
|
||||
BOOST_REQUIRE_EQUAL(custom_practice.size(), 1U);
|
||||
db.write("songs/a-managed.json", catalogue_bundle("Updated"));
|
||||
db.write("songs/b-new.json", catalogue_bundle("New"));
|
||||
db.write("songs/custom.json", catalogue_bundle("Collision"));
|
||||
db.write("practice/melody/lesson/custom/c.json", catalogue_bundle("Collision", "C", "Eb"));
|
||||
db.write("practice/melody/lesson/exercise/c.json", catalogue_bundle("Updated Exercise", "C", "Eb"));
|
||||
std::filesystem::remove(db.assets / "songs/stale.json");
|
||||
for (auto invalid : {std::string{"{}"}, catalogue_bundle("Exercise", "D", "F")}) {
|
||||
db.write("practice/melody/lesson/exercise/d.json", invalid);
|
||||
auto imported = db.import_assets();
|
||||
BOOST_REQUIRE(!imported);
|
||||
BOOST_TEST(imported.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
|
||||
BOOST_TEST(db.rows("practice_exercises") == practice, boost::test_tools::per_element());
|
||||
}
|
||||
db.write("practice/melody/lesson/exercise/d.json", catalogue_bundle("Updated Exercise", "D", "Eb"));
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
auto updated = db.query("SELECT title FROM songs WHERE id = 'a-managed'");
|
||||
BOOST_REQUIRE_EQUAL(updated.rows.size(), 1U);
|
||||
BOOST_TEST(std::get<std::string>(updated.rows[0][0].value) == "Updated");
|
||||
BOOST_TEST(db.query("SELECT id FROM songs WHERE id = 'b-new'").rows.size() == 1U);
|
||||
BOOST_TEST(db.query("SELECT id FROM songs WHERE id = 'stale'").rows.empty());
|
||||
BOOST_TEST(db.rows("songs", " WHERE catalog_managed = 0") == custom_songs, boost::test_tools::per_element());
|
||||
BOOST_TEST(db.rows("practice_exercises", " WHERE catalog_managed = 0") == custom_practice, boost::test_tools::per_element());
|
||||
std::filesystem::remove_all(db.assets);
|
||||
std::filesystem::create_directories(db.assets / "songs");
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
BOOST_TEST(db.rows("songs") == custom_songs, boost::test_tools::per_element());
|
||||
BOOST_TEST(db.rows("practice_exercises") == custom_practice, boost::test_tools::per_element());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_legacy_metadata_backfill_retains_custom_rows_and_score_json) {
|
||||
auto db = catalogue_database{};
|
||||
db.query(R"sql(CREATE TABLE songs (
|
||||
id TEXT PRIMARY KEY, title TEXT NOT NULL, composer TEXT NOT NULL, source_json TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, part TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL, key_signature TEXT NOT NULL, catalog_managed INTEGER NOT NULL
|
||||
) STRICT)sql");
|
||||
db.query(R"sql(CREATE TABLE practice_exercises (
|
||||
id TEXT PRIMARY KEY, practice_type_id TEXT NOT NULL, practice_type_title TEXT NOT NULL,
|
||||
lesson_group_id TEXT NOT NULL, lesson_group_title TEXT NOT NULL, title TEXT NOT NULL, composer TEXT NOT NULL,
|
||||
key_signature TEXT NOT NULL, source_json TEXT NOT NULL, source_path TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, catalog_managed INTEGER NOT NULL
|
||||
) STRICT)sql");
|
||||
auto song = catalogue_bundle("Embedded Song", "D");
|
||||
auto exercise = catalogue_bundle("Embedded Exercise", "D", "Eb");
|
||||
db.query("INSERT INTO songs VALUES('legacy-song', 'Custom Song', 'Composer', ?1, 11, 17, 'Lead', 'legacy.json', 'D', 0)", {noto::sqlite_value{song}});
|
||||
db.query("INSERT INTO practice_exercises VALUES('legacy-practice', 'melody', 'Melody Practice', 'lesson', 'Lesson', "
|
||||
"'Custom Exercise', 'Composer', 'D', ?1, 'practice/melody/lesson/exercise/d.json', 11, 17, 0)",
|
||||
{noto::sqlite_value{exercise}});
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
BOOST_TEST(db.query("SELECT id FROM " + std::string{table} + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
|
||||
}
|
||||
auto performance = db.performance();
|
||||
auto practice = db.practice();
|
||||
BOOST_REQUIRE(performance);
|
||||
BOOST_REQUIRE(practice);
|
||||
BOOST_REQUIRE_EQUAL(performance->parts.size(), 1U);
|
||||
BOOST_TEST(performance->parts[0].id == "legacy-song");
|
||||
BOOST_TEST(performance->parts[0].title == "Custom Song");
|
||||
BOOST_TEST(performance->parts[0].measure_count == 3U);
|
||||
BOOST_TEST(performance->parts[0].total_pages == 2U);
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises.size(), 1U);
|
||||
BOOST_TEST(practice->exercises[0].title == "Custom Exercise");
|
||||
BOOST_TEST(practice->exercises[0].authored_key_signature == "Eb");
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 1U);
|
||||
BOOST_TEST(practice->exercises[0].variants[0].id == "legacy-practice");
|
||||
BOOST_TEST(practice->exercises[0].variants[0].measure_count == 3U);
|
||||
BOOST_TEST(practice->exercises[0].variants[0].total_pages == 2U);
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
auto row = db.query("SELECT source_json, catalog_managed, created_at_ms, updated_at_ms, measure_count, total_pages, authored_key_signature FROM " + std::string{table});
|
||||
BOOST_REQUIRE_EQUAL(row.rows.size(), 1U);
|
||||
auto const &values = row.rows[0];
|
||||
auto is_song = std::string_view{table} == "songs";
|
||||
BOOST_TEST(std::get<std::string>(values[0].value) == (is_song ? song : exercise));
|
||||
BOOST_TEST(std::get<std::int64_t>(values[1].value) == 0);
|
||||
BOOST_TEST(std::get<std::int64_t>(values[2].value) == 11);
|
||||
BOOST_TEST(std::get<std::int64_t>(values[3].value) == 17);
|
||||
BOOST_TEST(std::get<std::int64_t>(values[4].value) == 3);
|
||||
BOOST_TEST(std::get<std::int64_t>(values[5].value) == 2);
|
||||
BOOST_TEST(std::get<std::string>(values[6].value) == (is_song ? "" : "Eb"));
|
||||
}
|
||||
auto songs = db.rows("songs");
|
||||
auto exercises = db.rows("practice_exercises");
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
BOOST_REQUIRE(db.performance());
|
||||
BOOST_REQUIRE(db.practice());
|
||||
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
|
||||
BOOST_TEST(db.rows("practice_exercises") == exercises, boost::test_tools::per_element());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports_missing_or_invalid) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
auto song = catalogue_bundle("Song", "F");
|
||||
auto exercise = catalogue_bundle("Exercise", "D", "Eb");
|
||||
db.write("songs/shared.json", song);
|
||||
db.write("practice/melody/lesson/exercise/d.json", exercise);
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
BOOST_REQUIRE(db.build());
|
||||
db.query("UPDATE practice_exercises SET id = 'shared'");
|
||||
std::filesystem::remove_all(db.assets);
|
||||
for (auto category : {vamp_zero::score_category::PERFORMANCE, vamp_zero::score_category::PRACTICE}) {
|
||||
@@ -449,36 +334,35 @@ BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_sequential_score_loads_keep_executor_stack_bounded) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
db.write("songs/repeated.json", catalogue_bundle("Repeated"));
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
auto repeated_loads = [](boost::asio::any_io_executor executor, vamp_zero::database_config config)
|
||||
BOOST_REQUIRE(db.build());
|
||||
auto repeated_loads = [](boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets)
|
||||
-> boost::asio::awaitable<std::expected<std::uint32_t, modulus::error>> {
|
||||
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = "repeated"};
|
||||
for (auto count = std::uint32_t{}; count < 4096U; ++count) {
|
||||
auto score = co_await vamp_zero::async_load_score(executor, config, reference);
|
||||
auto score = co_await vamp_zero::async_load_score(executor, config, reference, assets);
|
||||
if (!score) {
|
||||
co_return std::unexpected(score.error());
|
||||
}
|
||||
}
|
||||
co_return 4096U;
|
||||
};
|
||||
auto result = run_catalogue_operation(repeated_loads, db.config);
|
||||
auto result = run_catalogue_operation(repeated_loads, db.config, db.assets.string());
|
||||
BOOST_REQUIRE_MESSAGE(result, (result ? "" : result.error().describe()));
|
||||
BOOST_TEST(*result == 4096U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score_json) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
BOOST_REQUIRE(db.build());
|
||||
// Complete cached metadata must not require parsing or retaining the score body.
|
||||
db.query("INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, "
|
||||
db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, "
|
||||
"key_signature, measure_count, total_pages, authored_key_signature) "
|
||||
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 1, 1, 'Lead', 'songs/tune.json', 'F', 3, 2, '')");
|
||||
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2, '')");
|
||||
for (auto key : {"C", "D"}) {
|
||||
db.query("INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, "
|
||||
"title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms, measure_count, total_pages, authored_key_signature) "
|
||||
"VALUES(?1, 'melody', 'Melody Practice', 'lesson', 'Lesson', 'Metadata Exercise', 'Composer', ?2, '{}', ?3, 1, 1, 3, 2, 'Eb')",
|
||||
db.query("INSERT INTO practice_exercises(id, exercise_id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, "
|
||||
"title, composer, key_signature, source_json, source_path, measure_count, total_pages, authored_key_signature) "
|
||||
"VALUES(?1, 'melody-lesson-exercise', 'melody', 'Melody Practice', 'lesson', 'Lesson', 'Metadata Exercise', 'Composer', ?2, '{}', ?3, 3, 2, 'Eb')",
|
||||
{noto::sqlite_value{std::string{"variant-"} + key}, noto::sqlite_value{std::string{key}},
|
||||
noto::sqlite_value{std::string{"practice/melody/lesson/exercise/"} + key + ".json"}});
|
||||
}
|
||||
@@ -519,117 +403,79 @@ BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score
|
||||
BOOST_TEST(disagreement.error().message().find("authored key") != std::string_view::npos);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_changed_score_json_invalidates_metadata_and_requires_successful_backfill) {
|
||||
BOOST_AUTO_TEST_CASE(catalogue_validation_rejects_missing_legacy_and_corrupt_files_without_repair) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
auto original = catalogue_bundle("Original", "D", "Eb");
|
||||
db.write("songs/tune.json", original);
|
||||
db.write("practice/melody/lesson/exercise/d.json", original);
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
auto changed = boost::json::parse(catalogue_bundle("Changed", "D", "F"));
|
||||
changed.at("music").at("staves").as_array()[0].at("measures").as_array().pop_back();
|
||||
changed.at("presentation").at("pages").as_array().pop_back();
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
auto name = std::string{table};
|
||||
auto before = db.rows(name);
|
||||
db.query("UPDATE " + name + " SET source_json = ?1", {noto::sqlite_value{original}});
|
||||
BOOST_TEST(db.rows(name) == before, boost::test_tools::per_element());
|
||||
db.query("UPDATE " + name + " SET source_json = ?1", {noto::sqlite_value{boost::json::serialize(changed)}});
|
||||
BOOST_TEST(db.query("SELECT id FROM " + name + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
|
||||
}
|
||||
auto performance = db.performance();
|
||||
auto validate = [&] { return run_catalogue_operation(vamp_zero::async_validate_database, db.config, true); };
|
||||
BOOST_TEST(!validate());
|
||||
BOOST_TEST(!std::filesystem::exists(db.config.path));
|
||||
BOOST_REQUIRE(db.build());
|
||||
BOOST_REQUIRE(validate());
|
||||
auto songs = db.performance();
|
||||
auto practice = db.practice();
|
||||
BOOST_REQUIRE(performance);
|
||||
BOOST_REQUIRE(songs);
|
||||
BOOST_REQUIRE(practice);
|
||||
BOOST_REQUIRE_EQUAL(performance->parts.size(), 1U);
|
||||
BOOST_TEST(performance->parts[0].measure_count == 2U);
|
||||
BOOST_TEST(performance->parts[0].total_pages == 1U);
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises.size(), 1U);
|
||||
BOOST_TEST(practice->exercises[0].authored_key_signature == "F");
|
||||
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 1U);
|
||||
BOOST_TEST(practice->exercises[0].variants[0].measure_count == 2U);
|
||||
BOOST_TEST(practice->exercises[0].variants[0].total_pages == 1U);
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
db.query("UPDATE " + std::string{table} + " SET source_json = '{}'");
|
||||
}
|
||||
performance = db.performance();
|
||||
practice = db.practice();
|
||||
BOOST_REQUIRE(!performance);
|
||||
BOOST_REQUIRE(!practice);
|
||||
BOOST_TEST(performance.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
BOOST_TEST(practice.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
BOOST_TEST(db.query("SELECT id FROM " + std::string{table} + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_rejects_ambiguous_paths_before_import_or_direct_load) {
|
||||
for (auto key : {"c", "d"}) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
db.write("songs/tune.json", catalogue_bundle("Original"));
|
||||
db.write("practice/chord/one-two/three/c.json", catalogue_bundle("Original"));
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
auto songs = db.rows("songs");
|
||||
auto practice = db.rows("practice_exercises");
|
||||
db.write("songs/tune.json", catalogue_bundle("Changed"));
|
||||
auto second = std::string{"practice/chord/one/two-three/"} + key + ".json";
|
||||
db.write(second, catalogue_bundle("Conflicting", key));
|
||||
auto result = db.import_assets();
|
||||
BOOST_TEST(songs->parts.empty());
|
||||
BOOST_TEST(practice->exercises.empty());
|
||||
for (auto version : {0, 2}) {
|
||||
db.query("PRAGMA user_version = " + std::to_string(version));
|
||||
auto result = validate();
|
||||
BOOST_REQUIRE(!result);
|
||||
auto message = result.error().describe();
|
||||
BOOST_TEST(message.find("chord-one-two-three") != std::string::npos);
|
||||
BOOST_TEST(message.find("practice/chord/one-two/three/c.json") != std::string::npos);
|
||||
BOOST_TEST(message.find(second) != std::string::npos);
|
||||
BOOST_TEST(!vamp_zero::load_practice_library(db.assets.string()));
|
||||
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
|
||||
BOOST_TEST(db.rows("practice_exercises") == practice, boost::test_tools::per_element());
|
||||
BOOST_TEST(result.error().message().find("reinstall") != std::string_view::npos);
|
||||
BOOST_TEST(std::get<std::int64_t>(db.query("PRAGMA user_version").rows[0][0].value) == version);
|
||||
}
|
||||
db.query("PRAGMA user_version = 1");
|
||||
db.query("PRAGMA application_id = 0");
|
||||
BOOST_TEST(!validate());
|
||||
auto output = std::ofstream{db.config.path, std::ios::trunc};
|
||||
output << "not a database";
|
||||
output.close();
|
||||
BOOST_TEST(!validate());
|
||||
BOOST_TEST(!std::filesystem::exists(db.config.path + "-wal"));
|
||||
BOOST_TEST(!std::filesystem::exists(db.config.path + "-journal"));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_changed_import_keeps_complete_cached_metadata) {
|
||||
BOOST_AUTO_TEST_CASE(catalogue_reads_readonly_database_without_json_and_resolves_relocated_assets) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
for (auto title : {"Original", "Changed"}) {
|
||||
db.write("songs/tune.json", catalogue_bundle(title, "D", "Eb"));
|
||||
db.write("practice/chord/lesson/exercise/d.json", catalogue_bundle(title, "D", "Eb"));
|
||||
BOOST_REQUIRE(db.import_assets());
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
auto result = db.query("SELECT measure_count, total_pages, authored_key_signature FROM " + std::string{table});
|
||||
BOOST_REQUIRE_EQUAL(result.rows.size(), 1U);
|
||||
BOOST_TEST(std::get<std::int64_t>(result.rows[0][0].value) == 3);
|
||||
BOOST_TEST(std::get<std::int64_t>(result.rows[0][1].value) == 2);
|
||||
BOOST_TEST(std::get<std::string>(result.rows[0][2].value) == "Eb");
|
||||
db.write("songs/tune.json", catalogue_bundle("Relocated"));
|
||||
db.write("practice/chord/lesson/exercise/c.json", catalogue_bundle("Exercise", "C", "Eb"));
|
||||
BOOST_REQUIRE(db.build());
|
||||
std::filesystem::remove(db.assets / "songs/tune.json");
|
||||
std::filesystem::remove(db.assets / "practice/chord/lesson/exercise/c.json");
|
||||
auto moved = db.directory.path / "relocated";
|
||||
std::filesystem::rename(db.assets, moved);
|
||||
db.assets = moved;
|
||||
auto timestamp = std::filesystem::last_write_time(db.config.path);
|
||||
std::filesystem::permissions(db.config.path, std::filesystem::perms::owner_read);
|
||||
BOOST_REQUIRE(run_catalogue_operation(vamp_zero::async_validate_database, db.config, true));
|
||||
auto songs = db.performance();
|
||||
auto practice = db.practice();
|
||||
auto score = db.score(vamp_zero::score_category::PERFORMANCE, "tune");
|
||||
BOOST_REQUIRE(songs);
|
||||
BOOST_REQUIRE(practice);
|
||||
BOOST_REQUIRE(score);
|
||||
BOOST_TEST(score->source_path == (moved / "songs/tune.json").string());
|
||||
BOOST_TEST(songs->parts[0].source_path == score->source_path);
|
||||
BOOST_TEST(practice->exercises[0].authored_key_signature == "Eb");
|
||||
BOOST_TEST((std::filesystem::last_write_time(db.config.path) == timestamp));
|
||||
BOOST_TEST(!std::filesystem::exists(db.config.path + "-wal"));
|
||||
BOOST_TEST(!std::filesystem::exists(db.config.path + "-shm"));
|
||||
BOOST_TEST(!std::filesystem::exists(db.config.path + "-journal"));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_rejects_paths_outside_the_asset_root) {
|
||||
auto db = catalogue_database{};
|
||||
db.write("songs/tune.json", catalogue_bundle("Fixture"));
|
||||
BOOST_REQUIRE(db.build());
|
||||
for (auto path : {"/tmp/tune.json", "../tune.json", "songs/../../tune.json", "songs/./tune.json", "songs\\tune.json", ""}) {
|
||||
if (std::string_view{path}.empty()) {
|
||||
continue;
|
||||
}
|
||||
db.query("UPDATE songs SET source_path = ?1", {noto::sqlite_value{std::string{path}}});
|
||||
BOOST_TEST(!db.performance());
|
||||
BOOST_TEST(!db.score(vamp_zero::score_category::PERFORMANCE, "tune"));
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_rejects_ambiguous_variant_ids_with_distinct_exercise_ids) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
db.write("practice/chord/lesson/exercise/c-d.json", catalogue_bundle("First"));
|
||||
db.write("practice/chord/lesson/exercise-c/d.json", catalogue_bundle("Second"));
|
||||
auto result = db.import_assets();
|
||||
BOOST_REQUIRE(!result);
|
||||
BOOST_TEST(result.error().describe().find("practice variant ID collision 'chord-lesson-exercise-c-d'") != std::string::npos);
|
||||
BOOST_TEST(db.rows("practice_exercises").empty());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_backfill_uses_pending_indexes_and_preserves_large_mixed_catalogues) {
|
||||
auto db = catalogue_database{};
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
BOOST_REQUIRE(db.ensure());
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
auto name = std::string{table};
|
||||
db.query("WITH RECURSIVE sequence(n) AS (VALUES(1) UNION ALL SELECT n + 1 FROM sequence WHERE n < 4000) INSERT INTO " + name + "(id, title, source_json, created_at_ms, updated_at_ms, catalog_managed, measure_count, total_pages, authored_key_signature) SELECT 'score-' || n, 'Fixture', ?1, 1, 2, 0, CASE WHEN n % 2 = 0 THEN 3 END, 2, 'Eb' FROM sequence", {noto::sqlite_value{catalogue_bundle("Fixture", "D", "Eb")}});
|
||||
auto plan = db.query("EXPLAIN QUERY PLAN SELECT id, source_path, source_json FROM " + name + " WHERE measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL LIMIT 1");
|
||||
BOOST_REQUIRE_EQUAL(plan.rows.size(), 1U);
|
||||
BOOST_TEST(std::get<std::string>(plan.rows[0][3].value).find("idx_" + name + "_pending_metadata") != std::string::npos);
|
||||
}
|
||||
BOOST_REQUIRE(db.performance());
|
||||
BOOST_REQUIRE(db.practice());
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
auto rows = db.query("SELECT count(*) FROM " + std::string{table} + " WHERE measure_count = 3 AND total_pages = 2 AND authored_key_signature = 'Eb' AND created_at_ms = 1 AND updated_at_ms = 2 AND catalog_managed = 0 AND source_json = ?1", {noto::sqlite_value{catalogue_bundle("Fixture", "D", "Eb")}});
|
||||
BOOST_TEST(std::get<std::int64_t>(rows.rows[0][0].value) == 4000);
|
||||
}
|
||||
std::filesystem::create_directory_symlink(db.directory.path, db.assets / "escape");
|
||||
db.query("UPDATE songs SET source_path = 'escape/tune.json'");
|
||||
BOOST_TEST(!db.performance());
|
||||
BOOST_TEST(!db.score(vamp_zero::score_category::PERFORMANCE, "tune"));
|
||||
}
|
||||
|
||||
+18
-4
@@ -29,6 +29,8 @@
|
||||
#include <vamp_zero/health.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
|
||||
#include "catalogue_fixture.hpp"
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -184,6 +186,7 @@ auto make_assets(vamp_zero::config const &config) -> void {
|
||||
write_score(root / "songs" / "second.json");
|
||||
write_score(root / "practice" / "melody" / "lesson" / "exercise" / "c.json");
|
||||
write_score(root / "practice" / "melody" / "lesson" / "exercise" / "d.json", "D");
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(root, config.database.path));
|
||||
}
|
||||
|
||||
auto run_once(std::filesystem::path const &root, vamp_zero::config const &config) -> std::expected<void, modulus::error> {
|
||||
@@ -330,18 +333,26 @@ BOOST_AUTO_TEST_CASE(portal_remote_commands_refresh_connection_without_backgroun
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_imports_catalogue_without_hardware_or_heartbeat) {
|
||||
BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalogue_without_hardware_or_heartbeat) {
|
||||
auto root = temporary_directory{};
|
||||
auto config = fixture_config(root.path);
|
||||
make_assets(config);
|
||||
auto database_time = std::filesystem::last_write_time(config.database.path);
|
||||
for (auto const &entry : std::filesystem::recursive_directory_iterator{config.runtime.assets_path}) {
|
||||
if (entry.path().extension() == ".json") {
|
||||
std::filesystem::remove(entry.path());
|
||||
}
|
||||
}
|
||||
auto ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
|
||||
BOOST_TEST(std::filesystem::exists(config.database.path));
|
||||
BOOST_TEST((std::filesystem::last_write_time(config.database.path) == database_time));
|
||||
BOOST_TEST(!std::filesystem::exists(config.database.path + "-wal"));
|
||||
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
|
||||
require_no_probe(config);
|
||||
auto io = boost::asio::io_context{};
|
||||
auto performance = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database), boost::asio::use_future);
|
||||
auto practice = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database), boost::asio::use_future);
|
||||
auto performance = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
|
||||
auto practice = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
|
||||
io.run();
|
||||
auto songs = performance.get();
|
||||
auto exercises = practice.get();
|
||||
@@ -385,6 +396,7 @@ BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_
|
||||
auto score = boost::json::parse(read_file(path));
|
||||
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
|
||||
write_file(path, boost::json::serialize(score));
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.database.path));
|
||||
auto ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
|
||||
auto &sample = score.at("playback").at("samples").as_array().front().as_object();
|
||||
@@ -394,6 +406,7 @@ BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_
|
||||
sample["sha256"] = checksum(corrupt);
|
||||
write_file(audio, corrupt);
|
||||
write_file(path, boost::json::serialize(score));
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.database.path));
|
||||
ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE(!ready);
|
||||
BOOST_TEST(std::string{ready.error().message()}.find("PCM16 WAV") != std::string::npos);
|
||||
@@ -426,7 +439,7 @@ BOOST_AUTO_TEST_CASE(readiness_rejects_missing_or_malformed_catalogues) {
|
||||
auto config = fixture_config(root.path);
|
||||
BOOST_TEST(!run_once(root.path, config));
|
||||
make_assets(config);
|
||||
write_file(std::filesystem::path{config.runtime.assets_path} / "songs" / "first.json", "not JSON");
|
||||
write_file(config.database.path, "not a database");
|
||||
BOOST_TEST(!run_once(root.path, config));
|
||||
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
|
||||
}
|
||||
@@ -463,6 +476,7 @@ BOOST_AUTO_TEST_CASE(readiness_decodes_pages_even_when_checksums_match) {
|
||||
write_file(score_path.parent_path() / std::string{page.at("path").as_string()}, bytes);
|
||||
page["sha256"] = checksum(bytes);
|
||||
write_file(score_path, boost::json::serialize(score));
|
||||
BOOST_REQUIRE(vamp_test::build_catalogue(config.runtime.assets_path, config.database.path));
|
||||
auto ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE_MESSAGE(!ready, kind);
|
||||
BOOST_TEST(ready.error().describe().find("first.pages/1.png") != std::string::npos);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import json
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import tarfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MOCK = '''#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
root = Path(os.environ['INSTALL_FIXTURE'])
|
||||
command, args = Path(sys.argv[0]).name, sys.argv[1:]
|
||||
with (root / 'commands').open('a') as output:
|
||||
output.write(json.dumps([command, *args]) + '\\n')
|
||||
if command == 'id':
|
||||
print('0')
|
||||
elif command == 'dpkg-query':
|
||||
print('install ok installed')
|
||||
elif command == 'docker':
|
||||
if args[:2] == ['image', 'inspect']:
|
||||
if os.environ.get('FIRST_INSTALL') and not (root / 'loaded').exists():
|
||||
sys.exit(1)
|
||||
print(os.environ['FIXTURE_IMAGE'] if (root / 'loaded').exists() else 'sha256:old')
|
||||
elif args[:2] == ['image', 'ls']:
|
||||
if not os.environ.get('FIRST_INSTALL'):
|
||||
print('sha256:older')
|
||||
elif args[:2] == ['container', 'inspect'] and os.environ.get('FIRST_INSTALL'):
|
||||
sys.exit(1)
|
||||
elif args[0] == 'load':
|
||||
(root / 'loaded').touch()
|
||||
elif args[0] == 'run' and os.environ.get('FAIL_PREFLIGHT'):
|
||||
sys.exit(1)
|
||||
elif args[0] == 'exec' and os.environ.get('FAIL_HEALTH'):
|
||||
sys.exit(1)
|
||||
elif args[0] == 'ps' and os.environ.get('SHARED_IMAGE') in args:
|
||||
print('another-container')
|
||||
elif command == 'systemctl' and args[0] == 'cat' and os.environ.get('FIRST_INSTALL'):
|
||||
sys.exit(1)
|
||||
elif command == 'nmcli' and args[:4] == ['-t', '-f', 'NAME', 'connection']:
|
||||
print('product-vamp-control')
|
||||
print('unrelated-network')
|
||||
'''
|
||||
|
||||
class InstallZeroTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.bundle = self.root / 'bundle'
|
||||
self.zero = self.bundle / 'zero'
|
||||
self.zero.mkdir(parents=True)
|
||||
(self.zero / 'config').mkdir()
|
||||
(self.zero / 'systemd').mkdir()
|
||||
self.image_tag = 'product-vamp-zero:dev'
|
||||
self.write_archive(self.image_tag)
|
||||
shutil.copyfile(ROOT / 'package/config/vamp.example.json', self.zero / 'config/vamp.example.json')
|
||||
for name in ('start-zero.sh', 'start-ap.sh'):
|
||||
shutil.copyfile(ROOT / 'package/zero' / name, self.zero / name)
|
||||
for name in ('product-vamp-zero.service', 'product-vamp-ap.service'):
|
||||
shutil.copyfile(ROOT / 'package/zero' / name, self.zero / 'systemd' / name)
|
||||
script = (ROOT / 'package/zero/install-zero.sh').read_text()
|
||||
for prefix in ('/tmp/', '/etc/', '/var/', '/run/', '/usr/local/', '/usr/share/'):
|
||||
script = script.replace(prefix, str(self.root) + prefix)
|
||||
(self.zero / 'install-zero.sh').write_text(script)
|
||||
for path in ('etc/systemd/system', 'usr/local/bin', 'tmp', 'bin'):
|
||||
(self.root / path).mkdir(parents=True, exist_ok=True)
|
||||
for command in ('id', 'dpkg-query', 'apt-get', 'systemctl', 'docker', 'nmcli', 'sleep', 'chvt', 'setterm'):
|
||||
path = self.root / 'bin' / command
|
||||
path.write_text(MOCK)
|
||||
path.chmod(0o755)
|
||||
self.environment = {**os.environ, 'INSTALL_FIXTURE': str(self.root), 'FIXTURE_IMAGE': self.image_id, 'PATH': str(self.root / 'bin') + os.pathsep + os.environ['PATH']}
|
||||
def write_archive(self, tag):
|
||||
configuration = b'{"architecture":"arm64","os":"linux"}'
|
||||
self.image_id = 'sha256:' + hashlib.sha256(configuration).hexdigest()
|
||||
manifest = json.dumps([{'Config': 'image.json', 'RepoTags': [tag]}]).encode()
|
||||
with tarfile.open(self.zero / 'product-vamp-zero-linux-arm64.docker.tar', 'w') as archive:
|
||||
for name, data in (('manifest.json', manifest), ('image.json', configuration)):
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(data)
|
||||
archive.addfile(info, io.BytesIO(data))
|
||||
(self.bundle / 'manifest.json').write_text(json.dumps({'zero': {'image_tag': tag}}))
|
||||
def install(self, **environment):
|
||||
return subprocess.run(['bash', str(self.zero / 'install-zero.sh')], env={**self.environment, **environment}, text=True, capture_output=True)
|
||||
def commands(self):
|
||||
return [json.loads(line) for line in (self.root / 'commands').read_text().splitlines()]
|
||||
def stale_state(self):
|
||||
for name in ('etc/product-vamp/vamp.json', 'etc/product-vamp/obsolete', 'var/lib/product-vamp/vamp.db',
|
||||
'var/lib/product-vamp/vamp.db-wal', 'var/lib/product-vamp/vamp.db-shm', 'run/product-vamp/heartbeat.json',
|
||||
'usr/share/product-vamp/songs/removed.json', 'etc/systemd/system/product-vamp-zero.service.d/override.conf',
|
||||
'run/systemd/system/product-vamp-ap.service.d/override.conf'):
|
||||
path = self.root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text('previous installation')
|
||||
unrelated = self.root / 'var/lib/unrelated/keep'
|
||||
unrelated.parent.mkdir(parents=True)
|
||||
unrelated.write_text('keep')
|
||||
def test_first_install_and_reinstall_reset_only_owned_state(self):
|
||||
first = self.install(FIRST_INSTALL='1')
|
||||
self.assertEqual(first.returncode, 0, first.stderr)
|
||||
self.stale_state()
|
||||
second = self.install()
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
self.assertEqual((self.root / 'etc/product-vamp/vamp.json').read_bytes(), (self.zero / 'config/vamp.example.json').read_bytes())
|
||||
self.assertEqual((self.root / 'etc/product-vamp/image-id').read_text().strip(), self.image_id)
|
||||
self.assertFalse((self.root / 'etc/product-vamp/obsolete').exists())
|
||||
self.assertFalse((self.root / 'var/lib/product-vamp').exists())
|
||||
self.assertFalse((self.root / 'usr/share/product-vamp').exists())
|
||||
self.assertFalse((self.root / 'etc/systemd/system/product-vamp-zero.service.d').exists())
|
||||
self.assertFalse((self.root / 'run/systemd/system/product-vamp-ap.service.d').exists())
|
||||
self.assertEqual(list((self.root / 'run/product-vamp').iterdir()), [])
|
||||
self.assertEqual((self.root / 'var/lib/unrelated/keep').read_text(), 'keep')
|
||||
self.assertEqual(list((self.root / 'tmp').iterdir()), [])
|
||||
commands = self.commands()
|
||||
self.assertIn(['nmcli', 'connection', 'delete', 'product-vamp-control'], commands)
|
||||
self.assertNotIn(['nmcli', 'connection', 'delete', 'unrelated-network'], commands)
|
||||
self.assertFalse(any('prune' in command for command in commands))
|
||||
probe = next(index for index, command in enumerate(commands) if command[:2] == ['docker', 'run'])
|
||||
stop = commands.index(['systemctl', 'stop', 'product-vamp-zero.service'])
|
||||
self.assertLess(probe, stop)
|
||||
def test_failed_preflight_keeps_old_state_and_restores_image_tag(self):
|
||||
self.stale_state()
|
||||
result = self.install(FAIL_PREFLIGHT='1')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual((self.root / 'var/lib/product-vamp/vamp.db').read_text(), 'previous installation')
|
||||
self.assertEqual((self.root / 'etc/product-vamp/vamp.json').read_text(), 'previous installation')
|
||||
self.assertNotIn(['systemctl', 'stop', 'product-vamp-zero.service'], self.commands())
|
||||
self.assertIn(['docker', 'tag', 'sha256:old', 'product-vamp-zero:dev'], self.commands())
|
||||
def test_failed_health_leaves_services_stopped_without_success(self):
|
||||
result = self.install(FAIL_HEALTH='1')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn('Zero installed.', result.stdout)
|
||||
self.assertIn(['systemctl', 'stop', 'product-vamp-zero.service', 'product-vamp-ap.service'], self.commands())
|
||||
self.assertIn(['docker', 'rm', '-f', 'product-vamp-zero'], self.commands())
|
||||
def test_symlink_target_aborts_without_deleting_external_data(self):
|
||||
external = self.root / 'external'
|
||||
external.mkdir()
|
||||
(external / 'keep').write_text('keep')
|
||||
(self.root / 'etc/product-vamp').symlink_to(external, target_is_directory=True)
|
||||
result = self.install()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('unsafe installation target', result.stderr)
|
||||
self.assertEqual((external / 'keep').read_text(), 'keep')
|
||||
self.assertNotIn(['systemctl', 'stop', 'product-vamp-zero.service'], self.commands())
|
||||
def test_image_in_use_by_another_container_is_not_removed(self):
|
||||
result = self.install(SHARED_IMAGE='ancestor=sha256:old')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn(['docker', 'image', 'rm', 'sha256:old'], self.commands())
|
||||
self.assertIn(['docker', 'image', 'rm', 'sha256:older'], self.commands())
|
||||
def test_custom_bundle_tag_selects_and_pins_the_incoming_image(self):
|
||||
self.write_archive('product-vamp-zero:release-test')
|
||||
result = self.install()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(['docker', 'image', 'inspect', '--format', '{{.Id}}', 'product-vamp-zero:release-test'], self.commands())
|
||||
self.assertEqual((self.root / 'etc/product-vamp/image-id').read_text().strip(), self.image_id)
|
||||
def test_wrong_archive_never_resets_the_old_installation(self):
|
||||
self.stale_state()
|
||||
(self.bundle / 'manifest.json').write_text(json.dumps({'zero': {'image_tag': 'product-vamp-zero:missing'}}))
|
||||
result = self.install()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual((self.root / 'var/lib/product-vamp/vamp.db').read_text(), 'previous installation')
|
||||
self.assertFalse(any(command[:2] == ['docker', 'load'] for command in self.commands()))
|
||||
def test_loaded_image_must_match_archive_digest(self):
|
||||
self.stale_state()
|
||||
result = self.install(FIXTURE_IMAGE='sha256:unexpected')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('does not match', result.stderr)
|
||||
self.assertEqual((self.root / 'var/lib/product-vamp/vamp.db').read_text(), 'previous installation')
|
||||
self.assertIn(['docker', 'tag', 'sha256:old', 'product-vamp-zero:dev'], self.commands())
|
||||
def test_startup_uses_pinned_image_without_mounting_legacy_database(self):
|
||||
result = self.install()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
environment = {**self.environment, 'PRODUCT_VAMP_CONFIG_DIR': str(self.root / 'etc/product-vamp'),
|
||||
'PRODUCT_VAMP_RUN_DIR': str(self.root / 'run/product-vamp'), 'PRODUCT_VAMP_DISPLAY_TTY': str(self.root / 'tty')}
|
||||
started = subprocess.run(['bash', str(ROOT / 'package/zero/start-zero.sh')], env=environment, text=True, capture_output=True)
|
||||
self.assertEqual(started.returncode, 0, started.stderr)
|
||||
command = [item for item in self.commands() if item[:2] == ['docker', 'run']][-1]
|
||||
self.assertIn(self.image_id, command)
|
||||
self.assertFalse(any('/var/lib/product-vamp' in argument for argument in command))
|
||||
@@ -21,6 +21,9 @@ class MusicModelTests(unittest.TestCase):
|
||||
engraving = patch('generator.__main__.engrave_tree')
|
||||
engraving.start()
|
||||
self.addCleanup(engraving.stop)
|
||||
catalogue = patch('generator.__main__.build_catalogue')
|
||||
catalogue.start()
|
||||
self.addCleanup(catalogue.stop)
|
||||
def test_chord_semantics_are_not_voicing_heuristics(self):
|
||||
cases = {'C': {0, 4, 7}, 'Cdim': {0, 3, 6}, 'Cdim7': {0, 3, 6, 9}, 'Csus2': {0, 2, 7},
|
||||
'C7b5': {0, 4, 6, 10}, 'G7b9': {7, 11, 2, 5, 8}, 'Fmaj7#11': {5, 9, 0, 4, 11}}
|
||||
|
||||
Reference in New Issue
Block a user