Files
MeshCore/test/test_cardputer_ui.py
T

462 lines
20 KiB
Python

#!/usr/bin/env python3
"""Execute Cardputer text-entry, rendering and radio validation without hardware."""
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
VARIANT = ROOT / "variants/m5stack_cardputer_adv"
UI = VARIANT / "companion_radio/ui-cardputer"
def function(source, signature):
start = source.index(signature)
opening = source.index("{", start)
depth = 1
end = opening + 1
while depth:
depth += (source[end] == "{") - (source[end] == "}")
end += 1
return source[start:end]
class CardputerUiTests(unittest.TestCase):
def test_actual_settings_entry_render_validation_and_headless_shutdown(self):
settings = (UI / "screen/SettingsScreen.cpp").read_text(encoding="ascii")
header = (UI / "screen/SettingsScreen.h").read_text(encoding="ascii")
enum = re.search(r"enum SettingsItem \{.*?\};", header, re.S).group()
limit = re.search(r"static constexpr size_t MAX_NUMERIC_EDIT_BYTES = \d+;", header).group()
cli = (ROOT / "src/helpers/CLICommandUtils.h").read_text()
radio = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
task = (UI / "CardputerUITask.cpp").read_text(encoding="ascii")
channels = (UI / "screen/main/ChannelsPage.cpp").read_text(encoding="ascii")
code = r'''
#include <cassert>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <algorithm>
#include "RadioProfiles.h"
using std::isfinite;
#define LORA_TX_POWER 22
#define MAX_GROUP_CHANNELS 40
class String {
std::string value;
public:
String() = default;
String(const char* s): value(s) {}
String& operator=(const char* s) { value=s; return *this; }
unsigned length() const { return value.size(); }
const char* c_str() const { return value.c_str(); }
long toInt() const { return std::strtol(value.c_str(),nullptr,10); }
int indexOf(char c) const { auto p=value.find(c); return p==std::string::npos ? -1 : int(p); }
void concat(char c) { value+=c; }
void concat(const char* s) { value+=s; }
char charAt(unsigned i) const { return i<value.size() ? value[i] : 0; }
void remove(unsigned i,unsigned n) { value.erase(i,n); }
};
#include "helpers.h"
namespace Keyboard {
enum Key { KEY_NONE, KEY_BACKSPACE, KEY_PERIOD, ARROW_LEFT, ARROW_RIGHT, KEY_1,
ARROW_UP, ARROW_DOWN, KEY_RETURN };
struct Event { Key key=KEY_NONE; const char* text=""; };
}
struct Layout {
const char* getCurrentCode() const { return "en"; }
const char* lookupDefault(Keyboard::Event& e) { return e.text; }
const char* lookup(Keyboard::Event& e) { return e.text; }
};
struct Board {
Layout layout;
unsigned off=0,boots=0;
Layout* getLayout() { return &layout; }
uint16_t getBattMilliVolts() const { return 4000; }
void powerOff() { ++off; }
void reboot() { ++boots; }
};
struct Task {
Board board;
Board* getBoard() { return &board; }
bool getGPSState() const { return true; }
bool isBluetoothEnabled() const { return true; }
bool powerSaveEnabled() const { return true; }
void showAlert(const char*,int) {}
};
struct Prefs {
char node_name[32]="node",default_scope_name[31]="";
uint8_t default_scope_key[32]={};
float freq=910.525f,bw=62.5f;
uint8_t sf=8,cr=5,path_hash_mode=1,buzzer_quiet=0;
int8_t tx_power_dbm=22;
};
struct CustomPrefs { float battery_correction=1; };
struct ChannelDetails { char name[32]={}; };
struct Mesh {
unsigned saves=0,applies=0;
Prefs* prefs=nullptr;
CustomPrefs* custom=nullptr;
CustomPrefs committed;
bool fail=false;
std::string command;
ChannelDetails channels[MAX_GROUP_CHANNELS];
bool savePrefs() { if(fail) return false; ++saves; return true; }
bool saveCustomPrefs() {
if(fail) { *custom=committed; return false; }
committed=*custom; ++saves; return true;
}
uint16_t getPrimaryRadioPreamble() const { return 96; }
bool handleLocalCommand(const char* text,char* reply) {
command=text;
if(fail) { strcpy(reply,"Error: save failed"); return true; }
if(command.rfind("set radio ",0)==0) {
float f,bw; unsigned sf,cr,pre;
assert(sscanf(text+10,"%f,%f,%u,%u,%u",&f,&bw,&sf,&cr,&pre)==5 && pre==96);
prefs->freq=f; prefs->bw=bw; prefs->sf=sf; prefs->cr=cr; ++applies;
} else if(command.rfind("set tx ",0)==0) {
prefs->tx_power_dbm=std::stoi(command.substr(7)); ++applies;
} else if(command.rfind("set name ",0)==0) {
snprintf(prefs->node_name,sizeof(prefs->node_name),"%s",text+9);
} else { assert(false); }
++saves; strcpy(reply,"OK"); return true;
}
bool getChannel(int i,ChannelDetails& result) {
if(i<0 || i>=MAX_GROUP_CHANNELS) return false;
result=channels[i]; return true;
}
} the_mesh_cp;
namespace mesh { namespace Utils {
void sha256(uint8_t* dest,size_t n,const uint8_t*,size_t,const uint8_t*,size_t) { memset(dest,0,n); }
} namespace cli {
'''
code += function(cli, "inline bool parseDecimalStrict(") + "\n}}\n"
code += r'''
struct RadioLibWrapper {
unsigned off=0;
bool supportsMeshtext() const { return false; }
bool validateProfile(const mesh::RadioProfileParams&) const;
bool isTxPowerValid(int8_t p) const { return p>=-9 && p<=22; }
void powerOff() { ++off; }
} radio_driver;
'''
code += function(radio, "bool RadioLibWrapper::validateProfile(") + "\n"
code += r'''
struct CardputerDisplay {
enum PaletteColor { P_GREEN,P_YELLOW,P_RED,P_BLACK,P_WHITE };
std::string last;
int getRowHeight() const { return 16; }
int height() const { return 135; }
int width() const { return 240; }
int getTextWidth(const char* s) { return strlen(s)*8; }
int getTextLevel() const { return 1; }
static const char* textLevelName(int) { return "Large"; }
void setColor(PaletteColor) {}
void setTextSize(int) {}
void drawTextCentered(int,int,const char* text) { last=text; }
void drawTextLeftAlign(int,int,const char*) {}
void drawTextEllipsized(int,int,int,const char*) {}
void drawTextLeftAlignWithScroll(int,int,int,const char* s) { last=s; }
void drawRect(int,int,int,int) {}
void fillRect(int,int,int,int) {}
void turnOff() {}
};
struct SettingsScreen {
'''
code += enum + "\n" + limit + r'''
Prefs* _node_prefs;
CustomPrefs* _custom_prefs;
Task* _task;
String edit_buffer;
uint8_t edit_u8=0;
int32_t edit_tx_power=0;
int menu_index=RadioFreq;
char edit_error[80]="Invalid value";
bool is_editing=true;
void renderItem(CardputerDisplay&,SettingsItem,int,int);
bool commitItemEdit(SettingsItem);
void handleEditInput(SettingsItem,Keyboard::Event&);
bool inputParsePositiveFloat(float&);
bool validRadioParams(float,float,uint8_t,uint8_t) const;
bool commitCommand(const char*);
bool commitRadio(float,float,uint8_t,uint8_t);
int render(CardputerDisplay&);
};
'''
for signature in (
"void SettingsScreen::renderItem(",
"bool SettingsScreen::commitCommand(",
"bool SettingsScreen::commitRadio(",
"bool SettingsScreen::commitItemEdit(",
"void SettingsScreen::handleEditInput(",
"bool SettingsScreen::inputParsePositiveFloat(",
"bool SettingsScreen::validRadioParams(",
"int SettingsScreen::render(",
):
code += function(settings, signature) + "\n"
code += r'''
struct CardputerUITask {
CardputerDisplay* _display=nullptr;
Board* _board;
bool allow_shutdown=true;
bool prepareForShutdown() { return allow_shutdown; }
void showAlert(const char*,int) {}
void shutdown(bool restart=false);
};
'''
code += function(task, "void CardputerUITask::shutdown(") + "\n"
code += r'''
struct MainScreen {
int selected=-1;
void selectChannel(const ChannelDetails&,int index) { selected=index; }
};
struct ChannelsPage {
MainScreen* _p;
int channel_list_idx=0;
int getChannelCount();
bool getChannelIndex(int,int&);
bool handleInput(Keyboard::Event&);
};
'''
for signature in (
"int ChannelsPage::getChannelCount(",
"bool ChannelsPage::getChannelIndex(",
"bool ChannelsPage::handleInput(",
):
code += function(channels, signature) + "\n"
code += r'''
int main() {
Prefs prefs; CustomPrefs custom; Task task; CardputerDisplay display;
SettingsScreen s{&prefs,&custom,&task};
the_mesh_cp.prefs=&prefs; the_mesh_cp.custom=&custom;
Keyboard::Event digit{Keyboard::KEY_1,"9"};
for(int i=0;i<4096;++i) s.handleEditInput(SettingsScreen::RadioFreq,digit);
assert(s.edit_buffer.length()==s.MAX_NUMERIC_EDIT_BYTES);
s.render(display); assert(display.last.size()<=63);
// Render must also remain safe when an external/current value exceeds the input bound.
std::string long_value(4096,'9'); s.edit_buffer=long_value.c_str();
s.render(display); assert(display.last.size()==63);
for(const char* bad:{"",".","0","-1","nan","inf","1e3","910x","42949672960"}) {
s.edit_buffer=bad; float value=123;
assert(!s.inputParsePositiveFloat(value) && value==123);
}
for(const char* bad:{"0","149.9","960.1","42949672960"}) {
s.edit_buffer=bad;
assert(!s.commitItemEdit(SettingsScreen::RadioFreq));
assert(prefs.freq==910.525f && the_mesh_cp.saves==0);
}
s.edit_buffer="915.25";
assert(s.commitItemEdit(SettingsScreen::RadioFreq));
assert(prefs.freq==915.25f && the_mesh_cp.saves==1 && the_mesh_cp.applies==1);
for(const char* bad:{"0","123.45","812.5"}) {
s.edit_buffer=bad;
assert(!s.commitItemEdit(SettingsScreen::RadioBw));
assert(prefs.bw==62.5f && the_mesh_cp.saves==1);
}
s.edit_buffer="125"; assert(s.commitItemEdit(SettingsScreen::RadioBw));
assert(prefs.bw==125 && the_mesh_cp.saves==2);
s.edit_u8=4; assert(!s.commitItemEdit(SettingsScreen::RadioSf));
assert(!s.commitItemEdit(SettingsScreen::RadioCr));
s.edit_tx_power=23; assert(!s.commitItemEdit(SettingsScreen::RadioPwr));
assert(prefs.sf==8 && prefs.cr==5 && prefs.tx_power_dbm==22 && the_mesh_cp.saves==2);
// The UI must use the live shared command path and leave prefs untouched on failure.
assert(the_mesh_cp.command=="set radio 915.250000,125.000000,8,5,96");
the_mesh_cp.fail=true;
s.edit_buffer="916"; assert(!s.commitItemEdit(SettingsScreen::RadioFreq));
assert(prefs.freq==915.25f && strstr(s.edit_error,"save failed"));
s.edit_buffer="renamed"; assert(!s.commitItemEdit(SettingsScreen::PublicInfoName));
assert(!strcmp(prefs.node_name,"node"));
strcpy(prefs.default_scope_name,"original"); memset(prefs.default_scope_key,0xA5,sizeof(prefs.default_scope_key));
s.edit_buffer="replacement"; assert(!s.commitItemEdit(SettingsScreen::MeshDefaultScope));
assert(!strcmp(prefs.default_scope_name,"original") && prefs.default_scope_key[0]==0xA5);
s.edit_u8=3; assert(!s.commitItemEdit(SettingsScreen::MeshPathSize));
assert(prefs.path_hash_mode==1);
s.edit_buffer="4400"; assert(!s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection));
assert(custom.battery_correction==1.0f);
the_mesh_cp.fail=false;
s.edit_buffer="node renamed"; assert(s.commitItemEdit(SettingsScreen::PublicInfoName));
assert(!strcmp(prefs.node_name,"node renamed"));
for(const char* bad:{"0","1","9999"}) {
s.edit_buffer=bad; assert(!s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection));
assert(custom.battery_correction==1.0f);
}
s.edit_buffer="4400"; assert(s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection));
assert(custom.battery_correction==1.1f);
// Signed TX values remain visible and editable through zero and both driver limits.
s.menu_index=SettingsScreen::RadioPwr; s.edit_tx_power=-9;
s.render(display); assert(display.last=="- -9 +");
Keyboard::Event left{Keyboard::ARROW_LEFT,""},right{Keyboard::ARROW_RIGHT,""};
s.handleEditInput(SettingsScreen::RadioPwr,left); assert(s.edit_tx_power==-9);
s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==-8);
assert(s.commitItemEdit(SettingsScreen::RadioPwr) && prefs.tx_power_dbm==-8);
s.edit_tx_power=-1; s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==0);
assert(s.commitItemEdit(SettingsScreen::RadioPwr) && prefs.tx_power_dbm==0);
s.edit_tx_power=22; s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==22);
for(int32_t invalid:{-129,-10,23,128}) {
s.edit_tx_power=invalid; assert(!s.commitItemEdit(SettingsScreen::RadioPwr));
}
// A missing/failed display cannot turn a battery shutdown into a null dereference.
CardputerUITask ui{nullptr,&task.board};
ui.shutdown(); assert(task.board.off==1 && radio_driver.off==1);
ui.shutdown(true); assert(task.board.boots==1);
ui.allow_shutdown=false; ui.shutdown(); ui.shutdown(true);
assert(task.board.off==1 && task.board.boots==1 && radio_driver.off==1);
// Channel list indices must map back to actual sparse channel slots.
MainScreen main_screen; ChannelsPage list{&main_screen};
for(int i:{2,7,39}) strcpy(the_mesh_cp.channels[i].name,"channel");
assert(list.getChannelCount()==3);
int real=-1;
assert(list.getChannelIndex(0,real) && real==2);
assert(list.getChannelIndex(1,real) && real==7);
assert(list.getChannelIndex(2,real) && real==39);
assert(!list.getChannelIndex(3,real));
Keyboard::Event enter{Keyboard::KEY_RETURN,""};
list.handleInput(enter); assert(main_screen.selected==2);
Keyboard::Event up{Keyboard::ARROW_UP,""},down{Keyboard::ARROW_DOWN,""};
list.handleInput(up); assert(list.channel_list_idx==2);
list.handleInput(enter); assert(main_screen.selected==39);
list.handleInput(down); assert(list.channel_list_idx==0);
for(auto& channel:the_mesh_cp.channels) channel=ChannelDetails{};
assert(list.getChannelCount()==0);
list.handleInput(enter); assert(main_screen.selected==39);
list.handleInput(up); assert(list.channel_list_idx==0);
std::puts("PASS: actual Cardputer entry, render, validation and headless shutdown");
}
'''
with tempfile.TemporaryDirectory(prefix="cardputer-ui-") as tmp:
source = Path(tmp) / "test.cpp"
binary = Path(tmp) / "test"
source.write_text(code, encoding="ascii")
compiled = subprocess.run([
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-Wno-missing-field-initializers",
"-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie",
"-DMESH_RADIO_PROFILE_CHIP=1262", "-DMESH_ENABLE_HOST_KISS=0",
"-DMESH_ENABLE_ETHERNET_WIRE=0", "-I", str(ROOT / "src"),
"-I", str(VARIANT), str(source), "-o", str(binary),
], capture_output=True, text=True)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
executed = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr)
def test_actual_tools_shutdown_release_timeout_and_guard(self):
tools = (UI / "screen/ToolsScreen.cpp").read_text(encoding="ascii")
header = (UI / "screen/ToolsScreen.h").read_text(encoding="ascii")
task = (UI / "CardputerUITask.cpp").read_text(encoding="ascii")
enums = re.search(r"enum ToolsPage.*?enum ToolsMenuItem.*?};", header, re.S).group()
timeout = re.search(r"static constexpr uint32_t POWER_OFF_RELEASE_TIMEOUT_MS = \d+;", header).group()
code = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <string>
uint32_t fake_now=0;
unsigned long millis() { return fake_now; }
namespace Keyboard {
enum Key { KEY_RETURN,KEY_ESC,KEY_A };
struct Event { Key key; bool changed,down; };
}
struct Board {
unsigned boots=0,offs=0;
void reboot() { ++boots; }
void powerOff() { ++offs; }
};
struct Display { void turnOff() {} };
struct Radio { unsigned offs=0; void powerOff() { ++offs; } } radio_driver;
struct CardputerUITask {
Board* _board;
Display* _display=nullptr;
bool allow_shutdown=true;
std::string alert;
bool prepareForShutdown() { return allow_shutdown; }
void showAlert(const char* text,int) { alert=text; }
void dismissAlert() { alert.clear(); }
void shutdown(bool restart=false);
};
struct Mesh {
bool sendAdvert(bool) { return true; }
bool sendRepeatersDiscover() { return true; }
} the_mesh_cp;
struct ToolsScreen {
'''
code += enums + "\n" + timeout + r'''
CardputerUITask* _task;
ToolsPage page=MenuPage;
uint16_t discovered_repeaters_count=0;
bool power_off_pending=false,power_off_released=false;
uint32_t power_off_deadline=0;
void menuItemEnter(ToolsMenuItem);
bool handlePowerOffInput(const Keyboard::Event&);
void poll();
};
'''
code += function(task, "void CardputerUITask::shutdown(") + "\n"
for signature in ("void ToolsScreen::menuItemEnter(", "bool ToolsScreen::handlePowerOffInput(", "void ToolsScreen::poll("):
code += function(tools, signature) + "\n"
code += r'''
int main() {
Board board; CardputerUITask task{&board}; ToolsScreen tools{&task};
Keyboard::Event held{Keyboard::KEY_RETURN,true,true},released{Keyboard::KEY_RETURN,true,false};
Keyboard::Event idle{Keyboard::KEY_A,false,false},escape{Keyboard::KEY_ESC,true,true};
// Menu activation returns immediately while the main loop can continue servicing work.
tools.menuItemEnter(ToolsScreen::PowerOff);
assert(tools.power_off_pending && board.offs==0);
for(unsigned tick=0; tick<tools.POWER_OFF_RELEASE_TIMEOUT_MS; ++tick) {
fake_now=tick; tools.handlePowerOffInput(idle); tools.poll(); assert(board.offs==0);
}
assert(tools.handlePowerOffInput(released)); tools.poll();
assert(!tools.power_off_pending && board.offs==1 && radio_driver.offs==1);
tools.poll(); assert(board.offs==1);
// Missing/held Enter release cancels at the bounded deadline instead of sleeping with a held key.
fake_now=5000; tools.menuItemEnter(ToolsScreen::PowerOff);
assert(tools.handlePowerOffInput(held));
fake_now+=tools.POWER_OFF_RELEASE_TIMEOUT_MS; tools.poll();
assert(!tools.power_off_pending && board.offs==1 && task.alert.find("cancelled")!=std::string::npos);
assert(!tools.handlePowerOffInput(released)); tools.poll(); assert(board.offs==1);
// An explicit cancellation leaves the device running.
tools.menuItemEnter(ToolsScreen::PowerOff); assert(tools.handlePowerOffInput(escape));
tools.poll(); assert(!tools.power_off_pending && board.offs==1);
// Both restart and release-confirmed power-off use the same durability guard.
task.allow_shutdown=false;
tools.menuItemEnter(ToolsScreen::Restart); assert(board.boots==0);
tools.menuItemEnter(ToolsScreen::PowerOff); tools.handlePowerOffInput(released); tools.poll();
assert(board.offs==1 && radio_driver.offs==1 && !tools.power_off_pending);
task.allow_shutdown=true; tools.menuItemEnter(ToolsScreen::Restart); assert(board.boots==1);
// Wrap-safe bounded release wait.
fake_now=0xFFFFFF00U; tools.menuItemEnter(ToolsScreen::PowerOff);
fake_now+=tools.POWER_OFF_RELEASE_TIMEOUT_MS-1; tools.poll(); assert(tools.power_off_pending);
++fake_now; tools.poll(); assert(!tools.power_off_pending && board.offs==1);
std::puts("PASS: actual Tools release wait, timeout, cancellation and restart/shutdown guard");
}
'''
with tempfile.TemporaryDirectory(prefix="cardputer-tools-") as tmp:
source, binary = Path(tmp) / "test.cpp", Path(tmp) / "test"
source.write_text(code, encoding="ascii")
compiled = subprocess.run([
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-missing-field-initializers", "-fsanitize=address,undefined",
"-fno-omit-frame-pointer", "-no-pie", str(source), "-o", str(binary),
], capture_output=True, text=True)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
executed = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr)
def test_bounded_formatting_and_font_attribution(self):
for source in UI.rglob("*.cpp"):
self.assertNotRegex(source.read_text(encoding="ascii"), r"\bsprintf\s*\(", str(source))
notice = (VARIANT / "LICENSE.DejaVu").read_text(encoding="ascii")
self.assertIn("Copyright (c) 2003 by Bitstream", notice)
self.assertIn("Copyright (c) 2006 by Tavmjong Bah", notice)
self.assertIn("included in all copies of one or more", notice)
for font in ("font.h", "font_16.h", "font_19.h"):
self.assertIn("LICENSE.DejaVu", (UI / font).read_text(encoding="ascii"))
if __name__ == "__main__":
unittest.main()