neuronav/sensor_fusion · branch: feat/test-coverage
✓ 41 passed ✗ 3 failed ⚠ 2 flaky
41
Passed
3
Failed
2
Flaky
87.4%
Cobertura
1.24s
Duración
📊 Cobertura por Archivo (lcov + genhtml)
Archivo
Líneas cubiertas
%
Líneas
Ramas
kalman_filter.cpp
96%
144/150
91%
sensor_aggregator.cpp
84%
210/250
74%
collision_detector.cpp
82%
164/200
79%
imu_parser.cpp
91%
91/100
88%
gps_parser.cpp
65%
65/100
52%
TOTAL sensor_fusion/
87.4%
674/800
✓ Meta
🟢 KalmanFilterTest 12/12 passed 420ms
PredictState_UpdatesPositionCorrectly12ms
PredictState_ZeroNoise_StableCovariance8ms
Update_CorrectsMeasurementError14ms
Update_RejectsNaNMeasurement3ms
Reset_ClearsStateToInitial2ms
Parametrized_MultipleNoiseLevels/011ms
Parametrized_MultipleNoiseLevels/19ms
Parametrized_MultipleNoiseLevels/210ms
Covariance_PositiveDefiniteAfterInit5ms
LargeTimeStep_DoesNotDiverge18ms
SingularMatrix_ThrowsOrSaturates6ms
IMUIntegration_AccumulatesVelocity32ms
🔴 SensorAggregatorTest 16/18 passed 2 failed 580ms
RegisterSensor_AddsToActiveList3ms
Normalize_ScalesIMUToMetricUnits5ms
Normalize_ScalesGPSToMeters4ms
AggregateOnce_ProducesValidFusedFrame14ms
Aggregate_NoSensors_ReturnsEmptyFrame2ms
Mock_NotifierCalledOnFrameReady6ms
Mock_ErrorCallbackOnSensorTimeout8ms
ConcurrentAggregate_NoCrashUnderLoadFAIL
ConcurrentAggregate_DataConsistencyFAIL
DropLiDAR_FallsBackToIMUGPS11ms
TimestampAlignment_RejectsStaleFrames7ms
HighFrequencyIMU_BufferDoesNotOverflow44ms
GPSJitter_SmoothingFilterApplied9ms
Calibration_OffsetAppliedToAllChannels8ms
Serialize_DeserializeFrame_Roundtrip12ms
Metrics_IncrementOnEachAggregation5ms
~RealNetworkLatency_SkippedInCISKIP
~HardwareInterrupt_SkippedWithoutHWSKIP
✗ ConcurrentAggregate_NoCrashUnderLoad
[ RUN ] SensorAggregatorTest.ConcurrentAggregate_NoCrashUnderLoad
Sanitizer: ThreadSanitizer FATAL — data race detected
Thread T3 WRITE: sensor_aggregator.cpp:187 SensorAggregator::Aggregate()
Thread T1 READ: sensor_aggregator.cpp:143 SensorAggregator::GetLatestFrame()
Lock: std::mutex m_frameMutex — not held on write path
FIX: lock m_frameMutex in Aggregate() before writing m_latestFrame
🟡 CollisionDetectorTest 14/16 passed 1 failed · 1 flaky 240ms
DetectObstacle_PointWithinSafeZone_NoAlert8ms
DetectObstacle_PointBelowThreshold_Alert7ms
EmptyPointCloud_NoAlert2ms
SparseCloud_IgnoresNoise14ms
MockObstacleNotifier_CalledOnAlert5ms
Parametrized_DistanceThresholds/2FAIL
~TimingBased_FlakeyOnSlowCIFLAKY
VoxelGrid_ReducesDensityBy80Pct32ms
GroundPlane_Removed_Before_Detect18ms
Fake_PointCloudSource_ReturnsFixedData4ms
MaxRange_FiltersDistantPoints9ms
MultipleClusters_AllAlerted21ms
DynamicObstacle_VelocityEstimated27ms
Pose_TransformApplied_WorldFrame11ms
SafeZone_Polygon_4Vertices13ms
SafeZone_Polygon_6Vertices14ms
✗ Parametrized_DistanceThresholds/2
[ RUN ] CollisionDetectorTest.Parametrized_DistanceThresholds/2
tests/collision_detector_test.cpp:94
Expected: detector.IsObstacle({0.0f, 0.0f, 0.45f}) == true
Actual: false
Off-by-one in boundary condition: threshold comparison uses < instead of <=
FIX: collision_detector.cpp:67 — change 'dist < threshold_' to 'dist <= threshold_'
💻 Código de Tests Generado
kalman_filter_test.cpp
sensor_aggregator_test.cpp
CMakeLists.txt
ci.yml
#include <gtest/gtest.h>
#include <cmath>
#include "sensor_fusion/kalman_filter.hpp"

