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/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. 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/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/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/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/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/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/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/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 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/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 {}; } 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 {}; } 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; -}