Keep UI startup and rescue windows closed after observed expiry
This commit is contained in:
1 parent
b7c2636aa9
commit
4348fdcc56
24 files changed
+554
-15
No files matched your search
@@ -801,6 +801,7 @@ jobs:
|
||||
python3 -B test/test_companion_pairing_display.py
|
||||
python3 -B test/test_companion_identity_startup.py
|
||||
python3 -B test/test_startup_screen.py
|
||||
python3 -B test/test_ui_startup_windows.py -v
|
||||
node test/test_notification_builder.js
|
||||
python3 -B test/test_notification_settings.py
|
||||
python3 -B test/test_notification_led_ownership.py -v
|
||||
|
||||
@@ -1968,6 +1968,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
|
||||
#endif
|
||||
|
||||
ui_started_at = millis();
|
||||
_startup_complete = false;
|
||||
next_batt_chck = _next_refresh = ui_started_at;
|
||||
_alert[0] = 0;
|
||||
_alert_expiry = 0;
|
||||
@@ -2275,6 +2276,10 @@ bool UITask::isButtonGesturePending() const {
|
||||
}
|
||||
|
||||
void UITask::loop() {
|
||||
// Observe startup expiry even while display/Morse/input defers rendering.
|
||||
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) >= 8000u) {
|
||||
_startup_complete = true;
|
||||
}
|
||||
const bool morse_timing = mesh::morseTimingActive(&radio_driver);
|
||||
serviceWiFiToggleButton();
|
||||
servicePairingState();
|
||||
@@ -2624,7 +2629,7 @@ char UITask::handleLongPress(char c) {
|
||||
return c;
|
||||
}
|
||||
#endif
|
||||
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
|
||||
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) < 8000u) { // early-boot CLI/rescue only
|
||||
the_mesh.enterCLIRescue();
|
||||
c = 0; // consume event
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ class UITask : public AbstractUITask {
|
||||
uint32_t _next_refresh;
|
||||
unsigned long _pairing_screen_until;
|
||||
bool _deferred_msg_preview;
|
||||
bool _startup_complete = false;
|
||||
CompanionNodePrefs* _node_prefs;
|
||||
char _alert[80];
|
||||
uint32_t _alert_expiry;
|
||||
|
||||
@@ -159,6 +159,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
|
||||
_userButtonAnalog->onAnyPress([this]() { handleButtonAnyPress(); });
|
||||
#endif
|
||||
ui_started_at = millis();
|
||||
_startup_complete = false;
|
||||
_next_refresh = ui_started_at;
|
||||
}
|
||||
|
||||
@@ -352,7 +353,8 @@ void UITask::renderCurrScreen() {
|
||||
_display->print(tmp);
|
||||
#endif
|
||||
_display->setColor(UIColor::secondary_txt); // last color will be kept on T114
|
||||
} else if (!pairing_screen_active && (millis() - ui_started_at) < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
} else if (!pairing_screen_active && !_startup_complete
|
||||
&& static_cast<uint32_t>(millis() - ui_started_at) < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
// meshcore logo
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
int logoWidth = 128;
|
||||
@@ -716,6 +718,10 @@ void UITask::servicePairingState() {
|
||||
}
|
||||
|
||||
void UITask::loop() {
|
||||
// Observe startup expiry before button callbacks and optional rendering.
|
||||
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) >= 8000u) {
|
||||
_startup_complete = true;
|
||||
}
|
||||
const bool morse_timing = mesh::morseTimingActive(&radio_driver);
|
||||
#ifdef PIN_VIBRATION
|
||||
vibration.loop();
|
||||
@@ -750,11 +756,11 @@ void UITask::loop() {
|
||||
|
||||
if (!morse_timing && _display != NULL && _display->isOn()) {
|
||||
if (!isPairingScreenActive() && !_alert[0] && !(_origin[0] && _msg[0])
|
||||
&& (millis() - ui_started_at) >= BOOT_SCREEN_MILLIS) {
|
||||
&& (_startup_complete || static_cast<uint32_t>(millis() - ui_started_at) >= BOOT_SCREEN_MILLIS)) {
|
||||
serviceRadioProfileDisplayPage();
|
||||
}
|
||||
static bool _firstBoot = true;
|
||||
if(_firstBoot && (millis() - ui_started_at) >= BOOT_SCREEN_MILLIS) {
|
||||
if(_firstBoot && (_startup_complete || static_cast<uint32_t>(millis() - ui_started_at) >= BOOT_SCREEN_MILLIS)) {
|
||||
_need_refresh = true;
|
||||
_firstBoot = false;
|
||||
}
|
||||
@@ -995,7 +1001,7 @@ void UITask::handleButtonLongPress() {
|
||||
_button_notification_cleared = false;
|
||||
#endif
|
||||
MESH_DEBUG_PRINTLN("UITask: long press triggered");
|
||||
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
|
||||
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) < 8000u) { // early-boot CLI/rescue only
|
||||
the_mesh.enterCLIRescue();
|
||||
#if defined(PIN_BUZZER) && MESH_GPS_VOICE
|
||||
// enterCLIRescue is supported independently of the framed USB interface.
|
||||
|
||||
@@ -56,6 +56,7 @@ class UITask : public AbstractUITask {
|
||||
unsigned long ui_started_at;
|
||||
uint32_t _radio_profile_page_started_at = 0;
|
||||
bool _dual_radio_enabled_seen = false;
|
||||
bool _startup_complete = false;
|
||||
uint8_t _radio_profile_display_page = 0;
|
||||
|
||||
// Button handlers
|
||||
|
||||
@@ -669,6 +669,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
|
||||
#endif
|
||||
|
||||
ui_started_at = millis();
|
||||
_startup_complete = false;
|
||||
next_batt_chck = _next_refresh = ui_started_at;
|
||||
_alert[0] = 0;
|
||||
_alert_expiry = 0;
|
||||
@@ -866,6 +867,10 @@ bool UITask::isButtonPressed() const {
|
||||
}
|
||||
|
||||
void UITask::loop() {
|
||||
// Observe startup expiry even while display/Morse/input defers rendering.
|
||||
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) >= 8000u) {
|
||||
_startup_complete = true;
|
||||
}
|
||||
const bool morse_timing = mesh::morseTimingActive(&radio_driver);
|
||||
servicePairingState();
|
||||
|
||||
@@ -1071,7 +1076,7 @@ char UITask::checkDisplayOn(char c) {
|
||||
}
|
||||
|
||||
char UITask::handleLongPress(char c) {
|
||||
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
|
||||
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) < 8000u) { // early-boot CLI/rescue only
|
||||
the_mesh.enterCLIRescue();
|
||||
c = 0; // consume event
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ class UITask : public AbstractUITask {
|
||||
uint32_t next_batt_chck;
|
||||
uint32_t last_backlight_btn_check = 0;
|
||||
uint16_t _cached_batt_mv;
|
||||
bool _startup_complete = false;
|
||||
#ifdef PIN_STATUS_LED
|
||||
int led_state = 0;
|
||||
uint32_t last_led_change = 0;
|
||||
|
||||
@@ -332,6 +332,7 @@ static const uint8_t meshcore_logo [] PROGMEM = {
|
||||
void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
|
||||
_prevBtnState = HIGH;
|
||||
_started_at = millis();
|
||||
_startup_complete = false;
|
||||
_node_prefs = node_prefs;
|
||||
resetRadioProfileDisplayPage();
|
||||
#ifdef DISPLAY_ACTIVITY_DASHBOARD
|
||||
@@ -418,7 +419,7 @@ void UITask::renderCurrScreen() {
|
||||
#ifdef DISPLAY_ACTIVITY_DASHBOARD
|
||||
_rows_valid = false;
|
||||
#endif
|
||||
if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
if (!_startup_complete && (uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
// meshcore logo
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
int logoWidth = 128;
|
||||
@@ -635,7 +636,7 @@ uint32_t UITask::getFrameSignature() {
|
||||
uint32_t signature = DisplayFrameSignature::INITIAL;
|
||||
char tmp[80];
|
||||
|
||||
if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) {
|
||||
if (!_startup_complete && (uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) {
|
||||
signature = DisplayFrameSignature::append(signature, "boot");
|
||||
return DisplayFrameSignature::append(signature, _version_info);
|
||||
}
|
||||
@@ -845,6 +846,10 @@ void UITask::toggleDisplay(const char* source) {
|
||||
#endif
|
||||
|
||||
void UITask::loop() {
|
||||
// Observe expiry outside display/Morse/input gates; startup is one-shot.
|
||||
if (!_startup_complete && (uint32_t)(millis() - _started_at) >= BOOT_SCREEN_MILLIS) {
|
||||
_startup_complete = true;
|
||||
}
|
||||
const bool morse_timing = mesh::morseTimingActive(the_mesh.getProfileRadio());
|
||||
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
|
||||
if (_display->isOn()) resetRadioProfileDisplayPage();
|
||||
|
||||
@@ -27,6 +27,7 @@ class UITask {
|
||||
unsigned long _started_at = 0;
|
||||
uint8_t _radio_profile_display_page = 0;
|
||||
bool _dual_radio_enabled_seen = false;
|
||||
bool _startup_complete = false;
|
||||
|
||||
void resetRadioProfileDisplayPage();
|
||||
void advanceRadioProfileDisplayPage();
|
||||
|
||||
@@ -131,6 +131,7 @@ static const uint8_t meshcore_logo [] PROGMEM = {
|
||||
void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
|
||||
_prevBtnState = HIGH;
|
||||
_started_at = millis();
|
||||
_startup_complete = false;
|
||||
_node_prefs = node_prefs;
|
||||
resetRadioProfileDisplayPage();
|
||||
#ifdef DISPLAY_ACTIVITY_DASHBOARD
|
||||
@@ -188,7 +189,7 @@ void UITask::renderCurrScreen() {
|
||||
#ifdef DISPLAY_ACTIVITY_DASHBOARD
|
||||
_rows_valid = false;
|
||||
#endif
|
||||
if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
if (!_startup_complete && (uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
// meshcore logo
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
int logoWidth = 128;
|
||||
@@ -341,7 +342,7 @@ uint32_t UITask::getFrameSignature() {
|
||||
uint32_t signature = DisplayFrameSignature::INITIAL;
|
||||
char tmp[80];
|
||||
|
||||
if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) {
|
||||
if (!_startup_complete && (uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) {
|
||||
signature = DisplayFrameSignature::append(signature, "boot");
|
||||
return DisplayFrameSignature::append(signature, _version_info);
|
||||
}
|
||||
@@ -496,6 +497,10 @@ void UITask::toggleDisplay(const char* source) {
|
||||
#endif
|
||||
|
||||
void UITask::loop() {
|
||||
// Observe expiry outside display/Morse/input gates; startup is one-shot.
|
||||
if (!_startup_complete && (uint32_t)(millis() - _started_at) >= BOOT_SCREEN_MILLIS) {
|
||||
_startup_complete = true;
|
||||
}
|
||||
const bool morse_timing = mesh::morseTimingActive(the_mesh.getProfileRadio());
|
||||
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
|
||||
resetRadioProfileDisplayPage();
|
||||
|
||||
@@ -28,6 +28,7 @@ class UITask {
|
||||
unsigned long _started_at = 0;
|
||||
uint32_t _radio_profile_page_started_at = 0;
|
||||
bool _dual_radio_enabled_seen = false;
|
||||
bool _startup_complete = false;
|
||||
#ifdef DISPLAY_TOUCH_TOGGLE
|
||||
unsigned long _powering_off_at = 0;
|
||||
#endif
|
||||
|
||||
@@ -105,6 +105,7 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
|
||||
const uint32_t now = millis();
|
||||
_last_read = now - 200u;
|
||||
_last_refresh = now - 1000u;
|
||||
_startup_complete = now >= BOOT_SCREEN_MILLIS;
|
||||
}
|
||||
|
||||
void UITask::resetRadioProfileDisplayPage() {
|
||||
@@ -128,7 +129,7 @@ bool UITask::showingSecondaryRadioProfilePage() {
|
||||
|
||||
void UITask::renderCurrScreen() {
|
||||
char tmp[80];
|
||||
if (millis() < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
if (!_startup_complete && millis() < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
// meshcore logo
|
||||
_display->setColor(UIColor::corp_blue);
|
||||
int logoWidth = 128;
|
||||
@@ -210,6 +211,10 @@ void UITask::renderCurrScreen() {
|
||||
}
|
||||
|
||||
void UITask::loop() {
|
||||
// This UI's splash is relative to boot, not to begin(). Retire it once.
|
||||
if (!_startup_complete && millis() >= BOOT_SCREEN_MILLIS) {
|
||||
_startup_complete = true;
|
||||
}
|
||||
const bool morse_timing = mesh::morseTimingActive(activeSensorMesh().getProfileRadio());
|
||||
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
|
||||
resetRadioProfileDisplayPage();
|
||||
|
||||
@@ -12,6 +12,7 @@ class UITask {
|
||||
char _version_info[32];
|
||||
uint32_t _radio_profile_page_started_at = 0;
|
||||
bool _dual_radio_enabled_seen = false;
|
||||
bool _startup_complete = false;
|
||||
|
||||
void resetRadioProfileDisplayPage();
|
||||
bool showingSecondaryRadioProfilePage();
|
||||
|
||||
+80
-2
@@ -5,12 +5,15 @@
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
|
||||
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;
|
||||
#define MESH_DEBUG_PRINTLN(...) do {} while (0)
|
||||
struct Radio { bool timing = false; } radio_driver;
|
||||
struct TestMesh { unsigned rescues = 0; void enterCLIRescue() { ++rescues; } } the_mesh;
|
||||
namespace mesh {
|
||||
static bool morseTimingActive(const Radio* radio) { return radio && radio->timing; }
|
||||
namespace ui { enum class DisplayWake { Button }; }
|
||||
@@ -52,6 +55,8 @@ struct UITask {
|
||||
HomeScreen screen;
|
||||
UIScreen* curr = &screen;
|
||||
UIScreen* home = &screen;
|
||||
UIScreen preview;
|
||||
UIScreen* msg_preview = &preview;
|
||||
StatusBar _statusBar;
|
||||
struct Prefs { const char* node_name = "test"; } prefs;
|
||||
Prefs* _node_prefs = &prefs;
|
||||
@@ -59,10 +64,11 @@ struct UITask {
|
||||
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;
|
||||
bool _startup_complete = false;
|
||||
uint16_t _cached_batt_mv = 4000, voltage = 4000;
|
||||
bool gesture_pending = false;
|
||||
bool _need_refresh = true;
|
||||
unsigned battery_reads = 0, shutdowns = 0;
|
||||
unsigned battery_reads = 0, shutdowns = 0, profile_services = 0;
|
||||
char _alert[80] = {}, _origin[80] = {}, _msg[80] = {};
|
||||
void servicePairingState() {}
|
||||
void serviceWiFiToggleButton() {}
|
||||
@@ -73,13 +79,15 @@ struct UITask {
|
||||
bool isBluetoothEnabled() const { return false; }
|
||||
void userLedHandler() {}
|
||||
void renderPairingBanner() {}
|
||||
void serviceRadioProfileDisplayPage() {}
|
||||
void serviceRadioProfileDisplayPage() { ++profile_services; }
|
||||
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();
|
||||
void handleButtonLongPress();
|
||||
char handleLongPress(char);
|
||||
};
|
||||
@LOOP@
|
||||
@ALERT@
|
||||
@@ -100,6 +108,76 @@ int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
const char* scenario = argv[1];
|
||||
UITask ui;
|
||||
if (!std::strcmp(scenario, "startup-first-draw")) {
|
||||
#if UI_ORIG
|
||||
ui.ui_started_at = now_ms = 1000;
|
||||
ui._need_refresh = false;
|
||||
ui.display.on = false;
|
||||
ui.loop();
|
||||
now_ms = 9000; ui.loop();
|
||||
for (uint64_t day = 1; day <= 49; ++day) {
|
||||
now_ms = uint32_t(1000u + day * 86400000ULL);
|
||||
ui.loop();
|
||||
}
|
||||
now_ms = 2000; ui.loop(); // Full-wrap alias, startup already observed closed.
|
||||
ui.display.on = true; ui.loop();
|
||||
assert(ui.display.frames == 1 && "deferred first paint must remain post-startup after full wrap");
|
||||
#endif
|
||||
std::puts("PASS: startup-first-draw");
|
||||
return 0;
|
||||
}
|
||||
if (!std::strcmp(scenario, "startup-one-shot")) {
|
||||
for (uint32_t started : {0u, 1000u, UINT32_MAX - 4000u}) {
|
||||
for (unsigned gate = 0; gate < 4; ++gate) {
|
||||
UITask window;
|
||||
window.ui_started_at = started;
|
||||
window._next_refresh = now_ms = started;
|
||||
window.display.on = (gate & 1) == 0;
|
||||
radio_driver.timing = (gate & 2) != 0;
|
||||
window.loop();
|
||||
now_ms = started + 7999u; window.loop();
|
||||
const auto before = the_mesh.rescues;
|
||||
#if UI_ORIG
|
||||
window.handleButtonLongPress();
|
||||
#else
|
||||
assert(window.handleLongPress('x') == 0);
|
||||
#endif
|
||||
assert(the_mesh.rescues == before + 1 && !window._startup_complete);
|
||||
now_ms = started + 8000u; window.loop();
|
||||
#if UI_ORIG
|
||||
window.handleButtonLongPress();
|
||||
assert(window.shutdowns == 1);
|
||||
#else
|
||||
assert(window.handleLongPress('x') == 'x');
|
||||
#endif
|
||||
assert(the_mesh.rescues == before + 1); // Exact 8-second cutoff.
|
||||
for (uint64_t day = 1; day <= 49; ++day) {
|
||||
now_ms = uint32_t(started + day * 86400000ULL);
|
||||
window.loop();
|
||||
}
|
||||
// Live observation of expiry precedes the complete-counter alias.
|
||||
now_ms = started + 1000u; window.loop();
|
||||
window.display.on = true; radio_driver.timing = false;
|
||||
const auto profiles = window.profile_services;
|
||||
window.loop();
|
||||
#if UI_ORIG
|
||||
window.handleButtonLongPress();
|
||||
#else
|
||||
(void)profiles;
|
||||
const char returned = window.handleLongPress('x');
|
||||
#endif
|
||||
assert(the_mesh.rescues == before + 1 && window._startup_complete
|
||||
&& "expired startup rescue must never reopen after full wrap");
|
||||
#if UI_ORIG
|
||||
assert(window.shutdowns == 2 && window.profile_services == profiles + 1);
|
||||
#else
|
||||
assert(returned == 'x');
|
||||
#endif
|
||||
}
|
||||
}
|
||||
std::puts("PASS: startup-one-shot");
|
||||
return 0;
|
||||
}
|
||||
if (!std::strcmp(scenario, "orig-long-clean-idle")) {
|
||||
#if UI_ORIG
|
||||
now_ms = 0x90001000u;
|
||||
|
||||
+1
@@ -5,6 +5,7 @@
|
||||
#include <string>
|
||||
|
||||
static uint32_t now_ms;
|
||||
static constexpr uint32_t BOOT_SCREEN_MILLIS = 4000;
|
||||
static uint32_t millis() { return now_ms; }
|
||||
static constexpr int HIGH = 1;
|
||||
static bool deny_copy;
|
||||
|
||||
Vendored
+1
@@ -8,6 +8,7 @@ static uint32_t now_ms = 0, read_delay = 0, render_delay = 0, end_delay = 0;
|
||||
static unsigned reads = 0, renders = 0;
|
||||
static constexpr int HIGH = 1, LOW = 0, USER_BTN_PRESSED = LOW;
|
||||
static constexpr int BUTTON_EVENT_NONE = 0;
|
||||
static constexpr uint32_t BOOT_SCREEN_MILLIS = 4000;
|
||||
static int button_level = HIGH;
|
||||
static uint32_t millis() { return now_ms; }
|
||||
[[maybe_unused]] static int digitalRead(int pin) {
|
||||
|
||||
Vendored
+201
@@ -0,0 +1,201 @@
|
||||
// Actual Sensor UI methods and header; infrastructure init/prefix/metadata only.
|
||||
// Hardware, optional status-page and radio dependencies are explicit adapters.
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
static uint32_t now_ms = 0;
|
||||
static uint32_t millis() { return now_ms; }
|
||||
static constexpr int HIGH = 1;
|
||||
enum class UIColor { corp_blue, primary_txt, secondary_txt, warning_txt };
|
||||
namespace mesh {
|
||||
struct Params { float freq = 915, bw = 125; uint8_t sf = 7, cr = 5; };
|
||||
struct RadioProfiles {
|
||||
bool enabled() const { return false; }
|
||||
bool secondary_temporary = false, primary_temporary = false;
|
||||
const Params& params(int) const { static Params value; return value; }
|
||||
};
|
||||
struct MainBoard {
|
||||
bool isExternalPowered() const { return true; }
|
||||
bool isUsbHostConnected() const { return true; }
|
||||
};
|
||||
namespace ui {
|
||||
enum class DisplayWake { Button };
|
||||
struct RadioProfileSystemStatus {};
|
||||
[[maybe_unused]] static bool showSecondaryRadioProfilePage(bool, uint8_t, uint32_t) {
|
||||
return false;
|
||||
}
|
||||
[[maybe_unused]] static bool showRadioProfileSystemStatusPage(bool, uint8_t, uint32_t,
|
||||
uint8_t*) { return false; }
|
||||
[[maybe_unused]] static const char* radioProfileDisplayTag(int, bool) { return ""; }
|
||||
}
|
||||
}
|
||||
struct DisplayDriver {
|
||||
bool on = true, changed = false;
|
||||
unsigned frames = 0, logos = 0, homes = 0;
|
||||
bool servicePower(bool) { bool result = changed; changed = false; return result; }
|
||||
bool isOn() const { return on; }
|
||||
void wake(mesh::ui::DisplayWake) { on = true; }
|
||||
void startFrame() { ++frames; }
|
||||
void endFrame() {}
|
||||
void setColor(UIColor) {}
|
||||
void setTextSize(float) {}
|
||||
void setCursor(int, int) {}
|
||||
int width() const { return 128; }
|
||||
unsigned getTextWidth(const char* text) const { return std::strlen(text) * 6; }
|
||||
void drawXbm(int, int, const uint8_t*, int, int) { ++logos; }
|
||||
void print(const char* text) { if (!std::strcmp(text, "normal-node")) ++homes; }
|
||||
};
|
||||
namespace mesh { namespace ui {
|
||||
[[maybe_unused]] static void drawRadioProfileSystemStatusPage(
|
||||
DisplayDriver&, const RadioProfileSystemStatus&, uint8_t) {}
|
||||
}}
|
||||
static mesh::MainBoard board;
|
||||
struct Radio { bool timing = false; } radio;
|
||||
namespace mesh {
|
||||
[[maybe_unused]] static bool morseTimingActive(const Radio* value) { return value->timing; }
|
||||
}
|
||||
struct SensorMesh {
|
||||
Radio* getProfileRadio() { return &radio; }
|
||||
bool isDualRadioActive() const { return false; }
|
||||
} sensor_mesh;
|
||||
[[maybe_unused]] static SensorMesh& activeSensorMesh() { return sensor_mesh; }
|
||||
static SensorMesh& the_mesh = sensor_mesh;
|
||||
struct NodePrefs {
|
||||
char node_name[32] = "normal-node";
|
||||
float freq = 915, bw = 125;
|
||||
uint8_t sf = 7, cr = 5;
|
||||
};
|
||||
[[maybe_unused]] static const mesh::RadioProfiles* configuredRadioProfiles() { return nullptr; }
|
||||
[[maybe_unused]] static mesh::ui::RadioProfileSystemStatus radioProfileSystemStatus(
|
||||
const NodePrefs&) { return {}; }
|
||||
[[maybe_unused]] static uint8_t radioProfileStatusPageCount(DisplayDriver&, bool) { return 0; }
|
||||
[[maybe_unused]] static const uint8_t meshcore_logo[] = {0};
|
||||
#define BOOT_SCREEN_MILLIS 4000
|
||||
#include "UITask.h"
|
||||
|
||||
@METHODS@
|
||||
|
||||
static const char* scenario;
|
||||
static unsigned checks = 0;
|
||||
#define CHECK(expression) do { ++checks; if (!(expression)) { \
|
||||
std::fprintf(stderr, "FAIL %s: %s (line %d)\n", scenario, #expression, __LINE__); \
|
||||
return 1; } } while (0)
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 2) return 2;
|
||||
scenario = argv[1];
|
||||
DisplayDriver display;
|
||||
NodePrefs prefs;
|
||||
#if TEST_REPEATER
|
||||
UITask ui(board, display);
|
||||
#else
|
||||
UITask ui(display);
|
||||
#endif
|
||||
#if TEST_SENSOR
|
||||
const bool late = !std::strcmp(scenario, "late-begin");
|
||||
const bool upper = !std::strcmp(scenario, "upper-begin");
|
||||
const bool maximum = !std::strcmp(scenario, "max-begin");
|
||||
const bool off_first = !std::strcmp(scenario, "off-first");
|
||||
const bool morse_first = !std::strcmp(scenario, "morse-first");
|
||||
now_ms = late ? 4000u : upper ? 0x80001000u : maximum ? UINT32_MAX : 0u;
|
||||
display.on = !off_first;
|
||||
radio.timing = morse_first;
|
||||
ui.begin(&prefs, "16 Oct 2026", "v1.2.3-test");
|
||||
ui.loop();
|
||||
CHECK(display.logos == unsigned(!late && !upper && !maximum && !off_first && !morse_first));
|
||||
CHECK(display.homes == unsigned(late || upper || maximum));
|
||||
if (!std::strcmp(scenario, "boundary")) {
|
||||
now_ms = 3999;
|
||||
display.changed = true;
|
||||
ui.loop();
|
||||
CHECK(display.logos == 2 && display.homes == 0);
|
||||
now_ms = 4000;
|
||||
display.changed = true;
|
||||
ui.loop();
|
||||
CHECK(display.logos == 2 && display.homes == 1);
|
||||
} else if (late || upper || maximum) {
|
||||
// Re-entering the low boot clock range must not undo begin's late cutoff.
|
||||
now_ms = 0;
|
||||
display.changed = true;
|
||||
ui.loop();
|
||||
CHECK(display.logos == 0 && display.homes == 2);
|
||||
} else if (!std::strcmp(scenario, "live-wrap")) {
|
||||
now_ms = 5000;
|
||||
ui.loop();
|
||||
CHECK(display.logos == 1 && display.homes == 1);
|
||||
for (uint64_t day = 1; day <= 49; ++day) {
|
||||
now_ms = static_cast<uint32_t>(day * 86400000ULL);
|
||||
ui.loop();
|
||||
CHECK(display.logos == 1);
|
||||
}
|
||||
const unsigned homes = display.homes;
|
||||
now_ms = 0; // absolute 2^32, reached after the live checkpoints above
|
||||
ui.loop();
|
||||
CHECK(display.logos == 1 && display.homes == homes + 1);
|
||||
} else if (!std::strcmp(scenario, "off-retirement")
|
||||
|| !std::strcmp(scenario, "morse-retirement") || off_first || morse_first) {
|
||||
const bool off = off_first || !std::strcmp(scenario, "off-retirement");
|
||||
display.on = !off;
|
||||
radio.timing = !off;
|
||||
const unsigned frames = display.frames, logos = display.logos;
|
||||
now_ms = 4000;
|
||||
ui.loop();
|
||||
CHECK(display.frames == frames);
|
||||
now_ms = 0; // full-wrap alias, after observed expiry with no rendered frame
|
||||
display.on = true;
|
||||
display.changed = true;
|
||||
radio.timing = false;
|
||||
ui.loop();
|
||||
CHECK(display.logos == logos && display.homes == 1);
|
||||
} else if (!std::strcmp(scenario, "wake")) {
|
||||
now_ms = 4000;
|
||||
ui.loop();
|
||||
CHECK(display.logos == 1 && display.homes == 1);
|
||||
display.on = false;
|
||||
now_ms = 12000;
|
||||
ui.loop();
|
||||
now_ms = 0;
|
||||
display.wake(mesh::ui::DisplayWake::Button);
|
||||
display.changed = true;
|
||||
ui.loop();
|
||||
CHECK(display.logos == 1 && display.homes == 2);
|
||||
} else if (!std::strcmp(scenario, "rebegin")) {
|
||||
now_ms = 4000;
|
||||
ui.loop();
|
||||
CHECK(display.logos == 1 && display.homes == 1);
|
||||
now_ms = 100;
|
||||
ui.begin(&prefs, "16 Oct 2026", "v1.2.3-test");
|
||||
ui.loop();
|
||||
CHECK(display.logos == 2 && display.homes == 1);
|
||||
now_ms = 4100;
|
||||
ui.loop();
|
||||
CHECK(display.logos == 2 && display.homes == 2);
|
||||
now_ms = 5000;
|
||||
ui.begin(&prefs, "16 Oct 2026", "v1.2.3-test");
|
||||
ui.loop();
|
||||
CHECK(display.logos == 2 && display.homes == 3);
|
||||
} else return 3;
|
||||
#else
|
||||
const bool wrapping = !std::strcmp(scenario, "infra-wrap-start");
|
||||
const uint32_t start = wrapping ? UINT32_MAX - 1000u : 1000u;
|
||||
now_ms = start;
|
||||
ui.begin(&prefs, "16 Oct 2026", "v1.2.3-test");
|
||||
CHECK(ui.testRenderBoot() && ui.testSignatureBoot());
|
||||
now_ms = start + 3999u;
|
||||
ui.loop();
|
||||
CHECK(ui.testRenderBoot() && ui.testSignatureBoot());
|
||||
now_ms = start + 4000u;
|
||||
ui.loop();
|
||||
CHECK(!ui.testRenderBoot() && !ui.testSignatureBoot());
|
||||
now_ms = start; // full-wrap alias, same retained boot timestamp
|
||||
ui.loop();
|
||||
CHECK(!ui.testRenderBoot() && !ui.testSignatureBoot());
|
||||
if (!std::strcmp(scenario, "infra-rebegin")) {
|
||||
ui.begin(&prefs, "16 Oct 2026", "v1.2.3-test");
|
||||
CHECK(ui.testRenderBoot() && ui.testSignatureBoot());
|
||||
} else if (std::strcmp(scenario, "infra-boundary") && !wrapping) return 3;
|
||||
#endif
|
||||
std::printf("PASS %s (%u checks)\n", scenario, checks);
|
||||
}
|
||||
@@ -293,6 +293,7 @@ struct UITask {
|
||||
char _alert[80]={},_origin[80]={},_msg[80]={};
|
||||
bool _need_refresh=false,_displayWasOn=false,_button_notification_cleared=false;
|
||||
bool _shutdown_pending=false,_shutdown_restart=false;
|
||||
bool _startup_complete=false;
|
||||
uint32_t _shutdown_started_at=0,ui_started_at=0;
|
||||
uint8_t _radio_profile_display_page=0,_notification_outputs=mesh::notify::Sound;
|
||||
bool guard_ok=true,guard_reject_drain=false;unsigned guards=0,preview_clears=0;
|
||||
|
||||
@@ -30,27 +30,58 @@ class CompanionUiDeadlineTests(unittest.TestCase):
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
work = Path(cls.directory.name)
|
||||
cls.binaries = {}
|
||||
cls.startup_mutants = {}
|
||||
cls.first_paint_mutant = None
|
||||
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()'))
|
||||
handler = ('void UITask::handleButtonLongPress()' if source_variant == 'ui-orig'
|
||||
else 'char UITask::handleLongPress(char c)')
|
||||
generated += '\n' + extract_braced(source, handler) + '\n'
|
||||
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([
|
||||
command = [
|
||||
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)
|
||||
str(cpp), '-o', str(binary)]
|
||||
built = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
||||
if built.returncode:
|
||||
raise AssertionError(built.stdout + built.stderr)
|
||||
cls.binaries[variant] = binary
|
||||
latch = extract_braced(source,
|
||||
'if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) >= 8000u)')
|
||||
if generated.count(latch) != 1:
|
||||
raise AssertionError('startup retirement must appear once in the complete foreground loop')
|
||||
mutant_cpp, mutant_binary = work / (variant + '-startup.cpp'), work / (variant + '-startup')
|
||||
mutant_cpp.write_text(generated.replace(latch, '', 1), encoding='ascii')
|
||||
mutant_command = [str(mutant_cpp) if arg == str(cpp)
|
||||
else str(mutant_binary) if arg == str(binary) else arg for arg in command]
|
||||
built = subprocess.run(mutant_command, capture_output=True, text=True, timeout=60)
|
||||
if built.returncode:
|
||||
raise AssertionError(built.stdout + built.stderr)
|
||||
cls.startup_mutants[variant] = mutant_binary
|
||||
if variant == 'ui-orig':
|
||||
first_paint = extract_braced(source, 'if(_firstBoot &&')
|
||||
if first_paint.count('_startup_complete || ') != 1:
|
||||
raise AssertionError('first paint must recognize completed startup')
|
||||
mutant_cpp, mutant_binary = work / 'orig-first-paint.cpp', work / 'orig-first-paint'
|
||||
mutant_cpp.write_text(generated.replace(first_paint,
|
||||
first_paint.replace('_startup_complete || ', '', 1), 1), encoding='ascii')
|
||||
mutant_command = [str(mutant_cpp) if arg == str(cpp)
|
||||
else str(mutant_binary) if arg == str(binary) else arg for arg in command]
|
||||
built = subprocess.run(mutant_command, capture_output=True, text=True, timeout=60)
|
||||
if built.returncode:
|
||||
raise AssertionError(built.stdout + built.stderr)
|
||||
cls.first_paint_mutant = mutant_binary
|
||||
|
||||
def check_scenario(self, scenario, variants=VARIANTS):
|
||||
for variant in variants:
|
||||
@@ -64,6 +95,24 @@ class CompanionUiDeadlineTests(unittest.TestCase):
|
||||
def test_healthy_refresh_schedule(self):
|
||||
self.check_scenario('healthy')
|
||||
|
||||
def test_startup_rescue_retires_outside_gates_and_stays_closed_after_full_wrap(self):
|
||||
self.check_scenario('startup-one-shot', BUILD_VARIANTS)
|
||||
|
||||
def test_missing_foreground_startup_retirement_is_rejected(self):
|
||||
for variant, binary in self.startup_mutants.items():
|
||||
with self.subTest(variant=variant):
|
||||
checked = subprocess.run([str(binary), 'startup-one-shot'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertNotEqual(checked.returncode, 0)
|
||||
self.assertIn('expired startup rescue must never reopen after full wrap', checked.stderr)
|
||||
|
||||
def test_orig_first_paint_can_be_deferred_past_startup_and_wrap(self):
|
||||
self.check_scenario('startup-first-draw', ('ui-orig',))
|
||||
checked = subprocess.run([str(self.first_paint_mutant), 'startup-first-draw'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertNotEqual(checked.returncode, 0)
|
||||
self.assertIn('deferred first paint must remain post-startup after full wrap', checked.stderr)
|
||||
|
||||
def test_upper_half_refresh_schedule(self):
|
||||
self.check_scenario('upper-half')
|
||||
|
||||
@@ -140,6 +189,8 @@ class CompanionUiDeadlineWiringContract(unittest.TestCase):
|
||||
# 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.assertIn('_startup_complete = false;', begin)
|
||||
self.assertIn('bool _startup_complete = false;', header)
|
||||
self.assertLess(begin.index('ui_started_at = millis();'),
|
||||
begin.index('_next_refresh = ui_started_at;'))
|
||||
without_constructor = header.replace(constructor, '', 1)
|
||||
|
||||
@@ -388,8 +388,11 @@ struct TestDisplay {
|
||||
bool isOn() const { return policy.on(); }
|
||||
void wake(mesh::ui::DisplayWake reason) { policy.wake(reason, millis()); }
|
||||
} display;
|
||||
static constexpr uint32_t BOOT_SCREEN_MILLIS = 4000;
|
||||
struct UITask {
|
||||
TestDisplay* _display = &display;
|
||||
bool _startup_complete = false;
|
||||
uint32_t _started_at = 0;
|
||||
uint32_t _next_refresh = 0, _powering_off_at = 0, _next_read = 0;
|
||||
uint32_t _last_refresh = 0, _last_read = 0; // Sensor uses elapsed-time anchors.
|
||||
int _prevBtnState = HIGH;
|
||||
|
||||
@@ -244,6 +244,7 @@ int main() {
|
||||
source = r'''
|
||||
#include <cassert>
|
||||
#define UI_WIFI_SETUP_HOME_PAGE 1
|
||||
#include <cstdint>
|
||||
unsigned long now = 4000;
|
||||
unsigned long millis() { return now; }
|
||||
struct Screen {};
|
||||
@@ -260,6 +261,7 @@ struct UITask {
|
||||
Screen* home;
|
||||
Screen* msg_preview;
|
||||
unsigned long ui_started_at = 0;
|
||||
bool _startup_complete = false;
|
||||
char handleLongPress(char c);
|
||||
};
|
||||
''' + ui[start:end] + r'''
|
||||
|
||||
@@ -54,6 +54,7 @@ struct UITask {
|
||||
CompanionNodePrefs* _node_prefs = nullptr;
|
||||
unsigned clears = 0, resets = 0;
|
||||
unsigned long ui_started_at = 0, _next_refresh = 0;
|
||||
bool _startup_complete = false;
|
||||
explicit UITask(char (&label)[@CAPACITY@]) : _version_info(label) {}
|
||||
void clearMsgPreview() { ++clears; }
|
||||
void resetRadioProfileDisplayPage() { ++resets; }
|
||||
|
||||
@@ -70,6 +70,7 @@ struct UI {
|
||||
uint32_t _last_refresh=0;
|
||||
uint16_t _cached_batt_mv=4000,voltage=3350;
|
||||
bool _need_refresh=true;
|
||||
bool _startup_complete=false;
|
||||
char _alert[4]={},_origin[4]={},_msg[4]={};
|
||||
bool isButtonGesturePending() { return false; }
|
||||
bool isPairingScreenActive() { return false; }
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Check one-shot infrastructure startup windows with a real uint32 clock.
|
||||
|
||||
Sensor coverage compiles its actual header, complete begin, page methods,
|
||||
renderer and foreground loop. Repeater/Room coverage compiles their headers,
|
||||
begin/reset methods, exact renderer/signature boot expressions and actual loop
|
||||
prefix only. Board, radio, display and optional page helpers are host adapters;
|
||||
this does not qualify complete infrastructure rendering or physical hardware.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURE = ROOT / 'test/fixtures/ui_startup_windows.cpp'
|
||||
ROLES = ('simple_sensor', 'simple_repeater', 'simple_room_server')
|
||||
|
||||
|
||||
def startup_retirement(loop):
|
||||
"""Extract the real first retirement branch, never a handwritten predicate."""
|
||||
return extract_braced(loop, 'if (!_startup_complete &&')
|
||||
|
||||
|
||||
def boot_expression(method):
|
||||
matched = re.search(r'\bif\s*\(([^\n]*_startup_complete[^\n]*)\)\s*\{', method)
|
||||
if not matched:
|
||||
raise AssertionError('expected a guarded single-line startup expression')
|
||||
return matched.group(1)
|
||||
|
||||
|
||||
class UiStartupWindowsTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')
|
||||
if not compiler:
|
||||
raise unittest.SkipTest('a host C++17 compiler is required')
|
||||
cls.temp = tempfile.TemporaryDirectory(prefix='ui-startup-windows-')
|
||||
cls.addClassCleanup(cls.temp.cleanup)
|
||||
work = Path(cls.temp.name)
|
||||
(work / 'helpers/ui').mkdir(parents=True)
|
||||
for stub in ('helpers/ui/DisplayDriver.h', 'helpers/CommonCLI.h'):
|
||||
(work / stub).write_text('#pragma once\n', encoding='ascii')
|
||||
sanitize = [] if not sys.platform.startswith('linux') else [
|
||||
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
|
||||
'-fno-omit-frame-pointer', '-fno-pie', '-no-pie']
|
||||
cls.binaries = {}
|
||||
fixture = FIXTURE.read_text(encoding='ascii')
|
||||
for role in ROLES:
|
||||
folder = ROOT / 'examples' / role
|
||||
source = (folder / 'UITask.cpp').read_text(encoding='ascii')
|
||||
header = (folder / 'UITask.h').read_text(encoding='ascii')
|
||||
if header.count('class UITask {') != 1:
|
||||
raise AssertionError('expected one real UI class')
|
||||
header = header.replace('class UITask {', 'struct UITask {')
|
||||
# Only expose observations; no extra state or changed declarations.
|
||||
header = header.replace('public:',
|
||||
'bool testRenderBoot();\n bool testSignatureBoot();\npublic:', 1)
|
||||
directory = work / role
|
||||
directory.mkdir()
|
||||
(directory / 'UITask.h').write_text(header, encoding='ascii')
|
||||
begin = extract_braced(source, 'void UITask::begin(')
|
||||
reset = extract_braced(source, 'void UITask::resetRadioProfileDisplayPage(')
|
||||
loop = extract_braced(source, 'void UITask::loop()')
|
||||
if role == 'simple_sensor':
|
||||
methods = '\n'.join((begin, reset,
|
||||
extract_braced(source, 'bool UITask::showingSecondaryRadioProfilePage('),
|
||||
extract_braced(source, 'void UITask::renderCurrScreen()'), loop))
|
||||
else:
|
||||
prefix = loop[:loop.index(' const bool morse_timing')]
|
||||
render = extract_braced(source, 'void UITask::renderCurrScreen()')
|
||||
signature = extract_braced(source, 'uint32_t UITask::getFrameSignature()')
|
||||
methods = '\n'.join((begin, reset, prefix + '}\n',
|
||||
'void UITask::applyDisplayFlip() {}',
|
||||
'bool UITask::testRenderBoot() { return ' + boot_expression(render) + '; }',
|
||||
'bool UITask::testSignatureBoot() { return ' + boot_expression(signature) + '; }'))
|
||||
variants = [('production', methods)]
|
||||
if role == 'simple_sensor':
|
||||
retirement = startup_retirement(loop)
|
||||
if methods.count(retirement) != 1:
|
||||
raise AssertionError('expected exactly one actual retirement branch')
|
||||
variants.append(('no-retirement', methods.replace(retirement, '', 1)))
|
||||
for kind, generated_methods in variants:
|
||||
cpp = directory / (kind + '.cpp')
|
||||
cpp.write_text(fixture.replace('@METHODS@', generated_methods), encoding='ascii')
|
||||
binary = directory / (kind + ('.exe' if os.name == 'nt' else ''))
|
||||
built = subprocess.run([
|
||||
compiler, '-std=c++17', '-O1', '-g', '-Wall', '-Wextra', '-Werror',
|
||||
*sanitize, '-DTEST_SENSOR=' + str(int(role == 'simple_sensor')),
|
||||
'-DTEST_REPEATER=' + str(int(role == 'simple_repeater')),
|
||||
'-I' + str(work), str(cpp), '-o', str(binary)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if built.returncode:
|
||||
raise AssertionError(built.stdout + built.stderr)
|
||||
cls.binaries[role, kind] = binary
|
||||
|
||||
def check_case(self, case, roles=('simple_sensor',), kind='production', fails=False):
|
||||
for role in roles:
|
||||
with self.subTest(role=role, case=case, kind=kind):
|
||||
checked = subprocess.run([str(self.binaries[role, kind]), case],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if fails:
|
||||
self.assertNotEqual(checked.returncode, 0, checked.stdout)
|
||||
self.assertIn('FAIL ' + case, checked.stderr)
|
||||
self.assertNotIn('runtime error:', checked.stderr)
|
||||
self.assertNotIn('AddressSanitizer:', checked.stderr)
|
||||
else:
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn('PASS ' + case, checked.stdout)
|
||||
|
||||
def test_sensor_exact_boot_boundary_and_absolute_late_begin(self):
|
||||
for case in ('boundary', 'late-begin', 'upper-begin', 'max-begin'):
|
||||
self.check_case(case)
|
||||
|
||||
def test_sensor_live_loops_do_not_replay_after_full_clock_wrap(self):
|
||||
self.check_case('live-wrap')
|
||||
|
||||
def test_sensor_retires_without_display_or_morse_rendering(self):
|
||||
for case in ('off-retirement', 'morse-retirement',
|
||||
'off-first', 'morse-first'):
|
||||
self.check_case(case)
|
||||
|
||||
def test_sensor_normal_wake_and_rebegin_preserve_startup_policy(self):
|
||||
for case in ('wake', 'rebegin'):
|
||||
self.check_case(case)
|
||||
|
||||
def test_removing_actual_sensor_retirement_reproduces_semantic_failure(self):
|
||||
for case in ('live-wrap', 'off-retirement', 'morse-retirement',
|
||||
'off-first', 'morse-first'):
|
||||
self.check_case(case, kind='no-retirement', fails=True)
|
||||
|
||||
def test_infrastructure_actual_init_prefix_and_render_metadata(self):
|
||||
for case in ('infra-boundary', 'infra-wrap-start', 'infra-rebegin'):
|
||||
self.check_case(case, ('simple_repeater', 'simple_room_server'))
|
||||
|
||||
def test_retirement_is_before_hardware_and_rendering_gates(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role):
|
||||
folder = ROOT / 'examples' / role
|
||||
source = (folder / 'UITask.cpp').read_text(encoding='ascii')
|
||||
header = (folder / 'UITask.h').read_text(encoding='ascii')
|
||||
self.assertRegex(header, r'bool _startup_complete\s*=\s*false;')
|
||||
loop = extract_braced(source, 'void UITask::loop()')
|
||||
retired = startup_retirement(loop)
|
||||
self.assertIn('_startup_complete = true;', retired)
|
||||
self.assertLess(loop.index(retired), loop.index('const bool morse_timing'))
|
||||
self.assertLess(loop.index(retired), loop.index('servicePower('))
|
||||
main = (folder / 'main.cpp').read_text(encoding='ascii')
|
||||
self.assertIn('ui_task.begin(', extract_braced(main, 'void setup()'))
|
||||
# Room's alternative preprocessor branches have unmatched
|
||||
# braces before preprocessing; do not parse them as one block.
|
||||
self.assertIn('ui_task.loop();', main[main.index('\nvoid loop()'):])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user