// Unit tests for shouldArmFixHold() / fixHoldInForce() in src/gps/GPS.cpp - the post-lock // ephemeris hold. // // In power-saving mode (gps_update_interval above GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS) the GPS holds // for up to 20s after a lock to download ephemeris, then publishes and sleeps. The predicate below // decides, once per GPS thread cycle that has a location, whether a hold should be armed. // // The case that matters is a hold that was consumed by a publish which did not sleep: GPS::runOnce() // clears fixHoldEnds whenever it publishes, but only calls down() when the search timed out or a // hold expired. If the predicate treats "not holding" as a reason to skip, nothing re-arms, nothing // publishes, and the receiver stays powered until searchedTooLong() fires. #include "Arduino.h" #include "TestUtil.h" #include "Throttle.h" #include "UptimeClock.h" #include #include // The predicates live beside their only caller in src/gps/GPS.cpp rather than in a header of their // own; the native test build compiles that file, so declaring the prototypes here is enough. A // signature change breaks the link rather than silently diverging from the definition. bool fixHoldInForce(uint32_t fixHoldEnds, uint32_t threadIntervalMs); bool holdJustExpired(uint32_t fixHoldEnds); bool shouldArmFixHold(bool hasValidLocation, uint8_t prevFixQual, uint32_t fixHoldEnds, uint32_t threadIntervalMs); // GPS_THREAD_INTERVAL, spelled out so the suite does not pull in GPS.h and its hardware deps. static constexpr uint32_t kThreadInterval = 200; // The two hold durations the firmware uses: GPS_FIX_HOLD_MAX_MS, and a short one. static constexpr uint32_t kHoldMs = 20 * 1000; void setUp(void) { Time::setTestMillis(0); } void tearDown(void) { Time::useRealClock(); } // Arms a hold at the current test time and returns the resulting fixHoldEnds. static uint32_t armHoldNow(uint32_t holdMs = kHoldMs) { return Time::getMillis() + holdMs; } // --- the reasons to arm --- // First lock of a cycle: hasValidLocation is still false on the rising edge. void test_arms_on_the_first_lock_of_a_cycle(void) { Time::setTestMillis(50 * 1000); TEST_ASSERT_TRUE(shouldArmFixHold(false, 3, 0, kThreadInterval)); } // Lock after the receiver was off: down() zeroes fixQual, so prev_fixQual is 0 on the way back up. void test_arms_on_the_first_lock_after_the_gps_was_off(void) { Time::setTestMillis(50 * 1000); TEST_ASSERT_TRUE(shouldArmFixHold(true, 0, 0, kThreadInterval)); } // The regression. A publish that did not sleep leaves hasValidLocation set, prev_fixQual non-zero // and fixHoldEnds cleared to 0. Nothing else in runOnce() re-arms, so if this returns false the // GPS never holds, never publishes again and never calls down() until the search times out. void test_arms_after_a_publish_cleared_the_hold_without_sleeping(void) { Time::setTestMillis(50 * 1000); TEST_ASSERT_TRUE_MESSAGE(shouldArmFixHold(true, 3, 0, kThreadInterval), "fixHoldEnds == 0 means 'not holding', which is a reason to arm"); } void test_arms_once_the_hold_has_expired(void) { Time::setTestMillis(50 * 1000); const uint32_t fixHoldEnds = armHoldNow(); Time::advanceTestMillis(kHoldMs + kThreadInterval); TEST_ASSERT_TRUE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); } // --- the reason not to arm --- void test_does_not_arm_while_a_hold_is_in_force(void) { Time::setTestMillis(50 * 1000); const uint32_t fixHoldEnds = armHoldNow(); Time::advanceTestMillis(kHoldMs / 2); TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); } // The GPS_THREAD_INTERVAL grace period: at the exact deadline the hold has not yet expired, because // the next cycle is one interval away. void test_does_not_arm_in_the_thread_interval_grace_after_the_deadline(void) { Time::setTestMillis(50 * 1000); const uint32_t fixHoldEnds = armHoldNow(); Time::advanceTestMillis(kHoldMs); // exactly at the deadline TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); Time::advanceTestMillis(kThreadInterval - 1); TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); Time::advanceTestMillis(1); // deadline + GPS_THREAD_INTERVAL, inclusive boundary TEST_ASSERT_TRUE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); } // --- across the 32-bit wrap --- // A hold armed just before the wrap must still be held through it. The naive form this replaced // (`(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`) read as expired for the whole pre-wrap window, // re-arming the hold on every single cycle. void test_does_not_arm_while_a_hold_straddling_the_wrap_is_in_force(void) { Time::setTestMillis(0xFFFFFF00u); // 256ms short of the wrap const uint32_t fixHoldEnds = armHoldNow(); TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); Time::advanceTestMillis(0x200u); // now past the wrap, still inside the hold TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); Time::advanceTestMillis(kHoldMs); // well past the deadline, still past the wrap TEST_ASSERT_TRUE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); } // The deadline itself wrapping (fixHoldEnds numerically below millis()) must not read as expired. void test_holds_when_the_deadline_wraps_but_now_has_not(void) { Time::setTestMillis(0xFFFFFF00u); const uint32_t fixHoldEnds = armHoldNow(); // wraps to ~0x4CFF TEST_ASSERT_TRUE_MESSAGE(fixHoldEnds < Time::getMillis(), "test setup: the deadline must have wrapped"); TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); } // --- the two readings of the same sentinel --- // runOnce() asks two questions of fixHoldEnds and they take opposite answers when nothing is armed: // "should I arm one?" (yes) and "did one just expire, so publish and sleep?" (no). Both are derived // from fixHoldInForce(), which is the only place the sentinel is interpreted. void test_no_hold_means_arm_but_does_not_mean_expired(void) { Time::setTestMillis(50 * 1000); TEST_ASSERT_FALSE_MESSAGE(fixHoldInForce(0, kThreadInterval), "a hold that was never armed is not in force"); TEST_ASSERT_TRUE_MESSAGE(shouldArmFixHold(true, 3, 0, kThreadInterval), "...so it is a reason to arm one"); TEST_ASSERT_FALSE_MESSAGE(holdJustExpired(0), "...but not a reason to publish and sleep"); } // holdJustExpired()'s sentinel guard is load-bearing on every cycle, not just past the half-range: // fixHoldInForce() calls an unarmed hold "not in force", so negating it alone reads as expired. void test_only_an_armed_hold_can_expire(void) { Time::setTestMillis(50 * 1000); const uint32_t fixHoldEnds = armHoldNow(); TEST_ASSERT_FALSE_MESSAGE(holdJustExpired(fixHoldEnds), "still inside the hold"); Time::advanceTestMillis(kHoldMs); // the deadline itself, no grace interval at this site TEST_ASSERT_TRUE_MESSAGE(holdJustExpired(fixHoldEnds), "the deadline is the moment to publish and sleep"); TEST_ASSERT_TRUE_MESSAGE(!fixHoldInForce(0, 0), "test premise: the negation alone calls an unarmed hold expired"); TEST_ASSERT_FALSE_MESSAGE(holdJustExpired(0), "so the sentinel test is what keeps it from expiring"); } // The `fixHoldEnds != 0` term inside fixHoldInForce() looks redundant, and for the first half of // each wrap cycle it is: deadlinePassed(0 + interval) is true once uptime exceeds one interval, so // "not in force" would fall out of the arithmetic on its own. Past 2^31 ms of uptime it flips. // deadlinePassed() is an unsigned half-range test, so `now - interval` lands in the top half and // the sentinel reads as a deadline ~24.8 days in the FUTURE - an unarmed hold would look like one // in force for the whole second half of every cycle, and nothing would ever re-arm. void test_the_sentinel_guard_is_load_bearing_past_the_half_range(void) { Time::setTestMillis(0x90000000u); // ~27.8 days of uptime, past the ~24.8-day half-range point // The arithmetic alone now says "not yet" for the sentinel... TEST_ASSERT_FALSE_MESSAGE(Throttle::deadlinePassed(0 + kThreadInterval), "test premise: past half-range the sentinel reads as a future deadline"); // ...so the explicit sentinel test is the only thing keeping the answer right. TEST_ASSERT_FALSE_MESSAGE(fixHoldInForce(0, kThreadInterval), "an unarmed hold is never in force"); TEST_ASSERT_TRUE_MESSAGE(shouldArmFixHold(true, 3, 0, kThreadInterval), "...so a hold must still be armed"); } void test_hold_in_force_tracks_the_deadline(void) { Time::setTestMillis(50 * 1000); const uint32_t fixHoldEnds = armHoldNow(); TEST_ASSERT_TRUE(fixHoldInForce(fixHoldEnds, kThreadInterval)); Time::advanceTestMillis(kHoldMs + kThreadInterval); TEST_ASSERT_FALSE(fixHoldInForce(fixHoldEnds, kThreadInterval)); } void setup() { initializeTestEnvironment(); UNITY_BEGIN(); RUN_TEST(test_no_hold_means_arm_but_does_not_mean_expired); RUN_TEST(test_only_an_armed_hold_can_expire); RUN_TEST(test_the_sentinel_guard_is_load_bearing_past_the_half_range); RUN_TEST(test_hold_in_force_tracks_the_deadline); RUN_TEST(test_arms_on_the_first_lock_of_a_cycle); RUN_TEST(test_arms_on_the_first_lock_after_the_gps_was_off); RUN_TEST(test_arms_after_a_publish_cleared_the_hold_without_sleeping); RUN_TEST(test_arms_once_the_hold_has_expired); RUN_TEST(test_does_not_arm_while_a_hold_is_in_force); RUN_TEST(test_does_not_arm_in_the_thread_interval_grace_after_the_deadline); RUN_TEST(test_does_not_arm_while_a_hold_straddling_the_wrap_is_in_force); RUN_TEST(test_holds_when_the_deadline_wraps_but_now_has_not); exit(UNITY_END()); } void loop() {}