Keep Companion UI deadlines working across rollover and long idle

This commit is contained in:
mikecarper committed 2026-10-16 16:28:58 -07:00
1 parent d01b63f0c3
commit 4c3a5691e1
16 files changed
+819 -57

No files matched your search

+2
View File
@@ -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
+32 -16
View File
@@ -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<ReaderScreen*>(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<int32_t>(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<int32_t>(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<int32_t>(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<int32_t>(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<int32_t>(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
+6 -5
View File
@@ -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,
+12 -4
View File
@@ -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<int32_t>(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<int32_t>(millis() - _next_refresh) >= 0 && _need_refresh) {
_display->startFrame();
renderCurrScreen();
_display->endFrame();
+1 -1
View File
@@ -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];
@@ -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<int32_t>(millis() - _next_scroll) >= 0;
}
// Render the status bar via DisplayDriver.
@@ -134,4 +136,4 @@ public:
_needs_redraw = false;
}
};
};
+35 -14
View File
@@ -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<int32_t>(refresh_now - _next_refresh) >= 0) {
_next_refresh = refresh_now;
}
#ifndef AUTO_SHUTDOWN_MILLIVOLTS
if (static_cast<int32_t>(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<int32_t>(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<int32_t>(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<int32_t>(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<int32_t>(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<int32_t>(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
+4 -3
View File
@@ -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
+398
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
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);
}
+4 -2
View File
@@ -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)",
+180
View File
@@ -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)
+1 -1
View File
@@ -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<int32_t>(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"
+2 -2
View File
@@ -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<int32_t>(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<int32_t>(millis() - next_batt_chck) >= 0)")
self.assertNotIn("morse_timing", cutoff)
code = FIXTURE.replace("@REDRAW@", "").replace("@CUTOFF@", cutoff) + r'''
int main() {
+128
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
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 <ScrollingStatusBar.h>
#include <cstdlib>
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)
+6 -3
View File
@@ -30,10 +30,13 @@ class TouchDebugCliTest(unittest.TestCase):
setter = extract_braced(mesh, 'if (strncmp(command, "set display.touch", 17) == 0')
source = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <initializer_list>
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);
+1 -1
View File
@@ -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)