From 31e86c260cd8d2c3f2d2a856b72ecb3c0ecfa2f9 Mon Sep 17 00:00:00 2001 From: SineStriker Date: Sun, 16 Aug 2026 01:23:29 +0800 Subject: [PATCH 1/5] Say 2023-present in the copyright line --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index d76871c..ad1ed63 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023-2024 Team OpenVPI + Copyright 2023-present Team OpenVPI Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 75a379f17d7bb45846764a50c159aa9c0e10e8c6 Mon Sep 17 00:00:00 2001 From: SineStriker Date: Sun, 16 Aug 2026 01:23:49 +0800 Subject: [PATCH 2/5] Gather the module headers under opendspx/ The three module directories sat beside opendspx/ rather than inside it, so a package installing into a shared prefix -- which is where vcpkg puts this one -- took four of the top-level names there instead of one. They are subdirectories of opendspx/ now, the way llvm/ holds ADT/ and Support/, and an include reads . Three changes to what the serializer does travel with it, because they live in the files that moved: - A workspace maps a key to an object rather than to any value, which is what the format says it holds. An entry that is not one is now turned down instead of carried through. - A rejected document answers with a code, a byte offset and a line and column, following the same change in stdcorelib. Matching on the wording was the only way to act on the failure before, and pointing at the place meant counting newlines. - The key of a map entry joins the error path, so a bad workspace entry reads $.content.workspace.loop rather than naming only the workspace it was in. The entries used to be values nothing could reject, so the path never showed. --- CMakeLists.txt | 5 ++-- README.md | 28 +++++++++++++------ .../converter}/midi/midiconverter.h | 8 +++--- .../converter}/midi/midiintermediatedata.h | 6 ++-- .../converter}/opendspxconverterglobal.h | 0 .../interpolator}/interpolator.h | 0 .../interpolator}/mixinterpolator.h | 0 .../interpolator}/parameterinterpolator.h | 2 +- .../interpolator}/vibratocurve.h | 0 .../serializer}/jsonconverterv1.h | 6 ++-- .../serializer}/opendspxserializerglobal.h | 0 .../serializer}/private/declarative_p.h | 16 ++++++----- .../serializer}/private/helpers_p.h | 26 +++++++++++++---- .../serializer}/private/sourcehelpers_p.h | 4 +-- .../serializer}/serializationerror.h | 26 ++++++++++++++--- .../serializer}/serializer.h | 4 +-- include/opendspx/workspace.h | 17 +++++++---- src/serializer/serializer.cpp | 8 +++--- 18 files changed, 106 insertions(+), 50 deletions(-) rename include/{opendspxconverter => opendspx/converter}/midi/midiconverter.h (87%) rename include/{opendspxconverter => opendspx/converter}/midi/midiintermediatedata.h (94%) rename include/{opendspxconverter => opendspx/converter}/opendspxconverterglobal.h (100%) rename include/{opendspxinterpolator => opendspx/interpolator}/interpolator.h (100%) rename include/{opendspxinterpolator => opendspx/interpolator}/mixinterpolator.h (100%) rename include/{opendspxinterpolator => opendspx/interpolator}/parameterinterpolator.h (98%) rename include/{opendspxinterpolator => opendspx/interpolator}/vibratocurve.h (100%) rename include/{opendspxserializer => opendspx/serializer}/jsonconverterv1.h (99%) rename include/{opendspxserializer => opendspx/serializer}/opendspxserializerglobal.h (100%) rename include/{opendspxserializer => opendspx/serializer}/private/declarative_p.h (96%) rename include/{opendspxserializer => opendspx/serializer}/private/helpers_p.h (94%) rename include/{opendspxserializer => opendspx/serializer}/private/sourcehelpers_p.h (97%) rename include/{opendspxserializer => opendspx/serializer}/serializationerror.h (94%) rename include/{opendspxserializer => opendspx/serializer}/serializer.h (90%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 45b9bd1..658305d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -149,7 +149,7 @@ macro(_opendspx_add_library identifier) set_target_properties(${OPENDSPX_${IDENTIFIER}_TARGET} PROPERTIES EXPORT_NAME ${identifier}) # Add sources - file(GLOB_RECURSE _src include/opendspx${identifier}/*.h src/${identifier}/*.h src/${identifier}/*.cpp) + file(GLOB_RECURSE _src include/opendspx/${identifier}/*.h src/${identifier}/*.h src/${identifier}/*.cpp) target_sources(${OPENDSPX_${IDENTIFIER}_TARGET} PRIVATE ${_src}) # Add features @@ -162,7 +162,7 @@ macro(_opendspx_add_library identifier) target_include_directories(${OPENDSPX_${IDENTIFIER}_TARGET} PUBLIC "$" ) - target_include_directories(${OPENDSPX_${IDENTIFIER}_TARGET} PRIVATE include/opendspx${identifier}) + target_include_directories(${OPENDSPX_${IDENTIFIER}_TARGET} PRIVATE include/opendspx/${identifier}) target_link_libraries(${OPENDSPX_${IDENTIFIER}_TARGET} PUBLIC ${OPENDSPX_MODEL_TARGET}) target_link_libraries(${OPENDSPX_${IDENTIFIER}_TARGET} PRIVATE stdcorelib::stdcorelib) @@ -202,6 +202,7 @@ if (OPENDSPX_BUILD_CONVERTER) endif() if (OPENDSPX_BUILD_TESTS) + enable_testing() add_subdirectory(tests) endif () diff --git a/README.md b/README.md index 28546a6..7fdebbb 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Build-time dependencies: - `stdcorelib` - `zstd` for the serializer module - `wolf-midi` for the converter module +- `boost-test` for the test suite The project is currently configured for Windows and other CMake-supported platforms. @@ -32,7 +33,7 @@ cmake --build build Useful options: - `OPENDSPX_BUILD_STATIC`: build static libraries instead of shared libraries -- `OPENDSPX_BUILD_TESTS`: build the test executable +- `OPENDSPX_BUILD_TESTS`: build the test suite - `OPENDSPX_BUILD_INTERPOLATOR`: build the interpolator interface library - `OPENDSPX_BUILD_SERIALIZER`: build the serializer library - `OPENDSPX_BUILD_CONVERTER`: build the converter library @@ -59,7 +60,7 @@ The public API provides helpers for: Example: ```cpp -#include +#include using opendspx::Interpolator; @@ -77,7 +78,7 @@ Use `opendspx::Serializer` to convert between `Model` and DSPX data streams. ```cpp #include #include -#include +#include using namespace opendspx; @@ -100,7 +101,7 @@ Use `opendspx::Converter` to convert between DSPX data and other formats. ```cpp #include -#include +#include using namespace opendspx; @@ -131,12 +132,23 @@ int main() { ## Project Structure - `include/opendspx/`: public model headers -- `include/opendspxinterpolator/`: interpolator API -- `include/opendspxserializer/`: serializer API -- `include/opendspxconverter/`: converter API +- `include/opendspx/interpolator/`: interpolator API +- `include/opendspx/serializer/`: serializer API +- `include/opendspx/converter/`: converter API - `src/serializer/`: serializer implementation - `src/converter/`: converter implementation, currently focused on MIDI -- `tests/`: usage tests +- `tests/auto/`: the regression suite, one directory per module + +## Tests + +Configure with `-DOPENDSPX_BUILD_TESTS=ON` and point CMake at Boost.Test, then: + +```bash +ctest --test-dir build --output-on-failure --no-tests=error +``` + +The whole suite is one binary, so a single case can be run on its own with +`test_auto --run_test=/`. ## License diff --git a/include/opendspxconverter/midi/midiconverter.h b/include/opendspx/converter/midi/midiconverter.h similarity index 87% rename from include/opendspxconverter/midi/midiconverter.h rename to include/opendspx/converter/midi/midiconverter.h index 727d4d1..0feb55e 100644 --- a/include/opendspxconverter/midi/midiconverter.h +++ b/include/opendspx/converter/midi/midiconverter.h @@ -1,11 +1,11 @@ -#ifndef OPENDSPX_CONVERTER_MIDICONVERTER_H -#define OPENDSPX_CONVERTER_MIDICONVERTER_H +#ifndef OPENDSPX_CONVERTER_MIDI_MIDICONVERTER_H +#define OPENDSPX_CONVERTER_MIDI_MIDICONVERTER_H #include #include #include -#include +#include namespace opendspx { @@ -43,4 +43,4 @@ namespace opendspx { } -#endif //OPENDSPX_CONVERTER_MIDICONVERTER_H +#endif //OPENDSPX_CONVERTER_MIDI_MIDICONVERTER_H diff --git a/include/opendspxconverter/midi/midiintermediatedata.h b/include/opendspx/converter/midi/midiintermediatedata.h similarity index 94% rename from include/opendspxconverter/midi/midiintermediatedata.h rename to include/opendspx/converter/midi/midiintermediatedata.h index 000700f..1b6a258 100644 --- a/include/opendspxconverter/midi/midiintermediatedata.h +++ b/include/opendspx/converter/midi/midiintermediatedata.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_CONVERTER_MIDIINTERMEDIATEDATA_H -#define OPENDSPX_CONVERTER_MIDIINTERMEDIATEDATA_H +#ifndef OPENDSPX_CONVERTER_MIDI_MIDIINTERMEDIATEDATA_H +#define OPENDSPX_CONVERTER_MIDI_MIDIINTERMEDIATEDATA_H #include #include @@ -101,4 +101,4 @@ namespace opendspx { } -#endif //OPENDSPX_CONVERTER_MIDIINTERMEDIATEDATA_H +#endif //OPENDSPX_CONVERTER_MIDI_MIDIINTERMEDIATEDATA_H diff --git a/include/opendspxconverter/opendspxconverterglobal.h b/include/opendspx/converter/opendspxconverterglobal.h similarity index 100% rename from include/opendspxconverter/opendspxconverterglobal.h rename to include/opendspx/converter/opendspxconverterglobal.h diff --git a/include/opendspxinterpolator/interpolator.h b/include/opendspx/interpolator/interpolator.h similarity index 100% rename from include/opendspxinterpolator/interpolator.h rename to include/opendspx/interpolator/interpolator.h diff --git a/include/opendspxinterpolator/mixinterpolator.h b/include/opendspx/interpolator/mixinterpolator.h similarity index 100% rename from include/opendspxinterpolator/mixinterpolator.h rename to include/opendspx/interpolator/mixinterpolator.h diff --git a/include/opendspxinterpolator/parameterinterpolator.h b/include/opendspx/interpolator/parameterinterpolator.h similarity index 98% rename from include/opendspxinterpolator/parameterinterpolator.h rename to include/opendspx/interpolator/parameterinterpolator.h index 54da44b..abfe166 100644 --- a/include/opendspxinterpolator/parameterinterpolator.h +++ b/include/opendspx/interpolator/parameterinterpolator.h @@ -7,7 +7,7 @@ #include #include -#include +#include namespace opendspx { diff --git a/include/opendspxinterpolator/vibratocurve.h b/include/opendspx/interpolator/vibratocurve.h similarity index 100% rename from include/opendspxinterpolator/vibratocurve.h rename to include/opendspx/interpolator/vibratocurve.h diff --git a/include/opendspxserializer/jsonconverterv1.h b/include/opendspx/serializer/jsonconverterv1.h similarity index 99% rename from include/opendspxserializer/jsonconverterv1.h rename to include/opendspx/serializer/jsonconverterv1.h index fbc84a1..d0431c6 100644 --- a/include/opendspxserializer/jsonconverterv1.h +++ b/include/opendspx/serializer/jsonconverterv1.h @@ -4,9 +4,9 @@ #include #include -#include -#include -#include +#include +#include +#include namespace opendspx { diff --git a/include/opendspxserializer/opendspxserializerglobal.h b/include/opendspx/serializer/opendspxserializerglobal.h similarity index 100% rename from include/opendspxserializer/opendspxserializerglobal.h rename to include/opendspx/serializer/opendspxserializerglobal.h diff --git a/include/opendspxserializer/private/declarative_p.h b/include/opendspx/serializer/private/declarative_p.h similarity index 96% rename from include/opendspxserializer/private/declarative_p.h rename to include/opendspx/serializer/private/declarative_p.h index f479f0a..98d9bad 100644 --- a/include/opendspxserializer/private/declarative_p.h +++ b/include/opendspx/serializer/private/declarative_p.h @@ -1,11 +1,11 @@ -#ifndef OPENDSPX_SERIALIZATION_DECLARATIVE_P_H -#define OPENDSPX_SERIALIZATION_DECLARATIVE_P_H +#ifndef OPENDSPX_SERIALIZER_DECLARATIVE_P_H +#define OPENDSPX_SERIALIZER_DECLARATIVE_P_H #include #include #include -#include +#include namespace opendspx::impl::decl { @@ -59,6 +59,8 @@ namespace opendspx::impl::decl { entity = object; return true; }; + } else if constexpr (std::is_same_v) { + return fromJsonObjectValueHelper; } else if constexpr (std::is_same_v) { return fromJsonIntHelperWithConstraint<>; } else if constexpr (std::is_same_v) { @@ -102,7 +104,7 @@ namespace opendspx::impl::decl { template static constexpr auto getToJsonFunc() { using T = std::remove_cvref_t; - if constexpr (std::is_same_v) { + if constexpr (std::is_same_v || std::is_same_v) { return [](stdc::JsonValue &object, const T &entity, const JsonSerializationContext &) { object = entity; return true; @@ -377,7 +379,7 @@ namespace opendspx::impl::decl { } for (const auto &[key, value] : *object) { typename T::mapped_type contentEntity; - if (!TrivialOrMappingConvert::getFromJsonFunc()(value, contentEntity, context)) { + if (!TrivialOrMappingConvert::getFromJsonFunc()(value, contentEntity, {context.errors, context.options, context.path + "." + key})) { if (context.options & Serializer::FailFast) return false; } @@ -395,7 +397,7 @@ namespace opendspx::impl::decl { stdc::JsonObject contents; for (const auto &[key, value] : entity) { stdc::JsonValue contentJson; - if (!TrivialOrMappingConvert::getToJsonFunc()(contentJson, value, context)) { + if (!TrivialOrMappingConvert::getToJsonFunc()(contentJson, value, {context.errors, context.options, context.path + "." + key})) { if (context.options & Serializer::FailFast) { object = std::move(contents); return false; @@ -413,4 +415,4 @@ namespace opendspx::impl::decl { } -#endif //OPENDSPX_SERIALIZATION_DECLARATIVE_P_H +#endif //OPENDSPX_SERIALIZER_DECLARATIVE_P_H diff --git a/include/opendspxserializer/private/helpers_p.h b/include/opendspx/serializer/private/helpers_p.h similarity index 94% rename from include/opendspxserializer/private/helpers_p.h rename to include/opendspx/serializer/private/helpers_p.h index b2385bd..c5190b1 100644 --- a/include/opendspxserializer/private/helpers_p.h +++ b/include/opendspx/serializer/private/helpers_p.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_SERIALIZATION_HELPERS_P_H -#define OPENDSPX_SERIALIZATION_HELPERS_P_H +#ifndef OPENDSPX_SERIALIZER_HELPERS_P_H +#define OPENDSPX_SERIALIZER_HELPERS_P_H #include #include @@ -12,8 +12,8 @@ #include -#include -#include +#include +#include namespace opendspx::impl { @@ -319,6 +319,22 @@ namespace opendspx::impl { return ok; } + // For a property whose declared type is an object rather than any value, so the object has to + // be handed over rather than pointed at + inline bool fromJsonObjectValueHelper(const stdc::JsonValue &value, stdc::JsonObject &out, const JsonSerializationContext &context) { + if (!(context.options & Serializer::CheckError)) { + out = value.toObject(); + return true; + } + if (auto actualType = getDataType(value); actualType != InvalidDataTypeError::Object) { + context.errors.addError(context.path, actualType, std::vector{InvalidDataTypeError::Object}); + out = {}; + return false; + } + out = value.toObject(); + return true; + } + // The object is handed back by pointer rather than by value, because a JsonValue owns its // children and copying one here would copy the whole subtree at every level of nesting. // A value that is not an object yields the shared empty object, so the caller never sees null. @@ -377,4 +393,4 @@ namespace opendspx::impl { } -#endif //OPENDSPX_SERIALIZATION_HELPERS_P_H +#endif //OPENDSPX_SERIALIZER_HELPERS_P_H diff --git a/include/opendspxserializer/private/sourcehelpers_p.h b/include/opendspx/serializer/private/sourcehelpers_p.h similarity index 97% rename from include/opendspxserializer/private/sourcehelpers_p.h rename to include/opendspx/serializer/private/sourcehelpers_p.h index 0c43220..7d06d8c 100644 --- a/include/opendspxserializer/private/sourcehelpers_p.h +++ b/include/opendspx/serializer/private/sourcehelpers_p.h @@ -10,8 +10,8 @@ #include #include #include -#include -#include +#include +#include namespace opendspx::impl { diff --git a/include/opendspxserializer/serializationerror.h b/include/opendspx/serializer/serializationerror.h similarity index 94% rename from include/opendspxserializer/serializationerror.h rename to include/opendspx/serializer/serializationerror.h index e1b3d34..259679c 100644 --- a/include/opendspxserializer/serializationerror.h +++ b/include/opendspx/serializer/serializationerror.h @@ -7,6 +7,8 @@ #include #include +#include + namespace opendspx { class SerializationError { @@ -57,14 +59,30 @@ namespace opendspx { class JsonParseFailureError : public SerializationError { public: - explicit JsonParseFailureError(std::string message) - : SerializationError(JsonParseFailure), m_message(std::move(message)) { + explicit JsonParseFailureError(stdc::JsonParseError error) + : SerializationError(JsonParseFailure), m_error(std::move(error)) { + } + stdc::JsonParseError::Code code() const { + return m_error.code; + } + std::size_t offset() const { + return m_error.offset; + } + std::size_t line() const { + return m_error.line; } + std::size_t column() const { + return m_error.column; + } + // What was wrong, with the line and column in front of it std::string message() const { - return m_message; + return m_error.message(); + } + const stdc::JsonParseError &error() const { + return m_error; } private: - std::string m_message; + stdc::JsonParseError m_error; }; class JsonRootIsNotObjectError : public SerializationError { diff --git a/include/opendspxserializer/serializer.h b/include/opendspx/serializer/serializer.h similarity index 90% rename from include/opendspxserializer/serializer.h rename to include/opendspx/serializer/serializer.h index 33788ec..e9886fc 100644 --- a/include/opendspxserializer/serializer.h +++ b/include/opendspx/serializer/serializer.h @@ -7,8 +7,8 @@ #include -#include -#include +#include +#include namespace opendspx { diff --git a/include/opendspx/workspace.h b/include/opendspx/workspace.h index 5d0a689..75947ca 100644 --- a/include/opendspx/workspace.h +++ b/include/opendspx/workspace.h @@ -1,15 +1,22 @@ -#ifndef OPENDSPX_MODEL_WORKSPACE_H -#define OPENDSPX_MODEL_WORKSPACE_H +#ifndef OPENDSPX_WORKSPACE_H +#define OPENDSPX_WORKSPACE_H + +#include +#include +#include #include namespace opendspx{ - class Workspace : public stdc::JsonObject { + // A class of its own rather than an alias for the map, so that it stays a distinct type for + // template matching. An alias would be the same type as any other map spelled the same way, + // and the serializer's Mapping specialization would then claim that type too. + class Workspace : public std::map> { public: - using stdc::JsonObject::map; + using std::map>::map; }; } -#endif //OPENDSPX_MODEL_WORKSPACE_H +#endif //OPENDSPX_WORKSPACE_H diff --git a/src/serializer/serializer.cpp b/src/serializer/serializer.cpp index e13f1c3..ee1eb72 100644 --- a/src/serializer/serializer.cpp +++ b/src/serializer/serializer.cpp @@ -13,8 +13,8 @@ #include -#include -#include +#include +#include namespace opendspx { @@ -138,9 +138,9 @@ namespace opendspx { return {}; } - std::string parseError; + stdc::JsonParseError parseError; auto doc = stdc::JsonValue::fromJson(text, false, &parseError); - if (!parseError.empty()) { + if (parseError) { errors.addError(std::move(parseError)); return {}; } From f324998385f6a3621e779a5044182a91d399f76e Mon Sep 17 00:00:00 2001 From: SineStriker Date: Sun, 16 Aug 2026 01:24:07 +0800 Subject: [PATCH 3/5] Name every header guard after the path to the header The guards said MODEL for headers that sit in no such directory, and the ones under serializer/private/ said SERIALIZATION for two of the three and SERIALIZER for the other. Each is now the path with the separators replaced, so a new header needs no decision and a moved one has one obvious answer. PRIVATE is left out of the three private ones, since the _p already says it. --- include/opendspx/anchornode.h | 6 +++--- include/opendspx/audioclip.h | 6 +++--- include/opendspx/buscontrol.h | 6 +++--- include/opendspx/clip.h | 6 +++--- include/opendspx/cliptime.h | 6 +++--- include/opendspx/content.h | 6 +++--- include/opendspx/controlpoint.h | 6 +++--- include/opendspx/dynamicmixinganchor.h | 6 +++--- include/opendspx/global.h | 6 +++--- include/opendspx/label.h | 6 +++--- include/opendspx/master.h | 6 +++--- include/opendspx/mixedsinger.h | 6 +++--- include/opendspx/model.h | 6 +++--- include/opendspx/note.h | 6 +++--- include/opendspx/param.h | 6 +++--- include/opendspx/paramcurve.h | 6 +++--- include/opendspx/paramcurveanchor.h | 6 +++--- include/opendspx/paramcurvefree.h | 6 +++--- include/opendspx/params.h | 6 +++--- include/opendspx/phoneme.h | 6 +++--- include/opendspx/phonemes.h | 6 +++--- include/opendspx/pronunciation.h | 6 +++--- include/opendspx/singer.h | 6 +++--- include/opendspx/singingclip.h | 6 +++--- include/opendspx/singlesinger.h | 6 +++--- include/opendspx/sourcemixingratio.h | 6 +++--- include/opendspx/sources.h | 6 +++--- include/opendspx/tempo.h | 6 +++--- include/opendspx/timeline.h | 6 +++--- include/opendspx/timesignature.h | 6 +++--- include/opendspx/track.h | 6 +++--- include/opendspx/trackcontrol.h | 6 +++--- include/opendspx/vibrato.h | 6 +++--- include/opendspx/vibratopoints.h | 6 +++--- 34 files changed, 102 insertions(+), 102 deletions(-) diff --git a/include/opendspx/anchornode.h b/include/opendspx/anchornode.h index d4f7ad9..577ae79 100644 --- a/include/opendspx/anchornode.h +++ b/include/opendspx/anchornode.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_ANCHORNODE_H -#define OPENDSPX_MODEL_ANCHORNODE_H +#ifndef OPENDSPX_ANCHORNODE_H +#define OPENDSPX_ANCHORNODE_H namespace opendspx { @@ -16,4 +16,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_ANCHORNODE_H \ No newline at end of file +#endif //OPENDSPX_ANCHORNODE_H \ No newline at end of file diff --git a/include/opendspx/audioclip.h b/include/opendspx/audioclip.h index 565445d..85baec4 100644 --- a/include/opendspx/audioclip.h +++ b/include/opendspx/audioclip.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_AUDIOCLIP_H -#define OPENDSPX_MODEL_AUDIOCLIP_H +#ifndef OPENDSPX_AUDIOCLIP_H +#define OPENDSPX_AUDIOCLIP_H #include @@ -18,4 +18,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_AUDIOCLIP_H \ No newline at end of file +#endif //OPENDSPX_AUDIOCLIP_H \ No newline at end of file diff --git a/include/opendspx/buscontrol.h b/include/opendspx/buscontrol.h index 0377e27..9667f71 100644 --- a/include/opendspx/buscontrol.h +++ b/include/opendspx/buscontrol.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_BUSCONTROL_H -#define OPENDSPX_MODEL_BUSCONTROL_H +#ifndef OPENDSPX_BUSCONTROL_H +#define OPENDSPX_BUSCONTROL_H namespace opendspx { @@ -11,4 +11,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_BUSCONTROL_H +#endif //OPENDSPX_BUSCONTROL_H diff --git a/include/opendspx/clip.h b/include/opendspx/clip.h index 4b5550f..110426b 100644 --- a/include/opendspx/clip.h +++ b/include/opendspx/clip.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_CLIP_H -#define OPENDSPX_MODEL_CLIP_H +#ifndef OPENDSPX_CLIP_H +#define OPENDSPX_CLIP_H #include #include @@ -32,4 +32,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_CLIP_H \ No newline at end of file +#endif //OPENDSPX_CLIP_H \ No newline at end of file diff --git a/include/opendspx/cliptime.h b/include/opendspx/cliptime.h index 3916ad0..77b93f9 100644 --- a/include/opendspx/cliptime.h +++ b/include/opendspx/cliptime.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_CLIPTIME_H -#define OPENDSPX_MODEL_CLIPTIME_H +#ifndef OPENDSPX_CLIPTIME_H +#define OPENDSPX_CLIPTIME_H namespace opendspx { @@ -12,4 +12,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_CLIPTIME_H +#endif //OPENDSPX_CLIPTIME_H diff --git a/include/opendspx/content.h b/include/opendspx/content.h index 6d7d23c..869d1ff 100644 --- a/include/opendspx/content.h +++ b/include/opendspx/content.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_CONTENT_H -#define OPENDSPX_MODEL_CONTENT_H +#ifndef OPENDSPX_CONTENT_H +#define OPENDSPX_CONTENT_H #include @@ -21,4 +21,4 @@ namespace opendspx{ } -#endif //OPENDSPX_MODEL_CONTENT_H +#endif //OPENDSPX_CONTENT_H diff --git a/include/opendspx/controlpoint.h b/include/opendspx/controlpoint.h index 70dc65f..1eac08f 100644 --- a/include/opendspx/controlpoint.h +++ b/include/opendspx/controlpoint.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_CONTROLPOINT_H -#define OPENDSPX_MODEL_CONTROLPOINT_H +#ifndef OPENDSPX_CONTROLPOINT_H +#define OPENDSPX_CONTROLPOINT_H namespace opendspx { @@ -10,4 +10,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_CONTROLPOINT_H \ No newline at end of file +#endif //OPENDSPX_CONTROLPOINT_H \ No newline at end of file diff --git a/include/opendspx/dynamicmixinganchor.h b/include/opendspx/dynamicmixinganchor.h index 7d7f17f..364cc4f 100644 --- a/include/opendspx/dynamicmixinganchor.h +++ b/include/opendspx/dynamicmixinganchor.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_DYNAMICMIXINGANCHOR_H -#define OPENDSPX_MODEL_DYNAMICMIXINGANCHOR_H +#ifndef OPENDSPX_DYNAMICMIXINGANCHOR_H +#define OPENDSPX_DYNAMICMIXINGANCHOR_H #include @@ -12,4 +12,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_DYNAMICMIXINGANCHOR_H +#endif //OPENDSPX_DYNAMICMIXINGANCHOR_H diff --git a/include/opendspx/global.h b/include/opendspx/global.h index dc0103f..bc9548a 100644 --- a/include/opendspx/global.h +++ b/include/opendspx/global.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_GLOBAL_H -#define OPENDSPX_MODEL_GLOBAL_H +#ifndef OPENDSPX_GLOBAL_H +#define OPENDSPX_GLOBAL_H #include @@ -15,4 +15,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_GLOBAL_H +#endif //OPENDSPX_GLOBAL_H diff --git a/include/opendspx/label.h b/include/opendspx/label.h index bbdd95a..e5dd838 100644 --- a/include/opendspx/label.h +++ b/include/opendspx/label.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_LABEL_H -#define OPENDSPX_MODEL_LABEL_H +#ifndef OPENDSPX_LABEL_H +#define OPENDSPX_LABEL_H #include @@ -12,4 +12,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_LABEL_H \ No newline at end of file +#endif //OPENDSPX_LABEL_H \ No newline at end of file diff --git a/include/opendspx/master.h b/include/opendspx/master.h index 19eb022..8effd18 100644 --- a/include/opendspx/master.h +++ b/include/opendspx/master.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_MASTER_H -#define OPENDSPX_MODEL_MASTER_H +#ifndef OPENDSPX_MASTER_H +#define OPENDSPX_MASTER_H #include @@ -11,4 +11,4 @@ namespace opendspx{ } -#endif //OPENDSPX_MODEL_MASTER_H +#endif //OPENDSPX_MASTER_H diff --git a/include/opendspx/mixedsinger.h b/include/opendspx/mixedsinger.h index d5daf4b..59d2855 100644 --- a/include/opendspx/mixedsinger.h +++ b/include/opendspx/mixedsinger.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_MIXEDSINGER_H -#define OPENDSPX_MODEL_MIXEDSINGER_H +#ifndef OPENDSPX_MIXEDSINGER_H +#define OPENDSPX_MIXEDSINGER_H #include #include @@ -22,4 +22,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_MIXEDSINGER_H +#endif //OPENDSPX_MIXEDSINGER_H diff --git a/include/opendspx/model.h b/include/opendspx/model.h index c6a908c..eda9430 100644 --- a/include/opendspx/model.h +++ b/include/opendspx/model.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_MODEL_H -#define OPENDSPX_MODEL_MODEL_H +#ifndef OPENDSPX_MODEL_H +#define OPENDSPX_MODEL_H #include @@ -15,4 +15,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_MODEL_H +#endif //OPENDSPX_MODEL_H diff --git a/include/opendspx/note.h b/include/opendspx/note.h index cb697cf..0586744 100644 --- a/include/opendspx/note.h +++ b/include/opendspx/note.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_NOTE_H -#define OPENDSPX_MODEL_NOTE_H +#ifndef OPENDSPX_NOTE_H +#define OPENDSPX_NOTE_H #include @@ -25,4 +25,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_NOTE_H \ No newline at end of file +#endif //OPENDSPX_NOTE_H \ No newline at end of file diff --git a/include/opendspx/param.h b/include/opendspx/param.h index b1af284..00e2dcc 100644 --- a/include/opendspx/param.h +++ b/include/opendspx/param.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_PARAM_H -#define OPENDSPX_MODEL_PARAM_H +#ifndef OPENDSPX_PARAM_H +#define OPENDSPX_PARAM_H #include @@ -17,4 +17,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_PARAM_H \ No newline at end of file +#endif //OPENDSPX_PARAM_H \ No newline at end of file diff --git a/include/opendspx/paramcurve.h b/include/opendspx/paramcurve.h index 2339164..c5fbd3c 100644 --- a/include/opendspx/paramcurve.h +++ b/include/opendspx/paramcurve.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_PARAMCURVE_H -#define OPENDSPX_MODEL_PARAMCURVE_H +#ifndef OPENDSPX_PARAMCURVE_H +#define OPENDSPX_PARAMCURVE_H #include @@ -23,4 +23,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_PARAMCURVE_H \ No newline at end of file +#endif //OPENDSPX_PARAMCURVE_H \ No newline at end of file diff --git a/include/opendspx/paramcurveanchor.h b/include/opendspx/paramcurveanchor.h index 3dd32e0..424884d 100644 --- a/include/opendspx/paramcurveanchor.h +++ b/include/opendspx/paramcurveanchor.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_PARAMCURVEANCHOR_H -#define OPENDSPX_MODEL_PARAMCURVEANCHOR_H +#ifndef OPENDSPX_PARAMCURVEANCHOR_H +#define OPENDSPX_PARAMCURVEANCHOR_H #include #include @@ -21,4 +21,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_PARAMCURVEANCHOR_H \ No newline at end of file +#endif //OPENDSPX_PARAMCURVEANCHOR_H \ No newline at end of file diff --git a/include/opendspx/paramcurvefree.h b/include/opendspx/paramcurvefree.h index 2d108c5..e1b1ced 100644 --- a/include/opendspx/paramcurvefree.h +++ b/include/opendspx/paramcurvefree.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_PARAMCURVEFREE_H -#define OPENDSPX_MODEL_PARAMCURVEFREE_H +#ifndef OPENDSPX_PARAMCURVEFREE_H +#define OPENDSPX_PARAMCURVEFREE_H #include #include @@ -21,4 +21,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_PARAMCURVEFREE_H \ No newline at end of file +#endif //OPENDSPX_PARAMCURVEFREE_H \ No newline at end of file diff --git a/include/opendspx/params.h b/include/opendspx/params.h index e6d599f..1460452 100644 --- a/include/opendspx/params.h +++ b/include/opendspx/params.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_PARAMS_H -#define OPENDSPX_MODEL_PARAMS_H +#ifndef OPENDSPX_PARAMS_H +#define OPENDSPX_PARAMS_H #include #include @@ -15,4 +15,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_PARAMS_H \ No newline at end of file +#endif //OPENDSPX_PARAMS_H \ No newline at end of file diff --git a/include/opendspx/phoneme.h b/include/opendspx/phoneme.h index dfc86ad..68d324d 100644 --- a/include/opendspx/phoneme.h +++ b/include/opendspx/phoneme.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_PHONEME_H -#define OPENDSPX_MODEL_PHONEME_H +#ifndef OPENDSPX_PHONEME_H +#define OPENDSPX_PHONEME_H #include @@ -14,4 +14,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_PHONEME_H \ No newline at end of file +#endif //OPENDSPX_PHONEME_H \ No newline at end of file diff --git a/include/opendspx/phonemes.h b/include/opendspx/phonemes.h index 1484c9d..7ec42d4 100644 --- a/include/opendspx/phonemes.h +++ b/include/opendspx/phonemes.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_PHONEMES_H -#define OPENDSPX_MODEL_PHONEMES_H +#ifndef OPENDSPX_PHONEMES_H +#define OPENDSPX_PHONEMES_H #include @@ -14,4 +14,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_PHONEMES_H \ No newline at end of file +#endif //OPENDSPX_PHONEMES_H \ No newline at end of file diff --git a/include/opendspx/pronunciation.h b/include/opendspx/pronunciation.h index c4c5723..cbf623c 100644 --- a/include/opendspx/pronunciation.h +++ b/include/opendspx/pronunciation.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_PRONUNCIATION_H -#define OPENDSPX_MODEL_PRONUNCIATION_H +#ifndef OPENDSPX_PRONUNCIATION_H +#define OPENDSPX_PRONUNCIATION_H #include @@ -12,4 +12,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_PRONUNCIATION_H \ No newline at end of file +#endif //OPENDSPX_PRONUNCIATION_H \ No newline at end of file diff --git a/include/opendspx/singer.h b/include/opendspx/singer.h index fb7071a..fe6bc11 100644 --- a/include/opendspx/singer.h +++ b/include/opendspx/singer.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_SINGER_H -#define OPENDSPX_MODEL_SINGER_H +#ifndef OPENDSPX_SINGER_H +#define OPENDSPX_SINGER_H #include #include @@ -29,4 +29,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_SINGER_H +#endif //OPENDSPX_SINGER_H diff --git a/include/opendspx/singingclip.h b/include/opendspx/singingclip.h index 3d308f6..7921c9f 100644 --- a/include/opendspx/singingclip.h +++ b/include/opendspx/singingclip.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_SINGINGCLIP_H -#define OPENDSPX_MODEL_SINGINGCLIP_H +#ifndef OPENDSPX_SINGINGCLIP_H +#define OPENDSPX_SINGINGCLIP_H #include #include @@ -26,4 +26,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_SINGINGCLIP_H \ No newline at end of file +#endif //OPENDSPX_SINGINGCLIP_H \ No newline at end of file diff --git a/include/opendspx/singlesinger.h b/include/opendspx/singlesinger.h index 0b43f0a..4345888 100644 --- a/include/opendspx/singlesinger.h +++ b/include/opendspx/singlesinger.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_SINGLESINGER_H -#define OPENDSPX_MODEL_SINGLESINGER_H +#ifndef OPENDSPX_SINGLESINGER_H +#define OPENDSPX_SINGLESINGER_H #include #include @@ -20,4 +20,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_SINGLESINGER_H +#endif //OPENDSPX_SINGLESINGER_H diff --git a/include/opendspx/sourcemixingratio.h b/include/opendspx/sourcemixingratio.h index 8f970f4..281cc59 100644 --- a/include/opendspx/sourcemixingratio.h +++ b/include/opendspx/sourcemixingratio.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_SOURCEMIXINGRATIO_H -#define OPENDSPX_MODEL_SOURCEMIXINGRATIO_H +#ifndef OPENDSPX_SOURCEMIXINGRATIO_H +#define OPENDSPX_SOURCEMIXINGRATIO_H #include #include @@ -18,4 +18,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_SOURCEMIXINGRATIO_H +#endif //OPENDSPX_SOURCEMIXINGRATIO_H diff --git a/include/opendspx/sources.h b/include/opendspx/sources.h index ad64820..18a007e 100644 --- a/include/opendspx/sources.h +++ b/include/opendspx/sources.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_SOURCES_H -#define OPENDSPX_MODEL_SOURCES_H +#ifndef OPENDSPX_SOURCES_H +#define OPENDSPX_SOURCES_H #include #include @@ -19,4 +19,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_SOURCES_H \ No newline at end of file +#endif //OPENDSPX_SOURCES_H \ No newline at end of file diff --git a/include/opendspx/tempo.h b/include/opendspx/tempo.h index 9de0fdc..d6fc832 100644 --- a/include/opendspx/tempo.h +++ b/include/opendspx/tempo.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_TEMPO_H -#define OPENDSPX_MODEL_TEMPO_H +#ifndef OPENDSPX_TEMPO_H +#define OPENDSPX_TEMPO_H namespace opendspx { @@ -10,4 +10,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_TEMPO_H \ No newline at end of file +#endif //OPENDSPX_TEMPO_H \ No newline at end of file diff --git a/include/opendspx/timeline.h b/include/opendspx/timeline.h index 8ad4307..2de0e5b 100644 --- a/include/opendspx/timeline.h +++ b/include/opendspx/timeline.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_TIMELINE_H -#define OPENDSPX_MODEL_TIMELINE_H +#ifndef OPENDSPX_TIMELINE_H +#define OPENDSPX_TIMELINE_H #include @@ -17,4 +17,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_TIMELINE_H \ No newline at end of file +#endif //OPENDSPX_TIMELINE_H \ No newline at end of file diff --git a/include/opendspx/timesignature.h b/include/opendspx/timesignature.h index a000f3e..838d46e 100644 --- a/include/opendspx/timesignature.h +++ b/include/opendspx/timesignature.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_TIMESIGNATURE_H -#define OPENDSPX_MODEL_TIMESIGNATURE_H +#ifndef OPENDSPX_TIMESIGNATURE_H +#define OPENDSPX_TIMESIGNATURE_H namespace opendspx { @@ -11,4 +11,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_TIMESIGNATURE_H \ No newline at end of file +#endif //OPENDSPX_TIMESIGNATURE_H \ No newline at end of file diff --git a/include/opendspx/track.h b/include/opendspx/track.h index cea2f71..a0858b6 100644 --- a/include/opendspx/track.h +++ b/include/opendspx/track.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_TRACK_H -#define OPENDSPX_MODEL_TRACK_H +#ifndef OPENDSPX_TRACK_H +#define OPENDSPX_TRACK_H #include #include @@ -21,4 +21,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_TRACK_H \ No newline at end of file +#endif //OPENDSPX_TRACK_H \ No newline at end of file diff --git a/include/opendspx/trackcontrol.h b/include/opendspx/trackcontrol.h index ac53230..eb3f5df 100644 --- a/include/opendspx/trackcontrol.h +++ b/include/opendspx/trackcontrol.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_TRACKCONTROL_H -#define OPENDSPX_MODEL_TRACKCONTROL_H +#ifndef OPENDSPX_TRACKCONTROL_H +#define OPENDSPX_TRACKCONTROL_H namespace opendspx { @@ -12,4 +12,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_TRACKCONTROL_H +#endif //OPENDSPX_TRACKCONTROL_H diff --git a/include/opendspx/vibrato.h b/include/opendspx/vibrato.h index c66e4f5..0cc7136 100644 --- a/include/opendspx/vibrato.h +++ b/include/opendspx/vibrato.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_VIBRATO_H -#define OPENDSPX_MODEL_VIBRATO_H +#ifndef OPENDSPX_VIBRATO_H +#define OPENDSPX_VIBRATO_H #include @@ -17,4 +17,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_VIBRATO_H \ No newline at end of file +#endif //OPENDSPX_VIBRATO_H \ No newline at end of file diff --git a/include/opendspx/vibratopoints.h b/include/opendspx/vibratopoints.h index 3f19888..af466e4 100644 --- a/include/opendspx/vibratopoints.h +++ b/include/opendspx/vibratopoints.h @@ -1,5 +1,5 @@ -#ifndef OPENDSPX_MODEL_VIBRATOPOINTS_H -#define OPENDSPX_MODEL_VIBRATOPOINTS_H +#ifndef OPENDSPX_VIBRATOPOINTS_H +#define OPENDSPX_VIBRATOPOINTS_H #include @@ -14,4 +14,4 @@ namespace opendspx { } -#endif //OPENDSPX_MODEL_VIBRATOPOINTS_H \ No newline at end of file +#endif //OPENDSPX_VIBRATOPOINTS_H \ No newline at end of file From afdc6b0bf075a6e0f96ea5aff17860921b52da92 Mon Sep 17 00:00:00 2001 From: SineStriker Date: Sun, 16 Aug 2026 01:24:20 +0800 Subject: [PATCH 4/5] Answer a corrupt MIDI file with a code rather than an exception MidiFile::load returns a bool, and the caller was reading it as the whole verdict. It is not: a file that stops in the middle of a field reaches the stream reader as a failure and comes out as std::ios_base::failure, past a signature that promised an Error out parameter and nothing else. An editor opening a half-written file got a crash where it had asked for InvalidMidiData. --- src/converter/midiconverter.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/converter/midiconverter.cpp b/src/converter/midiconverter.cpp index b5dbc2f..b5ec33d 100644 --- a/src/converter/midiconverter.cpp +++ b/src/converter/midiconverter.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -11,7 +12,7 @@ #include #include -#include +#include namespace opendspx { @@ -20,8 +21,17 @@ namespace opendspx { MidiIntermediateData MidiConverter::convertMidiToIntermediate(std::istream &in, Error &error, ConvertMidiToIntermediateOption option) { error = Error::NoError; + // load() answers false for some malformed input and throws for the rest -- a file that + // stops in the middle of a field reaches the stream reader as a failure rather than as a + // verdict -- and the caller was promised an error code either way. Midi::MidiFile midiFile; - if (!midiFile.load(in)) { + bool loaded = false; + try { + loaded = midiFile.load(in); + } catch (const std::exception &) { + loaded = false; + } + if (!loaded) { error = Error::InvalidMidiData; return {}; } From e7c09eeeb6dc6bf66bbf5a351a565e5d14c0afd8 Mon Sep 17 00:00:00 2001 From: SineStriker Date: Sun, 16 Aug 2026 01:24:38 +0800 Subject: [PATCH 5/5] Add a Boost.Test regression suite What was there ran one document through the serializer and printed the bytes, so it said a crash had not happened and nothing else. This is eighty-four cases over the four modules, in one binary, laid out the way stdcorelib's is: a directory per module, and a module's cases come in only when that module was built. Nothing that the compiler already guarantees is covered. What is: - serializer: a round trip over a model with both clip kinds, both singer kinds and both curve kinds; that writing what was read gives the same bytes; that a number keeps the form it was written in; that text which is not UTF-8 is replaced rather than fatal; the zstd frame and its detection; and each way a document can be turned down, with the position it was turned down at. - jsonconverterv1: the mapping machinery on its own -- the shape of an error path through arrays and objects, every data type mismatch, the bounds a range error carries, both enum kinds, polymorphic dispatch and its four failures, and that writing checks what reading checks. - interpolator: that a curve meets its nodes, that a straight run of anchors stays straight, and that a run which turns around does not bulge past the anchor it turns at. - converter: every rule in isValid on its own, and a model out through a MIDI file and back. The libraries come before Boost in target_link_libraries on purpose. Boost.Test usually comes out of a vcpkg tree, that tree is one include directory for everything in it, and the one here carries an older copy of a dependency this project also builds against. --- tests/CMakeLists.txt | 9 +- tests/auto/CMakeLists.txt | 63 +++ tests/auto/converter/test_midiconverter.cpp | 276 ++++++++++ tests/auto/interpolator/test_interpolator.cpp | 132 +++++ .../interpolator/test_mixinterpolator.cpp | 123 +++++ .../test_parameterinterpolator.cpp | 145 ++++++ tests/auto/interpolator/test_vibratocurve.cpp | 173 +++++++ tests/auto/main.cpp | 3 + tests/auto/serializer/helpers_p.h | 161 ++++++ .../auto/serializer/test_jsonconverterv1.cpp | 477 ++++++++++++++++++ tests/auto/serializer/test_serializer.cpp | 416 +++++++++++++++ tests/main.cpp | 372 -------------- 12 files changed, 1970 insertions(+), 380 deletions(-) create mode 100644 tests/auto/CMakeLists.txt create mode 100644 tests/auto/converter/test_midiconverter.cpp create mode 100644 tests/auto/interpolator/test_interpolator.cpp create mode 100644 tests/auto/interpolator/test_mixinterpolator.cpp create mode 100644 tests/auto/interpolator/test_parameterinterpolator.cpp create mode 100644 tests/auto/interpolator/test_vibratocurve.cpp create mode 100644 tests/auto/main.cpp create mode 100644 tests/auto/serializer/helpers_p.h create mode 100644 tests/auto/serializer/test_jsonconverterv1.cpp create mode 100644 tests/auto/serializer/test_serializer.cpp delete mode 100644 tests/main.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d10c026..0653827 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,8 +1 @@ -project(Test) - -add_executable(${PROJECT_NAME} main.cpp) - -target_link_libraries(${PROJECT_NAME} PRIVATE - opendspx::model - opendspx::serializer -) \ No newline at end of file +add_subdirectory(auto) diff --git a/tests/auto/CMakeLists.txt b/tests/auto/CMakeLists.txt new file mode 100644 index 0000000..9419731 --- /dev/null +++ b/tests/auto/CMakeLists.txt @@ -0,0 +1,63 @@ +project(test_auto LANGUAGES CXX) + +find_package(Boost CONFIG COMPONENTS unit_test_framework) + +# Fatal rather than a warning and a return. Somebody who set OPENDSPX_BUILD_TESTS=ON and got a +# green build with no tests in it has been told nothing. +if(NOT Boost_FOUND) + message(FATAL_ERROR + "OPENDSPX_BUILD_TESTS is on but Boost.Test was not found. Install it, or point CMake at " + "it with -DCMAKE_PREFIX_PATH, or turn the tests off with -DOPENDSPX_BUILD_TESTS=OFF.") +endif() + +# One binary over all the modules, so a case is free to reach across them -- the serializer +# suites read models the model layer built, and there is no reason to start a second process for +# that. Each module's sources come in only when that module was built. +file(GLOB _src *.cpp) +set(_libs opendspx::model) + +if(OPENDSPX_BUILD_SERIALIZER) + file(GLOB _serializer_src serializer/*.cpp) + list(APPEND _src ${_serializer_src}) + list(APPEND _libs opendspx::serializer) +endif() + +if(OPENDSPX_BUILD_INTERPOLATOR) + file(GLOB _interpolator_src interpolator/*.cpp) + list(APPEND _src ${_interpolator_src}) + list(APPEND _libs opendspx::interpolator) +endif() + +if(OPENDSPX_BUILD_CONVERTER) + file(GLOB _converter_src converter/*.cpp) + list(APPEND _src ${_converter_src}) + list(APPEND _libs opendspx::converter) +endif() + +add_executable(${PROJECT_NAME} ${_src}) + +# The project's own libraries first. Boost.Test usually comes out of a vcpkg tree, and that tree +# is one include directory for everything in it -- including, quite possibly, an older copy of a +# dependency this project also builds against. Listing it second keeps ours in front. +target_link_libraries(${PROJECT_NAME} PRIVATE ${_libs} Boost::unit_test_framework) + +# The serializer suites reach into the private declarative headers to test the mapping machinery +# on its own, rather than only through whole documents. +target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +set_target_properties(${PROJECT_NAME} PROPERTIES + CXX_EXTENSIONS OFF + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED ON +) + +# One entry rather than one per suite. The whole binary is a second, and a process start per +# suite costs more than the suites it would be splitting. +# +# Note that ctest exits 0 when it finds no tests at all, so anything running this in anger wants +# --no-tests=error, or it is back to a green run of nothing. +add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) + +# The default is 1500 seconds, long enough for a hang to pass for slowness. Well under a second +# here, so anything still running at a minute is stuck. +set_tests_properties(${PROJECT_NAME} PROPERTIES TIMEOUT 60) diff --git a/tests/auto/converter/test_midiconverter.cpp b/tests/auto/converter/test_midiconverter.cpp new file mode 100644 index 0000000..6883a89 --- /dev/null +++ b/tests/auto/converter/test_midiconverter.cpp @@ -0,0 +1,276 @@ +#include +#include +#include +#include + +#include +#include +#include + +#include + +using namespace opendspx; + +namespace { + + using Intermediate = MidiIntermediateData; + + // DSPX counts 480 ticks to a quarter note whatever the MIDI file says, so a case that wants + // to talk about beats says so here rather than spelling out the arithmetic each time. + constexpr int dspxQuarter = 480; + + Model modelWithOneTrack() { + Model model; + model.content.global.name = "song"; + model.content.timeline.tempos = {Tempo{0, 100.0}, Tempo{4 * dspxQuarter, 150.0}}; + model.content.timeline.timeSignatures = {TimeSignature{0, 3, 4}}; + model.content.timeline.labels = {Label{0, "intro"}, Label{4 * dspxQuarter, "verse"}}; + + auto clip = std::make_shared(); + clip->name = "vocal"; + clip->time = ClipTime{0, 8 * dspxQuarter, 0, 8 * dspxQuarter}; + + Note first; + first.pos = 0; + first.length = dspxQuarter; + first.keyNum = 60; + first.lyric = "do"; + + Note second; + second.pos = 2 * dspxQuarter; + second.length = dspxQuarter / 2; + second.keyNum = 67; + second.lyric = "so"; + + clip->notes = {first, second}; + + Track track; + track.name = "lead"; + track.clips = {clip}; + model.content.tracks = {track}; + return model; + } + + std::string toMidiBytes(const Intermediate &data) { + std::ostringstream out(std::ios::binary); + MidiConverter::convertIntermediateToMidi(out, data); + return out.str(); + } + + Intermediate fromMidiBytes(const std::string &bytes, MidiConverter::Error &error) { + std::istringstream in(bytes, std::ios::binary | std::ios::in); + return MidiConverter::convertMidiToIntermediate(in, error); + } + +} + +BOOST_AUTO_TEST_SUITE(test_midiconverter) + +// isValid is what stands between a model and a file nobody can open, so each of its rules is +// checked on its own rather than through one example that satisfies all of them. +BOOST_AUTO_TEST_CASE(test_intermediate_validity) { + BOOST_CHECK(Intermediate{}.isValid()); + BOOST_CHECK(Intermediate(480, {}, {}, {}, {}).isValid()); + + // Tempo has to be a tempo somebody could play. + BOOST_CHECK(Intermediate(480, {{0, 10.0}}, {}, {}, {}).isValid()); + BOOST_CHECK(Intermediate(480, {{0, 1000.0}}, {}, {}, {}).isValid()); + BOOST_CHECK(!Intermediate(480, {{0, 9.9}}, {}, {}, {}).isValid()); + BOOST_CHECK(!Intermediate(480, {{0, 1000.1}}, {}, {}, {}).isValid()); + BOOST_CHECK(!Intermediate(480, {{-1, 120.0}}, {}, {}, {}).isValid()); + + // A denominator is a power of two up to 128, because that is what a MIDI time signature can + // say; a numerator only has to be positive. + for (int denominator : {1, 2, 4, 8, 16, 32, 64, 128}) { + BOOST_CHECK(Intermediate(480, {}, {{0, 4, denominator}}, {}, {}).isValid()); + } + for (int denominator : {0, 3, 5, 6, 12, 256}) { + BOOST_CHECK(!Intermediate(480, {}, {{0, 4, denominator}}, {}, {}).isValid()); + } + BOOST_CHECK(!Intermediate(480, {}, {{0, 0, 4}}, {}, {}).isValid()); + BOOST_CHECK(!Intermediate(480, {}, {{-1, 4, 4}}, {}, {}).isValid()); + + BOOST_CHECK(!Intermediate(480, {}, {}, {{-1, "x"}}, {}).isValid()); + + // A key is a MIDI key. + const auto withNote = [](int key, int tick, int length) { + return Intermediate(480, {}, {}, {}, {{"t", {{tick, length, key, ""}}, 0, 0}}); + }; + BOOST_CHECK(withNote(0, 0, 1).isValid()); + BOOST_CHECK(withNote(127, 0, 1).isValid()); + BOOST_CHECK(!withNote(128, 0, 1).isValid()); + BOOST_CHECK(!withNote(-1, 0, 1).isValid()); + BOOST_CHECK(!withNote(60, -1, 1).isValid()); + BOOST_CHECK(!withNote(60, 0, -1).isValid()); + BOOST_CHECK(!Intermediate(-1, {}, {}, {}, {}).isValid()); +} + +// Nothing is written for data that would not read back, rather than a file that fails later. +BOOST_AUTO_TEST_CASE(test_invalid_data_writes_nothing) { + BOOST_CHECK(toMidiBytes(Intermediate(480, {{0, 5000.0}}, {}, {}, {})).empty()); + BOOST_CHECK(toMidiBytes(Intermediate(0, {}, {}, {}, {})).empty()); +} + +BOOST_AUTO_TEST_CASE(test_dspx_to_intermediate) { + const auto data = MidiConverter::convertDspxToIntermediate(modelWithOneTrack()); + BOOST_CHECK_EQUAL(data.resolution(), 480); + + BOOST_REQUIRE_EQUAL(data.tempos().size(), 2); + BOOST_CHECK_EQUAL(data.tempos()[0].tick, 0); + BOOST_CHECK_EQUAL(data.tempos()[0].tempo, 100.0); + BOOST_CHECK_EQUAL(data.tempos()[1].tempo, 150.0); + + BOOST_REQUIRE_EQUAL(data.timeSignatures().size(), 1); + BOOST_CHECK_EQUAL(data.timeSignatures()[0].numerator, 3); + BOOST_CHECK_EQUAL(data.timeSignatures()[0].denominator, 4); + + BOOST_REQUIRE_EQUAL(data.markers().size(), 2); + BOOST_CHECK_EQUAL(data.markers()[0].text, "intro"); + BOOST_CHECK_EQUAL(data.markers()[1].text, "verse"); + + BOOST_REQUIRE_EQUAL(data.tracks().size(), 1); + BOOST_CHECK_EQUAL(data.tracks()[0].title, "lead"); + BOOST_REQUIRE_EQUAL(data.tracks()[0].notes.size(), 2); + BOOST_CHECK_EQUAL(data.tracks()[0].notes[0].key, 60); + BOOST_CHECK_EQUAL(data.tracks()[0].notes[0].lyric, "do"); + BOOST_CHECK_EQUAL(data.tracks()[0].notes[1].key, 67); + + BOOST_CHECK(data.isValid()); +} + +// A tick is a fraction of a quarter note, so asking for a different resolution rescales rather +// than renumbers. A resolution of nothing is not a resolution and falls back. +BOOST_AUTO_TEST_CASE(test_resolution_scales_the_ticks) { + const auto model = modelWithOneTrack(); + + const auto half = MidiConverter::convertDspxToIntermediate(model, {240, false}); + BOOST_CHECK_EQUAL(half.resolution(), 240); + BOOST_REQUIRE_EQUAL(half.tempos().size(), 2); + BOOST_CHECK_EQUAL(half.tempos()[1].tick, 4 * 240); + BOOST_REQUIRE_EQUAL(half.tracks().size(), 1); + BOOST_CHECK_EQUAL(half.tracks()[0].notes[1].noteOnTick, 2 * 240); + BOOST_CHECK_EQUAL(half.tracks()[0].notes[1].length, 240 / 2); + + // A resolution of nothing is not a resolution. Nothing is converted rather than a number + // being made up, and the empty result does not pass isValid's resolution rule either. + for (int bad : {0, -1}) { + const auto refused = MidiConverter::convertDspxToIntermediate(model, {bad, false}); + BOOST_CHECK_EQUAL(refused.resolution(), 0); + BOOST_CHECK(refused.tracks().empty()); + BOOST_CHECK(refused.tempos().empty()); + BOOST_CHECK(toMidiBytes(refused).empty()); + } +} + +// A text hook is applied on the way out and on the way back, which is how a file in a legacy +// encoding is read at all. +BOOST_AUTO_TEST_CASE(test_text_hooks) { + const auto shout = [](const std::string &text) { + return text + "!"; + }; + + const auto data = MidiConverter::convertDspxToIntermediate(modelWithOneTrack(), shout); + BOOST_REQUIRE_EQUAL(data.markers().size(), 2); + BOOST_CHECK_EQUAL(data.markers()[0].text, "intro!"); + BOOST_REQUIRE_EQUAL(data.tracks().size(), 1); + BOOST_CHECK_EQUAL(data.tracks()[0].notes[0].lyric, "do!"); + + bool ok = false; + const auto model = MidiConverter::convertIntermediateToDspx(data, shout, &ok); + BOOST_CHECK(ok); + BOOST_REQUIRE_EQUAL(model.content.timeline.labels.size(), 2); + BOOST_CHECK_EQUAL(model.content.timeline.labels[0].text, "intro!!"); +} + +// The whole way out and back: a model, a MIDI file, and a model again. The parts a MIDI file can +// carry have to survive; the rest is not this converter's to keep. +BOOST_AUTO_TEST_CASE(test_round_trip_through_a_midi_file) { + const auto original = modelWithOneTrack(); + const auto written = MidiConverter::convertDspxToIntermediate(original); + const auto bytes = toMidiBytes(written); + BOOST_REQUIRE(!bytes.empty()); + BOOST_CHECK_EQUAL(bytes.compare(0, 4, "MThd"), 0); + + auto error = MidiConverter::Error::InvalidMidiData; + const auto read = fromMidiBytes(bytes, error); + BOOST_REQUIRE(error == MidiConverter::Error::NoError); + + BOOST_CHECK_EQUAL(read.resolution(), written.resolution()); + + BOOST_REQUIRE_EQUAL(read.tempos().size(), written.tempos().size()); + for (std::size_t i = 0; i < read.tempos().size(); ++i) { + BOOST_CHECK_EQUAL(read.tempos()[i].tick, written.tempos()[i].tick); + // A MIDI tempo is microseconds per quarter note, so it comes back rounded. + BOOST_CHECK_CLOSE(read.tempos()[i].tempo, written.tempos()[i].tempo, 0.01); + } + + BOOST_REQUIRE_EQUAL(read.timeSignatures().size(), written.timeSignatures().size()); + BOOST_CHECK_EQUAL(read.timeSignatures()[0].numerator, 3); + BOOST_CHECK_EQUAL(read.timeSignatures()[0].denominator, 4); + + BOOST_REQUIRE_EQUAL(read.markers().size(), written.markers().size()); + BOOST_CHECK_EQUAL(read.markers()[0].text, "intro"); + BOOST_CHECK_EQUAL(read.markers()[1].text, "verse"); + + // Held by value: the accessors hand back a copy, so a reference into what they return would + // be pointing at a vector that is already gone. + const auto readTracks = read.tracks(); + BOOST_REQUIRE_EQUAL(readTracks.size(), 1); + BOOST_CHECK_EQUAL(readTracks[0].title, "lead"); + const auto ¬es = readTracks[0].notes; + BOOST_REQUIRE_EQUAL(notes.size(), 2); + BOOST_CHECK_EQUAL(notes[0].noteOnTick, 0); + BOOST_CHECK_EQUAL(notes[0].length, dspxQuarter); + BOOST_CHECK_EQUAL(notes[0].key, 60); + BOOST_CHECK_EQUAL(notes[0].lyric, "do"); + BOOST_CHECK_EQUAL(notes[1].noteOnTick, 2 * dspxQuarter); + BOOST_CHECK_EQUAL(notes[1].key, 67); + BOOST_CHECK_EQUAL(notes[1].lyric, "so"); + + bool ok = false; + const auto back = MidiConverter::convertIntermediateToDspx(read, &ok); + BOOST_CHECK(ok); + BOOST_REQUIRE_EQUAL(back.content.timeline.tempos.size(), 2); + BOOST_CHECK_CLOSE(back.content.timeline.tempos[1].value, 150.0, 0.01); + BOOST_REQUIRE_EQUAL(back.content.timeline.labels.size(), 2); + BOOST_CHECK_EQUAL(back.content.timeline.labels[1].pos, 4 * dspxQuarter); + BOOST_REQUIRE_EQUAL(back.content.tracks.size(), 1); + BOOST_REQUIRE_EQUAL(back.content.tracks[0].clips.size(), 1); +} + +// Bytes that are not a MIDI file are turned down rather than read as an empty song, which the +// caller would show as a project that lost everything. +BOOST_AUTO_TEST_CASE(test_rejects_what_is_not_midi) { + for (const auto *bytes : {"", "not a midi file at all", "MThd"}) { + auto error = MidiConverter::Error::NoError; + fromMidiBytes(bytes, error); + BOOST_CHECK_MESSAGE(error != MidiConverter::Error::NoError, "accepted " << bytes); + } +} + +// A model with nothing in it is still a MIDI file, and reading it back is still a model. +BOOST_AUTO_TEST_CASE(test_empty_model) { + const auto data = MidiConverter::convertDspxToIntermediate(Model{}); + BOOST_CHECK(data.isValid()); + + const auto bytes = toMidiBytes(data); + BOOST_REQUIRE(!bytes.empty()); + + auto error = MidiConverter::Error::InvalidMidiData; + const auto read = fromMidiBytes(bytes, error); + BOOST_CHECK(error == MidiConverter::Error::NoError); + + bool ok = false; + MidiConverter::convertIntermediateToDspx(read, &ok); + BOOST_CHECK(ok); +} + +// The ok flag is optional on both overloads, and asking without it must not reach through a null. +BOOST_AUTO_TEST_CASE(test_ok_flag_is_optional) { + const auto data = MidiConverter::convertDspxToIntermediate(modelWithOneTrack()); + BOOST_CHECK_NO_THROW(MidiConverter::convertIntermediateToDspx(data)); + BOOST_CHECK_NO_THROW(MidiConverter::convertIntermediateToDspx( + data, [](const std::string &text) { return text; })); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/auto/interpolator/test_interpolator.cpp b/tests/auto/interpolator/test_interpolator.cpp new file mode 100644 index 0000000..2c79128 --- /dev/null +++ b/tests/auto/interpolator/test_interpolator.cpp @@ -0,0 +1,132 @@ +#include + +#include + +#include + +using namespace opendspx; + +namespace { + + constexpr double tolerance = 1e-9; + + // The four factories differ only in where the end derivatives come from, so every case that + // checks a curve checks it the same way: at the two nodes it was built from. + void checkPassesThroughNodes(const Interpolator &curve, double x1, double y1, double x2, + double y2) { + BOOST_CHECK_SMALL(curve.evaluate(x1) - y1, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(x2) - y2, tolerance); + } + +} + +BOOST_AUTO_TEST_SUITE(test_interpolator) + +// The whole class is constexpr, and a curve evaluated at build time is the reason it is. Losing +// that would still compile everywhere it is called from, so it is asserted rather than checked. +BOOST_AUTO_TEST_CASE(test_usable_at_compile_time) { + constexpr auto line = Interpolator::createLinear(0.0, 0.0, 2.0, 4.0); + static_assert(line.evaluate(1.0) == 2.0); + static_assert(line.evaluate(0.0) == 0.0); + + constexpr auto hermite = + Interpolator::create(0.0, 0.0, 1.0, 1.0, -1.0, -1.0, 2.0, 2.0); + static_assert(hermite.evaluate(0.5) > 0.0); + + BOOST_CHECK(true); // the assertions above are the case +} + +BOOST_AUTO_TEST_CASE(test_linear) { + const auto curve = Interpolator::createLinear(1.0, 10.0, 3.0, 20.0); + checkPassesThroughNodes(curve, 1.0, 10.0, 3.0, 20.0); + BOOST_CHECK_SMALL(curve.evaluate(2.0) - 15.0, tolerance); + + // A line is a line outside its own two nodes as well; nothing clamps. + BOOST_CHECK_SMALL(curve.evaluate(5.0) - 30.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(-1.0) - 0.0, tolerance); +} + +// A descending segment, which is where a sign error in createLinear's constant term would show. +BOOST_AUTO_TEST_CASE(test_linear_descending) { + const auto curve = Interpolator::createLinear(-2.0, 8.0, 4.0, -4.0); + checkPassesThroughNodes(curve, -2.0, 8.0, 4.0, -4.0); + BOOST_CHECK_SMALL(curve.evaluate(1.0) - 2.0, tolerance); +} + +BOOST_AUTO_TEST_CASE(test_hermite_passes_through_its_nodes) { + // Reference points on both sides, on one side, and on the other. + checkPassesThroughNodes( + Interpolator::create(1.0, 2.0, 4.0, 9.0, -3.0, 7.0, 6.0, 1.0), 1.0, 2.0, 4.0, 9.0); + checkPassesThroughNodes( + Interpolator::createWithRef1Only(1.0, 2.0, 4.0, 9.0, -3.0, 7.0), 1.0, 2.0, 4.0, + 9.0); + checkPassesThroughNodes( + Interpolator::createWithRef2Only(1.0, 2.0, 4.0, 9.0, 6.0, 1.0), 1.0, 2.0, 4.0, 9.0); +} + +// When every point given is on one line, the estimated derivatives come out as that line's slope +// and the cubic collapses to it. This is the property that keeps a straight run of anchors +// straight even though the user asked for Hermite. +BOOST_AUTO_TEST_CASE(test_hermite_reproduces_a_straight_line) { + const auto line = [](double x) { + return 3.0 * x - 1.0; + }; + + const auto both = Interpolator::create(1.0, line(1.0), 2.0, line(2.0), 0.0, line(0.0), + 3.0, line(3.0)); + const auto left = + Interpolator::createWithRef1Only(1.0, line(1.0), 2.0, line(2.0), 0.0, line(0.0)); + const auto right = + Interpolator::createWithRef2Only(1.0, line(1.0), 2.0, line(2.0), 3.0, line(3.0)); + + for (double x = 1.0; x <= 2.0; x += 0.125) { + BOOST_CHECK_SMALL(both.evaluate(x) - line(x), tolerance); + BOOST_CHECK_SMALL(left.evaluate(x) - line(x), tolerance); + BOOST_CHECK_SMALL(right.evaluate(x) - line(x), tolerance); + } +} + +// The derivative estimator answers zero when the two neighbouring slopes disagree in sign, which +// is what stops the curve from bulging past a node that is a local extremum. Without it a run of +// anchors that turns around produces values outside the range the user drew. +BOOST_AUTO_TEST_CASE(test_hermite_does_not_overshoot_a_turning_point) { + // Rises to (1, 1) and falls away again, so x = 1 is a maximum. + const auto curve = Interpolator::create(1.0, 1.0, 2.0, 0.0, 0.0, 0.0, 3.0, 1.0); + + for (double x = 1.0; x <= 2.0; x += 1.0 / 64) { + const double y = curve.evaluate(x); + BOOST_CHECK_LE(y, 1.0 + tolerance); + BOOST_CHECK_GE(y, 0.0 - tolerance); + } + + // The maximum is at the node itself: the segment leaves it going flat, not upward. + BOOST_CHECK_LE(curve.evaluate(1.0 + 1e-6), 1.0 + tolerance); +} + +// A flat neighbour is the boundary of that rule -- the product of the slopes is zero, not +// negative -- and it has to take the same branch, or a plateau grows a bump. +BOOST_AUTO_TEST_CASE(test_hermite_keeps_a_plateau_flat) { + const auto curve = Interpolator::create(1.0, 1.0, 2.0, 1.0, 0.0, 0.0, 3.0, 1.0); + for (double x = 1.0; x <= 2.0; x += 1.0 / 64) { + BOOST_CHECK_LE(curve.evaluate(x), 1.0 + tolerance); + } +} + +// The estimator weights the two slopes by the widths of the intervals they came from, so an +// uneven spacing is not the same as an even one. Checked as a property -- the curve stays inside +// the values it was given -- rather than against coefficients nobody can read. +BOOST_AUTO_TEST_CASE(test_hermite_stays_within_range_on_uneven_spacing) { + const auto curve = Interpolator::create(0.0, 0.0, 10.0, 1.0, -0.5, 0.0, 10.25, 1.0); + for (double x = 0.0; x <= 10.0; x += 0.25) { + const double y = curve.evaluate(x); + BOOST_CHECK_GE(y, -tolerance); + BOOST_CHECK_LE(y, 1.0 + tolerance); + } +} + +BOOST_AUTO_TEST_CASE(test_float_instantiation) { + const auto curve = Interpolator::createLinear(0.0f, 0.0f, 4.0f, 2.0f); + BOOST_CHECK_CLOSE(curve.evaluate(2.0f), 1.0f, 1e-3); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/auto/interpolator/test_mixinterpolator.cpp b/tests/auto/interpolator/test_mixinterpolator.cpp new file mode 100644 index 0000000..12fc331 --- /dev/null +++ b/tests/auto/interpolator/test_mixinterpolator.cpp @@ -0,0 +1,123 @@ +#include +#include + +#include + +#include + +using namespace opendspx; + +namespace { + + constexpr double tolerance = 1e-9; + + DynamicMixingAnchor anchor(int pos, SourceMixingRatio ratio) { + DynamicMixingAnchor a; + a.pos = pos; + a.ratio = std::move(ratio); + return a; + } + + void checkRatio(const SourceMixingRatio &actual, const std::vector &expected) { + BOOST_REQUIRE_EQUAL(actual.size(), expected.size()); + for (std::size_t i = 0; i < expected.size(); ++i) { + BOOST_CHECK_SMALL(actual[i] - expected[i], tolerance); + } + } + +} + +BOOST_AUTO_TEST_SUITE(test_mixinterpolator) + +// A ratio holds one number fewer than there are sources -- the last source gets what is left -- +// so a mismatch is caught when the interpolator is built rather than read out later as a value +// nobody can interpret. +BOOST_AUTO_TEST_CASE(test_rejects_a_ratio_of_the_wrong_length) { + BOOST_CHECK_THROW(MixInterpolator({anchor(0, SourceMixingRatio{0.5})}, 3), + std::invalid_argument); + BOOST_CHECK_THROW(MixInterpolator({anchor(0, SourceMixingRatio{0.2, 0.3, 0.4})}, 3), + std::invalid_argument); + BOOST_CHECK_NO_THROW(MixInterpolator({anchor(0, SourceMixingRatio{0.2, 0.3})}, 3)); + + // The check is over every anchor, not only the first. + BOOST_CHECK_THROW( + MixInterpolator({anchor(0, SourceMixingRatio{0.2, 0.3}), anchor(1, SourceMixingRatio{0.5})}, + 3), + std::invalid_argument); +} + +BOOST_AUTO_TEST_CASE(test_rejects_no_sources) { + BOOST_CHECK_THROW(MixInterpolator({}, 0), std::invalid_argument); +} + +// With nothing drawn, every source is worth the same. One source means an empty ratio -- there +// is nothing to divide -- which is the case an off-by-one in the size would get wrong. +BOOST_AUTO_TEST_CASE(test_no_anchors_is_an_even_mix) { + checkRatio(MixInterpolator({}, 4).evaluate(0.0), {0.25, 0.25, 0.25}); + checkRatio(MixInterpolator({}, 2).evaluate(1234.0), {0.5}); + checkRatio(MixInterpolator({}, 1).evaluate(0.0), {}); +} + +BOOST_AUTO_TEST_CASE(test_landing_on_an_anchor) { + const MixInterpolator curve( + {anchor(0, SourceMixingRatio{0.1}), anchor(100, SourceMixingRatio{0.9})}, 2); + + checkRatio(curve.evaluate(0.0), {0.1}); + checkRatio(curve.evaluate(100.0), {0.9}); +} + +// Outside the anchors the nearest one holds, rather than the mix running off to nothing. +BOOST_AUTO_TEST_CASE(test_clamps_outside_the_anchors) { + const MixInterpolator curve( + {anchor(10, SourceMixingRatio{0.2}), anchor(20, SourceMixingRatio{0.8})}, 2); + + checkRatio(curve.evaluate(-1000.0), {0.2}); + checkRatio(curve.evaluate(9.999), {0.2}); + checkRatio(curve.evaluate(20.001), {0.8}); + checkRatio(curve.evaluate(1e9), {0.8}); +} + +BOOST_AUTO_TEST_CASE(test_blends_between_anchors) { + const MixInterpolator curve( + {anchor(0, SourceMixingRatio{0.0, 1.0}), anchor(100, SourceMixingRatio{1.0, 0.0})}, 3); + + checkRatio(curve.evaluate(25.0), {0.25, 0.75}); + checkRatio(curve.evaluate(50.0), {0.5, 0.5}); + checkRatio(curve.evaluate(75.0), {0.75, 0.25}); +} + +// Each component is blended on its own, so a mix where the components move by different amounts +// is not the same as scaling one of them. +BOOST_AUTO_TEST_CASE(test_blends_each_component_separately) { + const MixInterpolator curve( + {anchor(0, SourceMixingRatio{0.1, 0.6}), anchor(10, SourceMixingRatio{0.5, 0.2})}, 3); + + checkRatio(curve.evaluate(5.0), {0.3, 0.4}); + checkRatio(curve.evaluate(2.5), {0.2, 0.5}); +} + +BOOST_AUTO_TEST_CASE(test_unsorted_anchors_are_sorted) { + const MixInterpolator curve({anchor(100, SourceMixingRatio{1.0}), + anchor(0, SourceMixingRatio{0.0}), + anchor(50, SourceMixingRatio{0.25})}, + 2); + + checkRatio(curve.evaluate(0.0), {0.0}); + checkRatio(curve.evaluate(50.0), {0.25}); + checkRatio(curve.evaluate(100.0), {1.0}); + // Between the first two, so the 0.0 -> 0.25 leg rather than the whole span. + checkRatio(curve.evaluate(25.0), {0.125}); +} + +// A blend of two valid partitions is still a valid partition, which is the property the model +// relies on when it hands a mid-anchor ratio to a renderer. +BOOST_AUTO_TEST_CASE(test_a_blend_of_valid_ratios_stays_valid) { + const MixInterpolator curve( + {anchor(0, SourceMixingRatio{0.5, 0.25}), anchor(10, SourceMixingRatio{0.1, 0.1})}, 3); + + for (double pos = 0.0; pos <= 10.0; pos += 0.5) { + BOOST_CHECK(curve.evaluate(pos).valid()); + } +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/auto/interpolator/test_parameterinterpolator.cpp b/tests/auto/interpolator/test_parameterinterpolator.cpp new file mode 100644 index 0000000..a68cd2e --- /dev/null +++ b/tests/auto/interpolator/test_parameterinterpolator.cpp @@ -0,0 +1,145 @@ +#include +#include + +#include + +#include + +using namespace opendspx; + +namespace { + + constexpr double tolerance = 1e-9; + + AnchorNode node(int x, int y, AnchorNode::Interpolation interp) { + AnchorNode n; + n.x = x; + n.y = y; + n.interp = interp; + return n; + } + + constexpr auto None = AnchorNode::Interpolation::None; + constexpr auto Linear = AnchorNode::Interpolation::Linear; + constexpr auto Hermite = AnchorNode::Interpolation::Hermite; + +} + +BOOST_AUTO_TEST_SUITE(test_parameterinterpolator) + +// Outside the anchors there is nothing to interpolate between, which is not the same as zero -- +// the caller has to be able to tell "no value here" from "the value is zero". +BOOST_AUTO_TEST_CASE(test_outside_the_anchors_is_no_value) { + const ParameterInterpolator curve({node(10, 100, Linear), node(20, 200, Linear)}); + + BOOST_CHECK(!curve.evaluate(9.999).has_value()); + BOOST_CHECK(!curve.evaluate(20.001).has_value()); + BOOST_CHECK(curve.evaluate(10.0).has_value()); + BOOST_CHECK(curve.evaluate(20.0).has_value()); +} + +BOOST_AUTO_TEST_CASE(test_empty_and_single_anchor) { + BOOST_CHECK(!ParameterInterpolator({}).evaluate(0.0).has_value()); + + const ParameterInterpolator one({node(5, 50, Linear)}); + BOOST_CHECK_EQUAL(one.evaluate(5.0).value(), 50.0); + BOOST_CHECK(!one.evaluate(4.0).has_value()); + BOOST_CHECK(!one.evaluate(6.0).has_value()); +} + +// An anchor's own x answers with its own y whatever the interpolation says, including None. +BOOST_AUTO_TEST_CASE(test_landing_on_an_anchor) { + const ParameterInterpolator curve({node(0, 1, None), node(10, 2, None), node(20, 3, None)}); + + BOOST_CHECK_EQUAL(curve.evaluate(0.0).value(), 1.0); + BOOST_CHECK_EQUAL(curve.evaluate(10.0).value(), 2.0); + BOOST_CHECK_EQUAL(curve.evaluate(20.0).value(), 3.0); +} + +// None leaves the span between two anchors undefined rather than holding the left value. A +// caller that drew it as a step would be drawing something the model does not say. +BOOST_AUTO_TEST_CASE(test_none_leaves_the_span_empty) { + const ParameterInterpolator curve({node(0, 0, None), node(10, 100, Linear)}); + + BOOST_CHECK(!curve.evaluate(5.0).has_value()); + BOOST_CHECK(curve.evaluate(0.0).has_value()); + BOOST_CHECK(curve.evaluate(10.0).has_value()); +} + +// The interpolation of the *left* anchor governs the span, not the right one. +BOOST_AUTO_TEST_CASE(test_the_left_anchor_governs_the_span) { + const ParameterInterpolator curve( + {node(0, 0, Linear), node(10, 100, None), node(20, 200, Linear)}); + + BOOST_CHECK_SMALL(curve.evaluate(5.0).value() - 50.0, tolerance); + BOOST_CHECK(!curve.evaluate(15.0).has_value()); +} + +BOOST_AUTO_TEST_CASE(test_linear_span) { + const ParameterInterpolator curve({node(0, 0, Linear), node(4, 100, Linear)}); + + BOOST_CHECK_SMALL(curve.evaluate(1.0).value() - 25.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(2.0).value() - 50.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(3.0).value() - 75.0, tolerance); +} + +// Anchors are sorted on construction, so the caller is not required to hand them over in order. +BOOST_AUTO_TEST_CASE(test_unsorted_input_is_sorted) { + const ParameterInterpolator curve( + {node(20, 200, Linear), node(0, 0, Linear), node(10, 100, Linear)}); + + BOOST_CHECK_SMALL(curve.evaluate(5.0).value() - 50.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(15.0).value() - 150.0, tolerance); + BOOST_CHECK(!curve.evaluate(-1.0).has_value()); + BOOST_CHECK(!curve.evaluate(21.0).has_value()); +} + +// The sort is stable, so two anchors on the same x keep the order they were given and the first +// of them is the one lower_bound lands on. +BOOST_AUTO_TEST_CASE(test_duplicate_x_keeps_the_first) { + const ParameterInterpolator curve( + {node(0, 0, Linear), node(10, 111, Linear), node(10, 222, Linear), node(20, 300, Linear)}); + + BOOST_CHECK_EQUAL(curve.evaluate(10.0).value(), 111.0); +} + +// A Hermite span with no neighbour on either side has no reference to estimate a slope from, so +// it falls back to the straight line between its two anchors. +BOOST_AUTO_TEST_CASE(test_hermite_without_references_is_linear) { + const ParameterInterpolator curve({node(0, 0, Hermite), node(10, 100, Hermite)}); + + BOOST_CHECK_SMALL(curve.evaluate(2.5).value() - 25.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(7.5).value() - 75.0, tolerance); +} + +// With neighbours on both sides the span is a cubic, but it still meets its anchors exactly and +// stays inside them while the run is monotone. +BOOST_AUTO_TEST_CASE(test_hermite_with_references) { + const ParameterInterpolator curve({node(0, 0, Hermite), node(10, 100, Hermite), + node(20, 300, Hermite), node(30, 600, Hermite)}); + + BOOST_CHECK_SMALL(curve.evaluate(10.0).value() - 100.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(20.0).value() - 300.0, tolerance); + + for (double x = 10.0; x <= 20.0; x += 0.5) { + const auto y = curve.evaluate(x); + BOOST_REQUIRE(y.has_value()); + BOOST_CHECK_GE(*y, 100.0 - tolerance); + BOOST_CHECK_LE(*y, 300.0 + tolerance); + } +} + +// A run that turns around must not bulge past the anchor it turns at. +BOOST_AUTO_TEST_CASE(test_hermite_does_not_overshoot_a_turning_point) { + const ParameterInterpolator curve( + {node(0, 0, Hermite), node(10, 100, Hermite), node(20, 0, Hermite), node(30, 100, Hermite)}); + + for (double x = 10.0; x <= 20.0; x += 0.25) { + const auto y = curve.evaluate(x); + BOOST_REQUIRE(y.has_value()); + BOOST_CHECK_LE(*y, 100.0 + tolerance); + BOOST_CHECK_GE(*y, 0.0 - tolerance); + } +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/auto/interpolator/test_vibratocurve.cpp b/tests/auto/interpolator/test_vibratocurve.cpp new file mode 100644 index 0000000..a2c33c6 --- /dev/null +++ b/tests/auto/interpolator/test_vibratocurve.cpp @@ -0,0 +1,173 @@ +#include +#include + +#include + +#include + +using namespace opendspx; + +namespace { + + constexpr double tolerance = 1e-9; + + ControlPoint point(double x, double y) { + return ControlPoint{x, y}; + } + + // A plain vibrato over the whole note: one cycle across it, amplitude ten, nothing shaped. + Vibrato plainVibrato() { + Vibrato v; + v.start = 0.0; + v.end = 1.0; + v.amp = 10; + v.freq = 1.0; + v.phase = 0.0; + v.offset = 0; + return v; + } + +} + +BOOST_AUTO_TEST_SUITE(test_vibratocurve) + +// Outside the window the curve is flat zero, not the offset. A note is unaffected where the +// vibrato does not reach, and an offset applied outside would bend the pitch there. +BOOST_AUTO_TEST_CASE(test_outside_the_window_is_zero) { + auto v = plainVibrato(); + v.start = 0.25; + v.end = 0.75; + v.offset = 7; + const VibratoCurve curve(v, 1.0); + + BOOST_CHECK_EQUAL(curve.evaluate(0.0), 0.0); + BOOST_CHECK_EQUAL(curve.evaluate(0.249), 0.0); + BOOST_CHECK_EQUAL(curve.evaluate(0.751), 0.0); + BOOST_CHECK_EQUAL(curve.evaluate(1.0), 0.0); + + // The two ends belong to the window. + BOOST_CHECK_NE(curve.evaluate(0.25), 0.0); + BOOST_CHECK_NE(curve.evaluate(0.75), 0.0); +} + +BOOST_AUTO_TEST_CASE(test_one_cycle_over_the_note) { + const VibratoCurve curve(plainVibrato(), 1.0); + + BOOST_CHECK_SMALL(curve.evaluate(0.0), tolerance); + BOOST_CHECK_SMALL(curve.evaluate(0.25) - 10.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(0.5), tolerance); + BOOST_CHECK_SMALL(curve.evaluate(0.75) + 10.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(1.0), tolerance); +} + +// freq is cycles per unit length, so the note's length is what turns it into cycles across the +// window. A longer note at the same freq oscillates more times, which is the point of storing +// the two separately. +BOOST_AUTO_TEST_CASE(test_length_scales_the_cycle_count) { + const VibratoCurve twice(plainVibrato(), 2.0); + BOOST_CHECK_SMALL(twice.evaluate(0.125) - 10.0, tolerance); + BOOST_CHECK_SMALL(twice.evaluate(0.25), tolerance); + + auto faster = plainVibrato(); + faster.freq = 2.0; + const VibratoCurve doubled(faster, 1.0); + BOOST_CHECK_SMALL(doubled.evaluate(0.125) - 10.0, tolerance); +} + +// Phase is in cycles, not radians. A quarter turn starts the curve at its peak. +BOOST_AUTO_TEST_CASE(test_phase_is_in_cycles) { + auto v = plainVibrato(); + v.phase = 0.25; + const VibratoCurve curve(v, 1.0); + + BOOST_CHECK_SMALL(curve.evaluate(0.0) - 10.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(0.25), tolerance); + + v.phase = 0.5; + const VibratoCurve inverted(v, 1.0); + BOOST_CHECK_SMALL(inverted.evaluate(0.25) + 10.0, tolerance); +} + +BOOST_AUTO_TEST_CASE(test_offset_shifts_the_whole_curve) { + auto v = plainVibrato(); + v.offset = 3; + const VibratoCurve curve(v, 1.0); + + BOOST_CHECK_SMALL(curve.evaluate(0.0) - 3.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(0.25) - 13.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(0.75) + 7.0, tolerance); +} + +// An empty shaping curve means no shaping -- amplitude one everywhere -- rather than zero, which +// would silence every vibrato that did not draw one. +BOOST_AUTO_TEST_CASE(test_no_shaping_curve_means_full_amplitude) { + auto v = plainVibrato(); + BOOST_CHECK(v.points.amp.empty()); + BOOST_CHECK(v.points.freq.empty()); + + const VibratoCurve curve(v, 1.0); + BOOST_CHECK_SMALL(curve.evaluate(0.25) - 10.0, tolerance); +} + +BOOST_AUTO_TEST_CASE(test_amplitude_curve_scales_the_peak) { + auto v = plainVibrato(); + v.points.amp = {point(0.0, 0.0), point(1.0, 1.0)}; + const VibratoCurve curve(v, 1.0); + + // A ramp from silent to full, sampled at the peak of the first cycle. + BOOST_CHECK_SMALL(curve.evaluate(0.25) - 2.5, tolerance); + + v.points.amp = {point(0.0, 0.5), point(1.0, 0.5)}; + const VibratoCurve half(v, 1.0); + BOOST_CHECK_SMALL(half.evaluate(0.25) - 5.0, tolerance); +} + +// Outside the drawn range the shaping curve holds its end value rather than falling to zero. +BOOST_AUTO_TEST_CASE(test_amplitude_curve_holds_beyond_its_ends) { + auto v = plainVibrato(); + v.points.amp = {point(0.4, 0.5), point(0.6, 0.5)}; + const VibratoCurve curve(v, 1.0); + + BOOST_CHECK_SMALL(curve.evaluate(0.25) - 5.0, tolerance); + BOOST_CHECK_SMALL(curve.evaluate(0.75) + 5.0, tolerance); +} + +// The frequency curve is integrated, not sampled: it says how fast phase advances, so a constant +// two doubles the cycles rather than doubling the value. +BOOST_AUTO_TEST_CASE(test_frequency_curve_is_integrated) { + auto v = plainVibrato(); + v.points.freq = {point(0.0, 2.0)}; + const VibratoCurve doubled(v, 1.0); + + // Phase now advances at twice the rate, so the first peak arrives at an eighth. + BOOST_CHECK_SMALL(doubled.evaluate(0.125) - 10.0, tolerance); + BOOST_CHECK_SMALL(doubled.evaluate(0.25), tolerance); + + // A ramp from zero to two integrates to t^2 over [0, 1], so a quarter cycle lands at t = 1/2. + v.points.freq = {point(0.0, 0.0), point(1.0, 2.0)}; + const VibratoCurve ramp(v, 1.0); + BOOST_CHECK_SMALL(ramp.evaluate(0.5) - 10.0, tolerance); + BOOST_CHECK_SMALL(ramp.evaluate(0.0), tolerance); +} + +BOOST_AUTO_TEST_CASE(test_shaping_points_are_sorted) { + auto v = plainVibrato(); + v.points.amp = {point(1.0, 1.0), point(0.0, 0.0)}; + const VibratoCurve curve(v, 1.0); + + BOOST_CHECK_SMALL(curve.evaluate(0.25) - 2.5, tolerance); +} + +// Two points on the same x are one point, and the later one is the one that counts. Without the +// collapse the segment between them has zero width and the interpolation divides by zero. +BOOST_AUTO_TEST_CASE(test_duplicate_x_keeps_the_last) { + auto v = plainVibrato(); + v.points.amp = {point(0.0, 1.0), point(1.0, 0.0), point(1.0, 0.5)}; + const VibratoCurve curve(v, 1.0); + + // The ramp now runs 1.0 -> 0.5, so a quarter of the way in the scale is 0.875. + BOOST_CHECK_SMALL(curve.evaluate(0.25) - 8.75, tolerance); + BOOST_CHECK(std::isfinite(curve.evaluate(1.0))); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/auto/main.cpp b/tests/auto/main.cpp new file mode 100644 index 0000000..4b702c2 --- /dev/null +++ b/tests/auto/main.cpp @@ -0,0 +1,3 @@ +#define BOOST_TEST_MAIN + +#include diff --git a/tests/auto/serializer/helpers_p.h b/tests/auto/serializer/helpers_p.h new file mode 100644 index 0000000..043c3db --- /dev/null +++ b/tests/auto/serializer/helpers_p.h @@ -0,0 +1,161 @@ +#ifndef OPENDSPX_TEST_SERIALIZER_HELPERS_P_H +#define OPENDSPX_TEST_SERIALIZER_HELPERS_P_H + +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include + +namespace opendspx { + + // So that a failing BOOST_CHECK_EQUAL on an error type prints the code rather than refusing + // to compile. Found by ADL, which is why it lives in opendspx rather than in the test's own + // namespace. + inline std::ostream &operator<<(std::ostream &os, SerializationError::Type type) { + return os << "0x" << std::hex << static_cast(type) << std::dec; + } + +} + +namespace opendspx::test { + + constexpr Serializer::Option defaultOptions = Serializer::FailFast | Serializer::CheckError; + + inline std::string serialize(const Model &model, SerializationErrorList &errors, + Serializer::Option options = defaultOptions, + bool compress = false) { + std::ostringstream out(std::ios::binary); + Serializer::serialize(out, model, errors, options, compress); + return out.str(); + } + + inline Model deserialize(const std::string &text, SerializationErrorList &errors, + Serializer::Option options = defaultOptions) { + std::istringstream in(text, std::ios::binary | std::ios::in); + return Serializer::deserialize(in, errors, options); + } + + inline stdc::JsonValue parse(std::string_view text) { + stdc::JsonParseError error; + auto value = stdc::JsonValue::fromJson(text, false, &error); + BOOST_REQUIRE_MESSAGE(!error, "the test's own JSON does not parse: " << error.message()); + return value; + } + + // SerializationError has no vtable -- it is discriminated by type() -- so a downcast is + // checked against that rather than by dynamic_cast. + template + std::shared_ptr errorAs(const SerializationErrorRef &error) { + return std::static_pointer_cast(error); + } + + // Clip, Singer and ParamCurve are the same shape: a base holding a type enum and no virtual + // anything, so which one an object is has to be read off that enum. Returns null when the + // object is not the kind asked for, which is what the cases check. + template + std::shared_ptr derivedAs(const std::shared_ptr &value, Type type) { + if (!value || value->type != type) { + return nullptr; + } + return std::static_pointer_cast(value); + } + + inline std::string replaceFirst(std::string text, std::string_view from, std::string_view to) { + const auto at = text.find(from); + BOOST_REQUIRE_MESSAGE(at != std::string::npos, + "the document to mutate does not contain " << from); + return text.replace(at, from.size(), to); + } + + // A model with something in every corner the mappings have to reach: both clip kinds, both + // singer kinds, both parameter curve kinds, a workspace at each level. + inline Model richModel() { + Model model; + + model.content.global.name = "song"; + model.content.global.author = "author"; + model.content.global.centShift = -3; + model.content.global.editorId = "editor"; + model.content.global.editorName = "Editor"; + + model.content.master.control.gain = -1.5; + model.content.master.control.pan = 0.25; + model.content.master.control.mute = true; + + model.content.timeline.tempos = {Tempo{0, 120.0}, Tempo{1920, 87.5}}; + model.content.timeline.timeSignatures = {TimeSignature{0, 3, 4}, TimeSignature{4, 7, 8}}; + model.content.timeline.labels = {Label{0, "intro"}, Label{1920, "verse"}}; + model.content.workspace["app"] = stdc::JsonObject{{"zoom", 1.5}, {"page", 2}}; + + Note note; + note.pos = 0; + note.length = 480; + note.keyNum = 60; + note.centShift = 25; + note.language = "eng"; + note.lyric = "la"; + note.pronunciation = Pronunciation{"l a", "l a"}; + note.phonemes.original = {Phoneme{"eng", "l", 0, true}, Phoneme{"eng", "a", 100, false}}; + note.phonemes.edited = note.phonemes.original; + note.vibrato.start = 0.2; + note.vibrato.end = 0.9; + note.vibrato.amp = 40; + note.vibrato.freq = 5.5; + note.vibrato.phase = 0.125; + note.vibrato.offset = 1; + note.vibrato.points.amp = {ControlPoint{0.0, 0.0}, ControlPoint{1.0, 1.0}}; + note.vibrato.points.freq = {ControlPoint{0.0, 1.0}}; + note.workspace["note"] = stdc::JsonObject{{"marked", true}}; + + auto singing = std::make_shared(); + singing->name = "vocal"; + singing->time = ClipTime{0, 1920, 0, 1920}; + singing->control.gain = 0.5; + singing->notes = {note}; + singing->workspace["clip"] = stdc::JsonObject{{"colour", "red"}}; + + Param pitch; + pitch.original = {std::make_shared(0, 5, std::vector{1, 2, 3})}; + pitch.edited = {std::make_shared( + 0, std::vector{AnchorNode{AnchorNode::Interpolation::Linear, 0, 6000}, + AnchorNode{AnchorNode::Interpolation::Hermite, 480, 6200}})}; + singing->params["pitch"] = pitch; + + Sources sources; + sources.category = "voice"; + sources.singers = {std::make_shared("alice"), + std::make_shared( + std::vector{std::make_shared("bob"), + std::make_shared("carol")}, + SourceMixingRatio{0.4})}; + sources.mix = {DynamicMixingAnchor{0, SourceMixingRatio{0.5}}}; + singing->sources = sources; + + auto audio = std::make_shared(); + audio->name = "backing"; + audio->path = "a/b.wav"; + audio->time = ClipTime{0, 3840, 0, 3840}; + + Track track; + track.name = "track"; + track.control.gain = -6.0; + track.control.pan = -0.5; + track.control.solo = true; + track.clips = {singing, audio}; + track.workspace["track"] = stdc::JsonObject{{"height", 120}}; + + model.content.tracks = {track}; + return model; + } + +} + +#endif // OPENDSPX_TEST_SERIALIZER_HELPERS_P_H diff --git a/tests/auto/serializer/test_jsonconverterv1.cpp b/tests/auto/serializer/test_jsonconverterv1.cpp new file mode 100644 index 0000000..aae8eea --- /dev/null +++ b/tests/auto/serializer/test_jsonconverterv1.cpp @@ -0,0 +1,477 @@ +#include +#include + +#include + +#include + +#include + +using namespace opendspx; +using namespace opendspx::test; + +namespace { + + // The mapping layer is where the error paths are built, so most cases here read a fragment + // and look at what came back rather than at the model. + template + T read(std::string_view text, SerializationErrorList &errors, + Serializer::Option options = Serializer::CheckError) { + return JsonConverterV1::fromJson(parse(text), errors, options); + } + + template + stdc::JsonValue write(const T &entity, SerializationErrorList &errors, + Serializer::Option options = Serializer::CheckError) { + return JsonConverterV1::toJson(entity, errors, options); + } + + +} + +BOOST_AUTO_TEST_SUITE(test_jsonconverterv1) + +BOOST_AUTO_TEST_CASE(test_entity_round_trip) { + Global global; + global.author = "author"; + global.name = "name"; + global.centShift = 12; + global.editorId = "id"; + global.editorName = "editor"; + + SerializationErrorList errors; + const auto json = write(global, errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + BOOST_CHECK(json.isObject()); + + const auto back = JsonConverterV1::fromJson(json, errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + BOOST_CHECK_EQUAL(back.author, "author"); + BOOST_CHECK_EQUAL(back.centShift, 12); +} + +// The root of a path is the caller's to name, so an error from a fragment can be reported +// against wherever that fragment sits in a larger document. +BOOST_AUTO_TEST_CASE(test_path_root_is_the_callers) { + SerializationErrorList errors; + JsonConverterV1::fromJson(parse(R"({"centShift":900})"), errors, + Serializer::CheckError, "somewhere"); + + BOOST_REQUIRE_GE(errors.size(), 1); + bool sawIt = false; + for (const auto &error : errors) { + if (error->type() == SerializationError::RangeConstraintViolation) { + BOOST_CHECK_EQUAL(errorAs(error)->path(), + "somewhere.centShift"); + sawIt = true; + } + } + BOOST_CHECK(sawIt); +} + +// An index for an array, a dot for a property, all the way down. This is what an editor puts in +// front of the user, so it is checked at more than one level of nesting. +BOOST_AUTO_TEST_CASE(test_error_paths) { + SerializationErrorList errors; + read( + R"({"labels":[],"timeSignatures":[],"tempos":[{"pos":0,"value":120},{"pos":0,"value":5}]})", + errors); + + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::RangeConstraintViolation); + BOOST_CHECK_EQUAL(errorAs(errors[0])->path(), + "$.tempos[1].value"); +} + +BOOST_AUTO_TEST_CASE(test_deeply_nested_error_path) { + SerializationErrorList errors; + read( + R"({"type":"anchor","start":0,"nodes":[{"x":0,"y":0,"interp":"linear"},)" + R"({"x":-5,"y":0,"interp":"linear"}]})", + errors); + + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_CHECK_EQUAL(errorAs(errors[0])->path(), + "$.nodes[1].x"); +} + +// Every scalar kind reports what it was given as well as what it wanted, so the message can say +// both. The reported kind is the JSON one, not the C++ one. +BOOST_AUTO_TEST_CASE(test_data_type_errors) { + const struct { + const char *json; + InvalidDataTypeError::DataType actual; + } cases[] = { + {R"({"author":1,"name":"n","centShift":0,"editorId":"e","editorName":"E"})", + InvalidDataTypeError::Integer}, + {R"({"author":1.5,"name":"n","centShift":0,"editorId":"e","editorName":"E"})", + InvalidDataTypeError::Double}, + {R"({"author":true,"name":"n","centShift":0,"editorId":"e","editorName":"E"})", + InvalidDataTypeError::Bool}, + {R"({"author":null,"name":"n","centShift":0,"editorId":"e","editorName":"E"})", + InvalidDataTypeError::Null}, + {R"({"author":[],"name":"n","centShift":0,"editorId":"e","editorName":"E"})", + InvalidDataTypeError::Array}, + {R"({"author":{},"name":"n","centShift":0,"editorId":"e","editorName":"E"})", + InvalidDataTypeError::Object}, + }; + + for (const auto &[json, actual] : cases) { + SerializationErrorList errors; + read(json, errors); + BOOST_REQUIRE_MESSAGE(errors.size() == 1, "for " << json); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::InvalidDataType); + const auto error = errorAs(errors[0]); + BOOST_CHECK_EQUAL(error->path(), "$.author"); + BOOST_CHECK_EQUAL(static_cast(error->actualType()), static_cast(actual)); + BOOST_REQUIRE_EQUAL(error->expectedTypes().size(), 1); + BOOST_CHECK_EQUAL(static_cast(error->expectedTypes().front()), + static_cast(InvalidDataTypeError::String)); + } +} + +// A number is an integer when its value is one, not when it was written without a point. 1.0 +// where an integer belongs is the same number, and a document that has been through a library +// that writes every number as a double still reads. +BOOST_AUTO_TEST_CASE(test_a_whole_double_counts_as_an_integer) { + { + SerializationErrorList errors; + const auto global = read( + R"({"author":"a","name":"n","centShift":12.0,"editorId":"e","editorName":"E"})", + errors); + BOOST_CHECK_EQUAL(errors.size(), 0); + BOOST_CHECK_EQUAL(global.centShift, 12); + } + { + SerializationErrorList errors; + read(R"({"author":"a","name":"n","centShift":12.5,"editorId":"e","editorName":"E"})", + errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::InvalidDataType); + BOOST_CHECK_EQUAL(static_cast(errorAs(errors[0])->actualType()), + static_cast(InvalidDataTypeError::Double)); + } + // The other way round is fine: a double property takes a number written without a point. + { + SerializationErrorList errors; + const auto tempo = read(R"({"pos":0,"value":120})", errors); + BOOST_CHECK_EQUAL(errors.size(), 0); + BOOST_CHECK_EQUAL(tempo.value, 120.0); + } +} + +// A range error carries the value and the bounds, not only a sentence, so a caller can offer to +// clamp. A one-sided bound leaves the other empty rather than making one up. +BOOST_AUTO_TEST_CASE(test_range_constraint_carries_its_bounds) { + { + SerializationErrorList errors; + read(R"({"pos":0,"length":0,"keyNum":200,"centShift":0,"language":"","lyric":"",)" + R"("pronunciation":{"original":"","edited":""},)" + R"("phonemes":{"original":[],"edited":[]},)" + R"("vibrato":{"start":0,"end":1,"amp":0,"freq":0,"phase":0,"offset":0,)" + R"("points":{"amp":[],"freq":[]}},"workspace":{}})", + errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + const auto error = errorAs(errors[0]); + BOOST_CHECK_EQUAL(error->path(), "$.keyNum"); + BOOST_CHECK_EQUAL(std::any_cast(error->actualValue()), 200); + BOOST_CHECK_EQUAL(std::any_cast(error->expectedMinimum()), 0); + BOOST_CHECK_EQUAL(std::any_cast(error->expectedMaximum()), 127); + } + { + // pos has a floor and no ceiling. + SerializationErrorList errors; + read(R"({"pos":-1,"value":120})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + const auto error = errorAs(errors[0]); + BOOST_CHECK_EQUAL(std::any_cast(error->expectedMinimum()), 0); + BOOST_CHECK(!error->expectedMaximum().has_value()); + } + // The bounds are inclusive. + { + SerializationErrorList errors; + read(R"({"pos":0,"value":10})", errors); + read(R"({"pos":0,"value":1000})", errors); + BOOST_CHECK_EQUAL(errors.size(), 0); + } +} + +// A property with a single legal value is still a range, which is how "step must be 5" is said. +BOOST_AUTO_TEST_CASE(test_a_range_of_one) { + SerializationErrorList errors; + read(R"({"type":"free","start":0,"step":10,"values":[]})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + const auto error = errorAs(errors[0]); + BOOST_CHECK_EQUAL(error->path(), "$.step"); + BOOST_CHECK_EQUAL(std::any_cast(error->expectedMinimum()), 5); + BOOST_CHECK_EQUAL(std::any_cast(error->expectedMaximum()), 5); +} + +BOOST_AUTO_TEST_CASE(test_enum_constraint) { + { + SerializationErrorList errors; + read(R"({"x":0,"y":0,"interp":"wobble"})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::EnumConstraintViolation); + const auto error = errorAs(errors[0]); + BOOST_CHECK_EQUAL(error->path(), "$.interp"); + // The three the format allows, offered back to the caller. + BOOST_CHECK_EQUAL(error->expectedEnumValues().size(), 3); + } + { + // A name where a name belongs, but the wrong kind of value, is a type error rather than + // an enum one -- there is nothing to compare against the list. + SerializationErrorList errors; + read(R"({"x":0,"y":0,"interp":3})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_CHECK_EQUAL(errors[0]->type(), SerializationError::InvalidDataType); + } + { + // The time signature denominator is an enum of numbers rather than of names. + SerializationErrorList errors; + read(R"({"index":0,"numerator":4,"denominator":5})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::EnumConstraintViolation); + BOOST_CHECK_EQUAL(errorAs(errors[0])->expectedEnumValues().size(), 8); + } + { + SerializationErrorList errors; + const auto signature = read(R"({"index":0,"numerator":4,"denominator":128})", errors); + BOOST_CHECK_EQUAL(errors.size(), 0); + BOOST_CHECK_EQUAL(signature.denominator, 128); + } +} + +// Which kind of clip a fragment is comes from its type property, and an unknown one is answered +// with the ones there are. +BOOST_AUTO_TEST_CASE(test_polymorphic_dispatch) { + { + SerializationErrorList errors; + const auto clip = read( + R"({"type":"audio","name":"n","path":"p","control":{"gain":0,"pan":0,"mute":false},)" + R"("time":{"pos":0,"length":0,"clipStart":0,"clipLen":0},"workspace":{}})", + errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + BOOST_REQUIRE(clip); + BOOST_CHECK(clip->type == Clip::Type::Audio); + BOOST_CHECK_EQUAL(derivedAs(clip, Clip::Type::Audio)->path, "p"); + } + { + SerializationErrorList errors; + read(R"({"type":"video"})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::InvalidObjectType); + const auto error = errorAs(errors[0]); + BOOST_CHECK_EQUAL(error->actualType(), "video"); + BOOST_REQUIRE_EQUAL(error->expectedTypes().size(), 2); + BOOST_CHECK_EQUAL(error->expectedTypes()[0], "audio"); + BOOST_CHECK_EQUAL(error->expectedTypes()[1], "singing"); + } + { + // No type property at all, which is a missing property rather than an unknown type. + SerializationErrorList errors; + read(R"({"name":"n"})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::MissingProperty); + BOOST_CHECK_EQUAL(errorAs(errors[0])->missingProperties().front(), + "type"); + } + { + // Not an object at all. + SerializationErrorList errors; + read("42", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_CHECK_EQUAL(errors[0]->type(), SerializationError::InvalidDataType); + } + { + // A type property that is not a name. It used to reach the JSON library as a bad cast. + SerializationErrorList errors; + read(R"({"type":7})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_CHECK_EQUAL(errors[0]->type(), SerializationError::InvalidObjectType); + } +} + +// An optional property is null when it is absent from the model, and null reads back as absent. +// A missing one is a different thing, and is reported. +BOOST_AUTO_TEST_CASE(test_optional_property) { + SingingClip clip; + SerializationErrorList errors; + const auto json = write(clip, errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + BOOST_CHECK(json["sources"].isNull()); + + const auto back = JsonConverterV1::fromJson(json, errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + BOOST_CHECK(!back.sources.has_value()); +} + +// A workspace value is an object, and the key it was found under is part of the path -- without +// it the report says only which workspace, not which entry of it. +BOOST_AUTO_TEST_CASE(test_workspace_entries) { + { + SerializationErrorList errors; + const auto workspace = read(R"({"a":{"x":1},"b":{}})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + BOOST_CHECK_EQUAL(workspace.size(), 2); + BOOST_CHECK_EQUAL(workspace.at("a").at("x").toInt(), 1); + } + { + SerializationErrorList errors; + read(R"({"a":{},"bad":42})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::InvalidDataType); + BOOST_CHECK_EQUAL(errorAs(errors[0])->path(), "$.bad"); + } + { + // A workspace that is not an object at all. + SerializationErrorList errors; + read("[]", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_CHECK_EQUAL(errors[0]->type(), SerializationError::InvalidDataType); + } +} + +// A mixing ratio holds one number fewer than there are sources and the numbers have to be a +// share of a whole, so both the individual bounds and the total are checked. +BOOST_AUTO_TEST_CASE(test_mixing_ratio) { + { + SerializationErrorList errors; + const auto ratio = read("[0.25,0.5]", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + BOOST_CHECK_EQUAL(ratio.size(), 2); + } + { + SerializationErrorList errors; + read("[0.25,1.5]", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::RangeConstraintViolation); + BOOST_CHECK_EQUAL(errorAs(errors[0])->path(), "$[1]"); + } + { + // Each share is legal on its own, but together they are more than there is. + SerializationErrorList errors; + read(R"({"pos":0,"ratio":[0.7,0.6]})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::InvalidRatioPartition); + const auto error = errorAs(errors[0]); + BOOST_CHECK_EQUAL(error->path(), "$.ratio"); + BOOST_REQUIRE_EQUAL(error->ratio().size(), 2); + BOOST_CHECK_EQUAL(error->ratio()[0], 0.7); + } +} + +// The two source constraints that are not about one property: a mix has to name somebody, and +// each anchor has to have a share for each of them. +BOOST_AUTO_TEST_CASE(test_mixed_singer_constraints) { + const auto singer = [](const char *singers, const char *ratio) { + return std::string(R"({"type":"mixed","extra":{},"workspace":{},"singers":)") + singers + + R"(,"ratio":)" + ratio + "}"; + }; + constexpr const char *one = R"([{"type":"single","id":"a","extra":{},"workspace":{}}])"; + constexpr const char *two = + R"([{"type":"single","id":"a","extra":{},"workspace":{}},)" + R"({"type":"single","id":"b","extra":{},"workspace":{}}])"; + + { + SerializationErrorList errors; + read(singer(two, "[0.4]"), errors); + BOOST_CHECK_EQUAL(errors.size(), 0); + } + { + SerializationErrorList errors; + read(singer("[]", "[]"), errors); + BOOST_REQUIRE_GE(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::EmptySingerMixing); + BOOST_CHECK_EQUAL(errorAs(errors[0])->path(), "$.singers"); + } + { + // Two singers but a ratio for three. + SerializationErrorList errors; + read(singer(two, "[0.3,0.3]"), errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::PartCountNotMatch); + const auto error = errorAs(errors[0]); + BOOST_CHECK_EQUAL(error->path(), "$.ratio"); + BOOST_CHECK_EQUAL(error->expectedPartCount(), 2); + BOOST_CHECK_EQUAL(error->actualPartCount(), 3); + } + { + SerializationErrorList errors; + read(singer(one, "[]"), errors); + BOOST_CHECK_EQUAL(errors.size(), 0); + } +} + +// Writing checks what reading checks. A model held in memory can be out of range -- nothing stops +// a caller assigning it -- and the error has to come before the file does, not after. +BOOST_AUTO_TEST_CASE(test_writing_checks_constraints_too) { + { + Note note; + note.keyNum = 300; + SerializationErrorList errors; + write(note, errors); + BOOST_REQUIRE_GE(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::RangeConstraintViolation); + BOOST_CHECK_EQUAL(errorAs(errors[0])->path(), "$.keyNum"); + } + { + MixedSinger mixed; + SerializationErrorList errors; + write(mixed, errors); + BOOST_REQUIRE_GE(errors.size(), 1); + BOOST_CHECK_EQUAL(errors[0]->type(), SerializationError::EmptySingerMixing); + } + { + DynamicMixingAnchor anchor; + anchor.ratio = SourceMixingRatio{0.7, 0.6}; + SerializationErrorList errors; + write(anchor, errors); + BOOST_REQUIRE_GE(errors.size(), 1); + BOOST_CHECK_EQUAL(errors[0]->type(), SerializationError::InvalidRatioPartition); + } + // With checking off the same model writes without complaint. + { + Note note; + note.keyNum = 300; + SerializationErrorList errors; + const auto json = write(note, errors, Serializer::Option{}); + BOOST_CHECK_EQUAL(errors.size(), 0); + BOOST_CHECK_EQUAL(json["keyNum"].toInt(), 300); + } +} + +// An enum value the format has no name for cannot be written, and saying so is better than +// writing a document that will not read back. +BOOST_AUTO_TEST_CASE(test_writing_an_unnamed_enum_value) { + AnchorNode node; + node.interp = static_cast(42); + + SerializationErrorList errors; + const auto json = write(node, errors); + BOOST_REQUIRE_GE(errors.size(), 1); + BOOST_CHECK_EQUAL(errors[0]->type(), SerializationError::EnumConstraintViolation); + BOOST_CHECK(json["interp"].isNull()); +} + +// The error list keeps a running answer to "is any of this fatal", so a caller can decide +// whether to go on without walking the list. +BOOST_AUTO_TEST_CASE(test_error_list_summarises_itself) { + SerializationErrorList errors; + BOOST_CHECK(!errors.containsFatal()); + BOOST_CHECK(!errors.containsError()); + BOOST_CHECK(!errors.containsWarning()); + + read(R"({"author":1,"name":"n","centShift":0,"editorId":"e","editorName":"E"})", + errors); + BOOST_CHECK(errors.containsError()); + BOOST_CHECK(!errors.containsFatal()); + + errors.addError(); + BOOST_CHECK(errors.containsFatal()); + + errors.addError("$"); + BOOST_CHECK(errors.containsWarning()); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/auto/serializer/test_serializer.cpp b/tests/auto/serializer/test_serializer.cpp new file mode 100644 index 0000000..de616b2 --- /dev/null +++ b/tests/auto/serializer/test_serializer.cpp @@ -0,0 +1,416 @@ +#include +#include + +#include + +#include + +using namespace opendspx; +using namespace opendspx::test; + +BOOST_AUTO_TEST_SUITE(test_serializer) + +BOOST_AUTO_TEST_CASE(test_version_text) { + BOOST_CHECK_EQUAL(Serializer::versionToText(Model::Version::V1), "1.0.0"); + + bool ok = false; + BOOST_CHECK(Serializer::versionFromText("1.0.0", &ok) == Model::Version::V1); + BOOST_CHECK(ok); + + Serializer::versionFromText("1.0", &ok); + BOOST_CHECK(!ok); + Serializer::versionFromText("", &ok); + BOOST_CHECK(!ok); + + // The out parameter is optional, and asking without it must not reach through a null. + BOOST_CHECK_NO_THROW(Serializer::versionFromText("nonsense")); +} + +// The document a model writes reads back as the same model. Checked over a model with something +// in every corner, because the mappings are generated per property and a missing one is silent. +BOOST_AUTO_TEST_CASE(test_round_trip) { + SerializationErrorList writeErrors; + const auto text = serialize(richModel(), writeErrors); + BOOST_REQUIRE_EQUAL(writeErrors.size(), 0); + + SerializationErrorList readErrors; + const auto model = deserialize(text, readErrors); + BOOST_REQUIRE_EQUAL(readErrors.size(), 0); + + BOOST_CHECK(model.version == Model::Version::V1); + BOOST_CHECK_EQUAL(model.content.global.name, "song"); + BOOST_CHECK_EQUAL(model.content.global.centShift, -3); + BOOST_CHECK_EQUAL(model.content.master.control.gain, -1.5); + BOOST_CHECK_EQUAL(model.content.master.control.mute, true); + + BOOST_REQUIRE_EQUAL(model.content.timeline.tempos.size(), 2); + BOOST_CHECK_EQUAL(model.content.timeline.tempos[1].value, 87.5); + BOOST_REQUIRE_EQUAL(model.content.timeline.timeSignatures.size(), 2); + BOOST_CHECK_EQUAL(model.content.timeline.timeSignatures[1].denominator, 8); + BOOST_REQUIRE_EQUAL(model.content.timeline.labels.size(), 2); + BOOST_CHECK_EQUAL(model.content.timeline.labels[1].text, "verse"); + + BOOST_REQUIRE_EQUAL(model.content.tracks.size(), 1); + const auto &track = model.content.tracks.front(); + BOOST_CHECK_EQUAL(track.name, "track"); + BOOST_CHECK_EQUAL(track.control.solo, true); + BOOST_REQUIRE_EQUAL(track.clips.size(), 2); + + // The clips came back as their own kinds, not as the base. + const auto singing = derivedAs(track.clips[0], Clip::Type::Singing); + BOOST_REQUIRE(singing); + BOOST_CHECK(singing->type == Clip::Type::Singing); + BOOST_CHECK_EQUAL(singing->name, "vocal"); + BOOST_REQUIRE_EQUAL(singing->notes.size(), 1); + + const auto ¬e = singing->notes.front(); + BOOST_CHECK_EQUAL(note.keyNum, 60); + BOOST_CHECK_EQUAL(note.centShift, 25); + BOOST_CHECK_EQUAL(note.lyric, "la"); + BOOST_CHECK_EQUAL(note.pronunciation.edited, "l a"); + BOOST_REQUIRE_EQUAL(note.phonemes.original.size(), 2); + BOOST_CHECK_EQUAL(note.phonemes.original[0].onset, true); + BOOST_CHECK_EQUAL(note.phonemes.original[1].start, 100); + BOOST_CHECK_EQUAL(note.vibrato.amp, 40); + BOOST_CHECK_EQUAL(note.vibrato.freq, 5.5); + BOOST_REQUIRE_EQUAL(note.vibrato.points.amp.size(), 2); + BOOST_CHECK_EQUAL(note.vibrato.points.amp[1].y, 1.0); + + // Both parameter curve kinds, again dispatched by their type property. + const auto pitch = singing->params.find("pitch"); + BOOST_REQUIRE(pitch != singing->params.end()); + BOOST_REQUIRE_EQUAL(pitch->second.original.size(), 1); + const auto free = derivedAs(pitch->second.original.front(), ParamCurve::Free); + BOOST_REQUIRE(free); + BOOST_CHECK_EQUAL(free->step, 5); + BOOST_CHECK_EQUAL(free->values.size(), 3); + const auto anchors = derivedAs(pitch->second.edited.front(), ParamCurve::Anchor); + BOOST_REQUIRE(anchors); + BOOST_REQUIRE_EQUAL(anchors->nodes.size(), 2); + BOOST_CHECK(anchors->nodes[1].interp == AnchorNode::Interpolation::Hermite); + BOOST_CHECK_EQUAL(anchors->nodes[1].y, 6200); + + // Both singer kinds, the mixed one holding singers of its own. + BOOST_REQUIRE(singing->sources.has_value()); + BOOST_CHECK_EQUAL(singing->sources->category, "voice"); + BOOST_REQUIRE_EQUAL(singing->sources->singers.size(), 2); + const auto single = derivedAs(singing->sources->singers[0], Singer::Type::Single); + BOOST_REQUIRE(single); + BOOST_CHECK_EQUAL(single->id, "alice"); + const auto mixed = derivedAs(singing->sources->singers[1], Singer::Type::Mixed); + BOOST_REQUIRE(mixed); + BOOST_REQUIRE_EQUAL(mixed->singers.size(), 2); + BOOST_REQUIRE_EQUAL(mixed->ratio.size(), 1); + BOOST_CHECK_EQUAL(mixed->ratio[0], 0.4); + + const auto audio = derivedAs(track.clips[1], Clip::Type::Audio); + BOOST_REQUIRE(audio); + BOOST_CHECK_EQUAL(audio->path, "a/b.wav"); + + // Workspaces are carried through untouched at every level they appear. + BOOST_CHECK_EQUAL(model.content.workspace.at("app").at("page").toInt(), 2); + BOOST_CHECK_EQUAL(track.workspace.at("track").at("height").toInt(), 120); + BOOST_CHECK_EQUAL(singing->workspace.at("clip").at("colour").toString(), "red"); + BOOST_CHECK_EQUAL(note.workspace.at("note").at("marked").toBool(), true); +} + +// Writing what was read has to give the same bytes, or a save that changed nothing would still +// show up as a change to whatever is watching the file. +BOOST_AUTO_TEST_CASE(test_round_trip_is_stable) { + SerializationErrorList errors; + const auto first = serialize(richModel(), errors); + const auto second = serialize(deserialize(first, errors), errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + BOOST_CHECK_EQUAL(first, second); +} + +BOOST_AUTO_TEST_CASE(test_output_is_compact) { + SerializationErrorList errors; + const auto text = serialize(richModel(), errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + + BOOST_CHECK(text.find('\n') == std::string::npos); + BOOST_CHECK(text.find(": ") == std::string::npos); + BOOST_CHECK(text.find(", ") == std::string::npos); +} + +BOOST_AUTO_TEST_CASE(test_compressed_round_trip) { + SerializationErrorList errors; + const auto plain = serialize(richModel(), errors); + const auto packed = serialize(richModel(), errors, defaultOptions, true); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + + // A zstd frame, and actually smaller than the text it came from. + BOOST_REQUIRE_GE(packed.size(), 4); + const std::array magic{0x28, 0xB5, 0x2F, 0xFD}; + for (std::size_t i = 0; i < magic.size(); ++i) { + BOOST_CHECK_EQUAL(static_cast(packed[i]), magic[i]); + } + BOOST_CHECK_LT(packed.size(), plain.size()); + + // Reading does not have to be told which one it was given. + SerializationErrorList readErrors; + const auto model = deserialize(packed, readErrors); + BOOST_REQUIRE_EQUAL(readErrors.size(), 0); + BOOST_CHECK_EQUAL(serialize(model, readErrors), plain); +} + +// A number keeps the form it was written in through a workspace, which is the one place the +// document carries values the model does not describe. An integer that came back as 1.0 would +// change the file every time it was opened and saved. +BOOST_AUTO_TEST_CASE(test_number_form_survives_a_round_trip) { + Model model; + model.content.workspace["k"] = stdc::JsonObject{ + {"i", stdc::JsonValue(1)}, + {"d", stdc::JsonValue(1.0)}, + {"negative", stdc::JsonValue(-7)}, + {"big", stdc::JsonValue(std::int64_t(9007199254740993))}, + }; + + SerializationErrorList errors; + const auto text = serialize(model, errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + BOOST_CHECK(text.find("\"i\":1,") != std::string::npos); + BOOST_CHECK(text.find("\"d\":1.0") != std::string::npos); + BOOST_CHECK(text.find("\"negative\":-7") != std::string::npos); + BOOST_CHECK(text.find("9007199254740993") != std::string::npos); + + const auto back = deserialize(text, errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + const auto &k = back.content.workspace.at("k"); + BOOST_CHECK(k.at("i").isInt()); + BOOST_CHECK(k.at("d").isDouble()); + BOOST_CHECK_EQUAL(k.at("big").toInt(), 9007199254740993); +} + +// Writing must not fail on text that is not UTF-8, because the model can hold whatever a caller +// put in a std::string. The offending bytes are replaced and the rest of the document survives. +BOOST_AUTO_TEST_CASE(test_invalid_utf8_is_replaced_rather_than_fatal) { + Model model; + model.content.global.name = std::string("a\xFF\xFE" + "b"); + model.content.global.author = "kept"; + + SerializationErrorList errors; + const auto text = serialize(model, errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + + SerializationErrorList readErrors; + const auto back = deserialize(text, readErrors); + BOOST_REQUIRE_EQUAL(readErrors.size(), 0); + + BOOST_CHECK_EQUAL(back.content.global.author, "kept"); + BOOST_CHECK_NE(back.content.global.name, model.content.global.name); + BOOST_CHECK(back.content.global.name.find("\xEF\xBF\xBD") != std::string::npos); + BOOST_CHECK_EQUAL(back.content.global.name.front(), 'a'); + BOOST_CHECK_EQUAL(back.content.global.name.back(), 'b'); +} + +// A rejected document says where it was rejected. This used to be a sentence the caller had to +// match on; an editor that wants to put the cursor on the offending character needs the numbers. +BOOST_AUTO_TEST_CASE(test_parse_failure_says_where) { + const std::string text = "{\"version\": \"1.0.0\",\n oops}"; + + SerializationErrorList errors; + deserialize(text, errors); + + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::JsonParseFailure); + BOOST_CHECK(errors[0]->isFatal()); + + const auto failure = errorAs(errors[0]); + BOOST_CHECK_EQUAL(static_cast(failure->code()), + static_cast(stdc::JsonParseError::UnexpectedToken)); + BOOST_CHECK_EQUAL(failure->line(), 2); + BOOST_CHECK_EQUAL(failure->column(), 3); + BOOST_CHECK_EQUAL(failure->offset(), 23); + BOOST_CHECK_EQUAL(text[failure->offset()], 'o'); + BOOST_CHECK(!failure->message().empty()); + BOOST_CHECK(!failure->error().what.empty()); +} + +// Each of the parser's codes reaches the caller as itself rather than collapsing into one. +BOOST_AUTO_TEST_CASE(test_parse_failure_codes) { + const std::pair cases[] = { + {"{\"version\":", stdc::JsonParseError::UnexpectedEnd}, + {"{\"version\":01}", stdc::JsonParseError::IllegalNumber}, + {"{\"version\":\"\\q\"}", stdc::JsonParseError::IllegalEscape}, + {"{\"version\":\"a\tb\"}", stdc::JsonParseError::IllegalString}, + {"{\"version\":\"1.0.0\"} trailing", stdc::JsonParseError::TrailingContent}, + // A comment is called out by name only where a value was expected. One after the whole + // document is trailing content, comments enabled or not, so it stays that. + {"{\"version\":/* c */\"1.0.0\"}", stdc::JsonParseError::CommentNotAllowed}, + {"{\"version\":\"1.0.0\"} /* c */", stdc::JsonParseError::TrailingContent}, + }; + + for (const auto &[text, code] : cases) { + SerializationErrorList errors; + deserialize(text, errors); + BOOST_REQUIRE_MESSAGE(errors.size() >= 1, "no error for " << text); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::JsonParseFailure); + BOOST_CHECK_EQUAL(static_cast(errorAs(errors[0])->code()), + static_cast(code)); + } +} + +BOOST_AUTO_TEST_CASE(test_root_must_be_an_object) { + SerializationErrorList errors; + deserialize("[1,2,3]", errors); + + BOOST_REQUIRE_GE(errors.size(), 1); + BOOST_CHECK_EQUAL(errors[0]->type(), SerializationError::JsonRootIsNotObject); + BOOST_CHECK(errors[0]->isFatal()); +} + +// The version decides which mapping reads the rest, so it is looked at before anything else and +// nothing is reported about a document whose version nobody knows. +BOOST_AUTO_TEST_CASE(test_version_must_be_recognised) { + { + SerializationErrorList errors; + deserialize(R"({"content":{}})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::UnrecognizedVersion); + BOOST_CHECK_EQUAL(errorAs(errors[0])->actualVersion(), ""); + } + { + SerializationErrorList errors; + deserialize(R"({"version":"9.9.9","content":{}})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_REQUIRE_EQUAL(errors[0]->type(), SerializationError::UnrecognizedVersion); + BOOST_CHECK_EQUAL(errorAs(errors[0])->actualVersion(), "9.9.9"); + } + { + // A version that is not text at all, which used to come out of the JSON library as an + // exception rather than as an error in the list. + SerializationErrorList errors; + deserialize(R"({"version":42,"content":{}})", errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_CHECK_EQUAL(errors[0]->type(), SerializationError::UnrecognizedVersion); + } +} + +BOOST_AUTO_TEST_CASE(test_writing_an_unknown_version) { + Model model; + model.version = static_cast(99); + + SerializationErrorList errors; + const auto text = serialize(model, errors); + BOOST_REQUIRE_EQUAL(errors.size(), 1); + BOOST_CHECK_EQUAL(errors[0]->type(), SerializationError::UnrecognizedVersion); + BOOST_CHECK_EQUAL(errorAs(errors[0])->actualVersionFlag(), 99); + BOOST_CHECK(text.empty()); +} + +// With CheckError off nothing is reported and whatever could be read is read. This is the mode +// for opening a file that is known to be imperfect. +BOOST_AUTO_TEST_CASE(test_check_error_off_reports_nothing) { + SerializationErrorList errors; + auto text = serialize(richModel(), errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + text = replaceFirst(text, R"("keyNum":60)", R"("keyNum":9000)"); + text = replaceFirst(text, R"("centShift":-3)", R"("centShift":"nonsense")"); + + { + SerializationErrorList quiet; + const auto model = deserialize(text, quiet, Serializer::Option{}); + BOOST_CHECK_EQUAL(quiet.size(), 0); + // The good parts still arrived. + BOOST_CHECK_EQUAL(model.content.global.name, "song"); + BOOST_REQUIRE_EQUAL(model.content.tracks.size(), 1); + } + { + SerializationErrorList loud; + deserialize(text, loud, Serializer::CheckError); + BOOST_CHECK_GE(loud.size(), 2); + } +} + +// Fail-fast stops at the first thing that is wrong; without it the whole document is walked and +// everything wrong with it comes back at once. +BOOST_AUTO_TEST_CASE(test_fail_fast_stops_at_the_first_error) { + SerializationErrorList errors; + auto text = serialize(richModel(), errors); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + text = replaceFirst(text, R"("centShift":-3)", R"("centShift":-999)"); + text = replaceFirst(text, R"("pan":0.25)", R"("pan":9.0)"); + + SerializationErrorList fast; + deserialize(text, fast, Serializer::FailFast | Serializer::CheckError); + BOOST_CHECK_EQUAL(fast.size(), 1); + + SerializationErrorList all; + deserialize(text, all, Serializer::CheckError); + BOOST_CHECK_EQUAL(all.size(), 2); + BOOST_CHECK_EQUAL(all[0]->type(), SerializationError::RangeConstraintViolation); + BOOST_CHECK_EQUAL(errorAs(all[0])->path(), + "$.content.global.centShift"); + BOOST_CHECK_EQUAL(errorAs(all[1])->path(), + "$.content.master.control.pan"); +} + +// A property nobody knows is an error by default -- it is usually a typo or a newer file -- but +// a caller that wants to be lenient can say so. +BOOST_AUTO_TEST_CASE(test_redundant_property) { + SerializationErrorList errors; + const auto text = + replaceFirst(serialize(richModel(), errors), R"({"content")", R"({"stowaway":1,"content")"); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + + SerializationErrorList strict; + deserialize(text, strict, Serializer::CheckError); + BOOST_REQUIRE_EQUAL(strict.size(), 1); + BOOST_REQUIRE_EQUAL(strict[0]->type(), SerializationError::RedundantProperty); + const auto redundant = errorAs(strict[0]); + BOOST_CHECK_EQUAL(redundant->path(), "$"); + BOOST_REQUIRE_EQUAL(redundant->redundantProperties().size(), 1); + BOOST_CHECK_EQUAL(redundant->redundantProperties().front(), "stowaway"); + BOOST_CHECK(strict[0]->isError()); + + SerializationErrorList lenient; + const auto model = deserialize( + text, lenient, Serializer::CheckError | Serializer::TolerateRedundantProperty); + BOOST_CHECK_EQUAL(lenient.size(), 0); + BOOST_CHECK_EQUAL(model.content.global.name, "song"); +} + +BOOST_AUTO_TEST_CASE(test_missing_property) { + SerializationErrorList errors; + const auto text = replaceFirst(serialize(richModel(), errors), R"("author")", R"("Author")"); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + + SerializationErrorList found; + deserialize(text, found, Serializer::CheckError); + + // One for the property that is gone and one for the one nobody asked for. + BOOST_REQUIRE_GE(found.size(), 1); + BOOST_REQUIRE_EQUAL(found[0]->type(), SerializationError::MissingProperty); + const auto missing = errorAs(found[0]); + BOOST_CHECK_EQUAL(missing->path(), "$.content.global"); + BOOST_REQUIRE_EQUAL(missing->missingProperties().size(), 1); + BOOST_CHECK_EQUAL(missing->missingProperties().front(), "author"); +} + +// An empty stream is not a document, and the reader has to say so rather than hand back a model +// that looks like a new project. +BOOST_AUTO_TEST_CASE(test_empty_input) { + SerializationErrorList errors; + deserialize("", errors); + BOOST_REQUIRE_GE(errors.size(), 1); + BOOST_CHECK(errors[0]->isFatal()); +} + +// Bytes that start like a zstd frame and then are not. The compression failure has to be +// reported as itself rather than as whatever the parser makes of the wreckage. +BOOST_AUTO_TEST_CASE(test_truncated_compressed_input) { + SerializationErrorList errors; + auto packed = serialize(richModel(), errors, defaultOptions, true); + BOOST_REQUIRE_EQUAL(errors.size(), 0); + packed.resize(packed.size() / 2); + + SerializationErrorList readErrors; + deserialize(packed, readErrors); + BOOST_REQUIRE_GE(readErrors.size(), 1); + BOOST_CHECK(readErrors[0]->isFatal()); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/main.cpp b/tests/main.cpp deleted file mode 100644 index 36e59cb..0000000 --- a/tests/main.cpp +++ /dev/null @@ -1,372 +0,0 @@ -#include -#include -#include - -#include -#include - -using namespace opendspx; - -const auto data = R"( -{ - "version": "1.0.0", - "content": { - "global": { - "name": "My First Song", - "author": "CrSjimo", - "centShift": 0, - "editorId": "dspx-editor", - "editorName": "DiffScope Editor" - }, - "master": { - "control": { - "gain": 0.8, - "mute": false, - "pan": 0.0 - } - }, - "timeline": { - "timeSignatures": [ - { - "index": 0, - "numerator": 4, - "denominator": 4 - } - ], - "tempos": [ - { - "pos": 0, - "value": 120.0 - } - ], - "labels": [ - { - "pos": 0, - "text": "Intro" - }, - { - "pos": 7680, - "text": "Verse 1" - } - ] - }, - "tracks": [ - { - "name": "Vocal Track", - "control": { - "gain": 0.75, - "mute": false, - "pan": 0.0, - "solo": false - }, - "clips": [ - { - "type": "singing", - "name": "Verse 1 Vocal", - "time": { - "pos": 0, - "length": 7680, - "clipStart": 0, - "clipLen": 7680 - }, - "control": { - "gain": -1.0, - "mute": false, - "pan": 0.0 - }, - "notes": [ - { - "pos": 0, - "length": 1920, - "keyNum": 60, - "lyric": "Hello", - "language": "eng", - "centShift": 0, - "pronunciation": { - "original": "h ə l oʊ", - "edited": "h ə l oʊ" - }, - "phonemes": { - "original": [ - { - "token": "h", - "start": 0, - "language": "eng", - "onset": true - }, - { - "token": "ə", - "start": 200, - "language": "eng", - "onset": false - }, - { - "token": "l", - "start": 400, - "language": "eng", - "onset": false - }, - { - "token": "oʊ", - "start": 600, - "language": "eng", - "onset": false - } - ], - "edited": [ - { - "token": "h", - "start": 0, - "language": "eng", - "onset": true - }, - { - "token": "ə", - "start": 200, - "language": "eng", - "onset": false - }, - { - "token": "l", - "start": 400, - "language": "eng", - "onset": false - }, - { - "token": "oʊ", - "start": 600, - "language": "eng", - "onset": false - } - ] - }, - "vibrato": { - "start": 0.8, - "end": 1.0, - "freq": 5.5, - "amp": 50, - "offset": 0, - "phase": 0.0, - "points": { - "freq": [ - { - "x": 0.8, - "y": 0.0 - }, - { - "x": 1.0, - "y": 1.0 - } - ], - "amp": [ - { - "x": 0.8, - "y": 0.0 - }, - { - "x": 1.0, - "y": 1.0 - } - ] - } - }, - "workspace": {} - }, - { - "pos": 1920, - "length": 1920, - "keyNum": 62, - "lyric": "world", - "language": "eng", - "centShift": 5, - "pronunciation": { - "original": "w ɜr l d", - "edited": "w ɜr l d" - }, - "phonemes": { - "original": [ - { - "token": "w", - "start": 0, - "language": "eng", - "onset": true - }, - { - "token": "ɜr", - "start": 150, - "language": "eng", - "onset": false - }, - { - "token": "l", - "start": 500, - "language": "eng", - "onset": false - }, - { - "token": "d", - "start": 700, - "language": "eng", - "onset": false - } - ], - "edited": [ - { - "token": "w", - "start": 0, - "language": "eng", - "onset": true - }, - { - "token": "ɜr", - "start": 150, - "language": "eng", - "onset": false - }, - { - "token": "l", - "start": 500, - "language": "eng", - "onset": false - }, - { - "token": "d", - "start": 700, - "language": "eng", - "onset": false - } - ] - }, - "vibrato": { - "start": 0.7, - "end": 1.0, - "freq": 6.0, - "amp": 75, - "offset": 0, - "phase": 0.25, - "points": { - "freq": [ - { - "x": 0.7, - "y": 0.0 - }, - { - "x": 1.0, - "y": 1.0 - } - ], - "amp": [ - { - "x": 0.7, - "y": 0.0 - }, - { - "x": 1.0, - "y": 1.0 - } - ] - } - }, - "workspace": {} - } - ], - "params": { - "pitch": { - "original": [ - { - "type": "free", - "start": 0, - "step": 5, - "values": [ - 6000, 6000, 6000, 6200, 6200, 6200 - ] - } - ], - "edited": [ - { - "type": "anchor", - "start": 0, - "nodes": [ - { - "x": 0, - "y": 6000, - "interp": "linear" - }, - { - "x": 1920, - "y": 6000, - "interp": "hermite" - }, - { - "x": 3840, - "y": 6200, - "interp": "linear" - } - ] - } - ], - "transform": [] - } - }, - "sources": null, - "workspace": {} - } - ], - "workspace": {} - }, - { - "name": "Instrumental Track", - "control": { - "gain": 0.6, - "mute": false, - "pan": 0.0, - "solo": false - }, - "clips": [ - { - "type": "audio", - "name": "Background Music", - "path": "assets/background.wav", - "time": { - "pos": 0, - "length": 15360, - "clipStart": 0, - "clipLen": 15360 - }, - "control": { - "gain": 0.8, - "mute": false, - "pan": -0.2 - }, - "workspace": {} - } - ], - "workspace": {} - } - ], - "workspace": { - "projectSettings": { - "sampleRate": 44100, - "bufferSize": 512 - }, - "uiState": { - "zoom": 1.0, - "scrollPosition": 0 - } - } - } -} -)"; - -int main() { - SerializationErrorList errors; - std::stringstream in(data, std::ios::in); - auto model = Serializer::deserialize(in, errors); - std::cerr << errors.size() << std::endl; - std::stringstream out; - Serializer::serialize(out, model, errors, Serializer::FailFast | Serializer::CheckError, true); - for (auto ch : out.str()) { - std::cout << std::setbase(16) << std::setfill('0') << std::setw(2) << static_cast(static_cast(ch)) << " "; - } - return 0; -}