143 lines
6.0 KiB
Python
143 lines
6.0 KiB
Python
"""Run the actual RAK12500 provider against retained-PVT GNSS/RTC adapters.
|
|
|
|
The actual full provider class and LocationProvider header are compiled. The
|
|
GNSS adapter models SparkFun's per-field unread flags and retained data after
|
|
failed polls; it is not an I2C/parser or hardware test. A separate installed-
|
|
library probe established that contract without making CI depend on libdeps.
|
|
"""
|
|
from pathlib import Path
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_replay_reset_integration import extract_braced
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FIXTURE = ROOT / 'test/fixtures/rak_gps_clock_freshness.cpp'
|
|
|
|
ARDUINO = r'''
|
|
#pragma once
|
|
#include <cstdint>
|
|
#include <cstddef>
|
|
#include <cstring>
|
|
inline uint32_t now_ms = 0;
|
|
inline uint32_t millis() { return now_ms; }
|
|
inline constexpr int HIGH = 1;
|
|
inline int digitalRead(int) { return HIGH; }
|
|
'''
|
|
|
|
MESH = r'''
|
|
#pragma once
|
|
#include <Arduino.h>
|
|
namespace mesh {
|
|
class RTCClock {
|
|
uint32_t utc = 1772323200U, anchored = 0;
|
|
public:
|
|
unsigned writes = 0;
|
|
uint32_t getCurrentTime() { return utc + (now_ms - anchored) / 1000U; }
|
|
void setCurrentTime(uint32_t value) { utc = value; anchored = now_ms; ++writes; }
|
|
};
|
|
}
|
|
'''
|
|
|
|
|
|
class RakGpsClockFreshnessTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
compiler = shutil.which('g++') or shutil.which('clang++')
|
|
if compiler is None:
|
|
raise unittest.SkipTest('a host C++17 compiler is required')
|
|
temporary = tempfile.TemporaryDirectory(prefix='rak-gps-clock-')
|
|
cls.addClassCleanup(temporary.cleanup)
|
|
work = Path(temporary.name)
|
|
(work / 'Arduino.h').write_text(ARDUINO, encoding='ascii')
|
|
(work / 'Mesh.h').write_text(MESH, encoding='ascii')
|
|
source = (ROOT / 'src/helpers/sensors/EnvironmentSensorManager.cpp').read_text(
|
|
encoding='utf-8')
|
|
provider = extract_braced(source, 'class RAK12500LocationProvider') + ';\n'
|
|
loop = extract_braced(provider, 'void loop() override')
|
|
failed_poll = extract_braced(loop, 'if (!ublox_GNSS.getPVT(8))')
|
|
start = loop.index('ublox_GNSS.flushPVT();')
|
|
end = loop.index(failed_poll) + len(failed_poll)
|
|
admission = loop[start:end]
|
|
if admission.count('ublox_GNSS.getPVT(8)') != 1:
|
|
raise AssertionError('expected one actual explicit PVT admission')
|
|
# Restore the historical normal-operation omission, without referring
|
|
# to its now-removed position-request flag or copying a stale provider.
|
|
legacy_loop = loop[:start] + 'if (_fresh_time_sync_required) {\n' + admission \
|
|
+ '\n }' + loop[end:]
|
|
legacy = provider.replace(loop, legacy_loop, 1)
|
|
fixture = FIXTURE.read_text(encoding='ascii')
|
|
sanitize = [] if not sys.platform.startswith('linux') else [
|
|
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
|
|
'-fno-omit-frame-pointer', '-fno-pie', '-no-pie']
|
|
cls.binaries = {}
|
|
for kind, actual in (('production', provider), ('legacy', legacy)):
|
|
cpp = work / (kind + '.cpp')
|
|
cpp.write_text(fixture.replace('@PROVIDER@', actual), encoding='utf-8')
|
|
binary = work / (kind + ('.exe' if os.name == 'nt' else ''))
|
|
built = subprocess.run([
|
|
compiler, '-std=c++17', '-O1', '-g', '-Wall', '-Wextra', '-Werror',
|
|
'-Wno-unused-parameter', *sanitize, '-I' + str(work),
|
|
'-I' + str(ROOT / 'src'), str(cpp), '-o', str(binary)],
|
|
capture_output=True, text=True, timeout=60)
|
|
if built.returncode:
|
|
raise AssertionError(built.stdout + built.stderr)
|
|
cls.binaries[kind] = binary
|
|
|
|
def check_case(self, case, kind='production', diagnostic=None):
|
|
checked = subprocess.run([str(self.binaries[kind]), case],
|
|
capture_output=True, text=True, timeout=10)
|
|
if diagnostic is not None:
|
|
self.assertNotEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
|
self.assertIn(diagnostic, checked.stderr)
|
|
self.assertNotIn('runtime error:', checked.stderr)
|
|
self.assertNotIn('AddressSanitizer:', checked.stderr)
|
|
else:
|
|
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
|
self.assertIn('PASS ' + case, checked.stdout)
|
|
|
|
def test_one_startup_pvt_cannot_be_counted_again_after_poll_failure(self):
|
|
self.check_case('startup-silence')
|
|
|
|
def test_periodic_resync_does_not_rewind_clock_from_retained_pvt(self):
|
|
self.check_case('periodic-silence')
|
|
|
|
def test_poll_failure_resets_consecutive_count_and_three_new_samples_recover(self):
|
|
self.check_case('consecutive-failure')
|
|
|
|
def test_success_failure_and_throttled_calls_have_bounded_poll_and_getter_cost(self):
|
|
self.check_case('poll-budget')
|
|
|
|
def test_tracker_position_and_fresh_time_requests_require_new_pvt(self):
|
|
for case in ('position-request', 'fresh-tracker'):
|
|
with self.subTest(case=case):
|
|
self.check_case(case)
|
|
|
|
def test_reset_cannot_reuse_retained_receiver_cache(self):
|
|
self.check_case('reset')
|
|
|
|
def test_null_clock_still_updates_position_without_time_side_effects(self):
|
|
self.check_case('null-clock')
|
|
|
|
def test_unqualified_fresh_frames_reset_consecutive_time_samples(self):
|
|
for field in ('fix', 'date', 'time', 'sats', 'epoch'):
|
|
with self.subTest(field=field):
|
|
self.check_case('invalid-' + field)
|
|
|
|
def test_adapter_retains_cache_on_failed_explicit_and_implicit_polls(self):
|
|
self.check_case('boundary-contract')
|
|
|
|
def test_historical_omission_fails_clock_oracles_but_preserves_fresh_tracker(self):
|
|
for case in ('startup-silence', 'periodic-silence'):
|
|
with self.subTest(case=case):
|
|
self.check_case(case, 'legacy', 'stale PVT must not update the clock')
|
|
self.check_case('fresh-tracker', 'legacy')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|