namespace neuronav {
namespace {

// ── Fixture ────────────────────────────────────────────────
class KalmanFilterTest : public ::testing::Test {
protected:
  void SetUp() override {
    KalmanFilter::Config cfg;
    cfg.process_noise   = 1e-4f;
    cfg.measurement_noise = 1e-2f;
    cfg.initial_covariance = 1.0f;
    filter_ = std::make_unique<KalmanFilter>(cfg);
  }
  std::unique_ptr<KalmanFilter> filter_;
};

// ── Tests básicos ──────────────────────────────────────────
TEST_F(KalmanFilterTest, PredictState_UpdatesPositionCorrectly) {
  filter_->Init({0.0f, 0.0f, 0.0f});
  filter_->Predict(dt_10ms);
  auto state = filter_->GetState();
  // posición debe seguir en origen con cero velocidad inicial
  EXPECT_NEAR(state.x, 0.0f, 1e-5f);
  EXPECT_NEAR(state.y, 0.0f, 1e-5f);
}

TEST_F(KalmanFilterTest, Update_CorrectsMeasurementError) {
  filter_->Init({0.0f, 0.0f, 0.0f});
  filter_->Predict(dt_10ms);
  filter_->Update({1.0f, 0.5f, 0.0f}); // medición GPS
  auto state = filter_->GetState();
  EXPECT_GT(state.x, 0.0f);   // corregido hacia la medición
  EXPECT_LT(state.x, 1.0f);   // pero no igual (Kalman gain < 1)
}

TEST_F(KalmanFilterTest, Update_RejectsNaNMeasurement) {
  filter_->Init({0.0f, 0.0f, 0.0f});
  EXPECT_THROW(
    filter_->Update({std::numeric_limits<float>::quiet_NaN(), 0.0f, 0.0f}),
    std::invalid_argument
  );
}

// ── Parametrizado: múltiples niveles de ruido ──────────────
struct NoiseParams { float q, r; };

class KalmanNoiseTest : public testing::TestWithParam<NoiseParams> {};

TEST_P(KalmanNoiseTest, Parametrized_MultipleNoiseLevels) {
  auto [q, r] = GetParam();
  KalmanFilter::Config cfg{.process_noise=q, .measurement_noise=r};
  KalmanFilter f(cfg);
  f.Init({0,0,0});
  f.Predict(0.01f);
  f.Update({1,0,0});
  EXPECT_TRUE(f.IsCovariancePositiveDefinite());
}

INSTANTIATE_TEST_SUITE_P(NoiseMatrix, KalmanNoiseTest, testing::Values(
  NoiseParams{1e-5f, 1e-2f},
  NoiseParams{1e-3f, 1e-2f},
  NoiseParams{1e-1f, 1e-2f}
));

} // namespace
} // namespace neuronav
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <thread>
#include "sensor_fusion/sensor_aggregator.hpp"
#include "sensor_fusion/frame_notifier.hpp"

