Files
Ben MeadorsandClaude Opus 5 83fd62b756 test(native): add 14 suites for routing, persistence, parsing and identity gaps (#11515)
* test(native): add 14 suites for routing, persistence, parsing and identity gaps

Coverage audit of the native test tree; adds the highest-value untested
logic as 11 new suites and extends 3 existing ones (200 test functions).

New: test_stream_framing, test_nodedb_boot_recovery,
test_nodedb_legacy_migration, test_nodedb_v25_roundtrip,
test_nodedb_identity_hygiene, test_channel_keys, test_reliable_ack_matrix,
test_hop_start_policy, test_routing_response_hops,
test_phone_api_config_dump, test_observer.
Extended: test_rtc, test_mqtt, test_xmodem.

Two source changes the audit produced:

- StreamAPI::handleRecStream copied stream->read()'s `cInt < 0` EOF check
  into the buffer-fed path, where there is no EOF sentinel; with signed
  char any byte >= 0x80 (START1 is 0x94) aborted the parse. Read the byte
  as uint8_t directly. Latent on develop (no callers), pinned by
  test_stream_framing.
- Extract the post-decode pre-hop predicate from Router::handleReceived
  into shouldSkipHandleForPostDecodeHop() (NodeDB.h) so
  test_hop_start_policy drives the exact expression the router calls.
  No behavior change.

test/state-manifest.tsv declares the suites that construct a NodeDB.
Full 68-suite Docker coverage run matches the pre-change baseline.

* test(native): address review - harden observer dispatch, trim comments

Review follow-ups on the coverage-audit suites:

- Observable::notifyObservers() erased list nodes while holding an iterator
  into them, so an observer that unobserves itself from onNotify corrupted the
  dispatch. Today the only self-detacher (PhoneAPI::onNotify ->
  checkConnectionTimeout -> close -> unobserve) survives solely because it
  returns -1 and aborts the chain before the increment; that unwritten contract
  is now gone. Removal during a dispatch nulls the entry and the outermost
  notify sweeps afterwards, which keeps self-detach, next-detach and
  destruction-during-notify all safe without an allocation. Hoisting the next
  iterator instead would have inverted the hazard and broken the existing
  next-detach case. Two regression tests added.

- Correct the documented caller of shouldSkipHandleForPostDecodeHop: the call
  is in Router::dispatchReceived, not handleReceived.

- Cast hop fields to unsigned at the %u call site in test_hop_start_policy.

- Trim the new suites' file headers to the one-or-two-line rule in AGENTS.md.

- Rename eight test functions whose names were exactly `test_` + 35 chars:
  that is the shape of a Lob API key, so trufflehog flagged them as secrets
  and failed the Trunk CI check.

Full 68-suite Docker coverage run matches the pre-change baseline.

* test(native): revert the observer dispatch change, keep the contract test

Backs out the notifyObservers() deferred-removal hardening from the previous
commit. It was reviewer-driven scope creep: nothing in the coverage audit
needed it, no test required it, and it changes dispatch semantics in a header
with ~76 observe() call sites on native verification alone.

The hazard it addressed is not reachable today. The only observer that
unobserves itself from onNotify is PhoneAPI (onNotify ->
checkConnectionTimeout -> close -> unobserve), and it returns -1, which aborts
the chain before the iterator is advanced past the erased node.

test_self_detach_with_abort_during_notify stays: it passes against the
unmodified dispatch and pins that the -1 is load-bearing, so a later cleanup
that "simplifies" it away goes red. The unsafe variant (self-detach returning
0) is documented in a comment rather than tested, since asserting it would be
asserting UB.

* fix(serial): recover the frame behind a stray framing marker

A byte that failed the START2 check was discarded rather than re-tested as
a possible START1, so 0x94 0x94 0xc3 ... lost the real frame: one corrupted
byte on a noisy UART silently dropped the frame behind it. Re-test the byte
in place instead.

Applied to both copies of the receive state machine. readStream() is the one
that matters in the field - it is the serial path every phone client uses -
while handleRecStream() still has no callers on develop.

Strictly widens what the parser accepts; no frame that parsed before parses
differently. test_stream_framing covers it on both receive paths, plus a run
of stray markers and a START1-then-unrelated-byte resync.

This was originally documented as a known gap in the framing suite. Fixing it
instead was NomDeTom's call on review: a passing test asserting the bad
behavior is what makes it hard to change later, and it is the same defect
shape as the signedness fix three functions away.

Also: use Throttle::deadlinePassed() in test_reliable_ack_matrix rather than
a bare millis() compare, matching the house deadline rule.

* test(native): cover the stray-marker resync on the buffer path too

The stray-marker fix went into both copies of the receive state machine, but
only test_stray_start1_before_frame_still_delivers drove both. The repeated-
marker and unrelated-byte cases drove readStream() alone, so a regression in
handleRecStream() would have gone unnoticed by two of the three.

Verified load-bearing: reverting only the handleRecStream() half of the fix
turns test_repeated_stray_start1_before_frame_still_delivers red on the new
assertion. test_start1_then_unrelated_byte_resyncs stays green under that
mutation by design - its failing byte is 0x00, where both branches reset to 0 -
and covers the other half of the ternary.

Also drops the stale header on test_stray_start1_before_frame_still_delivers,
which still described the gap as pinned-as-is after the fix landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(native): make the hop-start truth table assert the rows it prints

test_truth_table_summary was six TEST_MESSAGE lines and no assertion, so it
reported as a case that could not fail - the anti-pattern #11517 names in its
unfinished assertion-presence lint, and the one exception to NomDeTom's "no
RUN_TEST without an assertion" pass over this PR.

The printed row and the checked expectation now come from one struct, so the
summary cannot narrate a table the predicates no longer implement. It also
covers the consequence columns the per-row tests do not assert together:
classifyHopStart, shouldDropPacketForPreHop and shouldSkipHandleForPostDecodeHop
for the same packet, with the expectations gated on MESHTASTIC_PREHOP_DROP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:41:08 +00:00

457 lines
20 KiB
C++

#include "TestUtil.h"
#include "UptimeClock.h"
#include "gps/RTC.h"
#include <cstdio>
#include <sys/time.h>
#include <time.h>
#include <unity.h>
// Regression coverage for issue #9828: on boards without a hardware RTC (e.g. RP2040),
// gettimeofday() can return uptime seconds rather than wall-clock time. A later readFromRTC()
// must not overwrite a higher-quality network/GPS time with that value, but it should still seed
// the clock when nothing better exists yet.
//
// The native test build compiles the RV3028 hardware-RTC branch (variants/native/portduino
// defines RV3028_RTC), so these tests use setReadFromRTCUseSystemTimeForTests() to force the
// no-hardware-RTC fallback path and setRTCSystemTimeForTests() to inject a deterministic clock.
static const uint32_t kAllowedDriftSeconds = 2;
static const time_t kUptimeSeconds = 21; // what gettimeofday() returns on RP2040 without a real clock
// Mirrors FORTY_YEARS in RTC.h, which is only visible when BUILD_EPOCH is defined. BUILD_EPOCH is
// injected by bin/platformio-custom.py into the src/ build (projenv) but not into test sources, so
// this TU cannot #ifdef on it; the bounds tests below probe for it at runtime instead.
static const uint64_t kFortyYears = 40ULL * 365 * SEC_PER_DAY;
#define MSG_BUF_LEN 200
#define TEST_MSG_FMT(fmt, ...) \
do { \
char _buf[MSG_BUF_LEN]; \
snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \
TEST_MESSAGE(_buf); \
} while (0)
// A clearly-valid wall-clock epoch, safely inside any BUILD_EPOCH validity window.
static time_t makeValidEpoch()
{
return time(NULL) + SEC_PER_DAY;
}
static struct timeval makeTv(time_t secs)
{
struct timeval tv;
tv.tv_sec = secs;
tv.tv_usec = 0;
return tv;
}
// Freeze the injected uptime clock at baseMs. perhapsSetRTC() anchors timeStartMs64 at the fake
// "now", so while the clock is frozen getTime() returns the applied epoch exactly - no drift
// tolerance needed. Reset the wrap carry first: a prior test may have published a larger instant,
// and stepping the clock backwards past a published snapshot reads as a ~49.7-day wrap.
static void beginFakeClock(uint32_t baseMs)
{
Time::resetMonotonicForTests();
Time::setTestMillis(baseMs);
Time::serviceMonotonic();
}
// Step the injected clock the way the firmware does: every advance is followed by a publish.
static void advanceFakeClock(uint32_t deltaMs)
{
Time::advanceTestMillis(deltaMs);
Time::serviceMonotonic();
}
void setUp(void)
{
resetRTCStateForTests();
}
void tearDown(void)
{
Time::useRealClock(); // don't leak the fake clock into later tests or other suites
Time::resetMonotonicForTests();
resetRTCStateForTests();
}
// A higher-quality network time must survive a later system-time read that only knows uptime.
static void test_readFromRTC_preserves_better_network_time(void)
{
const time_t networkEpoch = makeValidEpoch();
struct timeval networkTime;
networkTime.tv_sec = networkEpoch;
networkTime.tv_usec = 0;
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &networkTime));
// Simulate a later readFromRTC() falling back to a system clock that only knows uptime.
struct timeval uptime;
uptime.tv_sec = kUptimeSeconds;
uptime.tv_usec = 0;
setRTCSystemTimeForTests(&uptime);
setReadFromRTCUseSystemTimeForTests(true);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, readFromRTC());
TEST_ASSERT_EQUAL_INT(RTCQualityFromNet, getRTCQuality());
TEST_ASSERT_UINT32_WITHIN(kAllowedDriftSeconds, (uint32_t)networkEpoch, getValidTime(RTCQualityFromNet));
}
// Before any higher-quality source exists, the fallback should still seed the clock.
static void test_readFromRTC_initializes_time_when_no_better_source(void)
{
const time_t systemEpoch = makeValidEpoch();
struct timeval systemTime;
systemTime.tv_sec = systemEpoch;
systemTime.tv_usec = 0;
setRTCSystemTimeForTests(&systemTime);
setReadFromRTCUseSystemTimeForTests(true);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, readFromRTC());
TEST_ASSERT_EQUAL_INT(RTCQualityNone, getRTCQuality());
TEST_ASSERT_UINT32_WITHIN(kAllowedDriftSeconds, (uint32_t)systemEpoch, getTime());
}
// --- perhapsSetRTC(timeval) quality arbitration ---
// FromNet/Device sources are always rejected below a higher quality, and the rejection must
// leave quality and the running clock untouched (the #9828 mesh-time-poisoning family). NTP
// below GPS is rejected only while the 30-min drift throttle (stamped by the GPS set) is live;
// after it expires, NTP deliberately replaces even GPS-quality time (RTC.cpp drift-correction
// branch) - both halves are pinned here.
static void test_downgrade_rejected_state_untouched(void)
{
beginFakeClock(60 * 1000);
const time_t gpsEpoch = makeValidEpoch();
struct timeval tv = makeTv(gpsEpoch);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv));
TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality());
TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime());
struct timeval poison = makeTv(gpsEpoch + 777);
TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &poison));
TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityDevice, &poison));
// NTP below GPS: within 30 minutes of the GPS set (which stamped the drift throttle), rejected.
TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityNTP, &poison));
TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality());
// Time still tracks the GPS epoch, not the rejected one.
advanceFakeClock(5 * 1000);
TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 5, getTime());
// Once the drift throttle expires, NTP replaces GPS-quality time on purpose (drift
// correction), while FromNet/Device stay rejected: the throttle escape is NTP-only.
advanceFakeClock(31 * 60 * 1000);
struct timeval stillPoison = makeTv(gpsEpoch + 555);
TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &stillPoison));
TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityDevice, &stillPoison));
TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality());
struct timeval drift = makeTv(gpsEpoch + 999);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &drift));
TEST_ASSERT_EQUAL_INT(RTCQualityNTP, getRTCQuality());
TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 999, getTime());
}
// Equal-quality FromNet has no reapply branch: the second set is ignored.
static void test_equal_quality_fromnet_is_not_reapplied(void)
{
beginFakeClock(60 * 1000);
const time_t firstEpoch = makeValidEpoch();
struct timeval tv = makeTv(firstEpoch);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv));
struct timeval second = makeTv(firstEpoch + 500);
TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &second));
TEST_ASSERT_EQUAL_INT(RTCQualityFromNet, getRTCQuality());
TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch, getTime());
}
// Our own GPS is authoritative: a GPS-quality set is always applied, with no throttle.
static void test_gps_reapply_always_accepted(void)
{
beginFakeClock(60 * 1000);
const time_t firstEpoch = makeValidEpoch();
struct timeval tv = makeTv(firstEpoch);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv));
struct timeval second = makeTv(firstEpoch + 123);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &second));
TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality());
TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 123, getTime());
}
// Equal-quality NTP reapplies only after the 30-minute drift-correction throttle.
static void test_ntp_drift_throttle(void)
{
beginFakeClock(120 * 1000);
const time_t firstEpoch = makeValidEpoch();
struct timeval tv = makeTv(firstEpoch);
// The upgrade from None stamps the (function-static, not reset by resetRTCStateForTests)
// throttle timestamp at a known fake instant, keeping this test order-independent.
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &tv));
advanceFakeClock(10 * 60 * 1000); // +10 min: still inside the throttle window
struct timeval second = makeTv(firstEpoch + 900);
TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityNTP, &second));
TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 600, getTime());
advanceFakeClock(21 * 60 * 1000); // total +31 min: past the window
struct timeval third = makeTv(firstEpoch + 2000);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &third));
TEST_ASSERT_EQUAL_INT(RTCQualityNTP, getRTCQuality());
TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 2000, getTime());
}
// forceUpdate applies the incoming time even when it is a quality downgrade - the T-Watch
// RTC-pause workaround depends on this override.
static void test_force_update_overrides_downgrade(void)
{
beginFakeClock(60 * 1000);
const time_t gpsEpoch = makeValidEpoch();
struct timeval tv = makeTv(gpsEpoch);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv));
struct timeval forced = makeTv(gpsEpoch + 42);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityDevice, &forced, true));
TEST_ASSERT_EQUAL_INT(RTCQualityDevice, getRTCQuality());
TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 42, getTime());
}
// The BUILD_EPOCH validity window rejects implausible epochs before quality arbitration - even
// with forceUpdate - and leaves state untouched. BUILD_EPOCH is not visible to this TU (see
// kFortyYears above), so probe at runtime whether RTC.cpp was built with the window enabled.
static void test_build_epoch_bounds_rejected(void)
{
beginFakeClock(60 * 1000);
const time_t gpsEpoch = makeValidEpoch();
struct timeval tv = makeTv(gpsEpoch);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv));
struct timeval ancient = makeTv(1000000); // Jan 1970: below any plausible build epoch
RTCSetResult probe = perhapsSetRTC(RTCQualityGPS, &ancient);
if (probe == RTCSetResultSuccess) {
TEST_IGNORE_MESSAGE("BUILD_EPOCH not defined in the RTC.cpp build; validity window inactive");
}
TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, probe);
TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality());
TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime());
// BUILD_EPOCH <= time(NULL) at run time, so this is strictly beyond BUILD_EPOCH + FORTY_YEARS.
struct timeval far = makeTv((time_t)((uint64_t)time(NULL) + kFortyYears + 2 * SEC_PER_DAY));
TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, &far));
// The window is checked before the forceUpdate override: force cannot smuggle in garbage.
TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, &ancient, true));
TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality());
TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime());
}
// --- perhapsSetRTC(tm) overload ---
// The tm overload converts via gm_mktime and lands on the timeval path: a valid broken-down UTC
// time round-trips to the exact epoch (host gmtime() is the independent inverse).
static void test_tm_overload_roundtrip(void)
{
beginFakeClock(60 * 1000);
const time_t epoch = makeValidEpoch();
struct tm t = *gmtime(&epoch);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, t));
TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality());
TEST_ASSERT_EQUAL_UINT32((uint32_t)epoch, getTime());
}
// Implausible years are rejected with state untouched. On BUILD_EPOCH builds the validity window
// fires first, on windowless builds the tm_year guard (<0 or >=300) does; either way the caller
// must see RTCSetResultInvalidTime.
static void test_tm_overload_year_guard(void)
{
beginFakeClock(60 * 1000);
const time_t gpsEpoch = makeValidEpoch();
struct timeval tv = makeTv(gpsEpoch);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv));
struct tm farFuture = {};
farFuture.tm_year = 300; // year 2200
farFuture.tm_mon = 5;
farFuture.tm_mday = 15;
TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, farFuture));
struct tm preEpoch = {};
preEpoch.tm_year = -5; // year 1895
preEpoch.tm_mon = 0;
preEpoch.tm_mday = 1;
TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, preEpoch));
TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality());
TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime());
}
// --- getValidTime() threshold gating ---
static void test_getvalidtime_threshold_gating(void)
{
beginFakeClock(60 * 1000);
TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityDevice));
TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityFromNet));
const time_t netEpoch = makeValidEpoch();
struct timeval tv = makeTv(netEpoch);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv));
TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch, getValidTime(RTCQualityFromNet));
TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch, getValidTime(RTCQualityDevice)); // at-or-below passes
TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityNTP));
TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityGPS));
struct timeval gps = makeTv(netEpoch + 60);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &gps));
TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch + 60, getValidTime(RTCQualityGPS));
TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch + 60, getValidTime(RTCQualityNTP));
}
// --- lastSetFromPhoneNtpOrGps stamp ---
// Stamped only for quality >= NTP: this is the input PositionModule::hasQualityTimesource() uses
// to gate mesh-time acceptance, so a FromNet or Device set must never refresh it.
static void test_lastSetFromPhoneNtpOrGps_stamp(void)
{
beginFakeClock(200 * 1000);
TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps);
const time_t epoch = makeValidEpoch();
struct timeval tv = makeTv(epoch);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv));
TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps); // FromNet does not stamp
advanceFakeClock(1000);
struct timeval ntp = makeTv(epoch + 1);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &ntp));
TEST_ASSERT_EQUAL_UINT32(201 * 1000, lastSetFromPhoneNtpOrGps);
advanceFakeClock(2000);
struct timeval net = makeTv(epoch + 3);
TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &net));
TEST_ASSERT_EQUAL_UINT32(201 * 1000, lastSetFromPhoneNtpOrGps); // rejection leaves the stamp
advanceFakeClock(3000);
struct timeval gps = makeTv(epoch + 6);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &gps));
TEST_ASSERT_EQUAL_UINT32(206 * 1000, lastSetFromPhoneNtpOrGps);
// Device-quality set from a clean slate: applied, but still no stamp.
resetRTCStateForTests();
struct timeval dev = makeTv(epoch + 9);
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityDevice, &dev));
TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps);
}
// --- gm_mktime known answers ---
// Hardcoded expected epochs (no host timegm dependence). The native build compiles the hand-rolled
// UTC path (!MESHTASTIC_EXCLUDE_TZ), so these pin its leap-day and century rules directly.
static void test_gm_mktime_known_epochs(void)
{
struct KnownAnswer {
int year, mon1, mday, hour, min, sec; // human calendar: year AD, month 1-12
int64_t expected;
};
static const KnownAnswer cases[] = {
{1970, 1, 1, 0, 0, 0, 0LL},
{1970, 3, 1, 0, 0, 0, 5097600LL}, // non-leap February
{1972, 2, 29, 0, 0, 0, 68169600LL}, // first leap day after the epoch
{1999, 12, 31, 23, 59, 59, 946684799LL}, // second before Y2K
{2000, 1, 1, 0, 0, 0, 946684800LL},
{2000, 2, 29, 12, 0, 0, 951825600LL}, // 400-year-rule leap day
{2000, 3, 1, 0, 0, 0, 951868800LL},
{2023, 2, 28, 23, 59, 59, 1677628799LL}, // last second of a non-leap February
{2024, 2, 29, 0, 0, 0, 1709164800LL},
{2024, 3, 1, 0, 0, 0, 1709251200LL},
{2038, 1, 19, 3, 14, 7, 2147483647LL}, // INT32_MAX second
{2038, 1, 19, 3, 14, 8, 2147483648LL}, // one past it: 64-bit time_t on native
{2100, 2, 28, 0, 0, 0, 4107456000LL}, // 2100 is NOT leap (100-year rule)
{2100, 3, 1, 0, 0, 0, 4107542400LL},
{2400, 2, 29, 0, 0, 0, 13574563200LL}, // 2400 IS leap (400-year rule)
};
for (const KnownAnswer &c : cases) {
struct tm t = {};
t.tm_year = c.year - 1900;
t.tm_mon = c.mon1 - 1;
t.tm_mday = c.mday;
t.tm_hour = c.hour;
t.tm_min = c.min;
t.tm_sec = c.sec;
const int64_t got = (int64_t)gm_mktime(&t);
if (got != c.expected) {
TEST_MSG_FMT("gm_mktime(%04d-%02d-%02d %02d:%02d:%02d) = %lld, expected %lld", c.year, c.mon1, c.mday, c.hour, c.min,
c.sec, (long long)got, (long long)c.expected);
}
TEST_ASSERT_EQUAL_INT64(c.expected, got);
}
}
// February length as seen by gm_mktime for the years around each leap rule: Mar 1 minus Feb 28
// is two days in a leap year and one day otherwise. Self-consistent, anchored by the known
// answers above.
static void test_gm_mktime_leap_rule_sweep(void)
{
static const int leapYears[] = {1972, 2000, 2024, 2096, 2104, 2400}; // by-4 and by-400
static const int nonLeapYears[] = {1970, 2023, 2100, 2200, 2300}; // odd years and by-100
for (int year : leapYears) {
struct tm feb28 = {}, mar1 = {};
feb28.tm_year = year - 1900;
feb28.tm_mon = 1;
feb28.tm_mday = 28;
mar1.tm_year = year - 1900;
mar1.tm_mon = 2;
mar1.tm_mday = 1;
TEST_MSG_FMT("leap year %d", year);
TEST_ASSERT_EQUAL_INT64(2 * SEC_PER_DAY, (int64_t)gm_mktime(&mar1) - (int64_t)gm_mktime(&feb28));
}
for (int year : nonLeapYears) {
struct tm feb28 = {}, mar1 = {};
feb28.tm_year = year - 1900;
feb28.tm_mon = 1;
feb28.tm_mday = 28;
mar1.tm_year = year - 1900;
mar1.tm_mon = 2;
mar1.tm_mday = 1;
TEST_MSG_FMT("non-leap year %d", year);
TEST_ASSERT_EQUAL_INT64(SEC_PER_DAY, (int64_t)gm_mktime(&mar1) - (int64_t)gm_mktime(&feb28));
}
}
void setup()
{
delay(10);
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_readFromRTC_preserves_better_network_time);
RUN_TEST(test_readFromRTC_initializes_time_when_no_better_source);
printf("\n=== perhapsSetRTC(timeval) quality arbitration ===\n");
RUN_TEST(test_downgrade_rejected_state_untouched);
RUN_TEST(test_equal_quality_fromnet_is_not_reapplied);
RUN_TEST(test_gps_reapply_always_accepted);
RUN_TEST(test_ntp_drift_throttle);
RUN_TEST(test_force_update_overrides_downgrade);
RUN_TEST(test_build_epoch_bounds_rejected);
printf("\n=== perhapsSetRTC(tm) overload ===\n");
RUN_TEST(test_tm_overload_roundtrip);
RUN_TEST(test_tm_overload_year_guard);
printf("\n=== getValidTime / quality-source stamp ===\n");
RUN_TEST(test_getvalidtime_threshold_gating);
RUN_TEST(test_lastSetFromPhoneNtpOrGps_stamp);
printf("\n=== gm_mktime known answers ===\n");
RUN_TEST(test_gm_mktime_known_epochs);
RUN_TEST(test_gm_mktime_leap_rule_sweep);
exit(UNITY_END());
}
void loop() {}