diff --git a/include/acquire.zarr.h b/include/acquire.zarr.h index 54a7288a..d74a587f 100644 --- a/include/acquire.zarr.h +++ b/include/acquire.zarr.h @@ -39,9 +39,9 @@ extern "C" /** * @brief Get the version of the Zarr API. - * @return The version of the Zarr API. + * @return Semver formatted version of the Zarr API. */ - uint32_t Zarr_get_api_version(); + const char* Zarr_get_api_version(); /** * @brief Set the log level for the Zarr API. @@ -58,10 +58,10 @@ extern "C" /** * @brief Get the message for the given status code. - * @param status The status code. + * @param code The status code. * @return A human-readable status message. */ - const char* Zarr_get_status_message(ZarrStatusCode status); + const char* Zarr_get_status_message(ZarrStatusCode code); /** * @brief Allocate memory for the dimension array in the Zarr stream settings struct. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7f9bab73..5fe945b0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,4 +1,6 @@ add_subdirectory(logger) +add_subdirectory(streaming) + if (BUILD_ACQUIRE_DRIVER_ZARR) add_subdirectory(driver) endif () diff --git a/src/logger/logger.cpp b/src/logger/logger.cpp index 77e92435..c7af5582 100644 --- a/src/logger/logger.cpp +++ b/src/logger/logger.cpp @@ -10,6 +10,10 @@ std::mutex Logger::log_mutex_{}; void Logger::set_log_level(LogLevel level) { + if (level < LogLevel_Debug || level > LogLevel_Error) { + throw std::invalid_argument("Invalid log level"); + } + current_level_ = level; } diff --git a/src/streaming/CMakeLists.txt b/src/streaming/CMakeLists.txt new file mode 100644 index 00000000..480e3df3 --- /dev/null +++ b/src/streaming/CMakeLists.txt @@ -0,0 +1,40 @@ +set(tgt acquire-zarr) + +add_library(${tgt} + macros.hh + acquire.zarr.cpp + zarr.stream.hh + zarr.stream.cpp +) + +target_include_directories(${tgt} + PUBLIC + $ + PRIVATE + $ + $ +) + +target_link_libraries(${tgt} PRIVATE + acquire-logger + blosc_static + miniocpp::miniocpp +) + +target_compile_definitions(${tgt} PRIVATE + "ACQUIRE_ZARR_API_VERSION=\"0.0.1\"" +) + +set_target_properties(${tgt} PROPERTIES + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" +) + +install(TARGETS ${tgt} + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib +) + +# Install public header files +install(DIRECTORY ${CMAKE_SOURCE_DIR}/include/ + DESTINATION include +) \ No newline at end of file diff --git a/src/streaming/acquire.zarr.cpp b/src/streaming/acquire.zarr.cpp new file mode 100644 index 00000000..2804bf0c --- /dev/null +++ b/src/streaming/acquire.zarr.cpp @@ -0,0 +1,169 @@ +#include "acquire.zarr.h" +#include "zarr.stream.hh" +#include "macros.hh" + +#include // uint32_t + +extern "C" +{ + const char* Zarr_get_api_version() + { + return ACQUIRE_ZARR_API_VERSION; + } + + ZarrStatusCode Zarr_set_log_level(ZarrLogLevel level_) + { + LogLevel level; + switch (level_) { + case ZarrLogLevel_Debug: + level = LogLevel_Debug; + break; + case ZarrLogLevel_Info: + level = LogLevel_Info; + break; + case ZarrLogLevel_Warning: + level = LogLevel_Warning; + break; + case ZarrLogLevel_Error: + level = LogLevel_Error; + break; + default: + return ZarrStatusCode_InvalidArgument; + } + + try { + Logger::set_log_level(level); + } catch (const std::exception& e) { + LOG_ERROR("Error setting log level: %s", e.what()); + return ZarrStatusCode_InternalError; + } + return ZarrStatusCode_Success; + } + + ZarrLogLevel Zarr_get_log_level() + { + ZarrLogLevel level; + switch (Logger::get_log_level()) { + case LogLevel_Debug: + level = ZarrLogLevel_Debug; + break; + case LogLevel_Info: + level = ZarrLogLevel_Info; + break; + case LogLevel_Warning: + level = ZarrLogLevel_Warning; + break; + default: + level = ZarrLogLevel_Error; + break; + } + return level; + } + + const char* Zarr_get_status_message(ZarrStatusCode code) + { + switch (code) { + case ZarrStatusCode_Success: + return "Success"; + case ZarrStatusCode_InvalidArgument: + return "Invalid argument"; + case ZarrStatusCode_Overflow: + return "Buffer overflow"; + case ZarrStatusCode_InvalidIndex: + return "Invalid index"; + case ZarrStatusCode_NotYetImplemented: + return "Not yet implemented"; + case ZarrStatusCode_InternalError: + return "Internal error"; + case ZarrStatusCode_OutOfMemory: + return "Out of memory"; + case ZarrStatusCode_IOError: + return "I/O error"; + case ZarrStatusCode_CompressionError: + return "Compression error"; + case ZarrStatusCode_InvalidSettings: + return "Invalid settings"; + default: + return "Unknown error"; + } + } + + ZarrStatusCode ZarrStreamSettings_create_dimension_array( + struct ZarrStreamSettings_s* settings, + size_t dimension_count) + { + EXPECT_VALID_ARGUMENT(settings, "Null pointer: settings"); + EXPECT_VALID_ARGUMENT(dimension_count >= 3, + "Invalid dimension count: %zu", + dimension_count); + + ZarrDimensionProperties* dimensions = nullptr; + + try { + dimensions = new ZarrDimensionProperties[dimension_count]; + } catch (const std::bad_alloc&) { + LOG_ERROR("Failed to allocate memory for dimensions"); + return ZarrStatusCode_OutOfMemory; + } + + ZarrStreamSettings_destroy_dimension_array(settings); + settings->dimensions = dimensions; + settings->dimension_count = dimension_count; + + return ZarrStatusCode_Success; + } + + void ZarrStreamSettings_destroy_dimension_array( + struct ZarrStreamSettings_s* settings) + { + if (settings == nullptr) { + return; + } + + if (settings->dimensions != nullptr) { + delete[] settings->dimensions; + settings->dimensions = nullptr; + } + settings->dimension_count = 0; + } + + ZarrStream_s* ZarrStream_create(struct ZarrStreamSettings_s* settings) + { + + ZarrStream_s* stream = nullptr; + + try { + stream = new ZarrStream_s(settings); + } catch (const std::bad_alloc&) { + LOG_ERROR("Failed to allocate memory for Zarr stream"); + } catch (const std::exception& e) { + LOG_ERROR("Error creating Zarr stream: %s", e.what()); + } + + return stream; + } + + void ZarrStream_destroy(struct ZarrStream_s* stream) + { + delete stream; + } + + ZarrStatusCode ZarrStream_append(struct ZarrStream_s* stream, + const void* data, + size_t bytes_in, + size_t* bytes_out) + { + EXPECT_VALID_ARGUMENT(stream, "Null pointer: stream"); + EXPECT_VALID_ARGUMENT(data, "Null pointer: data"); + EXPECT_VALID_ARGUMENT(bytes_out, "Null pointer: bytes_out"); + + try { + *bytes_out = stream->append(data, bytes_in); + } catch (const std::exception& e) { + LOG_ERROR("Error appending data: %s", e.what()); + return ZarrStatusCode_InternalError; + } + + return ZarrStatusCode_Success; + } +} \ No newline at end of file diff --git a/src/streaming/macros.hh b/src/streaming/macros.hh new file mode 100644 index 00000000..2cc75bc1 --- /dev/null +++ b/src/streaming/macros.hh @@ -0,0 +1,28 @@ +#pragma once + +#include "logger.hh" + +#define EXPECT(e, ...) \ + do { \ + if (!(e)) { \ + const std::string __err = LOG_ERROR(__VA_ARGS__); \ + throw std::runtime_error(__err); \ + } \ + } while (0) +#define CHECK(e) EXPECT(e, "Expression evaluated as false:\n\t%s", #e) + +#define EXPECT_VALID_ARGUMENT(e, ...) \ + do { \ + if (!(e)) { \ + LOG_ERROR(__VA_ARGS__); \ + return ZarrStatusCode_InvalidArgument; \ + } \ + } while (0) + +#define EXPECT_VALID_INDEX(e, ...) \ + do { \ + if (!(e)) { \ + LOG_ERROR(__VA_ARGS__); \ + return ZarrStatusCode_InvalidIndex; \ + } \ + } while (0) \ No newline at end of file diff --git a/src/streaming/zarr.stream.cpp b/src/streaming/zarr.stream.cpp new file mode 100644 index 00000000..51c3fdee --- /dev/null +++ b/src/streaming/zarr.stream.cpp @@ -0,0 +1,502 @@ +#include "macros.hh" +#include "zarr.stream.hh" +#include "acquire.zarr.h" + +#include + +#include + +namespace fs = std::filesystem; + +namespace { +bool +is_s3_acquisition(const struct ZarrStreamSettings_s* settings) +{ + return nullptr != settings->s3_settings; +} + +bool +is_compressed_acquisition(const struct ZarrStreamSettings_s* settings) +{ + return nullptr != settings->compression_settings; +} + +[[nodiscard]] +std::string +trim(const char* s) +{ + if (s == nullptr || *s == '\0') { + return {}; + } + + const size_t length = strlen(s); + + // trim left + std::string trimmed(s, length); + trimmed.erase(trimmed.begin(), + std::find_if(trimmed.begin(), trimmed.end(), [](char c) { + return !std::isspace(c); + })); + + // trim right + trimmed.erase(std::find_if(trimmed.rbegin(), + trimmed.rend(), + [](char c) { return !std::isspace(c); }) + .base(), + trimmed.end()); + + return trimmed; +} + +bool +is_empty_string(const char* s, std::string_view error_msg) +{ + auto trimmed = trim(s); + if (trimmed.empty()) { + LOG_ERROR(error_msg); + return true; + } + return false; +} + +[[nodiscard]] +bool +validate_s3_settings(const ZarrS3Settings* settings) +{ + if (is_empty_string(settings->endpoint, "S3 endpoint is empty")) { + return false; + } + if (is_empty_string(settings->access_key_id, "S3 access key ID is empty")) { + return false; + } + if (is_empty_string(settings->secret_access_key, + "S3 secret access key is empty")) { + return false; + } + + std::string trimmed = trim(settings->bucket_name); + if (trimmed.length() < 3 || trimmed.length() > 63) { + LOG_ERROR("Invalid length for S3 bucket name: ", + trimmed.length(), + ". Must be between 3 " + "and 63 characters"); + return false; + } + + return true; +} + +[[nodiscard]] +bool +validate_filesystem_store_path(std::string_view data_root) +{ + fs::path path(data_root); + fs::path parent_path = path.parent_path(); + if (parent_path.empty()) { + parent_path = "."; + } + + // parent path must exist and be a directory + if (!fs::exists(parent_path) || !fs::is_directory(parent_path)) { + LOG_ERROR("Parent path '", + parent_path, + "' does not exist or is not a directory"); + return false; + } + + // parent path must be writable + const auto perms = fs::status(parent_path).permissions(); + const bool is_writable = + (perms & (fs::perms::owner_write | fs::perms::group_write | + fs::perms::others_write)) != fs::perms::none; + + if (!is_writable) { + LOG_ERROR("Parent path '", parent_path, "' is not writable"); + return false; + } + + return true; +} + +[[nodiscard]] +bool +validate_compression_settings(const ZarrCompressionSettings* settings) +{ + if (settings->compressor >= ZarrCompressorCount) { + LOG_ERROR("Invalid compressor: ", settings->compressor); + return false; + } + + if (settings->codec >= ZarrCompressionCodecCount) { + LOG_ERROR("Invalid compression codec: ", settings->codec); + return false; + } + + // if compressing, we require a compression codec + if (settings->compressor != ZarrCompressor_None && + settings->codec == ZarrCompressionCodec_None) { + LOG_ERROR("Compression codec must be set when using a compressor"); + return false; + } + + if (settings->level > 9) { + LOG_ERROR("Invalid compression level: ", + settings->level, + ". Must be between 0 and 9"); + return false; + } + + if (settings->shuffle != BLOSC_NOSHUFFLE && + settings->shuffle != BLOSC_SHUFFLE && + settings->shuffle != BLOSC_BITSHUFFLE) { + LOG_ERROR("Invalid shuffle: ", + settings->shuffle, + ". Must be ", + BLOSC_NOSHUFFLE, + " (no shuffle), ", + BLOSC_SHUFFLE, + " (byte shuffle), or ", + BLOSC_BITSHUFFLE, + " (bit shuffle)"); + return false; + } + + return true; +} + +[[nodiscard]] +bool +validate_custom_metadata(const char* metadata) +{ + if (metadata == nullptr || !*metadata) { + return true; // custom metadata is optional + } + + // parse the JSON + auto val = nlohmann::json::parse(metadata, + nullptr, // callback + false, // allow exceptions + true // ignore comments + ); + + if (val.is_discarded()) { + LOG_ERROR("Invalid JSON: ", metadata); + return false; + } + + return true; +} + +[[nodiscard]] +bool +validate_dimension(const ZarrDimensionProperties* dimension, + ZarrVersion version, + bool is_append) +{ + if (is_empty_string(dimension->name, "Dimension name is empty")) { + return false; + } + + if (dimension->type >= ZarrDimensionTypeCount) { + LOG_ERROR("Invalid dimension type: ", dimension->type); + return false; + } + + if (!is_append && dimension->array_size_px == 0) { + LOG_ERROR("Array size must be nonzero"); + return false; + } + + if (dimension->chunk_size_px == 0) { + LOG_ERROR("Invalid chunk size: ", dimension->chunk_size_px); + return false; + } + + if (version == ZarrVersion_3 && dimension->shard_size_chunks == 0) { + LOG_ERROR("Shard size must be nonzero"); + return false; + } + + return true; +} + +[[nodiscard]] +bool +validate_settings(const struct ZarrStreamSettings_s* settings) +{ + if (!settings) { + LOG_ERROR("Null pointer: settings"); + return false; + } + + auto version = settings->version; + if (version < ZarrVersion_2 || version >= ZarrVersionCount) { + LOG_ERROR("Invalid Zarr version: ", version); + return false; + } + + if (settings->store_path == nullptr) { + LOG_ERROR("Null pointer: store_path"); + return false; + } + std::string_view store_path(settings->store_path); + + // we require the store path (root of the dataset) to be nonempty + if (store_path.empty()) { + LOG_ERROR("Store path is empty"); + return false; + } + + if ((is_s3_acquisition(settings) && + !validate_s3_settings(settings->s3_settings)) || + (!is_s3_acquisition(settings) && + !validate_filesystem_store_path(store_path))) { + return false; + } + + if (settings->data_type >= ZarrDataTypeCount) { + LOG_ERROR("Invalid data type: ", settings->data_type); + return false; + } + + if (is_compressed_acquisition(settings) && + !validate_compression_settings(settings->compression_settings)) { + return false; + } + + if (!validate_custom_metadata(settings->custom_metadata)) { + return false; + } + + if (settings->dimensions == nullptr) { + LOG_ERROR("Null pointer: dimensions"); + return false; + } + + // we must have at least 3 dimensions + const size_t ndims = settings->dimension_count; + if (ndims < 3) { + LOG_ERROR("Invalid number of dimensions: ", ndims, ". Must be at least 3"); + return false; + } + + // check the final dimension (width), must be space + if (settings->dimensions[ndims - 1].type != ZarrDimensionType_Space) { + LOG_ERROR("Last dimension must be of type Space"); + return false; + } + + // check the penultimate dimension (height), must be space + if (settings->dimensions[ndims - 2].type != ZarrDimensionType_Space) { + LOG_ERROR("Second to last dimension must be of type Space"); + return false; + } + + // validate the dimensions individually + for (size_t i = 0; i < ndims; ++i) { + if (!validate_dimension(settings->dimensions + i, version, i == 0)) { + return false; + } + } + + return true; +} +} // namespace + +/* ZarrStream_s implementation */ + +ZarrStream::ZarrStream_s(struct ZarrStreamSettings_s* settings) + : error_() +{ + if (!validate_settings(settings)) { + throw std::runtime_error("Invalid Zarr stream settings"); + } + + commit_settings_(settings); + + // create the data store + EXPECT(create_store_(), error_); + + // allocate writers + EXPECT(create_writers_(), error_); + + // allocate multiscale frame placeholders + create_scaled_frames_(); + + // allocate metadata sinks + EXPECT(create_metadata_sinks_(), error_); + + // write base metadata + EXPECT(write_base_metadata_(), error_); + + // write group metadata + EXPECT(write_group_metadata_(), error_); + + // write external metadata + EXPECT(write_external_metadata_(), error_); +} + +ZarrStream_s::~ZarrStream_s() +{ + try { + // must precede close of chunk file + write_group_metadata_(); + } catch (const std::exception& e) { + LOG_ERROR("Error finalizing Zarr stream: ", e.what()); + } +} + +size_t +ZarrStream::append(const void* data, size_t nbytes) +{ + // TODO (aliddell): implement this + return 0; +} + +bool +ZarrStream_s::is_s3_acquisition_() const +{ + return s3_settings_.has_value(); +} + +bool +ZarrStream_s::is_compressed_acquisition_() const +{ + return compression_settings_.has_value(); +} + +void +ZarrStream_s::commit_settings_(const struct ZarrStreamSettings_s* settings) +{ + version_ = settings->version; + store_path_ = trim(settings->store_path); + custom_metadata_ = trim(settings->custom_metadata); + + if (is_s3_acquisition(settings)) { + s3_settings_ = { + .endpoint = trim(settings->s3_settings->endpoint), + .bucket_name = trim(settings->s3_settings->bucket_name), + .access_key_id = trim(settings->s3_settings->access_key_id), + .secret_access_key = trim(settings->s3_settings->secret_access_key), + }; + } + + if (is_compressed_acquisition(settings)) { + compression_settings_ = { + .compressor = settings->compression_settings->compressor, + .codec = settings->compression_settings->codec, + .level = settings->compression_settings->level, + .shuffle = settings->compression_settings->shuffle, + }; + } + + dtype_ = settings->data_type; + + for (auto i = 0; i < settings->dimension_count; ++i) { + const auto& dim = settings->dimensions[i]; + dimensions_.emplace_back(dim.name, + dim.type, + dim.array_size_px, + dim.chunk_size_px, + dim.shard_size_chunks); + } +} + +void +ZarrStream_s::set_error_(const std::string& msg) +{ + error_ = msg; +} + +bool +ZarrStream_s::create_store_() +{ + if (is_s3_acquisition_()) { + // TODO (aliddell): implement this + } else { + if (fs::exists(store_path_)) { + // remove everything inside the store path + std::error_code ec; + fs::remove_all(store_path_, ec); + + if (ec) { + set_error_("Failed to remove existing store path '" + + store_path_ + "': " + ec.message()); + return false; + } + } + + // create the store path + { + std::error_code ec; + if (!fs::create_directories(store_path_, ec)) { + set_error_("Failed to create store path '" + + store_path_ + "': " + ec.message()); + return false; + } + } + } + + return true; +} + +bool +ZarrStream_s::create_writers_() +{ + // TODO (aliddell): implement this + return true; +} + +void +ZarrStream_s::create_scaled_frames_() +{ + if (multiscale_) { + // TODO (aliddell): implement this + } +} + +bool +ZarrStream_s::create_metadata_sinks_() +{ + // TODO (aliddell): implement this + return true; +} + +bool +ZarrStream_s::write_base_metadata_() +{ + // TODO (aliddell): implement this + return true; +} + +bool +ZarrStream_s::write_group_metadata_() +{ + // TODO (aliddell): implement this + return true; +} + +bool +ZarrStream_s::write_external_metadata_() +{ + // TODO (aliddell): implement this + return true; +} + +nlohmann::json +ZarrStream_s::make_multiscale_metadata_() const +{ + // TODO (aliddell): implement this + return {}; +} + +void +ZarrStream_s::write_multiscale_frames_(const uint8_t* data, + size_t bytes_of_data) +{ + if (multiscale_) { + return; + } + + // TODO (aliddell): implement this +} diff --git a/src/streaming/zarr.stream.hh b/src/streaming/zarr.stream.hh new file mode 100644 index 00000000..2f31a796 --- /dev/null +++ b/src/streaming/zarr.stream.hh @@ -0,0 +1,113 @@ +#pragma once + +#include "zarr.types.h" + +#include + +#include // size_t +#include // unique_ptr +#include + +struct ZarrDimension_s +{ + ZarrDimension_s(const char* name, + ZarrDimensionType type, + uint32_t array_size_px, + uint32_t chunk_size_px, + uint32_t shard_size_chunks) + : name(name) + , type(type) + , array_size_px(array_size_px) + , chunk_size_px(chunk_size_px) + , shard_size_chunks(shard_size_chunks) + { + } + + std::string name; + ZarrDimensionType type; + + uint32_t array_size_px; + uint32_t chunk_size_px; + uint32_t shard_size_chunks; +}; + +struct ZarrStream_s +{ + public: + ZarrStream_s(struct ZarrStreamSettings_s* settings); + ~ZarrStream_s(); + + /** + * @brief Append data to the stream. + * @param data The data to append. + * @param nbytes The number of bytes to append. + * @return The number of bytes appended. + */ + size_t append(const void* data, size_t nbytes); + + private: + struct S3Settings { + std::string endpoint; + std::string bucket_name; + std::string access_key_id; + std::string secret_access_key; + }; + struct CompressionSettings { + ZarrCompressor compressor; + ZarrCompressionCodec codec; + uint8_t level; + uint8_t shuffle; + }; + + std::string error_; // error message. If nonempty, an error occurred. + + ZarrVersion version_; + std::string store_path_; + std::optional s3_settings_; + std::optional compression_settings_; + std::string custom_metadata_; + ZarrDataType dtype_; + std::vector dimensions_; + bool multiscale_; + + bool is_s3_acquisition_() const; + bool is_compressed_acquisition_() const; + + /** + * @brief Copy settings to the stream. + * @param settings Struct containing settings to copy. + */ + void commit_settings_(const struct ZarrStreamSettings_s* settings); + + /** + * @brief Set an error message. + * @param msg The error message to set. + */ + void set_error_(const std::string& msg); + + /** @brief Create the data store. */ + [[nodiscard]] bool create_store_(); + + /** @brief Create the writers. */ + [[nodiscard]] bool create_writers_(); + + /** @brief Create placeholders for multiscale frames. */ + void create_scaled_frames_(); + + /** @brief Create the metadata sinks. */ + [[nodiscard]] bool create_metadata_sinks_(); + + /** @brief Write per-acquisition metadata. */ + [[nodiscard]] bool write_base_metadata_(); + + /** @brief Write Zarr group metadata. */ + bool write_group_metadata_(); + + /** @brief Write external metadata. */ + [[nodiscard]] bool write_external_metadata_(); + + /** @brief Construct OME metadata pertaining to the multiscale pyramid. */ + nlohmann::json make_multiscale_metadata_() const; + + void write_multiscale_frames_(const uint8_t* data, size_t bytes_of_data); +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d10a9f44..57f3c4c8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,6 +1,8 @@ if (${NOTEST}) message(STATUS "Skipping test targets") else () + add_subdirectory(unit-tests) + if (BUILD_ACQUIRE_DRIVER_ZARR) add_subdirectory(driver) endif () diff --git a/tests/unit-tests/CMakeLists.txt b/tests/unit-tests/CMakeLists.txt new file mode 100644 index 00000000..e24ad4bb --- /dev/null +++ b/tests/unit-tests/CMakeLists.txt @@ -0,0 +1,27 @@ +set(project acquire-zarr) + +set(tests + create-stream +) + +foreach (name ${tests}) + set(tgt "${project}-unit-test-${name}") + add_executable(${tgt} ${name}.cpp unit.test.macros.hh) + target_compile_definitions(${tgt} PUBLIC "TEST=\"${tgt}\"") + set_target_properties(${tgt} PROPERTIES + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" + ) + target_include_directories(${tgt} PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/src/logger + ${CMAKE_SOURCE_DIR}/src/streaming + ) + target_link_libraries(${tgt} PRIVATE + acquire-logger + acquire-zarr + miniocpp::miniocpp + ) + + add_test(NAME test-${tgt} COMMAND ${tgt}) + set_tests_properties(test-${tgt} PROPERTIES LABELS "anyplatform;unit-tests;acquire-zarr") +endforeach () \ No newline at end of file diff --git a/tests/unit-tests/create-stream.cpp b/tests/unit-tests/create-stream.cpp new file mode 100644 index 00000000..c66bfacf --- /dev/null +++ b/tests/unit-tests/create-stream.cpp @@ -0,0 +1,78 @@ +#include "acquire.zarr.h" +#include "zarr.stream.hh" +#include "unit.test.macros.hh" + +#include + +namespace fs = std::filesystem; + +void +configure_stream_dimensions(ZarrStreamSettings* settings) +{ + CHECK(ZarrStatusCode_Success == ZarrStreamSettings_create_dimension_array(settings, 3)); + ZarrDimensionProperties *dim = settings->dimensions; + + *dim = ZarrDimensionProperties{ + .name = "t", + .type = ZarrDimensionType_Time, + .array_size_px = 100, + .chunk_size_px = 10, + }; + + dim = settings->dimensions + 1; + *dim = ZarrDimensionProperties{ + .name = "y", + .type = ZarrDimensionType_Space, + .array_size_px = 200, + .chunk_size_px = 20, + }; + + dim = settings->dimensions + 2; + *dim = ZarrDimensionProperties{ + .name = "x", + .type = ZarrDimensionType_Space, + .array_size_px = 300, + .chunk_size_px = 30, + }; +} + +int +main() +{ + int retval = 1; + + ZarrStream* stream; + ZarrStreamSettings settings; + memset(&settings, 0, sizeof(settings)); + settings.version = ZarrVersion_2; + + try { + // try to create a stream with no store path + stream = ZarrStream_create(&settings); + CHECK(nullptr == stream); + + // try to create a stream with no dimensions + settings.store_path = static_cast(TEST ".zarr"); + stream = ZarrStream_create(&settings); + CHECK(nullptr == stream); + CHECK(!fs::exists(settings.store_path)); + + // allocate dimensions + configure_stream_dimensions(&settings); + stream = ZarrStream_create(&settings); + CHECK(nullptr != stream); + CHECK(fs::is_directory(settings.store_path)); + + retval = 0; + } catch (const std::exception& exception) { + LOG_ERROR("%s", exception.what()); + } + + // cleanup + if (fs::is_directory(settings.store_path)) { + fs::remove_all(settings.store_path); + } + ZarrStreamSettings_destroy_dimension_array(&settings); + ZarrStream_destroy(stream); + return retval; +} \ No newline at end of file diff --git a/tests/unit-tests/unit.test.macros.hh b/tests/unit-tests/unit.test.macros.hh new file mode 100644 index 00000000..0100c112 --- /dev/null +++ b/tests/unit-tests/unit.test.macros.hh @@ -0,0 +1,34 @@ +#pragma once + +#include "logger.hh" + +#define EXPECT(e, ...) \ + do { \ + if (!(e)) { \ + const std::string __err = LOG_ERROR(__VA_ARGS__); \ + throw std::runtime_error(__err); \ + } \ + } while (0) +#define CHECK(e) EXPECT(e, "Expression evaluated as false:\n\t%s", #e) + +/// Check that a==b +/// example: `ASSERT_EQ(int,"%d",42,meaning_of_life())` +#define EXPECT_EQ(T, fmt, a, b) \ + do { \ + T a_ = (T)(a); \ + T b_ = (T)(b); \ + EXPECT(a_ == b_, "Expected %s==%s but " fmt "!=" fmt, #a, #b, a_, b_); \ + } while (0) + +#define EXPECT_STR_EQ(a, b) \ + do { \ + std::string a_ = (a) ? (a) : ""; \ + std::string b_ = (b) ? (b) : ""; \ + EXPECT(a_ == b_, \ + "Expected %s==%s but \"%s\"!=\"%s\"", \ + #a, \ + #b, \ + a_.c_str(), \ + b_.c_str()); \ + } while (0) +