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)