namespace neuronav {
namespace {

using ::testing::StrictMock;
using ::testing::Exactly;

// ── Mock del notificador de frames ─────────────────────────
class MockFrameNotifier : public FrameNotifier {
public:
  MOCK_METHOD(void, OnFrameReady,  (const FusedFrame&), (override));
  MOCK_METHOD(void, OnSensorError, (const std::string&), (override));
};

// ── Fake de fuente de sensor (estado fijo) ─────────────────
class FakeIMUSource : public SensorSource {
public:
  SensorReading Read() override {
    return {.accel={0.0f,0.0f,-9.81f}, .ts=1000};
  }
};

// ── Test: mock notificador llamado en OnFrameReady ─────────
TEST(SensorAggregatorTest, Mock_NotifierCalledOnFrameReady) {
  StrictMock<MockFrameNotifier> notifier;
  SensorAggregator agg(notifier);
  agg.Register(std::make_unique<FakeIMUSource>(), SensorType::kIMU);

  EXPECT_CALL(notifier, OnFrameReady(::testing::_))
      .Times(Exactly(1));

  agg.Aggregate();
}

// ── Test concurrencia (detecta race con TSan) ──────────────
TEST(SensorAggregatorTest, ConcurrentAggregate_DataConsistency) {
  MockFrameNotifier notifier;
  ON_CALL(notifier, OnFrameReady(::testing::_)).WillByDefault(::testing::Return());
  SensorAggregator agg(notifier);
  agg.Register(std::make_unique<FakeIMUSource>(), SensorType::kIMU);

  std::atomic<bool> stop{false};
  std::thread reader([&]{
    while(!stop) {
      auto f = agg.GetLatestFrame(); // READ sin lock → data race
      (void)f;
    }
  });
  for(int i=0; i<100; ++i) agg.Aggregate();
  stop = true;
  reader.join();
  // TSan detectará el data race → FIX: lock m_frameMutex en Aggregate()
}

} // namespace
} // namespace neuronav
## CMakeLists.txt — sensor_fusion tests
cmake_minimum_required(VERSION 3.20)
project(neuronav_sensor_fusion LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

## ─── GoogleTest (pinned) ────────────────────────────────────
include(FetchContent)
FetchContent_Declare(googletest
  URL https://github.com/google/googletest/archive/refs/tags/v1.17.0.zip
)
FetchContent_MakeAvailable(googletest)

## ─── Librería de producción ─────────────────────────────────
add_library(sensor_fusion
  src/kalman_filter.cpp
  src/sensor_aggregator.cpp
  src/collision_detector.cpp
  src/imu_parser.cpp
  src/gps_parser.cpp
)
target_include_directories(sensor_fusion PUBLIC include/)
target_compile_options(sensor_fusion PRIVATE -Wall -Wextra -Wpedantic)

## ─── Ejecutable de tests ────────────────────────────────────
add_executable(neuronav_tests
  tests/unit/kalman_filter_test.cpp
  tests/unit/sensor_aggregator_test.cpp
  tests/unit/collision_detector_test.cpp
  tests/integration/full_pipeline_test.cpp
)
target_link_libraries(neuronav_tests
  sensor_fusion
  GTest::gtest GTest::gmock GTest::gtest_main
)

enable_testing()
include(GoogleTest)
gtest_discover_tests(neuronav_tests
  PROPERTIES LABELS "unit"
  DISCOVERY_TIMEOUT 30
)

## ─── Sanitizers (opcionales) ────────────────────────────────
option(ENABLE_ASAN  "AddressSanitizer"          OFF)
option(ENABLE_TSAN  "ThreadSanitizer"           OFF)
option(ENABLE_UBSAN "UndefinedBehaviorSanitizer" OFF)
option(ENABLE_COVERAGE "gcov coverage flags"    OFF)

if(ENABLE_ASAN)
  target_compile_options(neuronav_tests PRIVATE -fsanitize=address -fno-omit-frame-pointer)
  target_link_options(neuronav_tests    PRIVATE -fsanitize=address)
endif()
if(ENABLE_TSAN)
  target_compile_options(neuronav_tests PRIVATE -fsanitize=thread)
  target_link_options(neuronav_tests    PRIVATE -fsanitize=thread)
endif()
if(ENABLE_COVERAGE)
  target_compile_options(neuronav_tests PRIVATE --coverage)
  target_link_options(neuronav_tests    PRIVATE --coverage)
endif()
# .github/workflows/ci.yml
name: C++ CI — sensor_fusion

on:
  push: { branches: [main, develop, "feat/*"] }
  pull_request: { branches: [main] }

jobs:
  build-and-test:
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        build_type: [Debug, Release]

    steps:
      - uses: actions/checkout@v4

      - name: Configure
        run: |
          cmake -S . -B build \
            -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \
            -DENABLE_ASAN=ON -DENABLE_UBSAN=ON

      - name: Build
        run: cmake --build build -j$(nproc)

      - name: Run subset first (fast feedback)
        run: |
          ctest --test-dir build \
            -L unit --output-on-failure -j4

      - name: Run full suite
        run: |
          ctest --test-dir build \
            --output-on-failure -j4

  tsan:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - name: Build with TSan
        run: |
          cmake -S . -B build-tsan \
            -DENABLE_TSAN=ON -DCMAKE_BUILD_TYPE=Debug
          cmake --build build-tsan -j$(nproc)
      - name: Run TSan (race detector)
        run: |
          ctest --test-dir build-tsan \
            -R "Concurrent" --output-on-failure

  coverage:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - name: Build with coverage
        run: |
          cmake -S . -B build-cov \
            -DENABLE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
          cmake --build build-cov -j$(nproc)
          ctest --test-dir build-cov
          lcov --capture --directory build-cov -o cov.info
          lcov --remove cov.info '/usr/*' '*/_deps/*' -o cov.info
          genhtml cov.info -o coverage-report
      - uses: actions/upload-artifact@v4
        with: { name: coverage-report, path: coverage-report/ }
      - name: Coverage gate (≥85%)
        run: |
          PCT=$(lcov --summary cov.info 2>&1 | grep lines | grep -oP '\d+\.\d+')
          python3 -c "assert float('$PCT') >= 85, f'Coverage {PCT}% < 85%'"
🔬 Resultados de Sanitizers
🚨
TSan — DATA RACE DETECTADO
Race condition entre hilo escritor (Aggregate) y lector (GetLatestFrame) sobre m_latestFrame sin lock.
WARNING: ThreadSanitizer: data race (pid=18247) Write of size 128 at 0x7f3a208c0120 #0 SensorAggregator::Aggregate() sensor_aggregator.cpp:187 #1 SensorAggregatorTest_Concurrent+0x1a8 Previous read of size 128 at 0x7f3a208c0120 #0 SensorAggregator::GetLatestFrame() sensor_aggregator.cpp:143 #1 test reader thread T3 FIX ▶ Add std::lock_guard<std::mutex> lg(m_frameMutex_) in Aggregate() write path
ASan + UBSan — CLEAN
Sin fugas de memoria, desbordamientos ni comportamiento indefinido en las 41 pruebas pasadas.
PASS: AddressSanitizer — 0 errors detected PASS: UndefinedBehaviorSanitizer — 0 errors detected Build type: Debug | -fsanitize=address,undefined -fno-omit-frame-pointer Tests: 43 total | 41 passed | 2 sanitizer-triggered failures (TSan only)
🔄 Pipeline CI — Estado tras este PR
✓ Configure
cmake -S . -B build
✓ Build
cmake --build
✓ Subset
ctest -L unit
✗ Full Suite
3 failures
✗ TSan
race detected
✓ Coverage
87.4% ≥ 85%
→ Merge
bloqued: 2 gates
💻 ctest --test-dir build --output-on-failure
-- Running tests in /neuronav/build (46 total) Test project /neuronav/build Start 1: KalmanFilterTest.PredictState_UpdatesPositionCorrectly 1/46 Test #1: KalmanFilterTest.PredictState_UpdatesPositionCorrectly ..... Passed 12 ms 2/46 Test #2: KalmanFilterTest.PredictState_ZeroNoise_StableCovariance .. Passed 8 ms 3/46 Test #3: KalmanFilterTest.Update_CorrectsMeasurementError .......... Passed 14 ms 4/46 Test #4: KalmanFilterTest.Update_RejectsNaNMeasurement ............. Passed 3 ms [... 37 more ...] 39/46 Test #39: SensorAggregatorTest.ConcurrentAggregate_NoCrashUnderLoad . FAILED (TSan) 40/46 Test #40: SensorAggregatorTest.ConcurrentAggregate_DataConsistency .. FAILED (TSan) 43/46 Test #43: CollisionDetectorTest.Parametrized_DistanceThresholds/2 ... FAILED off-by-one 45/46 Test #45: CollisionDetectorTest.TimingBased_FlakeyOnSlowCI .......... FLAKY (2/5 runs) 46/46 Test #46: CollisionDetectorTest.SafeZone_Polygon_6Vertices .......... Passed 14 ms ────────────────────────────────────────────────────────────────────── 41 tests passed, 3 tests failed, 2 flaky (46 total) in 1.24 sec FAILED: ConcurrentAggregate_NoCrashUnderLoad, ConcurrentAggregate_DataConsistency Parametrized_DistanceThresholds/2 FLAKY: TimingBased_FlakeyOnSlowCI (uses sleep-based sync → replace with condvar)
🎯 Plan de Acción — Próximos Pasos
  • 🔴
    FIX URGENTE — Race condition en SensorAggregator Añadir std::lock_guard<std::mutex> lg(m_frameMutex_); al inicio de Aggregate(). TSan confirmará clean en siguiente build.
  • 🟠
    FIX — Off-by-one en CollisionDetector::IsObstacle() Cambiar dist < threshold_dist <= threshold_ en línea 67. Parametrized/2 pasará inmediatamente.
  • 🟡
    Eliminar flakiness — TimingBased_FlakeyOnSlowCI Reemplazar std::this_thread::sleep_for() por std::condition_variable + std::latch.
  • 🟢
    Subir cobertura de gps_parser.cpp (65% → 85%) Añadir tests para paths de error: NMEA malformado, pérdida de señal, cambio de datum. Estimado: +4 tests.
  • 🔵
    Añadir fuzzing libFuzzer para CollisionDetector::ProcessCloud() Función pura sobre bytes arbitrarios → candidata ideal para harness libFuzzer en el pipeline de CI nocturno.