From 4c3a5691e10bca41538f0951a0f99fd8121c5ee4 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 16:28:58 -0700 Subject: [PATCH] Keep Companion UI deadlines working across rollover and long idle --- .github/workflows/run-unit-tests.yml | 2 + examples/companion_radio/ui-new/UITask.cpp | 48 ++- examples/companion_radio/ui-new/UITask.h | 11 +- examples/companion_radio/ui-orig/UITask.cpp | 16 +- examples/companion_radio/ui-orig/UITask.h | 2 +- .../ui-tiny/ScrollingStatusBar.h | 12 +- examples/companion_radio/ui-tiny/UITask.cpp | 49 ++- examples/companion_radio/ui-tiny/UITask.h | 7 +- test/fixtures/companion_ui_deadlines.cpp | 398 ++++++++++++++++++ test/test_companion_pairing_display.py | 6 +- test/test_companion_ui_deadlines.py | 180 ++++++++ test/test_message_navigation.py | 2 +- test/test_morse_loop_policy.py | 4 +- test/test_scrolling_status_bar_rollover.py | 128 ++++++ test/test_touch_debug_cli.py | 9 +- test/test_touch_debug_overlay.py | 2 +- 16 files changed, 819 insertions(+), 57 deletions(-) create mode 100644 test/fixtures/companion_ui_deadlines.cpp create mode 100644 test/test_companion_ui_deadlines.py create mode 100644 test/test_scrolling_status_bar_rollover.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 02c05024e..bb360e8aa 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -782,6 +782,8 @@ jobs: run: | 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_scrolling_status_bar_rollover.py -v python3 -B test/test_gps_voice_buttons.py -v python3 -B test/test_gps_voice_generator.py -v python3 -B test/test_button_voice_actions.py -v diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 4bd186803..835b6827d 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1964,6 +1964,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode #endif ui_started_at = millis(); + next_batt_chck = _next_refresh = ui_started_at; + _alert[0] = 0; _alert_expiry = 0; splash = new SplashScreen(this); @@ -1977,7 +1979,7 @@ void UITask::serviceWiFiToggleButton() { if (wifi_btn.check() == BUTTON_EVENT_CLICK) { const bool enabled = toggleCompanionWiFi(); showAlert(enabled ? "WiFi: ON" : "WiFi: OFF", 1200); - _next_refresh = 0; + _next_refresh = millis(); } #endif } @@ -2054,7 +2056,7 @@ if (_notification_outputs & mesh::notify::Sound) switch(t){ void UITask::msgRead(int msgcount) { _msgcount = msgcount; - _next_refresh = 0; + _next_refresh = millis(); } void UITask::syncMessageQueue(int msgcount, int removed_index) { @@ -2093,7 +2095,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, if (shouldWakeDisplayForMessage()) { _display->wake(mesh::ui::DisplayWake::Message); } - if (_display->isOn()) _next_refresh = 0; + if (_display->isOn()) _next_refresh = millis(); } } @@ -2127,7 +2129,7 @@ void UITask::setCurrScreen(UIScreen* c) { } #endif curr = c; - _next_refresh = 100; + _next_refresh = millis(); } bool UITask::isPairingScreenActive() const { @@ -2169,7 +2171,7 @@ void UITask::showPairingPin(bool from_button) { setCurrScreen(home); if (_display != NULL) { _display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), true); - _next_refresh = 0; + _next_refresh = millis(); } } @@ -2187,7 +2189,7 @@ void UITask::finishPairingScreen(bool timed_out) { if (timed_out && _display != NULL) { _display->dismiss(); } else { - _next_refresh = 0; + _next_refresh = millis(); } } } @@ -2207,7 +2209,7 @@ void UITask::servicePairingState() { if (_display != NULL && _display->servicePower( _board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), isPairingScreenActive())) { - _next_refresh = 0; + _next_refresh = millis(); #if COMPANION_FEATURE_READER if (!_display->isOn() && isReaderActive()) static_cast(reader)->flush(); @@ -2272,6 +2274,20 @@ void UITask::loop() { serviceWiFiToggleButton(); servicePairingState(); + // Keep an already-due refresh due while drawing is deferred, without + // moving a future throttle or letting it age past half the clock range. + const uint32_t refresh_now = millis(); + if (static_cast(refresh_now - _next_refresh) >= 0) { + _next_refresh = refresh_now; + } + + // Retire the popup even while sleep, Morse or input prevents drawing. + // Its deadline may be zero after wrapping; empty text means inactive. + if (_alert[0] && static_cast(millis() - _alert_expiry) >= 0) { + _alert[0] = 0; + _next_refresh = millis(); + } + char c = 0; #if UI_HAS_JOYSTICK int ev = user_btn.check(); @@ -2384,7 +2400,7 @@ void UITask::loop() { && touch_input.hasSeparateVerticalSwipes(), split_transport_selector, reader_touch_bar); const int debug_area = mesh::ui::touchDebugAreaAt(debug_areas, _touch_debug_x, _touch_debug_y); - if (debug_area != _touch_debug_area || curr != _touch_debug_screen) _next_refresh = 0; + if (debug_area != _touch_debug_area || curr != _touch_debug_screen) _next_refresh = millis(); _touch_debug_area = debug_area; _touch_debug_screen = curr; } @@ -2449,7 +2465,7 @@ void UITask::loop() { if (c != 0 && curr) { _display->wake(mesh::ui::DisplayWake::Button); curr->handleInput(c); - _next_refresh = 100; // trigger refresh + _next_refresh = millis(); // trigger refresh } userLedHandler(); @@ -2464,11 +2480,11 @@ void UITask::loop() { #endif if (_display != NULL && _display->isOn()) { - if (!morse_timing && millis() >= _next_refresh && curr && !isButtonGesturePending()) { + if (!morse_timing && static_cast(millis() - _next_refresh) >= 0 && curr && !isButtonGesturePending()) { _display->startFrame(); int delay_millis = curr->render(*_display); renderPairingBanner(); - if (millis() < _alert_expiry) { // render alert popup + if (_alert[0] && static_cast(millis() - _alert_expiry) < 0) { // render alert popup _display->setTextSize(1); int y = _display->height() / 3; int p = _display->height() / 32; @@ -2510,7 +2526,7 @@ void UITask::loop() { #endif #ifdef AUTO_SHUTDOWN_MILLIVOLTS - if (millis() > next_batt_chck) { + if (static_cast(millis() - next_batt_chck) >= 0) { uint16_t milliVolts = getBattMilliVolts(); if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) { if(!board.isExternalPowered()) { @@ -2574,7 +2590,7 @@ char UITask::checkDisplayOn(char c) { } c = 0; } - _next_refresh = 0; // trigger refresh + _next_refresh = millis(); // trigger refresh } return c; } @@ -2655,7 +2671,7 @@ void UITask::toggleGPS() { } the_mesh.savePrefs(); showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800); - _next_refresh = 0; + _next_refresh = millis(); break; } } @@ -2675,7 +2691,7 @@ void UITask::toggleBuzzer() { the_mesh.setNotificationOutputMute(mesh::notify::Sound, _node_prefs->buzzer_quiet); the_mesh.savePrefs(); showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800); - _next_refresh = 0; // trigger refresh + _next_refresh = millis(); // trigger refresh #endif } @@ -2701,7 +2717,7 @@ void UITask::notificationMelody(const char* text) { void UITask::notificationScreen(int8_t mode) { if (!_display) return; _display->notificationPower(mode); - _next_refresh = 0; + _next_refresh = millis(); } void UITask::notificationVibration(bool on) { #ifdef HAS_DRV2605 diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index d1bb39ffb..42b51e80a 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -39,14 +39,15 @@ class UITask : public AbstractUITask { #elif defined(PIN_VIBRATION) GenericVibration vibration; #endif - unsigned long _next_refresh; + uint32_t _next_refresh; unsigned long _pairing_screen_until; bool _deferred_msg_preview; CompanionNodePrefs* _node_prefs; char _alert[80]; - unsigned long _alert_expiry; + uint32_t _alert_expiry; int _msgcount; - unsigned long ui_started_at, next_batt_chck; + unsigned long ui_started_at; + uint32_t next_batt_chck; int next_backlight_btn_check = 0; #ifdef HAS_TOUCH #ifndef TOUCH_CENTER_ZONE_PERCENT @@ -155,7 +156,7 @@ public: _touch_debug_enabled = enabled; _touch_debug_x = _touch_debug_y = _touch_debug_area = -1; _touch_debug_screen = nullptr; - _next_refresh = 0; // Also removes already-drawn outlines when disabling. + _next_refresh = millis(); // Also removes already-drawn outlines when disabling. if (enabled) checkDisplayOn(0); // Wake without selecting or navigating. return true; } @@ -185,7 +186,7 @@ public: void msgRead(int msgcount) override; void syncMessageQueue(int msgcount, int removed_index = -1) override; bool supportsInboxModes() const override { return _display != nullptr; } - void inboxModeChanged() override { _next_refresh = 0; } + void inboxModeChanged() override { _next_refresh = millis(); } const char* inboxTitle() const; void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, int channel_idx = -1, diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 7109b3b4b..27fe91fef 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -159,6 +159,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode _userButtonAnalog->onAnyPress([this]() { handleButtonAnyPress(); }); #endif ui_started_at = millis(); + _next_refresh = ui_started_at; } void UITask::resetRadioProfileDisplayPage() { @@ -666,7 +667,7 @@ void UITask::showPairingPin() { const unsigned long now = millis(); _pairing_screen_until = now + BLE_PAIRING_DISPLAY_MILLIS; _need_refresh = true; - _next_refresh = 0; + _next_refresh = millis(); if (_display != NULL && !_display->isOn()) { _display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), true); } @@ -675,7 +676,7 @@ void UITask::showPairingPin() { void UITask::finishPairingScreen(bool timed_out) { _pairing_screen_until = 0; _need_refresh = true; - _next_refresh = 0; + _next_refresh = millis(); if (_display == NULL) return; if (timed_out) { @@ -698,7 +699,7 @@ void UITask::servicePairingState() { if (_display != NULL && _display->servicePower( _board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), isPairingScreenActive())) { - _next_refresh = 0; + _next_refresh = millis(); _need_refresh = true; } } @@ -710,6 +711,13 @@ void UITask::loop() { #endif servicePairingState(); + // Keep an already-due refresh due while clean or asleep, without moving + // a future throttle. It must not age past the signed clock's half range. + const uint32_t refresh_now = millis(); + if (static_cast(refresh_now - _next_refresh) >= 0) { + _next_refresh = refresh_now; + } + #ifdef PIN_USER_BTN if (_userButton) { _userButton->update(); @@ -739,7 +747,7 @@ void UITask::loop() { _need_refresh = true; _firstBoot = false; } - if (millis() >= _next_refresh && _need_refresh) { + if (static_cast(millis() - _next_refresh) >= 0 && _need_refresh) { _display->startFrame(); renderCurrScreen(); _display->endFrame(); diff --git a/examples/companion_radio/ui-orig/UITask.h b/examples/companion_radio/ui-orig/UITask.h index 6c3c4de92..0c8fa855e 100644 --- a/examples/companion_radio/ui-orig/UITask.h +++ b/examples/companion_radio/ui-orig/UITask.h @@ -31,7 +31,7 @@ class UITask : public AbstractUITask { #elif defined(PIN_VIBRATION) GenericVibration vibration; #endif - unsigned long _next_refresh; + uint32_t _next_refresh; CompanionNodePrefs* _node_prefs; char _version_info[32]; char _origin[62]; diff --git a/examples/companion_radio/ui-tiny/ScrollingStatusBar.h b/examples/companion_radio/ui-tiny/ScrollingStatusBar.h index 9967489fd..afa2a64e0 100644 --- a/examples/companion_radio/ui-tiny/ScrollingStatusBar.h +++ b/examples/companion_radio/ui-tiny/ScrollingStatusBar.h @@ -20,7 +20,7 @@ class ScrollingStatusBar { int _text_width; int _scroll_x; int _display_width; - unsigned long _next_scroll; + uint32_t _next_scroll; unsigned long _next_update; bool _needs_redraw; @@ -43,8 +43,9 @@ public: void begin(int display_width) { _display_width = display_width; _scroll_x = 0; - _next_scroll = 0; + _next_scroll = millis(); _next_update = 0; + _needs_redraw = true; } // Call periodically to update the status string content. @@ -92,8 +93,9 @@ public: // Returns true if the status bar needs a redraw this frame. bool needsRedraw() { - if (_text_width <= _display_width) return _needs_redraw; // static, no scrolling - return millis() >= _next_scroll; + if (_needs_redraw) return true; + if (_text_width <= _display_width) return false; // static, no scrolling + return static_cast(millis() - _next_scroll) >= 0; } // Render the status bar via DisplayDriver. @@ -134,4 +136,4 @@ public: _needs_redraw = false; } -}; \ No newline at end of file +}; diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 726edb3b0..a67293c58 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -665,6 +665,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode #endif ui_started_at = millis(); + next_batt_chck = _next_refresh = ui_started_at; + _alert[0] = 0; _alert_expiry = 0; splash = new SplashScreen(this); @@ -739,7 +741,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, if (shouldWakeDisplayForMessage()) { _display->wake(mesh::ui::DisplayWake::Message); } - if (_display->isOn()) _next_refresh = 0; + if (_display->isOn()) _next_refresh = millis(); } } @@ -767,7 +769,7 @@ void UITask::userLedHandler() { void UITask::setCurrScreen(UIScreen* c) { curr = c; - _next_refresh = 100; + _next_refresh = millis(); } bool UITask::isPairingScreenActive() const { @@ -786,13 +788,13 @@ void UITask::showPairingPin() { setCurrScreen(home); if (_display != NULL) { _display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), true); - _next_refresh = 0; + _next_refresh = millis(); } } void UITask::finishPairingScreen(bool timed_out) { _pairing_screen_until = 0; - _next_refresh = 0; + _next_refresh = millis(); #if UI_SMALL_MESSAGE_FONT == 1 if (_deferred_msg_preview) { _deferred_msg_preview = false; @@ -820,7 +822,7 @@ void UITask::servicePairingState() { } if (_display != NULL && _display->servicePower( _board->isExternalPowered() || _board->isUsbHostConnected(), - hasConnection(), isPairingScreenActive())) _next_refresh = 0; + hasConnection(), isPairingScreenActive())) _next_refresh = millis(); } /* @@ -862,6 +864,25 @@ void UITask::loop() { const bool morse_timing = mesh::morseTimingActive(&radio_driver); servicePairingState(); + // Keep already-due work due across long display-off/Morse intervals, + // without moving a future refresh or triggering any display/ADC work. + const uint32_t refresh_now = millis(); + if (static_cast(refresh_now - _next_refresh) >= 0) { + _next_refresh = refresh_now; + } +#ifndef AUTO_SHUTDOWN_MILLIVOLTS + if (static_cast(refresh_now - next_batt_chck) >= 0) { + next_batt_chck = refresh_now; + } +#endif + + // Retire the popup even while sleep, Morse or input prevents drawing. + // Its deadline may be zero after wrapping; empty text means inactive. + if (_alert[0] && static_cast(millis() - _alert_expiry) >= 0) { + _alert[0] = 0; + _next_refresh = millis(); + } + char c = 0; #if UI_HAS_JOYSTICK int ev = user_btn.check(); @@ -942,7 +963,7 @@ void UITask::loop() { if (c != 0 && curr) { _display->wake(mesh::ui::DisplayWake::Button); curr->handleInput(c); - _next_refresh = 100; // trigger refresh + _next_refresh = millis(); // trigger refresh } userLedHandler(); @@ -962,7 +983,7 @@ void UITask::loop() { isBluetoothEnabled()); bool status_dirty = _statusBar.needsRedraw(); - bool content_dirty = (millis() >= _next_refresh && curr); + bool content_dirty = (static_cast(millis() - _next_refresh) >= 0 && curr); if (status_dirty || content_dirty) { _display->startFrame(); @@ -975,7 +996,7 @@ void UITask::loop() { } } - if (!isPairingScreenActive() && millis() < _alert_expiry) { // render alert popup + if (!isPairingScreenActive() && _alert[0] && static_cast(millis() - _alert_expiry) < 0) { // render alert popup _display->setTextSize(1); int y = _display->height() / 3; int p = _display->height() / 32; @@ -1001,7 +1022,7 @@ void UITask::loop() { #endif #ifdef AUTO_SHUTDOWN_MILLIVOLTS - if (millis() > next_batt_chck) { + if (static_cast(millis() - next_batt_chck) >= 0) { _cached_batt_mv = getBattMilliVolts(); if (_cached_batt_mv > 0 && _cached_batt_mv < AUTO_SHUTDOWN_MILLIVOLTS) { if(!board.isExternalPowered()) { @@ -1020,7 +1041,7 @@ void UITask::loop() { next_batt_chck = millis() + 8000; } #else - if (!morse_timing && _display != NULL && _display->isOn() && millis() >= next_batt_chck) { + if (!morse_timing && _display != NULL && _display->isOn() && static_cast(millis() - next_batt_chck) >= 0) { _cached_batt_mv = getBattMilliVolts(); next_batt_chck = millis() + 8000; } @@ -1035,7 +1056,7 @@ char UITask::checkDisplayOn(char c) { if (!was_on) { c = 0; } - _next_refresh = 0; // trigger refresh + _next_refresh = millis(); // trigger refresh } return c; } @@ -1091,7 +1112,7 @@ void UITask::toggleGPS() { } the_mesh.savePrefs(); showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800); - _next_refresh = 0; + _next_refresh = millis(); break; } } @@ -1111,7 +1132,7 @@ void UITask::toggleBuzzer() { the_mesh.setNotificationOutputMute(mesh::notify::Sound, _node_prefs->buzzer_quiet); the_mesh.savePrefs(); showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800); - _next_refresh = 0; // trigger refresh + _next_refresh = millis(); // trigger refresh #endif } @@ -1137,7 +1158,7 @@ void UITask::notificationMelody(const char* text) { void UITask::notificationScreen(int8_t mode) { if (!_display) return; _display->notificationPower(mode); - _next_refresh = 0; + _next_refresh = millis(); } void UITask::notificationVibration(bool on) { #ifdef HAS_DRV2605 diff --git a/examples/companion_radio/ui-tiny/UITask.h b/examples/companion_radio/ui-tiny/UITask.h index 483724b9a..fa37e7c15 100644 --- a/examples/companion_radio/ui-tiny/UITask.h +++ b/examples/companion_radio/ui-tiny/UITask.h @@ -39,13 +39,14 @@ class UITask : public AbstractUITask { #elif defined(PIN_VIBRATION) GenericVibration vibration; #endif - unsigned long _next_refresh; + uint32_t _next_refresh; CompanionNodePrefs* _node_prefs; char _alert[80]; - unsigned long _alert_expiry; + uint32_t _alert_expiry; unsigned long _pairing_screen_until; int _msgcount; - unsigned long ui_started_at, next_batt_chck; + unsigned long ui_started_at; + uint32_t next_batt_chck; int next_backlight_btn_check = 0; uint16_t _cached_batt_mv; #ifdef PIN_STATUS_LED diff --git a/test/fixtures/companion_ui_deadlines.cpp b/test/fixtures/companion_ui_deadlines.cpp new file mode 100644 index 000000000..245ae5759 --- /dev/null +++ b/test/fixtures/companion_ui_deadlines.cpp @@ -0,0 +1,398 @@ +// Compile the complete production UITask::loop() with a true 32-bit clock. +// Screen rendering, radio timing, display and unrelated services are adapters; +// deadline, popup, polling and scheduling decisions remain production source. +#include +#include +#include +#include + +static uint32_t now_ms = 0; +static uint32_t millis() { return now_ms; } +static void delay(uint32_t duration) { now_ms += duration; } +static constexpr uint32_t BOOT_SCREEN_MILLIS = 3000; +struct Radio { bool timing = false; } radio_driver; +namespace mesh { +static bool morseTimingActive(const Radio* radio) { return radio && radio->timing; } +namespace ui { enum class DisplayWake { Button }; } +} +namespace UIColor { constexpr int popup_bkg = 0, popup_txt = 1, warning_txt = 2; } +struct Board { bool isExternalPowered() const { return false; } } board; +struct Display { + bool on = true; + unsigned frames = 0, popups = 0; + bool isOn() const { return on; } + bool isEink() const { return false; } + int height() const { return 64; } + int width() const { return 128; } + void wake(mesh::ui::DisplayWake) { on = true; } + void startFrame() { ++frames; } + void endFrame() {} + void setTextSize(int) {} + void setColor(int) {} + void fillRect(int, int, int, int) {} + void drawRect(int, int, int, int) {} + void drawTextCentered(int, int, const char* text) { if (!std::strcmp(text, "ALERT")) ++popups; } +}; +struct UIScreen { + unsigned renders = 0, polls = 0; + int delay_ms = 1000; + int render(Display&) { ++renders; return delay_ms; } + void poll() { ++polls; } + void handleInput(char) {} +}; +struct HomeScreen : UIScreen { void showFirstPage() {} }; +struct StatusBar { + void update(Display&, const char*, uint16_t, bool, bool, bool) {} + bool needsRedraw() const { return false; } + void render(Display&) {} +}; +struct UITask { + Display display; + Display* _display = &display; + HomeScreen screen; + UIScreen* curr = &screen; + UIScreen* home = &screen; + StatusBar _statusBar; + struct Prefs { const char* node_name = "test"; } prefs; + Prefs* _node_prefs = &prefs; + // Arduino unsigned long is 32 bits; the host unsigned long is NOT used. + uint32_t _next_refresh = 0, _alert_expiry = 0, ui_started_at = 0, next_batt_chck = 0; + uint32_t _pairing_screen_until = 0; + bool _pairing_from_button = false; + uint16_t _cached_batt_mv = 4000, voltage = 4000; + bool gesture_pending = false; + bool _need_refresh = true; + unsigned battery_reads = 0, shutdowns = 0; + char _alert[80] = {}, _origin[80] = {}, _msg[80] = {}; + void servicePairingState() {} + void serviceWiFiToggleButton() {} + bool isPairingScreenActive() const { return false; } + bool isButtonGesturePending() const { return gesture_pending; } + bool isBuzzerQuiet() const { return true; } + bool getGPSState() const { return false; } + bool isBluetoothEnabled() const { return false; } + void userLedHandler() {} + void renderPairingBanner() {} + void serviceRadioProfileDisplayPage() {} + void renderCurrScreen() { ++screen.renders; _need_refresh = false; } + uint16_t getBattMilliVolts() { ++battery_reads; return voltage; } + void shutdown() { ++shutdowns; } + void showAlert(const char* text, int duration_millis); + void setCurrScreen(UIScreen* screen); + void loop(); +}; +@LOOP@ +@ALERT@ +@SCREEN@ + +static void forceCurrentScreen(UITask& ui) { +#if UI_ORIG + // Orig has no screen selector. Dirty is a real incoming-event condition; + // retain its already-due refresh so the test never invents a comparator. + ui._need_refresh = true; +#else + ui.setCurrScreen(ui.home); // Actual source's force-refresh operation. +#endif +} + +int main(int argc, char** argv) { + setbuf(stdout, nullptr); + assert(argc == 2); + const char* scenario = argv[1]; + UITask ui; + if (!std::strcmp(scenario, "orig-long-clean-idle")) { +#if UI_ORIG + now_ms = 0x90001000u; + const uint32_t origin = now_ms; + ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS; + ui._next_refresh = now_ms; + // A persistent message preview disables automatic radio-page dirtiness. + std::strcpy(ui._origin, "sender"); + std::strcpy(ui._msg, "retained preview"); + ui.loop(); + assert(ui.display.frames == 1 && !ui._need_refresh); + for (uint32_t day = 1; day <= 26; ++day) { + now_ms = origin + day * 86400000u; + ui.loop(); // Every individual elapsed step is far below half a wrap. + assert(ui.display.frames == 1 && !ui._need_refresh); + } + ui._need_refresh = true; // A fresh message finally makes the view dirty. + ui.loop(); + std::printf("orig after 26 clean days: frames=%u next=%u now=%u\n", + ui.display.frames, ui._next_refresh, now_ms); + assert(ui.display.frames == 2 && "a clean interval cannot age a due deadline back into the future"); +#else + assert(false && "orig-only scenario"); +#endif + } else if (!std::strcmp(scenario, "display-off-long-idle")) { +#if !UI_ORIG + now_ms = 0x90001000u; + const uint32_t origin = now_ms; + ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS; + ui._next_refresh = now_ms; + ui.next_batt_chck = now_ms; + ui.display.on = false; + ui.loop(); + for (uint32_t day = 1; day <= 26; ++day) { + now_ms = origin + day * 86400000u; + ui.loop(); + assert(ui.display.frames == 0); + } + ui.display.on = true; // Do not inject a force-refresh assignment on wake. + ui.loop(); + std::printf("wake after 26 display-off days: frames=%u next=%u now=%u\n", + ui.display.frames, ui._next_refresh, now_ms); + assert(ui.display.frames == 1 && "live loops must preserve due refresh status while display is off"); +#else + assert(false && "screened layouts only"); +#endif + } else if (!std::strcmp(scenario, "fresh-dirty-throttle")) { +#if UI_ORIG + now_ms = 0x90001000u; + ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS; + ui._next_refresh = now_ms; + std::strcpy(ui._origin, "sender"); + std::strcpy(ui._msg, "preview"); + ui.loop(); + assert(ui.display.frames == 1 && !ui._need_refresh); + ++now_ms; + ui._need_refresh = true; // Fresh incoming dirtiness, not a force operation. + ui.loop(); + assert(ui.display.frames == 1 && "normal fresh dirtiness still respects the one-second throttle"); + now_ms += 998u; + ui.loop(); + assert(ui.display.frames == 1); + ++now_ms; + ui.loop(); + assert(ui.display.frames == 2); +#else + assert(false && "orig-only dirty-flag scenario"); +#endif + } else if (!std::strcmp(scenario, "cache-battery-long-idle")) { +#if UI_TINY_CACHE + now_ms = 0x90001000u; + const uint32_t origin = now_ms; + ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS; + ui._next_refresh = now_ms; + ui.next_batt_chck = now_ms; + ui.display.on = false; + ui.loop(); + for (uint32_t day = 1; day <= 26; ++day) { + now_ms = origin + day * 86400000u; + ui.loop(); + assert(ui.battery_reads == 0 && ui.display.frames == 0); + } + ui.display.on = true; + radio_driver.timing = true; + ui.loop(); + assert(ui.battery_reads == 0 && ui.display.frames == 0); + radio_driver.timing = false; + ++now_ms; + ui.loop(); + std::printf("cache ADC after 26 off days and Morse release: reads=%u next=%u now=%u\n", + ui.battery_reads, ui.next_batt_chck, now_ms); + assert(ui.battery_reads == 1 && "cached battery poll stays due while off but samples only on safe wake"); + assert(ui.display.frames == 1); +#else + assert(false && "tiny no-auto-shutdown cache-only scenario"); +#endif + } else if (!std::strcmp(scenario, "cache-battery-wrapped-future")) { +#if UI_TINY_CACHE + now_ms = UINT32_MAX - 7999u; + ui.next_batt_chck = now_ms; + ui.loop(); + assert(ui.battery_reads == 1 && ui.next_batt_chck == 0); + ++now_ms; + ui.loop(); + assert(ui.battery_reads == 1 && "future wrapped zero cannot be clamped into an immediate cache poll"); + ui.display.on = false; + now_ms = 1u; + ui.loop(); + assert(ui.battery_reads == 1); + ui.display.on = true; + radio_driver.timing = true; + ui.loop(); + assert(ui.battery_reads == 1); + radio_driver.timing = false; + ui.loop(); + assert(ui.battery_reads == 2 && "due cached ADC waits for both display and Morse gates"); +#else + assert(false && "tiny cache-only scenario"); +#endif + } else if (!std::strcmp(scenario, "healthy") || !std::strcmp(scenario, "upper-half")) { + now_ms = !std::strcmp(scenario, "healthy") ? 10000u : 0x80001000u; + ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS; + ui._next_refresh = now_ms + 1000u; + ui.loop(); + assert(ui.display.frames == 0); + now_ms += 999u; + ui.loop(); + assert(ui.display.frames == 0); + ++now_ms; + ui.loop(); + assert(ui.display.frames == 1 && ui.screen.renders == 1); + } else if (!std::strcmp(scenario, "force-upper-half")) { +#if UI_ORIG + assert(false && "orig has no setCurrScreen operation"); +#else + now_ms = 0x80001000u; + ui._next_refresh = now_ms + 1000u; + forceCurrentScreen(ui); + ui.loop(); + assert(ui.display.frames == 1 && "screen change must redraw immediately in upper half"); +#endif + } else if (!std::strcmp(scenario, "deferred-across-wrap")) { + now_ms = UINT32_MAX - 1500u; + ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS; + ui._next_refresh = UINT32_MAX - 1000u; + ui.loop(); + assert(ui.display.frames == 0); + radio_driver.timing = true; + now_ms = UINT32_MAX - 500u; + ui.loop(); + now_ms = 100u; + ui.loop(); + assert(ui.display.frames == 0); + radio_driver.timing = false; + ui.loop(); + std::printf("after wrapped Morse deferral: frames=%u next=%u now=%u\n", + ui.display.frames, ui._next_refresh, now_ms); + assert(ui.display.frames == 1 && "overdue refresh must run after Morse releases across wrap"); + } else if (!std::strcmp(scenario, "wrapped-future-deadline")) { + now_ms = UINT32_MAX - 999u; + ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS; + ui._next_refresh = now_ms; + ui.loop(); + assert(ui.display.frames == 1 && ui._next_refresh == 0); + ++now_ms; + // Orig correctly clears dirty after render; model a fresh incoming event. + // It still must respect the real loop's one-second redraw throttle. + ui._need_refresh = true; + ui.loop(); + std::printf("future deadline wrapped to zero: frames=%u now=%u\n", ui.display.frames, now_ms); + assert(ui.display.frames == 1 && "a future wrapped deadline is not an immediate force sentinel"); + now_ms = 0; + ui.loop(); + assert(ui.display.frames == 2); + } +#if !UI_ORIG + else if (!std::strcmp(scenario, "battery-healthy") || !std::strcmp(scenario, "battery-upper-half")) { + ui.display.on = false; + now_ms = !std::strcmp(scenario, "battery-healthy") ? 10000u : 0x80001000u; + const uint32_t due = now_ms + 1000u; + ui.next_batt_chck = due; + ui.loop(); + assert(ui.battery_reads == 0 && ui.shutdowns == 0); + now_ms = due + 1u; + ui.loop(); + assert(ui.battery_reads == 1 && ui.shutdowns == 0); + now_ms += 7999u; + ui.loop(); + assert(ui.battery_reads == 1); + now_ms += 2u; + ui.loop(); + assert(ui.battery_reads == 2 && ui.shutdowns == 0); + } else if (!std::strcmp(scenario, "alert-upper-half")) { + now_ms = 0x80001000u; + ui.showAlert("ALERT", 1000); + forceCurrentScreen(ui); + ui.loop(); + assert(ui.display.popups == 1); + now_ms += 999u; + forceCurrentScreen(ui); + ui.loop(); + assert(ui.display.popups == 2); + now_ms += 2u; + forceCurrentScreen(ui); + ui.loop(); + assert(ui.display.popups == 2 && "healthy upper-half alert must expire normally"); + } else if (!std::strcmp(scenario, "battery-deferred-wrap")) { + ui.display.on = false; + radio_driver.timing = true; + ui.voltage = 3350; + now_ms = UINT32_MAX - 1500u; + ui.next_batt_chck = UINT32_MAX - 1000u; + ui.loop(); + assert(ui.battery_reads == 0 && ui.shutdowns == 0); + now_ms = 100u; // The main loop crossed wrap before the protective poll ran. + ui.loop(); + std::printf("low-battery poll across wrap: reads=%u shutdowns=%u next=%u\n", + ui.battery_reads, ui.shutdowns, ui.next_batt_chck); + assert(ui.battery_reads == 1 && ui.shutdowns == 1 + && "overdue protective battery sample must not be postponed another full wrap"); + } else if (!std::strcmp(scenario, "battery-wrapped-future")) { + ui.display.on = false; + now_ms = UINT32_MAX - 7999u; + ui.next_batt_chck = now_ms - 1u; + ui.loop(); + assert(ui.battery_reads == 1 && ui.next_batt_chck == 0); + ++now_ms; + ui.loop(); + std::printf("future battery poll wrapped to zero: reads=%u next=%u\n", + ui.battery_reads, ui.next_batt_chck); + assert(ui.battery_reads == 1 && "eight-second battery poll must not run again one ms later"); + now_ms = 1u; + ui.loop(); + assert(ui.battery_reads == 2 && ui.shutdowns == 0); + } else if (!std::strcmp(scenario, "expire-with-display-off") + || !std::strcmp(scenario, "expire-during-morse") + || !std::strcmp(scenario, "expire-during-gesture")) { + now_ms = 10000u; + ui.showAlert("ALERT", 1000); + forceCurrentScreen(ui); + ui.loop(); + assert(ui.display.popups == 1); + const unsigned before_frames = ui.display.frames; + now_ms = 11001u; + if (!std::strcmp(scenario, "expire-with-display-off")) ui.display.on = false; + if (!std::strcmp(scenario, "expire-during-morse")) radio_driver.timing = true; + if (!std::strcmp(scenario, "expire-during-gesture")) ui.gesture_pending = true; + ui.loop(); + assert(ui.display.frames == before_frames); + std::printf("popup expiry while redraw gated: text_active=%u frames=%u\n", + unsigned(ui._alert[0] != 0), ui.display.frames); + assert(!ui._alert[0] && "expired popup text must be consumed even when redraw is suppressed"); + ui.display.on = true; + radio_driver.timing = false; + ui.gesture_pending = false; + now_ms = 0x80002af9u; + forceCurrentScreen(ui); + ui.loop(); + assert(ui.display.popups == 1 && "expired popup cannot revive after half a wrap"); + } + else if (!std::strcmp(scenario, "alert-across-wrap") || !std::strcmp(scenario, "alert-zero-deadline")) { + const bool zero_deadline = !std::strcmp(scenario, "alert-zero-deadline"); + now_ms = zero_deadline ? uint32_t(0u - 1000u) : UINT32_MAX - 500u; + ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS; + ui.showAlert("ALERT", 1000); + forceCurrentScreen(ui); + ui.loop(); + std::printf("new wrapped alert: popups=%u expiry=%u now=%u\n", + ui.display.popups, ui._alert_expiry, now_ms); + assert(ui.display.popups == 1 && "new popup must display before a wrapped expiry, including zero"); + now_ms = ui._alert_expiry; + forceCurrentScreen(ui); + ui.loop(); + assert(ui.display.popups == 1 && "popup expires at its exact deadline"); + } else if (!std::strcmp(scenario, "expired-after-wrap") || !std::strcmp(scenario, "expired-after-halfwrap")) { + now_ms = 10000u; + ui.showAlert("ALERT", 1000); + forceCurrentScreen(ui); + ui.loop(); + assert(ui.display.popups == 1); + now_ms = 11001u; + forceCurrentScreen(ui); + ui.loop(); + assert(ui.display.popups == 1); + // It was observed expired. A stale expiry must never reactivate it later. + now_ms = !std::strcmp(scenario, "expired-after-wrap") ? 100u : 0x80002af9u; + forceCurrentScreen(ui); + ui.loop(); + std::printf("previously expired alert: popups=%u expiry=%u now=%u\n", + ui.display.popups, ui._alert_expiry, now_ms); + assert(ui.display.popups == 1 && "observed expired popup must never reactivate"); + } +#endif + else assert(false && "unknown scenario"); + std::printf("PASS: %s\n", scenario); +} diff --git a/test/test_companion_pairing_display.py b/test/test_companion_pairing_display.py index d3bed4bab..4fd6c2c56 100644 --- a/test/test_companion_pairing_display.py +++ b/test/test_companion_pairing_display.py @@ -66,7 +66,8 @@ struct UITask { UIScreen* home = &home_screen; UIScreen* msg_preview = &preview_screen; UIScreen* curr = home; - unsigned long _pairing_screen_until = 0, _next_refresh = 0; + unsigned long _pairing_screen_until = 0; + uint32_t _next_refresh = 0; bool _pairing_from_button = false, _deferred_msg_preview = false; int _msgcount = 0; UITask(TestDisplay* display, Board* board, Interfaces* interfaces) @@ -77,7 +78,7 @@ struct UITask { const char* connectedClientLabel() const { return _interfaceManager->ble ? "BLUETOOTH" : _interfaceManager->usb ? "USB" : nullptr; } - void setCurrScreen(UIScreen* screen) { curr = screen; _next_refresh = 100; } + void setCurrScreen(UIScreen* screen); void gotoHomeScreen() { setCurrScreen(home); } bool isPairingPromptActive() const { return isPairingScreenActive(); } bool isPairingScreenActive() const; @@ -163,6 +164,7 @@ class CompanionPairingDisplayTests(unittest.TestCase): def test_real_pairing_methods_and_usb_oled_selection(self): source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text() signatures = ( + "void UITask::setCurrScreen(", "bool UITask::isPairingScreenActive() const", "void UITask::renderPairingBanner()", "void UITask::showPairingPin(bool from_button)", diff --git a/test/test_companion_ui_deadlines.py b/test/test_companion_ui_deadlines.py new file mode 100644 index 000000000..b383a0c73 --- /dev/null +++ b/test/test_companion_ui_deadlines.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Run complete actual UI loops against explicit 32-bit clocks and fields. + +The test adapts display/screen/radio/input dependencies, not scheduling or popup +conditions. It excludes optional button, touch, buzzer, reader and vibration +branches. Startup begin()/hardware renderers are not compiled by this boundary. +""" + +from pathlib import Path +import shutil +import subprocess +import unittest +import tempfile + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = Path(__file__).resolve().parent / 'fixtures/companion_ui_deadlines.cpp' +VARIANTS = ('ui-orig', 'ui-tiny', 'ui-new') +BUILD_VARIANTS = VARIANTS + ('ui-tiny-cache',) + + +class CompanionUiDeadlineTests(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') + cls.directory = tempfile.TemporaryDirectory(prefix='companion-ui-deadlines-') + cls.addClassCleanup(cls.directory.cleanup) + work = Path(cls.directory.name) + cls.binaries = {} + for variant in BUILD_VARIANTS: + source_variant = 'ui-tiny' if variant == 'ui-tiny-cache' else variant + source = (ROOT / f'examples/companion_radio/{source_variant}/UITask.cpp').read_text(encoding='ascii') + generated = FIXTURE.read_text(encoding='ascii').replace('@LOOP@', extract_braced(source, 'void UITask::loop()')) + generated = generated.replace('@ALERT@', '' if variant == 'ui-orig' else + extract_braced(source, 'void UITask::showAlert(')) + generated = generated.replace('@SCREEN@', '' if variant == 'ui-orig' else + extract_braced(source, 'void UITask::setCurrScreen(')) + cpp, binary = work / (variant + '.cpp'), work / variant + cpp.write_text(generated, encoding='ascii') + built = subprocess.run([ + compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', '-Wno-unused-function', + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie', + '-DUI_ORIG=' + str(int(variant == 'ui-orig')), '-DUI_HAS_JOYSTICK=0', + '-DUI_TINY_CACHE=' + str(int(variant == 'ui-tiny-cache')), + '-DCOMPANION_FEATURE_READER=0', '-DAUTO_OFF_MILLIS=0', + *([] if variant in ('ui-orig', 'ui-tiny-cache') else ['-DAUTO_SHUTDOWN_MILLIVOLTS=3400']), + str(cpp), '-o', str(binary)], capture_output=True, text=True, timeout=60) + if built.returncode: + raise AssertionError(built.stdout + built.stderr) + cls.binaries[variant] = binary + + def check_scenario(self, scenario, variants=VARIANTS): + for variant in variants: + with self.subTest(variant=variant, scenario=scenario): + checked = subprocess.run([str(self.binaries[variant]), scenario], + capture_output=True, text=True, timeout=10) + print(f'\n[{variant}; {scenario}]\n' + checked.stdout + checked.stderr, flush=True) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn('PASS: ' + scenario, checked.stdout) + + def test_healthy_refresh_schedule(self): + self.check_scenario('healthy') + + def test_upper_half_refresh_schedule(self): + self.check_scenario('upper-half') + + def test_actual_forced_screen_change_in_upper_half(self): + self.check_scenario('force-upper-half', VARIANTS[1:]) + + def test_morse_deferred_refresh_survives_wrap(self): + self.check_scenario('deferred-across-wrap') + + def test_wrapped_future_deadline_is_not_force_zero(self): + self.check_scenario('wrapped-future-deadline') + + def test_alert_remains_active_across_wrap(self): + self.check_scenario('alert-across-wrap', VARIANTS[1:]) + + def test_alert_with_zero_expiry_is_active(self): + self.check_scenario('alert-zero-deadline', VARIANTS[1:]) + + def test_expired_alert_never_returns_after_wrap(self): + self.check_scenario('expired-after-wrap', VARIANTS[1:]) + + def test_expired_alert_never_returns_after_halfwrap(self): + self.check_scenario('expired-after-halfwrap', VARIANTS[1:]) + + def test_protective_battery_poll_survives_wrap(self): + self.check_scenario('battery-deferred-wrap', VARIANTS[1:]) + + def test_healthy_and_upper_half_battery_schedule(self): + for scenario in ('battery-healthy', 'battery-upper-half'): + self.check_scenario(scenario, VARIANTS[1:]) + + def test_upper_half_alert_schedule(self): + self.check_scenario('alert-upper-half', VARIANTS[1:]) + + def test_future_battery_poll_does_not_run_early(self): + self.check_scenario('battery-wrapped-future', VARIANTS[1:]) + + def test_alert_expiry_is_consumed_outside_display_gate(self): + self.check_scenario('expire-with-display-off', VARIANTS[1:]) + + def test_alert_expiry_is_consumed_outside_morse_gate(self): + self.check_scenario('expire-during-morse', VARIANTS[1:]) + + def test_alert_expiry_is_consumed_outside_gesture_gate(self): + self.check_scenario('expire-during-gesture', ('ui-new',)) + + def test_clean_orig_preview_keeps_due_refresh_after_26_days(self): + self.check_scenario('orig-long-clean-idle', ('ui-orig',)) + + def test_display_off_live_loops_keep_refresh_due_after_26_days(self): + self.check_scenario('display-off-long-idle', ('ui-tiny', 'ui-new', 'ui-tiny-cache')) + + def test_fresh_orig_dirty_event_preserves_one_second_throttle(self): + self.check_scenario('fresh-dirty-throttle', ('ui-orig',)) + + def test_tiny_cached_adc_waits_for_safe_wake_after_26_days(self): + self.check_scenario('cache-battery-long-idle', ('ui-tiny-cache',)) + + def test_tiny_cached_adc_future_zero_and_off_morse_gates(self): + self.check_scenario('cache-battery-wrapped-future', ('ui-tiny-cache',)) + + +class CompanionUiDeadlineWiringContract(unittest.TestCase): + def test_begin_types_force_paths_and_ci_keep_deadlines_armed(self): + for variant in ('ui-orig', 'ui-tiny', 'ui-new'): + with self.subTest(variant=variant): + directory = ROOT / 'examples/companion_radio' / variant + source = (directory / 'UITask.cpp').read_text(encoding='ascii') + header = (directory / 'UITask.h').read_text(encoding='ascii') + begin = extract_braced(source, 'void UITask::begin(') + constructor = extract_braced(header, 'UITask(mesh::MainBoard*') + self.assertRegex(header, r'\buint32_t\s+_next_refresh\s*;') + # Construction precedes Arduino begin. It may use zero, but + # every actual begin/re-begin must re-arm from its fresh clock. + self.assertRegex(constructor, r'\b_next_refresh\s*=\s*0\s*;') + self.assertIn('ui_started_at = millis();', begin) + self.assertLess(begin.index('ui_started_at = millis();'), + begin.index('_next_refresh = ui_started_at;')) + without_constructor = header.replace(constructor, '', 1) + old_force = r'\b_next_refresh\s*=\s*(?:0|100)[uUlL]*\s*;' + self.assertNotRegex(source, old_force) + self.assertNotRegex(without_constructor, old_force) + if variant == 'ui-orig': + # Re-begin does not rely on a constructor-only dirty flag. + self.assertIn('clearMsgPreview();', begin) + clear = extract_braced(source, 'void UITask::clearMsgPreview(') + self.assertIn('_need_refresh = true;', clear) + else: + self.assertRegex(header, r'\buint32_t\s+_alert_expiry\s*;') + self.assertRegex(header, r'\buint32_t\s+next_batt_chck\s*;') + self.assertIn('next_batt_chck = _next_refresh = ui_started_at;', begin) + self.assertIn('_alert[0] = 0;', begin) + self.assertIn('_alert_expiry = 0;', begin) + for initialization in ('next_batt_chck = _next_refresh = ui_started_at;', + '_alert[0] = 0;', '_alert_expiry = 0;'): + self.assertLess(begin.index(initialization), + begin.index('setCurrScreen(splash);')) + screen = extract_braced(source, 'void UITask::setCurrScreen(') + self.assertIn('_next_refresh = millis();', screen) + if variant == 'ui-new': + for signature in ('bool setTouchDebugEnabled(bool enabled)', + 'void inboxModeChanged()'): + inline = extract_braced(header, signature) + self.assertIn('_next_refresh = millis();', inline) + main = (ROOT / 'examples/companion_radio/main.cpp').read_text(encoding='ascii') + self.assertIn('ui_task.begin(', extract_braced(main, 'void setup()')) + self.assertIn('ui_task.loop();', extract_braced(main, '\nvoid loop()')) + workflow = (ROOT / '.github/workflows/run-unit-tests.yml').read_text(encoding='ascii') + for module in ('test_companion_ui_deadlines', 'test_scrolling_status_bar_rollover'): + self.assertIn('python3 -B test/' + module + '.py -v', workflow) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/test/test_message_navigation.py b/test/test_message_navigation.py index 61879189a..0874856a9 100644 --- a/test/test_message_navigation.py +++ b/test/test_message_navigation.py @@ -408,7 +408,7 @@ class MessageNavigationTest(unittest.TestCase): implementation = "\n".join(extract_braced(source, signature) for signature in ( "char UITask::checkDisplayOn(", "char UITask::handleDoubleClick(", "char UITask::handleMultiClick(", "bool UITask::isButtonGesturePending(")) - self.assertIn("millis() >= _next_refresh && curr && !isButtonGesturePending()", source) + self.assertIn("static_cast(millis() - _next_refresh) >= 0 && curr && !isButtonGesturePending()", source) implementation += "\n" + extract_braced(source, "class MsgPreviewScreen :") + ";\n" implementation += "void UITask::pollButton() { char c=0; int ev=user_btn.check();\n" implementation += button_route + "\nif(c && curr) curr->handleInput(c);\n}\n" diff --git a/test/test_morse_loop_policy.py b/test/test_morse_loop_policy.py index 5a8760682..4d2071695 100644 --- a/test/test_morse_loop_policy.py +++ b/test/test_morse_loop_policy.py @@ -207,7 +207,7 @@ int main() { def test_actual_all_six_ui_redraw_blocks_defer_without_consuming_deadlines(self): specs = ( ("examples/companion_radio/ui-new/UITask.cpp", - "if (!morse_timing && millis() >= _next_refresh"), + "if (!morse_timing && static_cast(millis() - _next_refresh) >= 0"), ("examples/companion_radio/ui-tiny/UITask.cpp", "if (!morse_timing && _display != NULL && _display->isOn())"), ("examples/companion_radio/ui-orig/UITask.cpp", @@ -247,7 +247,7 @@ int main() { with self.subTest(ui=variant): source = (ROOT / f"examples/companion_radio/{variant}/UITask.cpp").read_text(encoding="ascii") loop = method(source, "void UITask::loop()") - cutoff = method(loop, "if (millis() > next_batt_chck)") + cutoff = method(loop, "if (static_cast(millis() - next_batt_chck) >= 0)") self.assertNotIn("morse_timing", cutoff) code = FIXTURE.replace("@REDRAW@", "").replace("@CUTOFF@", cutoff) + r''' int main() { diff --git a/test/test_scrolling_status_bar_rollover.py b/test/test_scrolling_status_bar_rollover.py new file mode 100644 index 000000000..641711e30 --- /dev/null +++ b/test/test_scrolling_status_bar_rollover.py @@ -0,0 +1,128 @@ +"""Exercise the actual tiny UI status-bar header across clock rollover.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +CXX = shutil.which("g++") + +DRIVER = r'''#pragma once +#include +#include +#include +#include +extern uint32_t now_ms; +inline uint32_t millis() { return now_ms; } +namespace UIColor { constexpr unsigned primary_txt = 1; } +struct DisplayDriver { + int width = 200; + unsigned prints = 0; + int last_x = 0; + void setTextSize(int) {} + int getTextWidth(const char*) { return width; } + void setColor(unsigned) {} + void setCursor(int x, int) { last_x = x; } + void print(const char*) { ++prints; } +}; +''' + +FIXTURE = r'''#include +#include +uint32_t now_ms = 0; + +int main(int argc, char** argv) { + assert(argc == 2); + const int mode = std::atoi(argv[1]); + ScrollingStatusBar bar; + DisplayDriver display; + auto update = [&](const char* name) { bar.update(display, name, 4000, false, false, true); }; + if (mode == 1) { + now_ms = 0xfffffff0U; + bar.begin(72); update("wrapped-next"); + assert(bar.needsRedraw()); + bar.render(display); + assert(!bar.needsRedraw()); + now_ms += 79; assert(!bar.needsRedraw()); + ++now_ms; assert(bar.needsRedraw()); + bar.render(display); assert(!bar.needsRedraw()); + } else if (mode == 2) { + now_ms = 0xffffffffU - 100; + bar.begin(72); update("overdue-high"); + assert(bar.needsRedraw()); bar.render(display); + now_ms = 5; assert(bar.needsRedraw()); + bar.render(display); assert(!bar.needsRedraw()); + } else if (mode == 3) { + now_ms = 0x80000000U; + bar.begin(72); update("upper-half"); + assert(bar.needsRedraw()); bar.render(display); + assert(!bar.needsRedraw()); + // Fresh content must redraw immediately, not inherit the old deadline. + update("changed-with-future-scroll"); assert(bar.needsRedraw()); + bar.render(display); assert(!bar.needsRedraw()); + } else if (mode == 4) { + now_ms = 0x80000000U; + display.width = 50; + bar.begin(72); update("static"); + assert(bar.needsRedraw()); bar.render(display); + assert(!bar.needsRedraw()); + now_ms += 1000; assert(!bar.needsRedraw()); + update("static-changed"); assert(bar.needsRedraw()); + bar.render(display); assert(!bar.needsRedraw()); + } else if (mode == 5) { + now_ms = 1000; + display.width = 50; + bar.begin(72); update("static"); + assert(bar.needsRedraw()); bar.render(display); + assert(!bar.needsRedraw()); + bar.begin(72); assert(bar.needsRedraw()); + } else assert(false); + assert(display.prints); +} +''' + +@unittest.skipUnless(CXX, "Native C++ compiler is required") +class ScrollingStatusBarRolloverTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory(prefix="statusbar-rollover-") + cls.addClassCleanup(cls.temporary.cleanup) + directory = Path(cls.temporary.name) + stub = directory / "helpers/ui/DisplayDriver.h" + stub.parent.mkdir(parents=True) + stub.write_text(DRIVER, encoding="ascii") + source = directory / "fixture.cpp" + source.write_text(FIXTURE, encoding="ascii") + cls.binary = directory / "probe" + subprocess.run([ + CXX, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", + "-I" + str(ROOT / "examples/companion_radio/ui-tiny"), + "-I" + str(directory), str(source), "-o", str(cls.binary), + ], check=True, capture_output=True, timeout=30) + + def run_mode(self, mode): + result = subprocess.run([str(self.binary), str(mode)], capture_output=True, + text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_future_wrapped_scroll_deadline_waits_exactly_eighty_ms(self): + self.run_mode(1) + + def test_high_deadline_expired_after_wrap_is_immediately_due(self): + self.run_mode(2) + + def test_begin_upper_half_and_changed_scrolling_content_are_immediate(self): + self.run_mode(3) + + def test_static_content_updates_only_when_dirty(self): + self.run_mode(4) + + def test_begin_rearms_static_content_for_redraw(self): + self.run_mode(5) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_touch_debug_cli.py b/test/test_touch_debug_cli.py index df12118fe..d545eca83 100644 --- a/test/test_touch_debug_cli.py +++ b/test/test_touch_debug_cli.py @@ -30,10 +30,13 @@ class TouchDebugCliTest(unittest.TestCase): setter = extract_braced(mesh, 'if (strncmp(command, "set display.touch", 17) == 0') source = r''' #include +#include #include #include #include #include +static uint32_t mock_now = 0x80001000u; +static uint32_t millis() { return mock_now; } struct UIScreen {}; struct Display {}; struct AbstractUITask { @@ -41,7 +44,7 @@ BASE_HOOKS }; struct UITask : AbstractUITask { Display* _display=nullptr; - unsigned long _next_refresh=999; + uint32_t _next_refresh=999; int wakes=0; char checkDisplayOn(char c) { assert(c==0); ++wakes; return 0; } FIELDS @@ -76,7 +79,7 @@ int main() { assert(ui.supportsTouchDebug()); assert(command("get display.touch")=="display.touch off (off after reboot)"); assert(command("set display.touch on")=="OK - display.touch on (off after reboot)"); - assert(ui.isTouchDebugEnabled() && ui.wakes==1 && ui._next_refresh==0); + assert(ui.isTouchDebugEnabled() && ui.wakes==1 && ui._next_refresh==millis()); assert(command("get display.touch")=="display.touch on (off after reboot)"); // Invalid inputs must not mutate state or accidentally accept a suffix. for(const char* input : {"set display.touch", "set display.touch ", @@ -90,7 +93,7 @@ int main() { ui._touch_debug_area=4; ui._touch_debug_screen=&screen; ui._next_refresh=999; assert(command("set display.touch\t off")=="OK - display.touch off (off after reboot)"); - assert(!ui.isTouchDebugEnabled() && ui.wakes==1 && ui._next_refresh==0); + assert(!ui.isTouchDebugEnabled() && ui.wakes==1 && ui._next_refresh==millis()); assert(ui._touch_debug_x==-1 && ui._touch_debug_y==-1 && ui._touch_debug_area==-1); assert(ui._touch_debug_screen==nullptr); assert(command("set display.touch on").find("OK")==0); diff --git a/test/test_touch_debug_overlay.py b/test/test_touch_debug_overlay.py index 1296169c6..2e92c2edc 100644 --- a/test/test_touch_debug_overlay.py +++ b/test/test_touch_debug_overlay.py @@ -159,7 +159,7 @@ class TouchDebugOverlayTest(unittest.TestCase): profile = (ROOT / "variants/sensecap_indicator-espnow/platformio.ini").read_text() self.assertNotIn("UI_TOUCH_DEBUG_OUTLINES", profile) self.assertIn("TOUCH_MIRROR_TAP_X_ENABLED ? _display->width() - 1 - touch_x : touch_x", loop) - self.assertIn("if (debug_area != _touch_debug_area || curr != _touch_debug_screen) _next_refresh = 0;", loop) + self.assertIn("if (debug_area != _touch_debug_area || curr != _touch_debug_screen) _next_refresh = millis();", loop) self.assertIn("_touch_debug_screen == curr ? _touch_debug_x : -1", loop)