Files
MeshCore/test/test_companion_headless_buttons.py
T

226 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""Run production Companion button routing and helpers with no display."""
from pathlib import Path
import os
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
UI = ROOT / "examples/companion_radio/ui-new/UITask.cpp"
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <initializer_list>
#include <string>
#define PIN_USER_BTN 7
#define UI_HAS_JOYSTICK 0
#define UI_WIFI_SETUP_HOME_PAGE 0
#define MESH_DEBUG_PRINTLN(...)
constexpr char KEY_ENTER='e', KEY_NEXT='n', KEY_PREV='p', KEY_SELECT='s', KEY_UP='u', KEY_DOWN='d';
constexpr int BUTTON_EVENT_CLICK=1, BUTTON_EVENT_LONG_PRESS=2, BUTTON_EVENT_DOUBLE_CLICK=3;
constexpr int BUTTON_EVENT_TRIPLE_CLICK=4, BUTTON_EVENT_QUADRUPLE_CLICK=5;
uint32_t now_ms=0;
uint32_t millis() { return now_ms; }
bool visual_allowed=false;
unsigned visual_checks=0;
void visual() { assert(visual_allowed && "headless visual path"); ++visual_checks; }
namespace mesh { namespace ui {
enum class DisplayWake { Button };
bool shouldDisplayBluetoothPairingPin(bool enabled, bool connected, uint32_t pin) {
return enabled && !connected && pin;
}
}}
struct Mesh {
unsigned notifications=0, rescues=0;
void notificationButton() { ++notifications; }
void enterCLIRescue() { ++rescues; }
uint32_t getBLEPin() const { return 123456; }
} the_mesh;
struct Button {
int event=0;
int check() { const int result=event; event=0; return result; }
} user_btn;
struct Display {
bool on=true;
unsigned wakes=0, dismissals=0;
bool isOn() const { visual(); return on; }
void wake(mesh::ui::DisplayWake) { visual(); ++wakes; on=true; }
void dismiss() { visual(); ++dismissals; on=false; }
} display;
struct UIScreen {
unsigned inputs=0;
char last=0;
void handleInput(char key) { visual(); ++inputs; last=key; }
};
struct HomeScreen : UIScreen {
bool radio_page=false;
bool isRadioPage() const { visual(); return radio_page; }
void showFirstPage() { visual(); }
};
struct UITask {
Display* _display=nullptr;
HomeScreen home_screen;
UIScreen preview_screen, reader_screen;
UIScreen *home=&home_screen, *msg_preview=&preview_screen, *reader=&reader_screen, *curr=home;
bool _pairing_from_button=false, _startup_complete=false;
uint32_t _pairing_screen_until=0, _next_refresh=0, ui_started_at=0;
unsigned buzzer_changes=0, reader_opens=0, reader_closes=0;
bool isPairingScreenActive() const { return false; }
bool isBluetoothEnabled() const { return false; }
bool hasBluetoothConnection() const { return false; }
void showPairingPin(bool) { visual(); }
bool isReaderActive() const { visual(); return reader && curr==reader; }
void closeReader() { visual(); ++reader_closes; curr=home; }
void showReader() { visual(); ++reader_opens; curr=reader; }
void setCurrScreen(UIScreen* screen) { visual(); curr=screen; }
void toggleBuzzer() { ++buzzer_changes; }
char checkDisplayOn(char);
char handleLongPress(char);
char handleDoubleClick(char);
char handleMultiClick(char, bool);
void pollButtons();
};
@HELPERS@
void UITask::pollButtons() {
@BUTTONS@
}
void event(UITask& ui, int code) { user_btn.event=code; ui.pollButtons(); }
void headless() {
visual_allowed=false;
for (int screen=0; screen<3; ++screen) {
for (int code : {BUTTON_EVENT_CLICK, BUTTON_EVENT_DOUBLE_CLICK,
BUTTON_EVENT_TRIPLE_CLICK, BUTTON_EVENT_QUADRUPLE_CLICK}) {
UITask ui;
ui.curr=screen==0 ? ui.home : screen==1 ? ui.msg_preview : ui.reader;
now_ms=10000; visual_checks=0; the_mesh={};
event(ui, code);
assert(visual_checks==0 && the_mesh.rescues==0);
assert(the_mesh.notifications==1 && "physical notification acknowledgement survives");
assert(ui.buzzer_changes==(code==BUTTON_EVENT_TRIPLE_CLICK || code==BUTTON_EVENT_QUADRUPLE_CLICK));
assert(ui.home_screen.inputs==0 && ui.preview_screen.inputs==0 && ui.reader_screen.inputs==0);
assert(ui.reader_opens==0 && ui.reader_closes==0);
}
// The early physical rescue remains available without consulting screens.
for (bool startup_complete : {false, true}) {
UITask ui;
ui.curr=screen==0 ? ui.home : screen==1 ? ui.msg_preview : ui.reader;
ui._startup_complete=startup_complete;
ui.ui_started_at=UINT32_MAX-4000u;
now_ms=500; the_mesh={}; visual_checks=0;
event(ui, BUTTON_EVENT_LONG_PRESS);
assert(the_mesh.rescues==unsigned(!startup_complete));
assert(visual_checks==0 && the_mesh.notifications==0);
now_ms=5000; the_mesh={};
event(ui, BUTTON_EVENT_LONG_PRESS);
assert(the_mesh.rescues==0 && visual_checks==0);
}
}
}
void visible() {
visual_allowed=true; now_ms=10000;
UITask ui; ui._display=&display; ui._startup_complete=true;
display={}; the_mesh={};
event(ui, BUTTON_EVENT_CLICK);
assert(ui.home_screen.inputs==1 && the_mesh.notifications==1);
#ifdef UI_HAS_NAV_INPUT
assert(ui.home_screen.last==KEY_ENTER);
#else
assert(ui.home_screen.last==KEY_NEXT);
#endif
// A single wake gesture remains consumed by the existing display policy.
display.on=false;
event(ui, BUTTON_EVENT_CLICK);
assert(display.on && ui.home_screen.inputs==1);
ui.curr=ui.msg_preview;
event(ui, BUTTON_EVENT_DOUBLE_CLICK);
#ifdef UI_HAS_NAV_INPUT
assert(ui.preview_screen.last==KEY_SELECT);
#else
assert(ui.preview_screen.last==KEY_PREV);
#endif
event(ui, BUTTON_EVENT_TRIPLE_CLICK);
assert(ui.preview_screen.last==KEY_DOWN && ui.buzzer_changes==0);
event(ui, BUTTON_EVENT_QUADRUPLE_CLICK);
assert(ui.preview_screen.last==KEY_UP && ui.buzzer_changes==0);
ui.curr=ui.home;
event(ui, BUTTON_EVENT_TRIPLE_CLICK);
assert(ui.buzzer_changes==1);
#if COMPANION_FEATURE_READER
ui.curr=ui.reader;
event(ui, BUTTON_EVENT_LONG_PRESS);
assert(ui.reader_closes==1 && ui.curr==ui.home);
ui.home_screen.radio_page=true;
event(ui, BUTTON_EVENT_LONG_PRESS);
assert(ui.reader_opens==1 && ui.curr==ui.reader);
#endif
}
int main(int argc, char** argv) {
assert(argc==2);
if (std::string(argv[1])=="headless") headless(); else visible();
}
'''
def fixture(source):
loop = extract_braced(source, "void UITask::loop()")
buttons = loop[loop.index(" char c = 0;"):loop.index(" userLedHandler();")]
helpers = "\n".join(extract_braced(source, "char UITask::" + name)
for name in ("checkDisplayOn(", "handleLongPress(",
"handleDoubleClick(", "handleMultiClick("))
return HARNESS.replace("@HELPERS@", helpers).replace("@BUTTONS@", buttons)
class CompanionHeadlessButtonTests(unittest.TestCase):
def build(self, directory, source, *, nav=False, reader=True):
cpp, binary = directory / "test.cpp", directory / "test"
directory.mkdir()
cpp.write_text(fixture(source))
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else [])
result = subprocess.run([
os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror",
*sanitizers, "-DCOMPANION_FEATURE_READER=" + str(int(reader)),
*(["-DUI_HAS_NAV_INPUT=1"] if nav else []), str(cpp), "-o", str(binary)
], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return binary
def run_case(self, binary, scenario, *, negative=False):
result = subprocess.run([str(binary), scenario], capture_output=True, text=True, timeout=10)
if negative:
self.assertNotEqual(result.returncode, 0, "old headless navigation unexpectedly survived")
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_actual_button_mapping_and_helpers_with_and_without_display(self):
with tempfile.TemporaryDirectory(prefix="companion-headless-") as directory:
for nav, reader in ((False, False), (False, True), (True, False), (True, True)):
with self.subTest(nav=nav, reader=reader):
binary = self.build(Path(directory) / f"{nav}-{reader}", UI.read_text(),
nav=nav, reader=reader)
self.run_case(binary, "headless")
self.run_case(binary, "visible")
def test_original_null_navigation_is_detected(self):
source = UI.read_text()
helper = extract_braced(source, "char UITask::checkDisplayOn(")
old_helper = helper.replace("return _display != NULL ? c : 0;", "return c;")
self.assertNotEqual(helper, old_helper)
source = source.replace(helper, old_helper)
old_dispatch = "if (c != 0 && curr && _display != NULL)"
self.assertIn(old_dispatch, source)
source = source.replace(old_dispatch, "if (c != 0 && curr)")
with tempfile.TemporaryDirectory(prefix="companion-headless-old-") as directory:
binary = self.build(Path(directory) / "old", source)
self.run_case(binary, "headless", negative=True)
if __name__ == "__main__":
unittest.main()