diff --git a/cmake/third_party.cmake b/cmake/third_party.cmake index 4a3d77dac..0d75c6356 100644 --- a/cmake/third_party.cmake +++ b/cmake/third_party.cmake @@ -28,6 +28,7 @@ find_package(ctre REQUIRED) find_package(magic_enum REQUIRED) find_package(Microsoft.GSL REQUIRED) find_package(RapidJSON REQUIRED) +find_package(ryml REQUIRED) if(WIN32) find_package(wil REQUIRED) diff --git a/conanfile.py b/conanfile.py index 73e3a1120..f8b6b57f5 100644 --- a/conanfile.py +++ b/conanfile.py @@ -82,6 +82,7 @@ class Morpheus(ConanFile): "magic_enum/0.9.5", "ms-gsl/4.0.0", "rapidjson/cci.20230929", + "rapidyaml/0.5.0", "range-v3/0.12.0", "scnlib/2.0.2", #"zlib/1.2.12" # xapian-core/1.4.19' requires 'zlib/1.2.12' while 'boost/1.81.0' requires 'zlib/1.2.13'. To fix this conflict you need to override the package 'zlib' in your root package. diff --git a/libraries/core/src/morpheus/core/serialisation/CMakeLists.txt b/libraries/core/src/morpheus/core/serialisation/CMakeLists.txt index 599a52ea6..02443e3bc 100644 --- a/libraries/core/src/morpheus/core/serialisation/CMakeLists.txt +++ b/libraries/core/src/morpheus/core/serialisation/CMakeLists.txt @@ -15,6 +15,8 @@ target_sources(MorpheusCore text_writer.hpp write_serialiser.hpp write_serialiser_decl.hpp + yaml_reader.hpp + yaml_writer.hpp PRIVATE exceptions.cpp json_reader.cpp @@ -24,6 +26,7 @@ target_sources(MorpheusCore target_link_libraries(MorpheusCore PUBLIC rapidjson + ryml::ryml # https://conan.io/center/recipes/rapidyaml?version=0.5.0 ) add_subdirectory(adapters) diff --git a/libraries/core/src/morpheus/core/serialisation/exceptions.cpp b/libraries/core/src/morpheus/core/serialisation/exceptions.cpp index dd8615d31..1b15a0f0f 100644 --- a/libraries/core/src/morpheus/core/serialisation/exceptions.cpp +++ b/libraries/core/src/morpheus/core/serialisation/exceptions.cpp @@ -18,4 +18,9 @@ void throwJsonException(std::string_view message) throw boost::enable_error_info(JsonException(std::string(message))) << ExceptionInfo(MORPHEUS_CURRENT_STACKTRACE); } +void throwYamlException(std::string_view message) +{ + throw boost::enable_error_info(YamlException(std::string(message))) << ExceptionInfo(MORPHEUS_CURRENT_STACKTRACE); +} + } // namespace morpheus::serialisation diff --git a/libraries/core/src/morpheus/core/serialisation/exceptions.hpp b/libraries/core/src/morpheus/core/serialisation/exceptions.hpp index 954ff84e9..0b2022e78 100644 --- a/libraries/core/src/morpheus/core/serialisation/exceptions.hpp +++ b/libraries/core/src/morpheus/core/serialisation/exceptions.hpp @@ -25,10 +25,21 @@ class JsonException : public std::runtime_error using std::runtime_error::runtime_error; }; +/// \class YamlException +/// Exception type to be thrown for errors when parsing YAML. +class YamlException : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + /// Throws a std::runtime_error derived binary exception with the attached message. MORPHEUSCORE_EXPORT [[noreturn]] MORPHEUS_FUNCTION_COLD void throwBinaryException(std::string_view message); /// Throws a std::runtime_error derived Json exception with the attached message. MORPHEUSCORE_EXPORT [[noreturn]] MORPHEUS_FUNCTION_COLD void throwJsonException(std::string_view message); +/// Throws a std::runtime_error derived Yaml exception with the attached message. +MORPHEUSCORE_EXPORT [[noreturn]] MORPHEUS_FUNCTION_COLD void throwYamlException(std::string_view message); + } // namespace morpheus::serialisation diff --git a/libraries/core/src/morpheus/core/serialisation/serialisers.hpp b/libraries/core/src/morpheus/core/serialisation/serialisers.hpp index 7da21fe71..d9c44c111 100644 --- a/libraries/core/src/morpheus/core/serialisation/serialisers.hpp +++ b/libraries/core/src/morpheus/core/serialisation/serialisers.hpp @@ -8,6 +8,8 @@ #include "morpheus/core/serialisation/serialise.hpp" #include "morpheus/core/serialisation/read_serialiser.hpp" #include "morpheus/core/serialisation/write_serialiser.hpp" +#include "morpheus/core/serialisation/yaml_writer.hpp" +#include "morpheus/core/serialisation/yaml_reader.hpp" namespace morpheus::serialisation { @@ -18,4 +20,6 @@ using BinaryReadSerialiser = ReadSerialiser; using JsonWriteSerialiser = WriteSerialiser; using JsonReadSerialiser = ReadSerialiser; +using YamlWriteSerialiser = WriteSerialiser; +using YamlReadSerialiser = ReadSerialiser; } diff --git a/libraries/core/src/morpheus/core/serialisation/yaml_reader.hpp b/libraries/core/src/morpheus/core/serialisation/yaml_reader.hpp new file mode 100644 index 000000000..44b5d845c --- /dev/null +++ b/libraries/core/src/morpheus/core/serialisation/yaml_reader.hpp @@ -0,0 +1,179 @@ +#pragma once + +#include "morpheus/core/base/assert.hpp" +#include "morpheus/core/base/cold.hpp" +#include "morpheus/core/functional/overload.hpp" +#include "morpheus/core/memory/polymorphic_value.hpp" +#include "morpheus/core/serialisation/exceptions.hpp" + +#include + +//#include +//#include +#include // details around which header to include are mentioned at + // https://github.com/biojppm/rapidyaml/blob/master/samples/quickstart.cpp + + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace morpheus::serialisation +{ + +/// \class YamlReader +/// Read in objects from an underlying yaml representation. +class MORPHEUSCORE_EXPORT YamlReader +{ + enum class FundamentalType : std::uint32_t + { + Boolean, + Int64, + Uint64, + Float, + Double, + String + }; + +public: + using OwnedStream = memory::polymorphic_value; + + + + static constexpr bool canBeTextual() { return true; } + + /// \copydoc morpheus::serialisation::concepts::ReaderArchtype::isTextual() + static constexpr bool isTextual() { return true; } + + /// Yaml reader take in a stream of yaml to extract data members from. + /// \param[in] stream Stream used to read in the yaml source. This must outlive the reader as its held by referece. + explicit YamlReader(OwnedStream stream, bool validate = true); + explicit YamlReader(YamlReader const& rhs); + ~YamlReader(); + + /// \copydoc morpheus::serialisation::concepts::ReaderArchtype::beginComposite() + void beginComposite(); + + /// \copydoc morpheus::serialisation::concepts::ReaderArchtype::endComposite() + void endComposite(); + + /// \copydoc morpheus::serialisation::concepts::ReaderArchtype::beginValue() + void beginValue(std::string_view const key); + + /// \copydoc morpheus::serialisation::concepts::ReaderArchtype::endValue() + void endValue(); + + /// \copydoc morpheus::serialisation::concepts::ReaderArchtype::beginSequence() + std::optional beginSequence(); + + /// \copydoc morpheus::serialisation::concepts::ReaderArchtype::endSequence() + void endSequence(); + + /// \copydoc morpheus::serialisation::concepts::ReaderArchtype::beginNullable() + bool beginNullable(); + + /// \copydoc morpheus::serialisation::concepts::ReaderArchtype::endNullable() + void endNullable(); + + // clang-format off + /// Read a boolean from the serialisation. + template + requires std::is_same_v + T read() + { + auto const [event, next] = getNext(); + MORPHEUS_ASSERT(next->index() == 0); + return std::get(*next); + } + + /// Reads a integral type from the serialisation. + template + requires(not std::is_same_v) + Interger read() + { + auto const [event, next] = getNext(); + return std::visit(functional::Overload{ + [](std::integral auto const value) { return boost::numeric_cast(value); }, + [](auto const) -> Interger { throwYamlException("Unable to convert to integral representation"); } + }, *next); + } + + /// Reads a float or double type from the serialisation. + template + Float read() + { + auto const [event, next] = getNext(); + return std::visit(functional::Overload { + [](std::integral auto const value) { return boost::numeric_cast(value); }, + [](std::floating_point auto const value) + { + if (std::isinf(value)) [[unlikely]] + { + if (value > 0) + return std::numeric_limits::infinity(); + else + return -std::numeric_limits::infinity(); + } + return boost::numeric_cast(value); + }, + [](auto const) -> Float { throwYamlException("Unable to convert to floating point representation"); } + }, *next); + } + + /// Reads a string type from the serialisation. + template + requires std::is_same_v + T read() + { + auto const [event, next] = getNext(); + MORPHEUS_ASSERT(next->index() == magic_enum::enum_integer(FundamentalType::String)); + return std::get(*next); + } + + template + requires std::is_same_v> + T read() + { + return {}; + } + // clang-format on + +private: + enum class Event : std::uint32_t + { + BeginComposite, + EndComposite, + BeginValue, + Value, + EndValue, + BeginSequence, + EndSequence, + }; + + friend struct YamlExtracter; + using FundamentalValue = std::variant; + using PossibleValue = std::optional; + using EventValue = std::tuple; + + [[nodiscard]] EventValue getNext(); + + memory::polymorphic_value mSourceStream; /// Owned input stream containing the Yaml source. + + rapidjson::IStreamWrapper mStream; + rapidjson::Reader mYamlReader; + std::unique_ptr mExtractor; + bool mValidate = true; +}; + +} // namespace morpheus::serialisation diff --git a/libraries/core/src/morpheus/core/serialisation/yaml_writer.hpp b/libraries/core/src/morpheus/core/serialisation/yaml_writer.hpp new file mode 100644 index 000000000..b49642c28 --- /dev/null +++ b/libraries/core/src/morpheus/core/serialisation/yaml_writer.hpp @@ -0,0 +1,103 @@ +#pragma once + +#include "morpheus/core/base/platform.hpp" + +//#include +//#include + +#include // details around which header to include are mentioned at + // https://github.com/biojppm/rapidyaml/blob/master/samples/quickstart.cpp + + +#include +#include +#include +#include +#include +#include + +namespace morpheus::serialisation +{ + +/// \class YamlWriter +/// Implementes the concept Writer for a streaming YAML writer which writes item by item to the output stream. +class MORPHEUSCORE_EXPORT YamlWriter +{ +public: + static constexpr bool canBeTextual() { return true; } + + /// \copydoc morpheus::serialisation::concepts::WriterArchtype::isTextual() + static constexpr bool isTextual() { return true; } + + explicit YamlWriter(std::ostream& stream); + + /// \copydoc morpheus::serialisation::concepts::WriterArchtype::beginComposite() + void beginComposite(); + + /// \copydoc morpheus::serialisation::concepts::WriterArchtype::endComposite() + void endComposite(); + + /// \copydoc morpheus::serialisation::concepts::WriterArchtype::beginValue() + void beginValue(std::string_view const key); + + /// \copydoc morpheus::serialisation::concepts::WriterArchtype::endValue() + void endValue(); + + /// \copydoc morpheus::serialisation::concepts::WriterArchtype::beginSequence() + void beginSequence(std::optional size = std::nullopt); + + /// \copydoc morpheus::serialisation::concepts::WriterArchtype::endSequence() + void endSequence(); + + /// \copydoc morpheus::serialisation::concepts::WriterArchtype::beginNullable() + void beginNullable(bool const null); + + /// \copydoc morpheus::serialisation::concepts::WriterArchtype::endNullable() + void endNullable(); + + /// Write a boolean to the serialisation. + void write(bool const value); + /// Write a 8-bit unsigned integer to the serialisation. + void write(std::uint8_t const value); + /// Write a 8-bit integer to the serialisation. + void write(std::int8_t const value); + /// Write a 16-bit unsigned integer to the serialisation. + void write(std::uint16_t const value); + /// Write a 16-bit integer to the serialisation. + void write(std::int16_t const value); + /// Write a 32-bit unsigned integer to the serialisation. + void write(std::uint32_t const value); + /// Write a 32-bit integer to the serialisation. + void write(std::int32_t const value); + /// Write a 64-bit unsigned integer to the serialisation. + void write(std::uint64_t const value); + /// Write a 64-bit integer to the serialisation. + void write(std::int64_t const value); + /// Write a float to the serialisation. + void write(float const value); + /// Write a double to the serialisation. + void write(double const value); + /// \copydoc morpheus::serialisation::concepts::WriterArchtype::write(std::string_view const) + void write(std::string_view const value); + /// \copydoc morpheus::serialisation::concepts::WriterArchtype::write(std::span const) + void write(std::span const value); + /// Write a string literal to the serialisation. + template void write(const char(&str)[N]) { write(std::string_view(str, N-1)); } + +private: + // TODO completely rework this section for ryml calls instead of rapidjson + template + using RapidYamlWriter = rapidjson::Writer< + OutputStream, + rapidjson::UTF8<>, + rapidjson::UTF8<>, + rapidjson::CrtAllocator, + (rapidjson::kWriteDefaultFlags | rapidjson::kWriteNanAndInfFlag) + >; + + rapidjson::OStreamWrapper mStream; + RapidYamlWriter mYamlWriter; +}; + + +} diff --git a/libraries/core/tests/serialisation/CMakeLists.txt b/libraries/core/tests/serialisation/CMakeLists.txt index 811c904a3..f6b1aa8d2 100644 --- a/libraries/core/tests/serialisation/CMakeLists.txt +++ b/libraries/core/tests/serialisation/CMakeLists.txt @@ -7,6 +7,8 @@ target_sources(MorpheusCoreTests exceptions.tests.cpp json_reader.tests.cpp json_writer.tests.cpp + yaml_reader.tests.cpp + yaml_writer.tests.cpp read_serialiser.tests.cpp write_serialiser.tests.cpp ) diff --git a/libraries/core/tests/serialisation/exceptions.tests.cpp b/libraries/core/tests/serialisation/exceptions.tests.cpp index 45e47299b..33a147f9e 100644 --- a/libraries/core/tests/serialisation/exceptions.tests.cpp +++ b/libraries/core/tests/serialisation/exceptions.tests.cpp @@ -17,6 +17,7 @@ TEST_CASE("Serialisatoin exception helpers", "[morpheus.serialisation.exception. { REQUIRE_THROWS_AS(throwBinaryException("Test binary exception"), BinaryException); REQUIRE_THROWS_AS(throwJsonException("Tesst Json exception"), JsonException); + REQUIRE_THROWS_AS(throwYamlException("Test Yaml exception"), YamlException); } diff --git a/libraries/core/tests/serialisation/yaml_reader.tests.cpp b/libraries/core/tests/serialisation/yaml_reader.tests.cpp new file mode 100644 index 000000000..d422adbcd --- /dev/null +++ b/libraries/core/tests/serialisation/yaml_reader.tests.cpp @@ -0,0 +1,374 @@ +#include "morpheus/core/conformance/format.hpp" +#include "morpheus/core/serialisation/adapters/aggregate.hpp" +#include "morpheus/core/serialisation/adapters/std/chrono.hpp" +#include "morpheus/core/serialisation/adapters/std/monostate.hpp" +#include "morpheus/core/serialisation/adapters/std/optional.hpp" +#include "morpheus/core/serialisation/adapters/std/pair.hpp" +#include "morpheus/core/serialisation/adapters/std/tuple.hpp" +#include "morpheus/core/serialisation/adapters/std/unique_ptr.hpp" +#include "morpheus/core/serialisation/adapters/std/variant.hpp" +#include "morpheus/core/serialisation/adapters/std/vector.hpp" +#include "morpheus/core/serialisation/read_serialiser.hpp" +#include "morpheus/core/serialisation/serialisers.hpp" + +#include + +using namespace Catch; + +namespace morpheus::serialisation +{ + +namespace test { + +struct ISteamCopier +{ + using deleter_type = std::default_delete; + + std::istream* operator()(std::istream const& rhs) const + { + auto ss = std::make_unique(rhs.rdbuf()); + return ss.release(); + } + + std::istringstream* operator()(std::istringstream const& rhs) const + { + auto ss = std::make_unique(rhs.str()); + return ss.release(); + } +}; + +template +T deserialise(std::string_view const value, bool const validate = true) +{ + using namespace memory; + auto strstream = std::make_unique(std::string{value}); + auto iss = polymorphic_value(strstream.release(), ISteamCopier{}); + YamlReadSerialiser serialiser(std::move(iss), validate); + return serialiser.deserialise(); +} + +auto readerFromString(std::string_view const value) +{ + using namespace memory; + auto strstream = std::make_unique(std::string{value}); + auto iss = polymorphic_value(strstream.release(), ISteamCopier{}); + return YamlReader(std::move(iss), false); +} + +} // namespace test + +TEMPLATE_TEST_CASE("Yaml writer can write single native types to underlying text representation", "[morpheus.serialisation.yaml_reader.native]", + bool, std::int8_t, std::uint8_t, std::int16_t, std::uint16_t, std::int32_t, std::uint32_t, std::int64_t, std::uint64_t, float, double) +{ + if constexpr (std::is_integral_v) + { + using Limits = std::numeric_limits; + REQUIRE(test::deserialise(fmt_ns::format("{}", Limits::min())) == Limits::min()); + REQUIRE(test::deserialise(fmt_ns::format("{}", Limits::lowest())) == Limits::lowest()); + REQUIRE(test::deserialise(fmt_ns::format("{}", Limits::max())) == Limits::max()); + + if constexpr (not std::is_same_v) + REQUIRE(test::deserialise(fmt_ns::format("{}", Limits::radix)) == Limits::radix); + } + else if constexpr (std::is_floating_point_v) + { + REQUIRE(test::deserialise("0") == 0); + REQUIRE(test::deserialise("-0") == 0); + REQUIRE(test::deserialise("2.75") == TestType(2.75)); + REQUIRE(test::deserialise("-2.75") == TestType(-2.75)); + REQUIRE(std::isinf(test::deserialise("Infinity"))); + REQUIRE(std::isinf(test::deserialise("-Infinity"))); + REQUIRE(std::isnan(test::deserialise("NaN"))); + } +} + +TEST_CASE("Create and then copy a reader and read from the copied stream", "[morpheus.serialisation.yaml_reader.copy]") +{ + GIVEN("A Yaml stream") + { + std::string_view str(R"("value")"); + + WHEN("Read an single value from the stream") + { + YamlReader reader = test::readerFromString(str); + YamlReader copiedReader(reader); + + THEN("Expect an empty composite in the yaml document") { REQUIRE("value" == copiedReader.read()); } + } + } +} + +TEST_CASE("Yaml reader provides basic reader functionality", "[morpheus.serialisation.yaml_reader.fundamental]") +{ + GIVEN("A Yaml stream") + { + std::string_view str(R"("value")"); + + WHEN("Read an single value from the stream") + { + YamlReader reader = test::readerFromString(str); + + THEN("Expect an empty composite in the yaml document") + { + REQUIRE("value" == reader.read()); + } + } + } + GIVEN("A Yaml stream") + { + std::string_view str(R"({})"); + + WHEN("Read an empty composite from the stream") + { + YamlReader reader = test::readerFromString(str); + + THEN("Expect an empty composite in the yaml document") + { + reader.beginComposite(); + reader.endComposite(); + } + } + } + GIVEN("A Yaml stream") + { + std::string_view str(R"({"key":"value"})"); + + WHEN("Read a composite of key pair from the stream") + { + YamlReader reader = test::readerFromString(str); + + THEN("Expect an empty composite in the yaml document") + { + reader.beginComposite(); + reader.beginValue("key"); + REQUIRE("value" == reader.read()); + reader.endValue(); + reader.endComposite(); + } + } + } + GIVEN("A Yaml stream") + { + std::string_view str(R"({"x":null})"); + + WHEN("Read a composite of key to null pair from the stream") + { + YamlReader reader = test::readerFromString(str); + + THEN("Expect an empty composite in the yaml document") + { + reader.beginComposite(); + reader.beginValue("x"); + REQUIRE(true == reader.beginNullable()); + reader.endNullable(); + reader.endValue(); + reader.endComposite(); + } + } + } +} + +struct SimpleComposite +{ + int first = 0; + bool second = false; + float third = 0.0f; + std::string forth; + + template + void deserialise(Serialiser& s) + { + first = s.template deserialise("first"); + second = s.template deserialise("second"); + third = s.template deserialise("third"); + forth = s.template deserialise("forth"); + } +}; + +struct ComplexComposite +{ + SimpleComposite first; + float second = 0.0f; + + template + void deserialise(Serialiser& s) + { + first = s.template deserialise("first"); + second = s.template deserialise("second"); + } +}; + + +TEST_CASE("Yaml reader can read simple composite types from underlying test representation", "[morpheus.serialisation.yaml_reader.composite]") +{ + GIVEN("A Yaml reader") + { + auto const simple = test::deserialise(R"({"first":100,"second":true,"third":50,"forth":"example"})"); + STATIC_REQUIRE(concepts::ReadSerialisableInsrusive); + + WHEN("Writing an empty composite") + { + THEN("Expect an empty composite in the yaml document") + { + REQUIRE(simple.first == 100); + REQUIRE(simple.second == true); + REQUIRE(simple.third == 50.0); + REQUIRE(simple.forth == "example"); + } + } + } + GIVEN("A Yaml reader") + { + auto const complex = test::deserialise(R"({"first":{"first":100,"second":true,"third":50,"forth":"example"},"second":3.14})"); + STATIC_REQUIRE(concepts::ReadSerialisableInsrusive); + + WHEN("Writing an empty composite") + { + THEN("Expect an empty composite in the yaml document") + { + REQUIRE(complex.first.first == 100); + REQUIRE(complex.first.second == true); + REQUIRE(complex.first.third == 50.0); + REQUIRE(complex.first.forth == "example"); + REQUIRE(complex.second == Approx(3.14)); + } + } + } +} + +template +struct ContainsType +{ + T value = T(); + + template + void deserialise(Serialiser& s) + { + value = s.template deserialise("value"); + } +}; + +TEST_CASE("Yaml reader raise an error on reading incorrect types", "[morpheus.serialisation.yaml_reader.invalid_values]") +{ + GIVEN("A test type for validating serialition of specific types") + { + using IntegralType = ContainsType; + WHEN("Deserialising from Yaml with a string where a integer is expected") + { + auto const yamlText = R"({"value":100})"; + THEN("Ensure serialisation works with valid input") + { + using Catch::Matchers::ContainsSubstring; + REQUIRE(test::deserialise(yamlText, false).value == 100); + } + } + } + GIVEN("A type serialising a integer") + { + using IntegralType = ContainsType; + WHEN("Deserialising from Yaml with a string where a integer is expected") + { + auto const yamlText = R"({"value":"InvalidValue"})"; + THEN("Expect an exception to be thrown on error to convert a string to a integer") + { + using Catch::Matchers::ContainsSubstring; + REQUIRE_THROWS_WITH(test::deserialise(yamlText, false), ContainsSubstring("Unable to convert to integral")); + } + } + } + GIVEN("A type serialising a float") + { + using FloatType = ContainsType; + WHEN("Deserialising from Yaml with a string where a float is expected") + { + auto const yamlText = R"({"value":"InvalidValue"})"; + THEN("Expect an exception to be thrown on error to convert a string to a float") + { + using Catch::Matchers::ContainsSubstring; + REQUIRE_THROWS_WITH(test::deserialise(yamlText, false), ContainsSubstring("Unable to convert to floating point")); + } + } + } +} + +TEST_CASE("Yaml reader thows an error on invalid yaml input", "[morpheus.serialisation.yaml_reader.invalid_yaml]") +{ + GIVEN("A type serialising a integer") + { + using IntegralType = ContainsType; + WHEN("Deserialising from invalid Yaml") + { + auto const yamlText = R"({"value" @ "AtSymbolIsNotAValidSeperator"})"; + THEN("Expect an exception to be thrown on error to convert a string to a integer") + { + using Catch::Matchers::ContainsSubstring; + REQUIRE_THROWS_WITH(test::deserialise(yamlText, false), ContainsSubstring("Parse error at offset")); + } + } + } +} + +TEST_CASE("Yaml reader can read std types from underlying text representation", "[morpheus.serialisation.yaml_reader.adapters.std]") +{ + SECTION("Chrono types") + { + REQUIRE(test::deserialise(R"("123ns")") == std::chrono::nanoseconds{123}); + REQUIRE(test::deserialise(R"("456us")") == std::chrono::microseconds{456}); + REQUIRE(test::deserialise(R"("789ms")") == std::chrono::milliseconds{789}); + REQUIRE(test::deserialise(R"("123s")") == std::chrono::seconds{123}); + REQUIRE(test::deserialise(R"("58min")") == std::chrono::minutes{58}); + REQUIRE(test::deserialise(R"("24h")") == std::chrono::hours{24}); + REQUIRE(test::deserialise(R"("8d")") == std::chrono::days{8}); + REQUIRE(test::deserialise(R"("12w")") == std::chrono::weeks{12}); + REQUIRE(test::deserialise(R"("100y")") == std::chrono::years{100}); + REQUIRE(test::deserialise(R"("12m")") == std::chrono::months{12}); + } + REQUIRE(test::deserialise(R"({})") == std::monostate{}); + REQUIRE(test::deserialise>(R"(100)") == std::optional{100}); + REQUIRE(test::deserialise>(R"(null)") == std::optional{}); + REQUIRE(test::deserialise>(R"([50,true])") == std::pair{50, true}); + REQUIRE(test::deserialise(R"("Hello")") == std::string("Hello")); + REQUIRE(test::deserialise>(R"([75,true,"Example"])") == std::tuple{75, true, "Example"}); +// REQUIRE(test::deserialise>(R"({"type":"bool","value":true})") == std::variant{true}); + REQUIRE(*test::deserialise>(R"(50)") == 50); +} + +TEST_CASE("Error handling test cases for unexpected errors in the input Yaml stream", "[morpheus.serialisation.yaml_reader.error_handling]") +{ + using Catch::Matchers::ContainsSubstring; + REQUIRE_THROWS_WITH(test::readerFromString("50").beginValue("expected_key"), + ContainsSubstring("BeginComposite expected") && ContainsSubstring("Value encountered")); + REQUIRE_THROWS_WITH(test::readerFromString("[1,2,3]").beginValue("expected_key"), + ContainsSubstring("BeginComposite expected") && ContainsSubstring("BeginSequence encountered")); + REQUIRE_THROWS_WITH(test::readerFromString("{}").beginValue("expected_key"), ContainsSubstring("empty composite")); + + GIVEN("A type which parses a key value pair") + { + using IntegralType = ContainsType; + WHEN("Deserialising from Yaml with an invalid type for the key (i.e. an integer not a string)") + { + auto const yamlText = R"({100:100})"; + THEN("Ensure serialisation captures the error in key type") + { + using Catch::Matchers::ContainsSubstring; + REQUIRE_THROWS_WITH(test::deserialise(yamlText, false), ContainsSubstring("error kParseErrorObjectMissName")); + } + } + } + GIVEN("A type which parses a key value pair") + { + using IntegralType = ContainsType; + WHEN("Deserialising from Yaml with an invalid key") + { + auto const yamlText = R"({"incorrect_key":100})"; + THEN("Ensure serialisation captures the error in key type") + { + using Catch::Matchers::ContainsSubstring; + REQUIRE_THROWS_WITH(test::deserialise(yamlText, false), + ContainsSubstring("Expected key value") && ContainsSubstring("actual key incorrect_key")); + } + } + } +} + +} // namespace morpheus::serialisation diff --git a/libraries/core/tests/serialisation/yaml_writer.tests.cpp b/libraries/core/tests/serialisation/yaml_writer.tests.cpp new file mode 100644 index 000000000..6dde84851 --- /dev/null +++ b/libraries/core/tests/serialisation/yaml_writer.tests.cpp @@ -0,0 +1,280 @@ +#include "morpheus/core/serialisation/yaml_writer.hpp" +#include "morpheus/core/conformance/format.hpp" +#include "morpheus/core/serialisation/adapters/aggregate.hpp" +#include "morpheus/core/serialisation/adapters/std/chrono.hpp" +#include "morpheus/core/serialisation/adapters/std/monostate.hpp" +#include "morpheus/core/serialisation/adapters/std/optional.hpp" +#include "morpheus/core/serialisation/adapters/std/pair.hpp" +#include "morpheus/core/serialisation/adapters/std/tuple.hpp" +#include "morpheus/core/serialisation/adapters/std/unique_ptr.hpp" +#include "morpheus/core/serialisation/adapters/std/variant.hpp" +#include "morpheus/core/serialisation/adapters/std/vector.hpp" +#include "morpheus/core/serialisation/serialisers.hpp" +#include "morpheus/core/serialisation/write_serialiser.hpp" + +#include +#include +#include +#include +#include + +using namespace Catch; + +namespace morpheus::serialisation +{ + +namespace test { + +template +std::string serialise(T const& value) +{ + std::ostringstream oss; + YamlWriteSerialiser serialiser{oss}; + serialiser.serialise(value); + return oss.str(); +} + +#if (__cpp_lib_to_chars >= 201611L) +template requires std::is_floating_point_v +T toFloatingPoint(std::string_view value) +{ + T result = 0; + auto [ptr, ec] { std::from_chars(value.data(), value.data() + value.size(), result) }; + + if (ec != std::errc()) + { + throw std::system_error(std::make_error_code(ec)); + } + return result; +} +#endif // (__cpp_lib_to_chars >= 201611L) + +} + +TEMPLATE_TEST_CASE("Yaml writer can write single native types to underlying text representation", "[morpheus.serialisation.yaml_writer.native]", + bool, std::int8_t, std::uint8_t, std::int16_t, std::uint16_t, std::int32_t, std::uint32_t, std::int64_t, std::uint64_t, float, double) +{ + if constexpr (std::is_integral_v) + { + REQUIRE(test::serialise(std::numeric_limits::min()) == fmt_ns::format("{}", std::numeric_limits::min())); + REQUIRE(test::serialise(std::numeric_limits::lowest()) == fmt_ns::format("{}", std::numeric_limits::lowest())); + REQUIRE(test::serialise(std::numeric_limits::max()) == fmt_ns::format("{}", std::numeric_limits::max())); + REQUIRE(test::serialise(std::numeric_limits::radix) == fmt_ns::format("{}", std::numeric_limits::radix)); + } + else if constexpr (std::is_floating_point_v) + { + REQUIRE(test::serialise(0) == "0"); + REQUIRE(test::serialise(-0) == "0"); + REQUIRE(test::serialise(TestType(2.75)) == "2.75"); + REQUIRE(test::serialise(TestType(-2.75)) == "-2.75"); + REQUIRE(test::serialise(std::numeric_limits::infinity()) == "Infinity"); + REQUIRE(test::serialise(-std::numeric_limits::infinity()) == "-Infinity"); + REQUIRE(test::serialise(std::numeric_limits::quiet_NaN()) == "NaN"); + REQUIRE(test::serialise(-std::numeric_limits::quiet_NaN()) == "NaN"); + REQUIRE(test::serialise(std::numeric_limits::signaling_NaN()) == "NaN"); + REQUIRE(test::serialise(-std::numeric_limits::signaling_NaN()) == "NaN"); + +#if (__cpp_lib_to_chars >= 201611L) +// Check the below for rapiyaml and remove or modify as necessary + // RapidJson ouputs "1.401298464324817e-45" vs "1e-45" for float, but SetMaxDecimalPlaces() would effect all non-scientific values so we compare + // against the underling value not string representation. +// REQUIRE(test::toFloatingPoint(test::serialise(std::numeric_limits::denorm_min())) == Approx(std::numeric_limits::denorm_min())); +// REQUIRE(test::toFloatingPoint(test::serialise(std::numeric_limits::denorm_min())) == Approx(std::numeric_limits::denorm_min())); +#endif // (__cpp_lib_to_chars >= 201611L) + } +} + +TEST_CASE("Yaml writer can write simple composite types to underlying text representation", "[morpheus.serialisation.yaml_writer.composite]") +{ + GIVEN("A Yaml writer") + { + std::ostringstream strStream; + YamlWriter writer{ strStream }; + + WHEN("Writing an empty composite") + { + writer.beginComposite(); + writer.endComposite(); + + THEN("Expect an empty composite in the yaml document") + { + REQUIRE(strStream.str() == "{}"); + } + } + WHEN("Writing an empty composite") + { + writer.beginComposite(); + writer.beginValue("x"); + writer.beginNullable(true); + writer.endNullable(); + writer.endValue(); + writer.endComposite(); + + THEN("Expect an null composite in the yaml document") + { + REQUIRE(strStream.str() == R"({"x":null})"); + } + } + WHEN("Writing an simple composite with single value") + { + writer.beginComposite(); + writer.beginValue("key"); + writer.write("value"); + writer.endValue(); + writer.endComposite(); + + THEN("Expect an empty composite in the yaml document") + { + REQUIRE(strStream.str() == R"({"key":"value"})"); + } + } + WHEN("Writing an simple composite with multiple values") + { + writer.beginComposite(); + writer.beginValue("1"); + writer.write("A"); + writer.endValue(); + writer.beginValue("2"); + writer.write("B"); + writer.endValue(); + writer.beginValue("3"); + writer.write("C"); + writer.endValue(); + writer.endComposite(); + + THEN("Expect an empty composite in the yaml document") + { + REQUIRE(strStream.str() == R"({"1":"A","2":"B","3":"C"})"); + } + } + } +} + +TEST_CASE("Yaml writer can write simple sequence types to underlying text representation", "[morpheus.serialisation.yaml_writer.squence]") +{ + GIVEN("A Yaml writer") + { + std::ostringstream strStream; + YamlWriter writer{ strStream }; + + WHEN("Writing an simple value") + { + writer.write("value"); + + THEN("Expect an empty composite in the yaml document") + { + REQUIRE(strStream.str() == R"("value")"); + } + } + WHEN("Writing an empty sequence") + { + writer.beginSequence(); + writer.endSequence(); + + THEN("Expect an empty composite in the yaml document") + { + REQUIRE(strStream.str() == "[]"); + } + } + WHEN("Writing an simple composite with single value") + { + writer.beginSequence(); + writer.write("value"); + writer.endSequence(); + + THEN("Expect an empty composite in the yaml document") + { + REQUIRE(strStream.str() == R"(["value"])"); + } + } + WHEN("Writing an simple composite with multiple values") + { + writer.beginSequence(); + writer.write("A"); + writer.write("B"); + writer.write("C"); + writer.endSequence(); + + THEN("Expect an empty composite in the yaml document") + { + REQUIRE(strStream.str() == R"(["A","B","C"])"); + } + } + } +} + +struct Example +{ + int a = 0; + bool b = true; + std::string c = "Example"; +}; + +template<> +inline constexpr bool delegateAggregateSerialisation = true; + +struct Example2 +{ + int a = 0; + Example b; + bool c = true; +}; + +template<> +inline constexpr bool delegateAggregateSerialisation = true; + +TEST_CASE("Yaml writer can write simple aggregates types to underlying text representation", "[morpheus.serialisation.yaml_writer.aggregate]") +{ + GIVEN("An aggregate that opts into serialisation") + { + STATIC_REQUIRE(SerialisableAggregate); + + WHEN("Writing default intialised instance") + { + THEN("Expect the aggregate values serialised as a sequence") + { + REQUIRE(test::serialise(Example{}) == R"([0,true,"Example"])"); + } + } + } + GIVEN("An aggregate containing aggregates that all opt into serialisation") + { + STATIC_REQUIRE(SerialisableAggregate); + + WHEN("Writing default intialised instance") + { + THEN("Expect the aggregate values serialised as a sequence embedding in a sequence") + { + REQUIRE(test::serialise(Example2{}) == R"([0,[0,true,"Example"],true])"); + } + } + } +} + +TEST_CASE("Yaml writer can write std types to underlying text representation", "[morpheus.serialisation.yaml_writer.adapters.std]") +{ + SECTION("Chrono types") + { + REQUIRE(test::serialise(std::chrono::nanoseconds{123}) == R"("123ns")"); + REQUIRE(test::serialise(std::chrono::microseconds{456}) == R"("456us")"); + REQUIRE(test::serialise(std::chrono::milliseconds{789}) == R"("789ms")"); + REQUIRE(test::serialise(std::chrono::seconds{123}) == R"("123s")"); + REQUIRE(test::serialise(std::chrono::minutes{58}) == R"("58min")"); + REQUIRE(test::serialise(std::chrono::hours{24}) == R"("24h")"); + REQUIRE(test::serialise(std::chrono::days{8}) == R"("8d")"); + REQUIRE(test::serialise(std::chrono::weeks{12}) == R"("12w")"); + REQUIRE(test::serialise(std::chrono::years{100}) == R"("100y")"); + REQUIRE(test::serialise(std::chrono::months{12}) == R"("12m")"); + } + REQUIRE(test::serialise(std::monostate{}) == R"({})"); + REQUIRE(test::serialise(std::optional{100}) == R"(100)"); + REQUIRE(test::serialise(std::optional{}) == R"(null)"); + REQUIRE(test::serialise(std::pair{50, true}) == R"([50,true])"); + REQUIRE(test::serialise(std::string("Hello")) == R"("Hello")"); + REQUIRE(test::serialise(std::tuple{75, true, "Example"}) == R"([75,true,"Example"])"); + REQUIRE(test::serialise(std::make_unique(123)) == R"(123)"); + REQUIRE(test::serialise(std::variant{true}) == R"({"type":"bool","value":true})"); + REQUIRE(test::serialise(std::vector{1,2,3,4,5}) == R"([1,2,3,4,5])"); +} + +} // namespace morpheus::serialisation