Fix
ci / checks (push) Has been cancelled

This commit is contained in:
khodowany
2026-09-19 08:17:25 -05:00
parent e1410b0fdc
commit cee9f45b0c
28 changed files with 860 additions and 1234 deletions
+1
View File
@@ -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"
)
+34
View File
@@ -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
View File
@@ -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");
+120
View File
@@ -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')))
+85 -239
View File
@@ -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
View File
@@ -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);
+183
View File
@@ -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))
+3
View File
@@ -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}}