diff --git a/.codecov.yml b/.codecov.yml index ecb52b8d0..eac7dc1d7 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -5,6 +5,10 @@ coverage: target: auto # allow slight decreases in coverage compared to the base ref threshold: 1% + patch: + default: + target: 80% + threshold: 5% ignore: - ^build.* # for generated message wrappers diff --git a/src/nebula_ouster/nebula_ouster_common/CMakeLists.txt b/src/nebula_ouster/nebula_ouster_common/CMakeLists.txt index 148da22ce..f0100e9df 100644 --- a/src/nebula_ouster/nebula_ouster_common/CMakeLists.txt +++ b/src/nebula_ouster/nebula_ouster_common/CMakeLists.txt @@ -20,4 +20,47 @@ install(DIRECTORY include/${PROJECT_NAME}/ DESTINATION include/${PROJECT_NAME}) ament_export_targets(export_nebula_ouster_common) ament_export_dependencies(nebula_core_common nebula_core_decoders) +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + find_package(ament_cmake_gtest REQUIRED) + + set(_nebula_ouster_sdk "${CMAKE_CURRENT_SOURCE_DIR}/../vendor/ouster-sdk") + + set(BUILD_SENSOR OFF CACHE BOOL "" FORCE) + set(BUILD_PCAP OFF CACHE BOOL "" FORCE) + set(BUILD_OSF OFF CACHE BOOL "" FORCE) + set(BUILD_VIZ OFF CACHE BOOL "" FORCE) + set(BUILD_MAPPING OFF CACHE BOOL "" FORCE) + set(_PARENT_BUILD_TESTING ${BUILD_TESTING}) + set(BUILD_TESTING OFF CACHE BOOL "" FORCE) + set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(BUILD_PYTHON_MODULE OFF CACHE BOOL "" FORCE) + + if(NOT TARGET ouster_client) + add_subdirectory("${_nebula_ouster_sdk}" "${CMAKE_CURRENT_BINARY_DIR}/ouster-sdk-build") + endif() + + # Restore BUILD_TESTING after ouster-sdk + set(BUILD_TESTING ${_PARENT_BUILD_TESTING} CACHE BOOL "" FORCE) + + set(_ouster_gen_includes "${CMAKE_CURRENT_BINARY_DIR}/ouster-sdk-build/generated") + + ament_add_gtest( + test_ouster_calibration_data + test/test_ouster_calibration_data.cpp + ) + target_link_libraries( + test_ouster_calibration_data + nebula_ouster_common + nebula_core_common::nebula_core_common + ouster_client + ) + target_include_directories( + test_ouster_calibration_data + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include + ${_nebula_ouster_sdk}/ouster_client/include + ${_ouster_gen_includes} + ) +endif() + ament_package() diff --git a/src/nebula_ouster/nebula_ouster_common/package.xml b/src/nebula_ouster/nebula_ouster_common/package.xml index 058a4a71a..ce17baa98 100644 --- a/src/nebula_ouster/nebula_ouster_common/package.xml +++ b/src/nebula_ouster/nebula_ouster_common/package.xml @@ -16,6 +16,9 @@ nebula_core_common nebula_core_decoders + ament_cmake_gtest + ament_lint_auto + ament_cmake diff --git a/src/nebula_ouster/nebula_ouster_common/test/test_ouster_calibration_data.cpp b/src/nebula_ouster/nebula_ouster_common/test/test_ouster_calibration_data.cpp new file mode 100644 index 000000000..350b14c5f --- /dev/null +++ b/src/nebula_ouster/nebula_ouster_common/test/test_ouster_calibration_data.cpp @@ -0,0 +1,123 @@ +// Copyright 2026 TIER IV, Inc. + +#include "nebula_ouster_common/ouster_calibration_data.hpp" + +#include + +#include +#include +#include + +namespace nebula::drivers +{ + +class TestOusterCalibrationData : public ::testing::Test +{ +protected: + void SetUp() override + { + // Generate a valid metadata JSON from the SDK's default sensor info + auto info = ouster::sdk::core::default_sensor_info(ouster::sdk::core::LidarMode::_512x10); + valid_metadata_json_ = info.to_json_string(); + } + + std::string valid_metadata_json_; +}; + +TEST_F(TestOusterCalibrationData, TestLoadFromStringSuccess) +{ + auto result = OusterCalibrationData::load_from_string(valid_metadata_json_); + ASSERT_TRUE(result.has_value()); + + auto calibration = result.value(); + EXPECT_FALSE(calibration.metadata_json.empty()); + EXPECT_NE(calibration.sensor_info, nullptr); + EXPECT_NE(calibration.packet_format, nullptr); +} + +TEST_F(TestOusterCalibrationData, TestLoadFromStringEmpty) +{ + auto result = OusterCalibrationData::load_from_string(""); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().code, OusterCalibrationData::ErrorCode::EMPTY_METADATA); +} + +TEST_F(TestOusterCalibrationData, TestLoadFromFileSuccess) +{ + // Write valid metadata to a temporary file + std::string tmp_path = "/tmp/test_ouster_metadata.json"; + { + std::ofstream f(tmp_path); + f << valid_metadata_json_; + } + + auto result = OusterCalibrationData::load_from_file(tmp_path); + ASSERT_TRUE(result.has_value()); + + auto calibration = result.value(); + EXPECT_EQ(calibration.calibration_file, tmp_path); + EXPECT_FALSE(calibration.metadata_json.empty()); + EXPECT_NE(calibration.sensor_info, nullptr); + EXPECT_NE(calibration.packet_format, nullptr); + + std::remove(tmp_path.c_str()); +} + +TEST_F(TestOusterCalibrationData, TestLoadFromFileNotFound) +{ + auto result = OusterCalibrationData::load_from_file("/nonexistent/path/metadata.json"); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().code, OusterCalibrationData::ErrorCode::OPEN_FOR_READ_FAILED); +} + +TEST_F(TestOusterCalibrationData, TestLoadFromFileEmpty) +{ + std::string tmp_path = "/tmp/test_ouster_empty_metadata.json"; + { + std::ofstream f(tmp_path); + // Write empty file + } + + auto result = OusterCalibrationData::load_from_file(tmp_path); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().code, OusterCalibrationData::ErrorCode::EMPTY_METADATA); + + std::remove(tmp_path.c_str()); +} + +TEST_F(TestOusterCalibrationData, TestSaveToFileSuccess) +{ + auto load_result = OusterCalibrationData::load_from_string(valid_metadata_json_); + ASSERT_TRUE(load_result.has_value()); + + auto calibration = load_result.value(); + std::string tmp_path = "/tmp/test_ouster_save_metadata.json"; + auto save_result = calibration.save_to_file(tmp_path); + ASSERT_TRUE(save_result.has_value()); + + // Verify file content + std::ifstream f(tmp_path); + std::string content((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + EXPECT_EQ(content, valid_metadata_json_); + + std::remove(tmp_path.c_str()); +} + +TEST_F(TestOusterCalibrationData, TestSaveToFileInvalidPath) +{ + auto load_result = OusterCalibrationData::load_from_string(valid_metadata_json_); + ASSERT_TRUE(load_result.has_value()); + + auto calibration = load_result.value(); + auto save_result = calibration.save_to_file("/nonexistent/directory/metadata.json"); + ASSERT_FALSE(save_result.has_value()); + EXPECT_EQ(save_result.error().code, OusterCalibrationData::ErrorCode::OPEN_FOR_WRITE_FAILED); +} + +} // namespace nebula::drivers + +int main(int argc, char * argv[]) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/nebula_ouster/nebula_ouster_decoders/CMakeLists.txt b/src/nebula_ouster/nebula_ouster_decoders/CMakeLists.txt index d9ed0df91..bb0aeee49 100644 --- a/src/nebula_ouster/nebula_ouster_decoders/CMakeLists.txt +++ b/src/nebula_ouster/nebula_ouster_decoders/CMakeLists.txt @@ -11,12 +11,16 @@ set(BUILD_PCAP OFF CACHE BOOL "Ouster SDK: pcap" FORCE) set(BUILD_OSF OFF CACHE BOOL "Ouster SDK: osf" FORCE) set(BUILD_VIZ OFF CACHE BOOL "Ouster SDK: visualizer" FORCE) set(BUILD_MAPPING OFF CACHE BOOL "Ouster SDK: mapping" FORCE) +set(_PARENT_BUILD_TESTING ${BUILD_TESTING}) set(BUILD_TESTING OFF CACHE BOOL "Ouster SDK: tests" FORCE) set(BUILD_EXAMPLES OFF CACHE BOOL "Ouster SDK: examples" FORCE) set(BUILD_PYTHON_MODULE OFF CACHE BOOL "Ouster SDK: python" FORCE) add_subdirectory("${_nebula_ouster_sdk}" "${CMAKE_CURRENT_BINARY_DIR}/ouster-sdk-build") +# Restore BUILD_TESTING after ouster-sdk subdirectory +set(BUILD_TESTING ${_PARENT_BUILD_TESTING} CACHE BOOL "Build tests" FORCE) + if(TARGET ouster_client) # ouster-sdk has few C++ warnings which are treated as errors by the CI. Disable them for now. target_compile_options(ouster_client PRIVATE -Wno-pedantic) @@ -94,4 +98,26 @@ ament_export_dependencies( nebula_core_decoders OpenSSL) +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + find_package(ament_cmake_gtest REQUIRED) + + ament_add_gtest( + test_ouster_decoder + test/test_ouster_decoder.cpp + ) + target_link_libraries( + test_ouster_decoder + nebula_ouster_decoders + nebula_core_common::nebula_core_common + nebula_ouster_common::nebula_ouster_common + ) + target_include_directories( + test_ouster_decoder + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include + ${_nebula_ouster_sdk}/ouster_client/include + ${_ouster_gen_includes} + ) +endif() + ament_package() diff --git a/src/nebula_ouster/nebula_ouster_decoders/package.xml b/src/nebula_ouster/nebula_ouster_decoders/package.xml index 6f84cb55a..b0272611d 100644 --- a/src/nebula_ouster/nebula_ouster_decoders/package.xml +++ b/src/nebula_ouster/nebula_ouster_decoders/package.xml @@ -17,6 +17,9 @@ nebula_core_decoders nebula_ouster_common + ament_cmake_gtest + ament_lint_auto + ament_cmake diff --git a/src/nebula_ouster/nebula_ouster_decoders/test/test_ouster_decoder.cpp b/src/nebula_ouster/nebula_ouster_decoders/test/test_ouster_decoder.cpp new file mode 100644 index 000000000..0a6b5260e --- /dev/null +++ b/src/nebula_ouster/nebula_ouster_decoders/test/test_ouster_decoder.cpp @@ -0,0 +1,157 @@ +// Copyright 2026 TIER IV, Inc. + +#include "nebula_ouster_decoders/ouster_decoder.hpp" + +#include + +#include + +#include + +#include +#include +#include +#include + +namespace nebula::drivers +{ + +class TestOusterDecoder : public ::testing::Test +{ +protected: + void SetUp() override + { + // Use the SDK's default_sensor_info to get a valid SensorInfo for testing + auto info = ouster::sdk::core::default_sensor_info(ouster::sdk::core::LidarMode::_512x10); + auto sensor_info = std::make_shared(info); + auto packet_format = std::make_shared(*sensor_info); + + calibration_.sensor_info = sensor_info; + calibration_.packet_format = packet_format; + calibration_.metadata_json = "{}"; // not used by decoder directly + } + + OusterCalibrationData calibration_; +}; + +TEST_F(TestOusterDecoder, TestEmptyPacketReturnsError) +{ + auto decoder = OusterDecoder( + FieldOfView{}, calibration_, + [](const NebulaPointCloudPtr &, double) {}); + + std::vector empty_packet; + auto result = decoder.unpack(empty_packet); + ASSERT_FALSE(result.metadata_or_error.has_value()); + EXPECT_EQ(result.metadata_or_error.error(), DecodeError::EMPTY_PACKET); +} + +TEST_F(TestOusterDecoder, TestCallbackNotSetReturnsError) +{ + auto decoder = OusterDecoder(FieldOfView{}, calibration_, nullptr); + + std::vector packet(100, 0x00); + auto result = decoder.unpack(packet); + ASSERT_FALSE(result.metadata_or_error.has_value()); + EXPECT_EQ(result.metadata_or_error.error(), DecodeError::CALLBACK_NOT_SET); +} + +TEST_F(TestOusterDecoder, TestInvalidPacketSizeReturnsError) +{ + auto decoder = OusterDecoder( + FieldOfView{}, calibration_, + [](const NebulaPointCloudPtr &, double) {}); + + // A packet whose size does not match lidar, imu, or zone packet size + std::vector bad_packet(42, 0xAA); + auto result = decoder.unpack(bad_packet); + ASSERT_FALSE(result.metadata_or_error.has_value()); + EXPECT_EQ(result.metadata_or_error.error(), DecodeError::PACKET_FORMAT_INVALID); +} + +TEST_F(TestOusterDecoder, TestValidLidarPacketDoesNotError) +{ + std::atomic_int callback_count{0}; + auto decoder = OusterDecoder( + FieldOfView{}, calibration_, + [&](const NebulaPointCloudPtr &, double) { callback_count++; }); + + // Create a packet with the correct lidar packet size (all zeros is fine for format check) + const size_t lidar_size = decoder.lidar_packet_size(); + std::vector lidar_packet(lidar_size, 0x00); + auto result = decoder.unpack(lidar_packet); + + // Should succeed (metadata returned) + ASSERT_TRUE(result.metadata_or_error.has_value()); + // Single packet typically does not complete a full scan + EXPECT_FALSE(result.metadata_or_error.value().did_scan_complete); + EXPECT_GT(result.performance_counters.decode_time_ns, 0U); +} + +TEST_F(TestOusterDecoder, TestSetPointcloudCallback) +{ + std::atomic_int callback_count{0}; + auto decoder = OusterDecoder(FieldOfView{}, calibration_, nullptr); + + // Should fail with callback not set + std::vector packet(42, 0x00); + auto result = decoder.unpack(packet); + EXPECT_EQ(result.metadata_or_error.error(), DecodeError::CALLBACK_NOT_SET); + + // Now set a valid callback + decoder.set_pointcloud_callback([&](const NebulaPointCloudPtr &, double) { callback_count++; }); + + // Still invalid size, but we should get FORMAT_INVALID, not CALLBACK_NOT_SET + result = decoder.unpack(packet); + EXPECT_EQ(result.metadata_or_error.error(), DecodeError::PACKET_FORMAT_INVALID); +} + +TEST_F(TestOusterDecoder, TestLidarPacketSizeIsNonZero) +{ + auto decoder = OusterDecoder( + FieldOfView{}, calibration_, + [](const NebulaPointCloudPtr &, double) {}); + + EXPECT_GT(decoder.lidar_packet_size(), 0U); +} + +TEST_F(TestOusterDecoder, TestDecodeErrorToCstr) +{ + EXPECT_STREQ(to_cstr(DecodeError::PACKET_FORMAT_INVALID), "packet format invalid"); + EXPECT_STREQ(to_cstr(DecodeError::CALLBACK_NOT_SET), "pointcloud callback is not set"); + EXPECT_STREQ(to_cstr(DecodeError::EMPTY_PACKET), "packet is empty"); +} + +TEST_F(TestOusterDecoder, TestPerformanceCountersRecorded) +{ + auto decoder = OusterDecoder( + FieldOfView{}, calibration_, + [](const NebulaPointCloudPtr &, double) {}); + + // Even for error paths, decode_time_ns should be recorded + std::vector empty_packet; + auto result = decoder.unpack(empty_packet); + EXPECT_GT(result.performance_counters.decode_time_ns, 0U); + EXPECT_EQ(result.performance_counters.callback_time_ns, 0U); +} + +TEST_F(TestOusterDecoder, TestMoveConstruction) +{ + auto decoder = OusterDecoder( + FieldOfView{}, calibration_, + [](const NebulaPointCloudPtr &, double) {}); + + size_t original_size = decoder.lidar_packet_size(); + + // Move construction + auto moved_decoder = std::move(decoder); + EXPECT_EQ(moved_decoder.lidar_packet_size(), original_size); +} + +} // namespace nebula::drivers + +int main(int argc, char * argv[]) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/nebula_ouster/vendor/ouster-sdk/ouster_client/include/ouster/impl/logging.h b/src/nebula_ouster/vendor/ouster-sdk/ouster_client/include/ouster/impl/logging.h index a8ac2d989..26f292e56 100644 --- a/src/nebula_ouster/vendor/ouster-sdk/ouster_client/include/ouster/impl/logging.h +++ b/src/nebula_ouster/vendor/ouster-sdk/ouster_client/include/ouster/impl/logging.h @@ -45,37 +45,37 @@ class Logger void trace(const std::string & format_string, Args &&... args) { return log(LOG_LEVEL::LOG_TRACE, format_string, std::forward(args)...); - }; + } template void debug(const std::string & format_string, Args &&... args) { return log(LOG_LEVEL::LOG_DEBUG, format_string, std::forward(args)...); - }; + } template void info(const std::string & format_string, Args &&... args) { return log(LOG_LEVEL::LOG_INFO, format_string, std::forward(args)...); - }; + } template void warn(const std::string & format_string, Args &&... args) { return log(LOG_LEVEL::LOG_WARN, format_string, std::forward(args)...); - }; + } template void error(const std::string & format_string, Args &&... args) { return log(LOG_LEVEL::LOG_ERROR, format_string, std::forward(args)...); - }; + } template void critical(const std::string & format_string, Args &&... args) { return log(LOG_LEVEL::LOG_CRITICAL, format_string, std::forward(args)...); - }; + } template void log(LOG_LEVEL level, const std::string & format_string, Args &&... args)