diff --git a/CMakeLists.txt b/CMakeLists.txt index f990dcb..ce31c4a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -75,6 +75,8 @@ include(CMakeDependentOption) # provides a method to download dependencies: include(FetchContent) include(CMakeHelpers/MdioHelpers) +# optional code coverage support: +include(CodeCoverage) list(APPEND mdio_DEFAULT_COPTS "-Wno-deprecated-declarations" diff --git a/cmake/CMakeHelpers/MdioHelpers.cmake b/cmake/CMakeHelpers/MdioHelpers.cmake index c2162d7..e51b49f 100644 --- a/cmake/CMakeHelpers/MdioHelpers.cmake +++ b/cmake/CMakeHelpers/MdioHelpers.cmake @@ -297,6 +297,12 @@ function(mdio_cc_test) PRIVATE ${mdio_CC_TEST_COPTS} ) + # Add coverage flags only to mdio test targets (not dependencies) + if(MDIO_ENABLE_COVERAGE) + target_compile_options(${_NAME} PRIVATE ${MDIO_COVERAGE_COMPILE_FLAGS}) + target_link_options(${_NAME} PRIVATE ${MDIO_COVERAGE_LINK_FLAGS}) + endif() + target_link_libraries(${_NAME} PUBLIC ${mdio_CC_TEST_DEPS} PRIVATE ${mdio_CC_TEST_LINKOPTS} diff --git a/cmake/CodeCoverage.cmake b/cmake/CodeCoverage.cmake new file mode 100644 index 0000000..120da7f --- /dev/null +++ b/cmake/CodeCoverage.cmake @@ -0,0 +1,166 @@ +# Copyright 2024 TGS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ============================================================================== +# Code Coverage Support (gcov + lcov) +# ============================================================================== +# +# Coverage instrumentation is applied ONLY to mdio test targets, not to +# dependencies like Tensorstore. This keeps build times and test execution fast. +# +# REQUIRED CMAKE FLAGS: +# -DMDIO_ENABLE_COVERAGE=ON Enable coverage instrumentation +# -DCMAKE_BUILD_TYPE=Debug Recommended for meaningful line coverage +# +# REQUIRED SYSTEM TOOLS: +# - gcov (usually bundled with GCC) +# - lcov (install via: apt install lcov / brew install lcov) +# - genhtml (included with lcov) +# +# USAGE: +# 1. Configure and build with coverage enabled: +# cd build +# cmake .. -DMDIO_ENABLE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug +# make +# +# 2. Run tests to generate coverage data (coverage accumulates across runs): +# ./mdio/mdio_variable_test # single test +# ./mdio/mdio_variable_test && ./mdio/mdio_dataset_test # multiple tests +# ctest # all registered tests +# +# 3. Generate HTML coverage report: +# make coverage-capture +# +# 4. View the report: +# Open build/coverage_report/index.html in a browser +# +# AVAILABLE TARGETS: +# make coverage - Reset counters, capture data, generate report +# make coverage-capture - Capture current data and generate report (no reset) +# make coverage-reset - Zero out all coverage counters +# +# ============================================================================== + +option(MDIO_ENABLE_COVERAGE "Enable code coverage instrumentation (requires GCC or Clang)" OFF) + +if(MDIO_ENABLE_COVERAGE) + message(STATUS "Code coverage enabled") + + # Check for supported compiler + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + message(FATAL_ERROR "Code coverage requires GCC or Clang compiler") + endif() + + # Coverage compiler/linker flags - exported for use in MdioHelpers.cmake + # These are applied only to mdio targets, not to dependencies like Tensorstore + set(MDIO_COVERAGE_COMPILE_FLAGS "-fprofile-arcs;-ftest-coverage" CACHE INTERNAL "Coverage compile flags") + set(MDIO_COVERAGE_LINK_FLAGS "--coverage" CACHE INTERNAL "Coverage link flags") + + # Find required tools + find_program(LCOV_PATH lcov) + find_program(GENHTML_PATH genhtml) + + if(NOT LCOV_PATH) + message(WARNING "lcov not found - coverage report generation will not be available") + endif() + + if(NOT GENHTML_PATH) + message(WARNING "genhtml not found - coverage report generation will not be available") + endif() + + # Create coverage report target if tools are available + if(LCOV_PATH AND GENHTML_PATH) + # Custom target to generate coverage report + add_custom_target(coverage + COMMENT "Generating code coverage report..." + + # Clear previous coverage data + COMMAND ${LCOV_PATH} --directory ${CMAKE_BINARY_DIR} --zerocounters + + # Run all tests (ctest must be run separately or you can uncomment below) + # COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + + # Capture coverage data + COMMAND ${LCOV_PATH} + --directory ${CMAKE_BINARY_DIR} + --capture + --output-file ${CMAKE_BINARY_DIR}/coverage.info + --ignore-errors mismatch,negative + + # Remove coverage data for external dependencies + COMMAND ${LCOV_PATH} + --remove ${CMAKE_BINARY_DIR}/coverage.info + '/usr/*' + '${CMAKE_BINARY_DIR}/_deps/*' + '*/googletest/*' + '*/gtest/*' + '*/gmock/*' + '*_test.cc' + --output-file ${CMAKE_BINARY_DIR}/coverage.info + --ignore-errors unused,negative + + # Generate HTML report + COMMAND ${GENHTML_PATH} + ${CMAKE_BINARY_DIR}/coverage.info + --output-directory ${CMAKE_BINARY_DIR}/coverage_report + --title "MDIO Code Coverage" + --legend + --show-details + + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + ) + + # Target to just capture coverage (without zeroing first) + add_custom_target(coverage-capture + COMMENT "Capturing coverage data..." + + COMMAND ${LCOV_PATH} + --directory ${CMAKE_BINARY_DIR} + --capture + --output-file ${CMAKE_BINARY_DIR}/coverage.info + --ignore-errors mismatch,negative + + COMMAND ${LCOV_PATH} + --remove ${CMAKE_BINARY_DIR}/coverage.info + '/usr/*' + '${CMAKE_BINARY_DIR}/_deps/*' + '*/googletest/*' + '*/gtest/*' + '*/gmock/*' + '*_test.cc' + --output-file ${CMAKE_BINARY_DIR}/coverage.info + --ignore-errors unused,negative + + COMMAND ${GENHTML_PATH} + ${CMAKE_BINARY_DIR}/coverage.info + --output-directory ${CMAKE_BINARY_DIR}/coverage_report + --title "MDIO Code Coverage" + --legend + --show-details + + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + ) + + # Target to reset coverage counters + add_custom_target(coverage-reset + COMMENT "Resetting coverage counters..." + COMMAND ${LCOV_PATH} --directory ${CMAKE_BINARY_DIR} --zerocounters + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + ) + + message(STATUS "Coverage targets available: 'coverage', 'coverage-capture', 'coverage-reset'") + message(STATUS "Coverage report will be generated at: ${CMAKE_BINARY_DIR}/coverage_report/index.html") + endif() +endif() + diff --git a/mdio/acceptance_test.cc b/mdio/acceptance_test.cc index 231620b..3d1cbce 100644 --- a/mdio/acceptance_test.cc +++ b/mdio/acceptance_test.cc @@ -542,6 +542,42 @@ TEST_P(VariableTest, SETUP) { auto f8 = mdio::internal::CreateVariable(f8Store, f8Metadata, std::move(options)); ASSERT_TRUE(f8.status().ok()) << f8.status(); + + // Create u1 (uint8) + auto u1Spec = CreateTypedVariableSpec(version_, "u1", base_path_, "::Open(CreateBaseSpec(version_, "f8", base_path_), mdio::constants::kOpen); + auto u1 = mdio::Variable<>::Open(CreateBaseSpec(version_, "u1", base_path_), + mdio::constants::kOpen); + auto u2 = mdio::Variable<>::Open(CreateBaseSpec(version_, "u2", base_path_), + mdio::constants::kOpen); + auto u8 = mdio::Variable<>::Open(CreateBaseSpec(version_, "u8", base_path_), + mdio::constants::kOpen); + auto b1 = mdio::Variable<>::Open(CreateBaseSpec(version_, "b1", base_path_), + mdio::constants::kOpen); ASSERT_TRUE(i2.status().ok()) << i2.status(); ASSERT_TRUE(i4.status().ok()) << i4.status(); @@ -715,6 +759,10 @@ TEST_P(VariableTest, dtype) { ASSERT_TRUE(f2.status().ok()) << f2.status(); ASSERT_TRUE(f4.status().ok()) << f4.status(); ASSERT_TRUE(f8.status().ok()) << f8.status(); + ASSERT_TRUE(u1.status().ok()) << u1.status(); + ASSERT_TRUE(u2.status().ok()) << u2.status(); + ASSERT_TRUE(u8.status().ok()) << u8.status(); + ASSERT_TRUE(b1.status().ok()) << b1.status(); EXPECT_EQ(i2.value().dtype(), mdio::constants::kInt16); EXPECT_EQ(i4.value().dtype(), mdio::constants::kInt32); @@ -722,6 +770,10 @@ TEST_P(VariableTest, dtype) { EXPECT_EQ(f2.value().dtype(), mdio::constants::kFloat16); EXPECT_EQ(f4.value().dtype(), mdio::constants::kFloat32); EXPECT_EQ(f8.value().dtype(), mdio::constants::kFloat64); + EXPECT_EQ(u1.value().dtype(), mdio::constants::kUint8); + EXPECT_EQ(u2.value().dtype(), mdio::constants::kUint16); + EXPECT_EQ(u8.value().dtype(), mdio::constants::kUint64); + EXPECT_EQ(b1.value().dtype(), mdio::constants::kBool); } TEST_P(VariableTest, domain) { @@ -741,6 +793,10 @@ TEST_P(VariableTest, TEARDOWN) { std::filesystem::remove_all(base_path_ + "/f2"); std::filesystem::remove_all(base_path_ + "/f4"); std::filesystem::remove_all(base_path_ + "/f8"); + std::filesystem::remove_all(base_path_ + "/u1"); + std::filesystem::remove_all(base_path_ + "/u2"); + std::filesystem::remove_all(base_path_ + "/u8"); + std::filesystem::remove_all(base_path_ + "/b1"); ASSERT_TRUE(true); } diff --git a/mdio/dataset_factory_test.cc b/mdio/dataset_factory_test.cc index 1baf703..78fc493 100644 --- a/mdio/dataset_factory_test.cc +++ b/mdio/dataset_factory_test.cc @@ -646,4 +646,172 @@ TEST(Xarray, open) { } } +TEST(EncodeBase64, basicEncoding) { + std::string input = "Hello, World!"; + std::string encoded = encode_base64(input); + EXPECT_EQ(encoded, "SGVsbG8sIFdvcmxkIQ=="); +} + +TEST(EncodeBase64, emptyString) { + std::string input = ""; + std::string encoded = encode_base64(input); + EXPECT_EQ(encoded, ""); +} + +TEST(EncodeBase64, binaryData) { + // Test with null bytes (as used for fill_value encoding) + std::string input(8, '\0'); + std::string encoded = encode_base64(input); + EXPECT_EQ(encoded, "AAAAAAAAAAA="); +} + +TEST(ToZarrDtype, unknownDtype) { + auto result = to_zarr_dtype("unknown_dtype"); + ASSERT_FALSE(result.status().ok()); + EXPECT_THAT(result.status().message(), + testing::HasSubstr("Unknown dtype")); +} + +TEST(ToZarrDtype, validDtypes) { + EXPECT_TRUE(to_zarr_dtype("float32").status().ok()); + EXPECT_TRUE(to_zarr_dtype("float64").status().ok()); + EXPECT_TRUE(to_zarr_dtype("int32").status().ok()); + EXPECT_TRUE(to_zarr_dtype("uint16").status().ok()); + EXPECT_TRUE(to_zarr_dtype("bool").status().ok()); +} + +TEST(TransformCompressor, nonBloscCompressorV2) { + nlohmann::json input = {{"compressor", {{"name", "gzip"}}}}; + nlohmann::json variable = {{"metadata", nlohmann::json::object()}}; + auto status = transform_compressor(input, variable, mdio::zarr::ZarrVersion::kV2); + ASSERT_FALSE(status.ok()); + EXPECT_THAT(status.message(), testing::HasSubstr("Only blosc compressor is supported")); +} + +TEST(TransformCompressor, nonBloscCompressorV3) { + nlohmann::json input = {{"compressor", {{"name", "gzip"}}}}; + nlohmann::json variable = {{"metadata", nlohmann::json::object()}}; + auto status = transform_compressor(input, variable, mdio::zarr::ZarrVersion::kV3); + ASSERT_FALSE(status.ok()); + EXPECT_THAT(status.message(), testing::HasSubstr("Only blosc compressor is supported")); +} + +TEST(TransformCompressor, missingCompressorName) { + nlohmann::json input = {{"compressor", {{"algorithm", "zstd"}}}}; + nlohmann::json variable = {{"metadata", nlohmann::json::object()}}; + auto status = transform_compressor(input, variable, mdio::zarr::ZarrVersion::kV2); + ASSERT_FALSE(status.ok()); + EXPECT_THAT(status.message(), testing::HasSubstr("Compressor name must be specified")); +} + +TEST(TransformCompressor, noCompressorV2) { + nlohmann::json input = nlohmann::json::object(); + nlohmann::json variable = {{"metadata", nlohmann::json::object()}}; + auto status = transform_compressor(input, variable, mdio::zarr::ZarrVersion::kV2); + ASSERT_TRUE(status.ok()); + EXPECT_TRUE(variable["metadata"]["compressor"].is_null()); +} + +TEST(TransformMetadata, gcsPath) { + nlohmann::json variable = {{"kvstore", {{"driver", "file"}, {"path", "myvar"}}}}; + auto status = transform_metadata("gs://my-bucket/path/to/dataset", variable); + ASSERT_TRUE(status.ok()) << status; + EXPECT_EQ(variable["kvstore"]["driver"], "gcs"); + EXPECT_EQ(variable["kvstore"]["bucket"], "my-bucket"); + EXPECT_THAT(variable["kvstore"]["path"].get(), + testing::HasSubstr("path/to/dataset")); +} + +TEST(TransformMetadata, s3Path) { + nlohmann::json variable = {{"kvstore", {{"driver", "file"}, {"path", "myvar"}}}}; + auto status = transform_metadata("s3://my-bucket/path/to/dataset", variable); + ASSERT_TRUE(status.ok()) << status; + EXPECT_EQ(variable["kvstore"]["driver"], "s3"); + EXPECT_EQ(variable["kvstore"]["bucket"], "my-bucket"); + EXPECT_THAT(variable["kvstore"]["path"].get(), + testing::HasSubstr("path/to/dataset")); +} + +TEST(TransformMetadata, localPath) { + nlohmann::json variable = {{"kvstore", {{"driver", "file"}, {"path", "myvar"}}}}; + auto status = transform_metadata("/local/path/to/dataset", variable); + ASSERT_TRUE(status.ok()) << status; + EXPECT_EQ(variable["kvstore"]["driver"], "file"); + EXPECT_FALSE(variable["kvstore"].contains("bucket")); + EXPECT_THAT(variable["kvstore"]["path"].get(), + testing::HasSubstr("/local/path/to/dataset")); +} + +TEST(GetDimensions, conflictingSizes) { + nlohmann::json spec = R"({ + "variables": [ + { + "name": "var1", + "dimensions": [ + {"name": "x", "size": 100} + ] + }, + { + "name": "var2", + "dimensions": [ + {"name": "x", "size": 200} + ] + } + ] + })"_json; + auto result = get_dimensions(spec); + ASSERT_FALSE(result.status().ok()); + EXPECT_THAT(result.status().message(), + testing::HasSubstr("conflicting sizes")); +} + +TEST(GetDimensions, consistentSizes) { + nlohmann::json spec = R"({ + "variables": [ + { + "name": "var1", + "dimensions": [ + {"name": "x", "size": 100}, + {"name": "y", "size": 50} + ] + }, + { + "name": "var2", + "dimensions": [ + {"name": "x", "size": 100} + ] + } + ] + })"_json; + auto result = get_dimensions(spec); + ASSERT_TRUE(result.status().ok()) << result.status(); + auto dims = result.value(); + EXPECT_EQ(dims["x"], 100); + EXPECT_EQ(dims["y"], 50); +} + +TEST(Construct, explicitV3Version) { + nlohmann::json j = nlohmann::json::parse(manifest); + auto res = Construct(j, "zarrs/v3_dataset", mdio::zarr::ZarrVersion::kV3); + ASSERT_TRUE(res.status().ok()) << res.status(); + + std::vector variables = std::get<1>(res.value()); + // All variables should use zarr3 driver + for (const auto& variable : variables) { + EXPECT_EQ(variable["driver"], "zarr3"); + } +} + +TEST(Construct, explicitV2Version) { + nlohmann::json j = nlohmann::json::parse(manifest); + auto res = Construct(j, "zarrs/v2_dataset", mdio::zarr::ZarrVersion::kV2); + ASSERT_TRUE(res.status().ok()) << res.status(); + + std::vector variables = std::get<1>(res.value()); + // All variables should use zarr driver (V2) + for (const auto& variable : variables) { + EXPECT_EQ(variable["driver"], "zarr"); + } +} + } // namespace diff --git a/mdio/dataset_test.cc b/mdio/dataset_test.cc index 44d382b..1869921 100644 --- a/mdio/dataset_test.cc +++ b/mdio/dataset_test.cc @@ -1708,4 +1708,82 @@ TEST(DatasetVersionNullopt, fromJsonWithNulloptVersion) { std::filesystem::remove_all("zarrs/nullopt_version"); } +TEST(Dataset, iselWithEmptySlicesVector) { + auto json_vars = GetToyExample(); + auto dataset = mdio::Dataset::from_json(json_vars, "zarrs/acceptance", + mdio::constants::kCreateClean); + ASSERT_TRUE(dataset.status().ok()) << dataset.status(); + auto ds = dataset.value(); + + std::vector> emptySlices; + auto sliceRes = ds.isel(emptySlices); + ASSERT_FALSE(sliceRes.status().ok()); + EXPECT_THAT(sliceRes.status().message(), + testing::HasSubstr("No slices provided")); +} + +TEST(Dataset, selWithDimensionIndex) { + std::string path = "zarrs/selTester.mdio"; + auto dsRes = makePopulated(path); + ASSERT_TRUE(dsRes.ok()) << dsRes.status(); + auto ds = dsRes.value(); + + // Using an index instead of a dimension name should fail + // The RangeDescriptor with index 0 instead of "inline" + mdio::RangeDescriptor indexDesc = {0, 2, 5, 1}; + + auto sliceRes = ds.sel(indexDesc); + ASSERT_FALSE(sliceRes.status().ok()); + EXPECT_THAT(sliceRes.status().message(), + testing::HasSubstr("not found")); +} + +TEST(Dataset, selWithRepeatedLabels) { + std::string path = "zarrs/selTester.mdio"; + auto dsRes = makePopulated(path); + ASSERT_TRUE(dsRes.ok()) << dsRes.status(); + auto ds = dsRes.value(); + + // Using the same label twice is invalid + mdio::ValueDescriptor desc1 = {"inline", 1}; + mdio::ValueDescriptor desc2 = {"inline", 2}; + + auto sliceRes = ds.sel(desc1, desc2); + ASSERT_FALSE(sliceRes.status().ok()); + EXPECT_THAT(sliceRes.status().message(), + testing::HasSubstr("not be repeated")); +} + +TEST(Dataset, commitMetadataNoChanges) { + const std::string path = "zarrs/acceptance_no_commit"; + std::filesystem::remove_all(path); + auto json_vars = GetToyExample(); + + auto datasetRes = + mdio::Dataset::from_json(json_vars, path, mdio::constants::kCreateClean); + ASSERT_TRUE(datasetRes.status().ok()) << datasetRes.status(); + auto dataset = datasetRes.value(); + + auto commitRes = dataset.CommitMetadata(); + ASSERT_FALSE(commitRes.status().ok()); + EXPECT_THAT(commitRes.status().message(), + testing::HasSubstr("No variables were modified")); + + std::filesystem::remove_all(path); +} + +TEST(Dataset, selRangeWithSameStartStop) { + std::string path = "zarrs/selTester.mdio"; + auto dsRes = makePopulated(path); + ASSERT_TRUE(dsRes.ok()) << dsRes.status(); + auto ds = dsRes.value(); + + mdio::RangeDescriptor rangeDesc = {"inline", 2, 2, 1}; + + auto sliceRes = ds.sel(rangeDesc); + ASSERT_FALSE(sliceRes.status().ok()); + EXPECT_THAT(sliceRes.status().message(), + testing::HasSubstr("Start and stop values must be different")); +} + } // namespace diff --git a/mdio/stats_test.cc b/mdio/stats_test.cc index 468c874..403cc8c 100644 --- a/mdio/stats_test.cc +++ b/mdio/stats_test.cc @@ -493,4 +493,96 @@ TEST(Units, unitsFromJsonString) { EXPECT_EQ(ua_json["unitsV1"], "rad"); } +TEST(HistogramTest, isBindable) { + EXPECT_TRUE(getCenterHist()->isBindable()); + EXPECT_TRUE(getEdgeHist()->isBindable()); +} + +TEST(HistogramTest, fromJsonErrorPaths) { + nlohmann::json noParent = {{"binCenters", {1.0, 2.0}}, {"counts", {1, 2}}}; + auto centered = mdio::internal::CenteredBinHistogram({}, {}); + EXPECT_FALSE(centered.FromJson(noParent).status().ok()); + + nlohmann::json noChild = {{"histogram", {{"counts", {1, 2}}}}}; + EXPECT_FALSE(centered.FromJson(noChild).status().ok()); + + nlohmann::json edgeNoParent = {{"binEdges", {0.0, 1.0}}, {"binWidths", {1.0}}, {"counts", {1}}}; + auto edge = mdio::internal::EdgeDefinedHistogram({}, {}, {}); + EXPECT_FALSE(edge.FromJson(edgeNoParent).status().ok()); + + nlohmann::json edgeNoChild = {{"histogram", {{"binEdges", {0.0, 1.0}}, {"counts", {1}}}}}; + EXPECT_FALSE(edge.FromJson(edgeNoChild).status().ok()); +} + +TEST(SummaryStatsTest, fromJsonEdgeHistogramAndMissingHistogram) { + nlohmann::json edgeStats = { + {"count", 50}, {"min", -500.0}, {"max", 500.0}, {"sum", 100.0}, {"sumSquares", 5000.0}, + {"histogram", {{"binEdges", {0.0, 1.0, 2.0}}, {"binWidths", {1.0, 1.0}}, {"counts", {10, 20}}}}}; + auto statsRes = mdio::internal::SummaryStats::FromJson(edgeStats); + ASSERT_TRUE(statsRes.status().ok()) << statsRes.status(); + EXPECT_TRUE(statsRes.value().getBindable()["histogram"].contains("binEdges")); + + nlohmann::json noHist = {{"count", 100}, {"min", 0.0}, {"max", 100.0}, {"sum", 50.0}, {"sumSquares", 500.0}}; + EXPECT_FALSE(mdio::internal::SummaryStats::FromJson(noHist).status().ok()); +} + +TEST(UserAttributesTest, fromVariableJson) { + nlohmann::json centered = { + {"name", "test"}, {"dataType", "float32"}, + {"metadata", {{"statsV1", {{"count", 100}, {"min", -10.0}, {"max", 10.0}, {"sum", 50.0}, {"sumSquares", 500.0}, + {"histogram", {{"binCenters", {-5.0, 0.0, 5.0}}, {"counts", {30, 40, 30}}}}}}}}}; + auto centeredRes = mdio::UserAttributes::FromVariableJson(centered); + ASSERT_TRUE(centeredRes.status().ok()) << centeredRes.status(); + EXPECT_EQ(centeredRes.value().ToJson()["statsV1"]["count"], 100); + + nlohmann::json edge = { + {"name", "test"}, {"dataType", "float32"}, + {"metadata", {{"statsV1", {{"count", 200}, {"min", 0.0}, {"max", 100.0}, {"sum", 5000.0}, {"sumSquares", 250000.0}, + {"histogram", {{"binEdges", {0.0, 50.0, 100.0}}, {"binWidths", {50.0, 50.0}}, {"counts", {100, 100}}}}}}}}}; + auto edgeRes = mdio::UserAttributes::FromVariableJson(edge); + ASSERT_TRUE(edgeRes.status().ok()) << edgeRes.status(); + EXPECT_TRUE(edgeRes.value().ToJson()["statsV1"]["histogram"].contains("binEdges")); + + nlohmann::json noMeta = {{"name", "simple"}, {"dataType", "int32"}}; + auto noMetaRes = mdio::UserAttributes::FromVariableJson(noMeta); + ASSERT_TRUE(noMetaRes.status().ok()) << noMetaRes.status(); + EXPECT_EQ(noMetaRes.value().ToJson(), nlohmann::json::object()); +} + +TEST(UserAttributesTest, accessors) { + nlohmann::json full = { + {"statsV1", {{"count", 100}, {"min", -1000.0}, {"max", 1000.0}, {"sum", 0.0}, {"sumSquares", 0.0}, + {"histogram", {{"binCenters", {1.0, 2.0}}, {"counts", {50, 50}}}}}}, + {"unitsV1", {{"length", "m"}, {"time", "s"}}}, + {"attributes", {{"foo", "bar"}, {"count", 42}}}}; + auto fullRes = mdio::UserAttributes::FromJson(full); + ASSERT_TRUE(fullRes.status().ok()) << fullRes.status(); + auto attrs = fullRes.value(); + EXPECT_EQ(attrs.getStatsV1()["count"], 100); + EXPECT_EQ(attrs.getUnitsV1().size(), 2); + EXPECT_EQ(attrs.getAttrs()["foo"], "bar"); + + nlohmann::json empty = nlohmann::json::object(); + auto emptyRes = mdio::UserAttributes::FromJson(empty); + ASSERT_TRUE(emptyRes.status().ok()); + auto emptyAttrs = emptyRes.value(); + EXPECT_TRUE(emptyAttrs.getStatsV1().empty()); + EXPECT_TRUE(emptyAttrs.getUnitsV1().empty()); + EXPECT_TRUE(emptyAttrs.getAttrs().empty()); +} + +TEST(UserAttributesTest, statsV1AsArray) { + nlohmann::json arrayStats = { + {"name", "test"}, + {"metadata", {{"statsV1", { + {{"count", 10}, {"min", 0}, {"max", 100}, {"sum", 500}, {"sumSquares", 25000}, + {"histogram", {{"binCenters", {1, 2, 3}}, {"counts", {3, 4, 3}}}}}, + {{"count", 20}, {"min", 0}, {"max", 200}, {"sum", 1000}, {"sumSquares", 50000}, + {"histogram", {{"binCenters", {1, 2, 3}}, {"counts", {6, 8, 6}}}}}} + }}}}; + auto res = mdio::UserAttributes::FromVariableJson(arrayStats); + ASSERT_TRUE(res.status().ok()) << res.status(); + EXPECT_EQ(res.value().ToJson()["statsV1"].size(), 2); +} + } // namespace diff --git a/mdio/zarr/zarr_test.cc b/mdio/zarr/zarr_test.cc index 1aafe92..3eec146 100644 --- a/mdio/zarr/zarr_test.cc +++ b/mdio/zarr/zarr_test.cc @@ -17,7 +17,9 @@ #include #include +#include #include +#include #include #include "tensorstore/kvstore/kvstore.h" @@ -98,6 +100,57 @@ TEST(ZarrDriver, ParseVersion_Invalid) { EXPECT_FALSE(result.ok()); } +TEST(ZarrDriver, ParseVersion_InvalidType_Array) { + nlohmann::json version_spec = nlohmann::json::array({2, 3}); + auto result = mdio::zarr::ParseVersion(version_spec); + EXPECT_FALSE(result.ok()); + EXPECT_THAT(result.status().message(), + testing::HasSubstr("Expected integer or string")); +} + +TEST(ZarrDriver, ParseVersion_InvalidType_Object) { + nlohmann::json version_spec = {{"version", 2}}; + auto result = mdio::zarr::ParseVersion(version_spec); + EXPECT_FALSE(result.ok()); + EXPECT_THAT(result.status().message(), + testing::HasSubstr("Expected integer or string")); +} + +TEST(ZarrDriver, ParseVersion_InvalidType_Null) { + nlohmann::json version_spec = nullptr; + auto result = mdio::zarr::ParseVersion(version_spec); + EXPECT_FALSE(result.ok()); + EXPECT_THAT(result.status().message(), + testing::HasSubstr("Expected integer or string")); +} + +TEST(ZarrDriver, ParseVersion_InvalidType_Boolean) { + nlohmann::json version_spec = true; + auto result = mdio::zarr::ParseVersion(version_spec); + EXPECT_FALSE(result.ok()); + EXPECT_THAT(result.status().message(), + testing::HasSubstr("Expected integer or string")); +} + +TEST(ZarrDriver, ZarrConfig_DefaultValues) { + mdio::zarr::ZarrConfig config; + + EXPECT_EQ(config.version, mdio::zarr::ZarrVersion::kV2); + EXPECT_TRUE(config.use_consolidated_metadata); + EXPECT_EQ(config.dimension_separator, "/"); +} + +TEST(ZarrDriver, ZarrConfig_CustomValues) { + mdio::zarr::ZarrConfig config; + config.version = mdio::zarr::ZarrVersion::kV3; + config.use_consolidated_metadata = false; + config.dimension_separator = "."; + + EXPECT_EQ(config.version, mdio::zarr::ZarrVersion::kV3); + EXPECT_FALSE(config.use_consolidated_metadata); + EXPECT_EQ(config.dimension_separator, "."); +} + TEST(ZarrDriver, SupportsConsolidatedMetadata_V2) { EXPECT_TRUE( mdio::zarr::SupportsConsolidatedMetadata(mdio::zarr::ZarrVersion::kV2)); @@ -304,6 +357,108 @@ TEST(ZarrV2, PrepareVariableAttributes) { EXPECT_FALSE(attrs.contains("chunkGrid")); } +TEST(ZarrV2, GetZarray_WithDefaults) { + // Test GetZarray with minimal metadata - should apply defaults + nlohmann::json input = { + {"metadata", {{"shape", {100, 200}}, {"dtype", "