Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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
11 changes: 10 additions & 1 deletion src/helpers/sensors/MicroNMEALocationProvider.h
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
24 changes: 24 additions & 0 deletions test/mocks/Arduino.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include <cstdint>
#include <cmath>
#include <ctype.h> // Arduino.h provides the C character classification functions
#include "Stream.h"

inline uint32_t g_mock_millis = 0;
Expand All @@ -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;
}
7 changes: 7 additions & 0 deletions test/mocks/Mesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
39 changes: 39 additions & 0 deletions test/mocks/RTClib.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#pragma once

#include <stdint.h>

// 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;
}
};
49 changes: 49 additions & 0 deletions test/test_gps_nmea/gps_test_support.h
Original file line number Diff line number Diff line change
@@ -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 <gtest/gtest.h>

#include <MicroNMEA.h>
#include <helpers/sensors/MicroNMEALocationProvider.h>

#include <cstdio>
#include <cstring>
#include <deque>
#include <string>


struct MockSerial : Stream {
std::deque<char> 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,,";
91 changes: 91 additions & 0 deletions test/test_gps_nmea/test_coordinate_range.cpp
Original file line number Diff line number Diff line change
@@ -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);
}
9 changes: 9 additions & 0 deletions test/test_gps_nmea/test_main.cpp
Original file line number Diff line number Diff line change
@@ -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();
}
30 changes: 30 additions & 0 deletions test/test_gps_nmea/test_parsing.cpp
Original file line number Diff line number Diff line change
@@ -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());
}