diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index bb360e8aa..789f15381 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -783,6 +783,7 @@ jobs: python3 -B test/test_rtttl_rollover.py -v python3 -B test/test_rtttl_rollover_recipes.py -v python3 -B test/test_companion_ui_deadlines.py -v + python3 -B test/test_home_sensor_refresh.py -v python3 -B test/test_scrolling_status_bar_rollover.py -v python3 -B test/test_gps_voice_buttons.py -v python3 -B test/test_gps_voice_generator.py -v diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 835b6827d..6ea322322 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -576,7 +576,7 @@ class HomeScreen : public UIScreen { int sensors_nb = 0; bool sensors_scroll = false; int sensors_scroll_offset = 0; - int next_sensors_refresh = 0; + uint32_t last_sensors_refresh = 0; void refresh_sensors() { // CayenneLPP retains its requested capacity even if its allocation fails. @@ -587,7 +587,15 @@ class HomeScreen : public UIScreen { sensors_scroll_offset = 0; return; } - if (millis() > next_sensors_refresh) { +#if AUTO_OFF_MILLIS > 0 + const uint32_t refresh_interval = 5000; +#else + const uint32_t refresh_interval = 60000; +#endif + // Empty data requests an initial/forced sample. Elapsed time also handles + // returning to this page after more than half a millis() cycle. + if (sensors_lpp.getSize() == 0 + || static_cast(millis() - last_sensors_refresh) >= refresh_interval) { sensors_lpp.reset(); sensors_nb = 0; sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); @@ -599,11 +607,7 @@ class HomeScreen : public UIScreen { sensors_nb ++; } sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE; -#if AUTO_OFF_MILLIS > 0 - next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec -#else - next_sensors_refresh = millis() + 60000; // refresh sensor values every 1 min -#endif + last_sensors_refresh = millis(); } } @@ -1420,7 +1424,7 @@ public: #if UI_SENSORS_PAGE == 1 if (key == KEY_ENTER && _page == HomePage::SENSORS) { _task->toggleGPS(); - next_sensors_refresh=0; + sensors_lpp.reset(); return true; } #endif diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index a67293c58..abb5b0fb1 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -168,13 +168,21 @@ class HomeScreen : public UIScreen { int sensors_nb = 0; bool sensors_scroll = false; int sensors_scroll_offset = 0; - int next_sensors_refresh = 0; + uint32_t last_sensors_refresh = 0; uint32_t _radio_profile_page_started_at = 0; uint8_t _radio_status_page = 0; bool _dual_radio_enabled_seen = false; void refresh_sensors() { - if (millis() > next_sensors_refresh) { +#if AUTO_OFF_MILLIS > 0 + const uint32_t refresh_interval = 5000; +#else + const uint32_t refresh_interval = 60000; +#endif + // Empty data requests an initial/forced sample. Elapsed time also handles + // returning to this page after more than half a millis() cycle. + if (sensors_lpp.getSize() == 0 + || static_cast(millis() - last_sensors_refresh) >= refresh_interval) { sensors_lpp.reset(); sensors_nb = 0; sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); @@ -186,11 +194,7 @@ class HomeScreen : public UIScreen { sensors_nb ++; } sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE; -#if AUTO_OFF_MILLIS > 0 - next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec -#else - next_sensors_refresh = millis() + 60000; // refresh sensor values every 1 min -#endif + last_sensors_refresh = millis(); } } @@ -575,7 +579,7 @@ public: #if UI_SENSORS_PAGE == 1 if (c == KEY_ENTER && _page == HomePage::SENSORS) { _task->toggleGPS(); - next_sensors_refresh=0; + sensors_lpp.reset(); return true; } #endif diff --git a/test/fixtures/home_sensor_refresh.cpp b/test/fixtures/home_sensor_refresh.cpp new file mode 100644 index 000000000..74b4bdbd2 --- /dev/null +++ b/test/fixtures/home_sensor_refresh.cpp @@ -0,0 +1,288 @@ +// The Home fields, refresh method, and input block come from production. +// Only clock, board, sensor/LPP transport, and task boundaries are mocked. +#include +#include +#include +#include + +#define TELEM_CHANNEL_SELF 0 +#define UI_RECENT_LIST_SIZE 3 +#define KEY_ENTER '\r' + +static uint32_t clock_ms = 0; +uint32_t millis() { return clock_ms; } + +static const char* active_case = "initialization"; +static unsigned checks = 0; +static unsigned scenarios = 0; +#define CHECK(condition) do { \ + ++checks; \ + if (!(condition)) { \ + std::fprintf(stderr, "FAIL %s: %s (line %d)\n", active_case, #condition, __LINE__); \ + std::exit(1); \ + } \ +} while (0) + +struct CayenneLPP { + uint8_t bytes[200] = {}; + uint8_t size = 0; + bool allocated = true; + unsigned writes = 0; + uint8_t* getBuffer() { return allocated ? bytes : nullptr; } + uint8_t getSize() { return size; } + void reset() { size = 0; } + void addVoltage(int channel, float voltage) { + CHECK(getBuffer() != nullptr); + CHECK(channel == TELEM_CHANNEL_SELF && voltage == 4.0f); + ++writes; + bytes[size++] = 4; + size += 3; + } + void addSample() { + CHECK(getBuffer() != nullptr && size < sizeof(bytes) - 4); + ++writes; + bytes[size++] = 4; + size += 3; + } +}; + +struct LPPReader { + unsigned remaining; + LPPReader(uint8_t* buffer, uint8_t size) : remaining(buffer ? size / 4 : 0) {} + bool readHeader(uint8_t& channel, uint8_t& type) { + if (!remaining) return false; + --remaining; + channel = 0; + type = 1; + return true; + } + void skipData(uint8_t) {} +}; + +struct Board { + unsigned reads = 0; + int getBattMilliVolts() { ++reads; return 4000; } +} board; + +struct Sensors { + unsigned calls = 0; + unsigned samples = 0; + uint32_t duration_ms = 0; + void querySensors(int mask, CayenneLPP& lpp) { + CHECK(mask == 0xFF); + ++calls; + for (unsigned i = 0; i < samples; ++i) lpp.addSample(); + clock_ms += duration_ms; + } +} sensors; + +struct Task { + unsigned toggles = 0; + void toggleGPS() { ++toggles; } +}; + +enum class HomePage { OTHER, SENSORS }; + +struct Home { + @FIELDS@ + Task task; + Task* _task = &task; + HomePage _page = HomePage::SENSORS; + + @REFRESH@ + + bool input(char @KEY@) { + @INPUT@ + return false; + } +}; + +static constexpr uint32_t interval_ms = AUTO_OFF_MILLIS > 0 ? 5000u : 60000u; + +static void begin_case(const char* name, uint32_t initial_ms) { + active_case = name; + ++scenarios; + clock_ms = initial_ms; + sensors = Sensors{}; + board = Board{}; +} + +static void initial_at(uint32_t at) { + begin_case("fresh page", at); + Home home; + CHECK(home.sensors_lpp.getSize() == 0); + home.refresh_sensors(); + CHECK(sensors.calls == 1 && board.reads == 1); + CHECK(home.sensors_lpp.getSize() == 4); + CHECK(home.sensors_nb == 1 && !home.sensors_scroll); + home.refresh_sensors(); + CHECK(sensors.calls == 1 && board.reads == 1); +} + +static void cadence_and_records() { + begin_case("normal cadence and records", 100); + Home home; + sensors.samples = 4; + home.refresh_sensors(); + CHECK(sensors.calls == 1 && home.sensors_nb == 5 && home.sensors_scroll); + clock_ms += interval_ms - 1; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + ++clock_ms; + sensors.samples = 0; + home.refresh_sensors(); + CHECK(sensors.calls == 2 && home.sensors_nb == 1 && !home.sensors_scroll); + CHECK(home.sensors_lpp.getSize() == 4); + home.refresh_sensors(); + CHECK(sensors.calls == 2); +} + +static void wrap_visible() { + begin_case("visible across wrap", UINT32_MAX - interval_ms / 2); + Home home; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + ++clock_ms; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + clock_ms += interval_ms - 2; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + ++clock_ms; + home.refresh_sensors(); + CHECK(sensors.calls == 2); +} + +static void wake_across_wrap() { + begin_case("wake across wrap", UINT32_MAX - 2 * interval_ms); + Home home; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + // The display is not rendered while asleep; wake after millis wraps. + clock_ms = 1000; + home.refresh_sensors(); + CHECK(sensors.calls == 2); + home.refresh_sensors(); + CHECK(sensors.calls == 2); +} + +static void exact_wrap_deadline() { + begin_case("deadline at zero", uint32_t(0u - interval_ms)); + Home home; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + ++clock_ms; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + clock_ms = UINT32_MAX; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + clock_ms = 0; + home.refresh_sensors(); + CHECK(sensors.calls == 2); + home.refresh_sensors(); + CHECK(sensors.calls == 2); +} + +static void long_idle(uint32_t elapsed) { + begin_case("long inactive page", 100); + Home home; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + clock_ms += elapsed; + home.refresh_sensors(); + CHECK(sensors.calls == 2); + home.refresh_sensors(); + CHECK(sensors.calls == 2); +} + +static void force_at(uint32_t initial, uint32_t forced) { + begin_case("forced input", initial); + Home home; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + clock_ms = forced; + CHECK(!home.input('x')); + CHECK(home.task.toggles == 0); + home._page = HomePage::OTHER; + CHECK(!home.input(KEY_ENTER)); + CHECK(home.task.toggles == 0); + home._page = HomePage::SENSORS; + CHECK(home.input(KEY_ENTER)); + CHECK(home.task.toggles == 1); + CHECK(sensors.calls == 1); + home.refresh_sensors(); + CHECK(sensors.calls == 2 && home.sensors_nb == 1); + home.refresh_sensors(); + CHECK(sensors.calls == 2); +} + +static void completion_anchor(bool wrap) { + begin_case("completion-relative cadence", wrap ? UINT32_MAX - 10 : 100); + Home home; + sensors.duration_ms = 37; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + const uint32_t completion = clock_ms; + clock_ms = completion + interval_ms - 1; + home.refresh_sensors(); + CHECK(sensors.calls == 1); + ++clock_ms; + home.refresh_sensors(); + CHECK(sensors.calls == 2); + CHECK(clock_ms == uint32_t(completion + interval_ms + sensors.duration_ms)); +} + +static void empty_query_keeps_base_voltage() { + begin_case("empty sensor query", 100); + Home home; + CHECK(sensors.samples == 0); + home.refresh_sensors(); + CHECK(home.sensors_lpp.getSize() == 4 && home.sensors_nb == 1); + ++clock_ms; + home.refresh_sensors(); + CHECK(sensors.calls == 1); +} + +#if TEST_NEW_UI +static void allocation_guard() { + begin_case("allocation guard", 0); + Home home; + home.sensors_lpp.allocated = false; + home.sensors_nb = 5; + home.sensors_scroll = true; + home.sensors_scroll_offset = 4; + home.refresh_sensors(); + CHECK(sensors.calls == 0 && board.reads == 0 && home.sensors_lpp.writes == 0); + CHECK(home.sensors_nb == 0 && !home.sensors_scroll && home.sensors_scroll_offset == 0); + home.sensors_lpp.allocated = true; + home.refresh_sensors(); + CHECK(sensors.calls == 1 && home.sensors_nb == 1); +} +#endif + +int main(int argc, char** argv) { + if (argc == 2 && std::strcmp(argv[1], "force") == 0) { + force_at(100, 101); + } else { + initial_at(0); + initial_at(0x80000000u); + initial_at(UINT32_MAX); + cadence_and_records(); + wrap_visible(); + wake_across_wrap(); + exact_wrap_deadline(); + long_idle(26u * 24u * 60u * 60u * 1000u); + long_idle(UINT32_MAX - 1000); + force_at(100, 101); + force_at(0x80000000u, 0x80000001u); + force_at(UINT32_MAX - 10, 0); + completion_anchor(false); + completion_anchor(true); + empty_query_keeps_base_voltage(); +#if TEST_NEW_UI + allocation_guard(); +#endif + } + std::printf("PASS: %u scenarios; %u checks\n", scenarios, checks); +} diff --git a/test/test_home_sensor_refresh.py b/test/test_home_sensor_refresh.py new file mode 100644 index 000000000..7d838bf0a --- /dev/null +++ b/test/test_home_sensor_refresh.py @@ -0,0 +1,94 @@ +"""Test actual HomeScreen sensor-page refresh and force-input timer bodies. + +Both UI implementations are compiled with the sensor page enabled at each +cadence. This does not claim that every normal board recipe enables that page. +Clock, task, and board/sensor/LPP boundaries are host fakes, not hardware tests. +""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / 'test/fixtures/home_sensor_refresh.cpp' + + +def harness(ui, mutation=None): + source = (ROOT / 'examples/companion_radio' / ui / 'UITask.cpp').read_text(encoding='ascii') + field_start = source.index(' CayenneLPP sensors_lpp;') + refresh_start = source.index(' void refresh_sensors()', field_start) + fields = source[field_start:refresh_start] + # Keep sensor fields exactly as declared, without the neighboring radio UI. + fields = fields.split(' uint32_t _radio_profile_page_started_at')[0] + refresh = extract_braced(source, 'void refresh_sensors()') + key = 'key' if ui == 'ui-new' else 'c' + input_block = extract_braced(source, + f'if ({key} == KEY_ENTER && _page == HomePage::SENSORS)') + if mutation == 'no-force-invalidation': + if input_block.count('sensors_lpp.reset();') != 1: + raise AssertionError('sensor-page input must explicitly invalidate its LPP cache') + input_block = input_block.replace('sensors_lpp.reset();', '', 1) + elif mutation == 'no-empty-marker': + refresh, count = re.subn(r'sensors_lpp\.getSize\(\)\s*==\s*0\s*\|\|\s*', + '', refresh, count=1) + if count != 1: + raise AssertionError('refresh must recognize its empty initial/forced cache') + code = FIXTURE.read_text(encoding='ascii') + for token, value in {'FIELDS': fields, 'REFRESH': refresh, + 'KEY': key, 'INPUT': input_block}.items(): + code = code.replace('@' + token + '@', value) + return code + + +class HomeSensorRefreshTest(unittest.TestCase): + def test_actual_sensor_page_cadence_wrap_force_and_allocation(self): + self.run_variants() + + def test_missing_force_invalidation_fails_intended_regression(self): + self.run_variants('no-force-invalidation', 'force', 'FAIL forced input:') + + def test_missing_empty_marker_fails_first_poll_at_zero(self): + self.run_variants('no-empty-marker', None, 'FAIL fresh page:') + + def run_variants(self, mutation=None, argument=None, expected_failure=None): + compiler = shutil.which('g++') or shutil.which('clang++') + if compiler is None: + self.skipTest('a host C++17 compiler is required') + sanitize = [] if os.name == 'nt' else [ + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', + '-fno-pie', '-no-pie'] + with tempfile.TemporaryDirectory(prefix='home-sensor-refresh-') as directory: + work = Path(directory) + for ui in ('ui-new', 'ui-tiny'): + source = work / (ui + '.cpp') + source.write_text(harness(ui, mutation), encoding='ascii') + for auto_off in (15000, 0): + with self.subTest(ui=ui, auto_off=auto_off): + binary = work / (ui + '-' + str(auto_off) + + ('.exe' if os.name == 'nt' else '')) + compiled = subprocess.run([ + compiler, '-std=c++17', '-O1', '-g', '-Wall', '-Wextra', '-Werror', + *sanitize, '-DAUTO_OFF_MILLIS=' + str(auto_off), + '-DTEST_NEW_UI=' + str(int(ui == 'ui-new')), + str(source), '-o', str(binary), + ], text=True, capture_output=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + tested = subprocess.run([str(binary), *([argument] if argument else [])], + text=True, capture_output=True, timeout=10) + if expected_failure: + self.assertEqual(tested.returncode, 1, tested.stdout + tested.stderr) + self.assertIn(expected_failure, tested.stderr) + self.assertNotIn('AddressSanitizer:', tested.stderr) + self.assertNotIn('runtime error:', tested.stderr) + else: + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + self.assertRegex(tested.stdout, r'PASS: (15|16) scenarios; \d+ checks') + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_t096_full_memory.py b/test/test_t096_full_memory.py index 87dc2e217..6d1f91231 100644 --- a/test/test_t096_full_memory.py +++ b/test/test_t096_full_memory.py @@ -98,7 +98,7 @@ class T096FullMemoryTest(unittest.TestCase): #define AUTO_OFF_MILLIS 15000 #define UI_RECENT_LIST_SIZE 3 #define TELEM_CHANNEL_SELF 0 -unsigned long millis() { return 100; } +uint32_t millis() { return 100; } struct Lpp { uint8_t storage[8]; bool allocated = false; @@ -121,7 +121,8 @@ struct Board { int getBattMilliVolts() { return 4000; } } board; struct Sensors { int calls = 0; void querySensors(int, Lpp&) { ++calls; } } sensors; struct Home { Lpp sensors_lpp; - int sensors_nb = 5, sensors_scroll_offset = 4, next_sensors_refresh = 0; + int sensors_nb = 5, sensors_scroll_offset = 4; + uint32_t last_sensors_refresh = 0; bool sensors_scroll = true; @REFRESH@ }; @@ -133,7 +134,7 @@ int main() { home.sensors_lpp.allocated = true; home.refresh_sensors(); assert(home.sensors_nb == 1 && sensors.calls == 1); - assert(home.next_sensors_refresh == 5100); + assert(home.last_sensors_refresh == 100); home.refresh_sensors(); assert(sensors.calls == 1); }