diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..edce11a9668 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_gps -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index f296f838213..44ca4a45f53 100644 --- a/platformio.ini +++ b/platformio.ini @@ -167,7 +167,7 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = test_kiss_modem, test_gps_nmea build_src_filter = -<*> +<../src/Utils.cpp> @@ -191,3 +191,26 @@ build_src_filter = +<../examples/kiss_modem/KissModem.cpp> lib_deps = google/googletest @ 1.17.0 + +; GPS/NMEA provider tests compile the real provider header against the real +; MicroNMEA library. The mocks must shadow src/ (same ordering as the KISS +; modem suite). MicroNMEA is an Arduino-format library, so compatibility +; checks are off to let it build for the native platform. Sanitizers make +; out-of-bounds reads in the parser fail the test instead of passing silently. +[env:native_gps] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/mocks + -I src + -fsanitize=address,undefined + -fno-sanitize-recover=all + -fno-omit-frame-pointer +test_build_src = yes +test_filter = test_gps_nmea +build_src_filter = + -<*> +lib_compat_mode = off +lib_deps = + google/googletest @ 1.17.0 + stevemarple/MicroNMEA @ 2.0.6 diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 37ef5762c48..ffd2706109f 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -166,7 +166,16 @@ public : return alt; } long satellitesCount() override { return nmea.getNumSatellites(); } - bool isValid() override { return nmea.isValid(); } + bool isValid() override { + // MicroNMEA can report a fix whose position fields did not parse, for + // example a checksum-valid sentence with a field missing. Do not call + // a position no receiver can report a fix. + if (!nmea.isValid()) return false; + const long lat = nmea.getLatitude(); + const long lon = nmea.getLongitude(); + return lat >= -90000000L && lat <= 90000000L + && lon >= -180000000L && lon <= 180000000L; + } long getTimestamp() override { DateTime dt(nmea.getYear(), nmea.getMonth(),nmea.getDay(),nmea.getHour(),nmea.getMinute(),nmea.getSecond()); diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 77499fe4145..bcd6620c4f4 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -2,6 +2,7 @@ #include #include +#include // Arduino.h provides the C character classification functions #include "Stream.h" inline uint32_t g_mock_millis = 0; @@ -15,3 +16,26 @@ inline uint32_t millis() { inline void delay(uint32_t ms) { g_mock_millis += ms; } + +// Busy-wait loops call yield(); let mock time move so they can time out. +inline void yield() { + g_mock_millis += 1; +} + +// Minimal digital I/O so headers that drive enable/reset pins compile natively. +constexpr uint8_t LOW = 0; +constexpr uint8_t HIGH = 1; +constexpr uint8_t INPUT = 0; +constexpr uint8_t OUTPUT = 1; + +inline uint8_t g_mock_pin_levels[64] = {}; + +inline void pinMode(uint8_t, uint8_t) {} + +inline void digitalWrite(uint8_t pin, uint8_t level) { + if (pin < 64) g_mock_pin_levels[pin] = level; +} + +inline int digitalRead(uint8_t pin) { + return pin < 64 ? g_mock_pin_levels[pin] : LOW; +} diff --git a/test/mocks/Mesh.h b/test/mocks/Mesh.h index b6c263c199a..6f73b0b0bc4 100644 --- a/test/mocks/Mesh.h +++ b/test/mocks/Mesh.h @@ -15,6 +15,13 @@ class Radio { virtual int16_t getNoiseFloor() { return -120; } }; +class RTCClock { +public: + virtual ~RTCClock() = default; + virtual uint32_t getCurrentTime() = 0; + virtual void setCurrentTime(uint32_t time) = 0; +}; + class MainBoard { public: virtual ~MainBoard() = default; diff --git a/test/mocks/RTClib.h b/test/mocks/RTClib.h new file mode 100644 index 00000000000..6a02326a04b --- /dev/null +++ b/test/mocks/RTClib.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +// Minimal DateTime stand-in for native tests. unixtime() matches RTClib for +// valid dates from 2000 to 2099, the range RTClib supports (seconds since +// 1970-01-01 UTC). Out-of-range fields map to a deterministic value instead of +// indexing past the month table, so tests can compare two parses of the same +// bytes; such values are not what RTClib would return. +class DateTime { + uint16_t _year; + uint8_t _month, _day, _hour, _minute, _second; + + static bool leap(uint16_t year) { + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + } + +public: + DateTime(uint16_t year, uint8_t month, uint8_t day, uint8_t hour, + uint8_t minute, uint8_t second) + : _year(year), _month(month), _day(day), _hour(hour), + _minute(minute), _second(second) {} + + uint32_t unixtime() const { + uint32_t days = 0; + for (uint16_t year = 1970; year < _year; ++year) + days += leap(year) ? 366 : 365; + if (_month >= 1 && _month <= 12) { + const uint8_t month_days[] = {31, 28, 31, 30, 31, 30, + 31, 31, 30, 31, 30, 31}; + for (uint8_t month = 1; month < _month; ++month) + days += month_days[month - 1] + (month == 2 && leap(_year) ? 1 : 0); + days += _day - 1; + } else { + days += 31UL * _month + _day; // deterministic, not calendar-correct + } + return days * 86400UL + _hour * 3600UL + _minute * 60UL + _second; + } +}; diff --git a/test/test_gps_nmea/gps_test_support.h b/test/test_gps_nmea/gps_test_support.h new file mode 100644 index 00000000000..0a7b733ef4c --- /dev/null +++ b/test/test_gps_nmea/gps_test_support.h @@ -0,0 +1,49 @@ +#pragma once + +// Shared helpers for the native GPS/NMEA tests: the real +// MicroNMEALocationProvider and MicroNMEA, fed through a mock UART. + +#include + +#include +#include + +#include +#include +#include +#include + + +struct MockSerial : Stream { + std::deque queue; + int available() override { return (int)queue.size(); } + int read() override { + if (queue.empty()) return -1; + char c = queue.front(); + queue.pop_front(); + return (unsigned char)c; + } + void feed(const std::string& text) { + for (char c : text) queue.push_back(c); + } +}; + +// "$" + body + "*" + two hex digits, the checksum MicroNMEA expects. +inline std::string withChecksum(const std::string& body) { + uint8_t sum = 0; + for (char c : body) sum ^= (uint8_t)c; + char hex[4]; + snprintf(hex, sizeof(hex), "%02X", sum); + return "$" + body + "*" + hex; +} + +inline void feedAndLoop(MicroNMEALocationProvider& gps, MockSerial& serial, + const std::string& line) { + serial.feed(line + "\r\n"); + gps.loop(); +} + +inline constexpr const char* kGoodRmc = + "GPRMC,120034.00,A,4716.1234,N,12233.5678,W,0.12,34.5,171026,003.1,W,A"; +inline constexpr const char* kGoodGga = + "GPGGA,120034.00,4716.1234,N,12233.5678,W,1,08,0.9,125.0,M,-17.0,M,,"; diff --git a/test/test_gps_nmea/test_coordinate_range.cpp b/test/test_gps_nmea/test_coordinate_range.cpp new file mode 100644 index 00000000000..cb3088b86ac --- /dev/null +++ b/test/test_gps_nmea/test_coordinate_range.cpp @@ -0,0 +1,91 @@ +#include "gps_test_support.h" + +namespace { + +// RMC with the given latitude/longitude fields and status, valid checksum. +std::string rmcAt(const char* lat, const char* ns, const char* lon, + const char* ew, const char* status = "A") { + return withChecksum(std::string("GPRMC,120034.00,") + status + "," + lat + "," + + ns + "," + lon + "," + ew + ",0.12,34.5,171026,003.1,W,A"); +} + +struct Fix { + bool valid; + long lat; + long lon; +}; + +Fix parseWithProvider(const std::string& sentence) { + MockSerial serial; + MicroNMEALocationProvider gps(serial, nullptr, -1, -1); + feedAndLoop(gps, serial, sentence); + return {gps.isValid(), gps.getLatitude(), gps.getLongitude()}; +} + +bool libraryCallsItAFix(const std::string& sentence) { + char buffer[100] = {}; + MicroNMEA nmea(buffer, sizeof(buffer)); + for (char c : sentence) nmea.process(c); + nmea.process('\n'); + return nmea.isValid(); +} + +} // namespace + +TEST(MicroNMEAProviderRange, RealFixesUpToTheBoundariesAreValid) { + const struct { const char *lat, *ns, *lon, *ew; long want_lat, want_lon; } cases[] = { + {"4716.1234", "N", "12233.5678", "W", 47268723L, -122559463L}, + {"9000.0000", "N", "18000.0000", "E", 90000000L, 180000000L}, + {"9000.0000", "S", "18000.0000", "W", -90000000L, -180000000L}, + {"0000.0000", "N", "00000.0000", "E", 0L, 0L}, + }; + for (const auto& c : cases) { + SCOPED_TRACE(std::string(c.lat) + c.ns + " " + c.lon + c.ew); + const Fix fix = parseWithProvider(rmcAt(c.lat, c.ns, c.lon, c.ew)); + EXPECT_TRUE(fix.valid); + EXPECT_EQ(c.want_lat, fix.lat); + EXPECT_EQ(c.want_lon, fix.lon); + } +} + +TEST(MicroNMEAProviderRange, PositionsNoReceiverCanReportAreNotAFix) { + const struct { const char *lat, *ns, *lon, *ew; } cases[] = { + {"9000.0010", "N", "12233.5678", "W"}, // 90.0000166 N + {"9000.0010", "S", "12233.5678", "W"}, // 90.0000166 S + {"9930.0000", "N", "12233.5678", "W"}, // 99.5 N, inside +-180 + {"4716.1234", "N", "18000.0010", "E"}, // 180.0000166 E + {"4716.1234", "N", "18000.0010", "W"}, // 180.0000166 W + {"4716.1234", "N", "18500.0000", "W"}, // 185 W + }; + for (const auto& c : cases) { + const std::string sentence = rmcAt(c.lat, c.ns, c.lon, c.ew); + SCOPED_TRACE(sentence); + ASSERT_TRUE(libraryCallsItAFix(sentence)); + EXPECT_FALSE(parseWithProvider(sentence).valid); + } +} + +TEST(MicroNMEAProviderRange, ChecksumValidSentenceWithAMissingFieldIsNotAFix) { + // The N/S field is missing. The library skips two characters of the + // longitude instead and reads "233.5678" as degrees and minutes: 233 deg. + const std::string sentence = withChecksum( + "GPRMC,120034.00,A,4716.1234,12233.5678,W,0.12,34.5,171026,003.1,W,A"); + ASSERT_TRUE(libraryCallsItAFix(sentence)); + const Fix fix = parseWithProvider(sentence); + EXPECT_GT(fix.lon < 0 ? -fix.lon : fix.lon, 180000000L); + EXPECT_FALSE(fix.valid); +} + +TEST(MicroNMEAProviderRange, NoFixIsStillNoFix) { + EXPECT_FALSE(parseWithProvider(rmcAt("4716.1234", "N", "12233.5678", "W", "V")).valid); +} + +TEST(MicroNMEAProviderRange, GgaPositionIsCheckedToo) { + const std::string good = withChecksum( + "GPGGA,120034.00,4716.1234,N,12233.5678,W,1,08,0.9,125.0,M,-17.0,M,,"); + const std::string bad = withChecksum( + "GPGGA,120034.00,9930.0000,N,12233.5678,W,1,08,0.9,125.0,M,-17.0,M,,"); + EXPECT_TRUE(parseWithProvider(good).valid); + ASSERT_TRUE(libraryCallsItAFix(bad)); + EXPECT_FALSE(parseWithProvider(bad).valid); +} diff --git a/test/test_gps_nmea/test_main.cpp b/test/test_gps_nmea/test_main.cpp new file mode 100644 index 00000000000..c26f6efeba8 --- /dev/null +++ b/test/test_gps_nmea/test_main.cpp @@ -0,0 +1,9 @@ +#include "gps_test_support.h" + +// Defined in EnvironmentSensorManager.cpp, which is not part of this build. +void LocationProvider::sendSentence(const char*) {} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_gps_nmea/test_parsing.cpp b/test/test_gps_nmea/test_parsing.cpp new file mode 100644 index 00000000000..4755ba77e0c --- /dev/null +++ b/test/test_gps_nmea/test_parsing.cpp @@ -0,0 +1,30 @@ +#include "gps_test_support.h" + +TEST(MicroNMEAProvider, ParsesWellFormedSentences) { + g_mock_millis = 1000; + MockSerial serial; + MicroNMEALocationProvider gps(serial, nullptr, -1, -1); + feedAndLoop(gps, serial, withChecksum(kGoodGga)); + feedAndLoop(gps, serial, withChecksum(kGoodRmc)); + + EXPECT_TRUE(gps.isValid()); + EXPECT_EQ(47268723L, gps.getLatitude()); // 47 deg 16.1234' N + EXPECT_EQ(-122559463L, gps.getLongitude()); // 122 deg 33.5678' W + EXPECT_EQ(8L, gps.satellitesCount()); + EXPECT_EQ(125000L, gps.getAltitude()); + EXPECT_EQ(1792238434L, gps.getTimestamp()); // 2026-10-17 12:00:34 UTC +} + +TEST(MicroNMEAProvider, LongestSentenceTheBufferHoldsStillParses) { + std::string body = kGoodRmc; + while (body.size() < 99 - 4) body.push_back('Z'); // pad the final field + const std::string sentence = withChecksum(body); + ASSERT_EQ(99u, sentence.size()); + + g_mock_millis = 1000; + MockSerial serial; + MicroNMEALocationProvider gps(serial, nullptr, -1, -1); + feedAndLoop(gps, serial, sentence); + EXPECT_TRUE(gps.isValid()); + EXPECT_EQ(47268723L, gps.getLatitude()); +}