Files
MeshCore/test/test_companion_inbox.py
T

401 lines
17 KiB
Python

"""Exercise the real message UI and queue together, including app downloads."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from test_message_navigation import PREAMBLE
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
UI_MEMBERS = r'''
int _msgcount = 0;
bool connected = false, pairing = false, _deferred_msg_preview = false;
int getMsgCount() const { return _msgcount; }
bool hasConnection() const { return connected; }
bool isPairingScreenActive() const { return pairing; }
bool shouldWakeDisplayForMessage() const { return !connected; }
void setCurrScreen(UIScreen* screen) { curr = screen; }
void newMsg(uint8_t, const char*, const char*, int, int, const char*, int);
void msgRead(int);
void syncMessageQueue(int, int = -1);
int getPreviewCount() const;
const char* inboxTitle() const;
const char* client = "USB";
const char* connectedClientLabel() const { return connected ? client : nullptr; }
bool hasBluetoothConnection() const {
return connected && (std::string(client) == "BLUETOOTH"
|| std::string(client) == "BLE + USB");
}
bool isBluetoothEnabled() const { return true; }
bool isPairingPromptActive() const { return pairing; }
'''
QUEUE = r'''
#define DISPLAY_CLASS TestDisplay
#define MAX_FRAME_SIZE 32
class MyMesh {
struct Frame {
int len;
uint8_t buf[MAX_FRAME_SIZE];
bool isChannelMsg() const { return buf[0] == 17; }
} frames[4];
public:
UITask* _ui;
int offline_queue_len = 0, offline_queue_head = 0;
explicit MyMesh(UITask* ui) : _ui(ui) {}
int getOfflineQueueCapacity() const { return 4; }
Frame& offlineQueueFrameAt(int index) { return frames[(offline_queue_head + index) % 4]; }
bool addToOfflineQueue(const uint8_t*, int);
int getFromOfflineQueue(uint8_t*);
bool receive(const char* text, uint8_t kind = 17) {
uint8_t bytes[2] = {kind, uint8_t(text[0])};
bool queued = addToOfflineQueue(bytes, sizeof(bytes));
_ui->newMsg(1, "Alice", text, offline_queue_len, 0, "Public",
queued ? offline_queue_len - 1 : -1);
return queued;
}
void download() { uint8_t frame[MAX_FRAME_SIZE]; getFromOfflineQueue(frame); }
};
'''
SCENARIOS = r'''
using namespace mesh::ui;
int main() {
resetArduinoMock();
displayPowerPrefs().battery.mode = DisplayMode::On;
Display display;
display.servicePower(false);
Screen home;
UITask task(display);
MsgPreviewScreen messages(&task);
task.home = task.curr = &home;
task.msg_preview = &messages;
MyMesh mesh(&task);
// Reproduce issue #5: twelve messages arrive and the connected app fetches
// every one. History retains twelve, while Pending returns to zero.
task.connected = true;
for (int i = 0; i < 12; ++i) {
mesh.receive("received by phone");
assert(task.curr == &home);
assert(task.getMsgCount() == 1);
mesh.download();
assert(task.curr == &home && task.getMsgCount() == 0);
assert(task.getPreviewCount() == i + 1);
}
assert(std::string(task.inboxTitle()) == "HISTORY");
displayPowerPrefs().inbox = DisplayInboxMode::Pending;
assert(task.getPreviewCount() == 0 && !messages.hasMessages());
assert(std::string(task.inboxTitle()) == "INBOX");
displayPowerPrefs().inbox = DisplayInboxMode::Unread;
assert(task.getPreviewCount() == 12); // App delivery is not a local read.
assert(std::string(task.inboxTitle()) == "UNREAD");
task.curr = &messages;
display.turnOff();
messages.render(display);
assert(task.getPreviewCount() == 12); // A dark display cannot mark it read.
display.turnOn();
messages.render(display);
assert(task.getPreviewCount() == 11);
messages.render(display);
assert(task.getPreviewCount() == 11); // Redraws do not consume another item.
// A connected client's new message must not replace the text being browsed.
displayPowerPrefs().inbox = DisplayInboxMode::History;
mesh.receive("new while browsing");
display.clear(); messages.render(display);
assert(display.contains("received by phone"));
mesh.download();
assert(task.curr == &messages && task.getPreviewCount() == 13);
// Without a client, select the new arrival. Reading it does not drain the
// delivery queue; it remains available to the next phone connection.
task.connected = false;
task.curr = &home;
mesh.receive("offline arrival");
assert(task.curr == &messages && task.getMsgCount() == 1);
display.clear(); messages.render(display);
assert(display.contains("offline arrival"));
assert(mesh.offline_queue_len == 1);
mesh.download();
// Non-text frames occupy queue positions too. Full queues can evict a
// channel message in the middle; Pending must hide only that exact entry.
displayPowerPrefs().inbox = DisplayInboxMode::Pending;
const uint8_t other[1] = {99};
assert(mesh.addToOfflineQueue(other, 1));
assert(task.getMsgCount() == 1 && !messages.hasMessages());
mesh.receive("evicted channel");
mesh.receive("retained channel");
assert(mesh.addToOfflineQueue(other, 1));
mesh.receive("replacement channel");
assert(task.getMsgCount() == 4);
assert(messages.messageCount() == 4); // Includes the two non-text frames.
display.clear(); messages.render(display);
assert(display.contains("replacement channel"));
messages.handleInput(KEY_NEXT);
display.clear(); messages.render(display);
assert(display.contains("retained channel"));
messages.handleInput(KEY_NEXT);
assert(task.curr == &home); // Evicted entry was excluded.
mesh.download(); // Non-text head, with no matching history entry.
assert(task.getMsgCount() == 3);
mesh.download(); // Retained channel.
mesh.download(); // Other non-text frame.
assert(task.getMsgCount() == 1 && messages.hasMessages());
mesh.download();
assert(task.getMsgCount() == 0 && !messages.hasMessages());
// Rejecting a fifth direct message must not assign it another frame's slot.
for (int i = 0; i < 4; ++i) assert(mesh.addToOfflineQueue(other, 1));
assert(!mesh.receive("not queued", 7));
assert(!messages.hasMessages());
while (mesh.offline_queue_len) mesh.download();
assert(!messages.hasMessages());
displayPowerPrefs().inbox = DisplayInboxMode::History;
for (int i = 0; i < 40; ++i) { mesh.receive("ring wrap"); mesh.download(); }
assert(task.getPreviewCount() == 32 && task.getMsgCount() == 0);
displayPowerPrefs().inbox = DisplayInboxMode::Pending;
assert(!messages.hasMessages());
#if COMPANION_FEATURE_READER
// Receiving and downloading messages must leave an open reader alone.
Screen reader;
task.reader = task.curr = &reader;
for (bool connected : {false, true}) {
task.connected = connected;
for (int i = 0; i < 2; ++i) {
mesh.receive("while reading");
assert(task.isReaderActive());
}
for (int remaining : {1, 0}) {
mesh.download();
assert(task.getMsgCount() == remaining);
assert(task.isReaderActive());
}
}
#endif
}
'''
class CompanionInboxTest(unittest.TestCase):
def test_labels_use_enabled_connected_interfaces(self):
abstract = (ROOT / "examples/companion_radio/AbstractUITask.h").read_text()
label = extract_braced(abstract, "const char* connectedClientLabel() const")
source = r'''
#include <helpers/MultiSerialInterface.h>
#include <cassert>
#include <string>
struct Link : BaseSerialInterface {
bool enabled = false, connected = false;
void enable() override { enabled = true; }
void disable() override { enabled = false; }
bool isEnabled() const override { return enabled; }
bool isConnected() const override { return connected; }
bool isReadBusy() const override { return false; }
bool isWriteBusy() const override { return false; }
size_t writeFrame(const uint8_t*, size_t len) override { return len; }
size_t checkRecvFrame(uint8_t*) override { return 0; }
};
struct UI {
MultiSerialInterface* _interfaceManager;
bool hasBluetoothConnection() const { return _interfaceManager->isBluetoothConnected(); }
'''+ label + r'''
};
int main() {
MultiSerialInterface interfaces;
Link usb, ble, wifi;
interfaces.addInterface(InterfaceType::USB, &usb);
interfaces.addInterface(InterfaceType::Bluetooth, &ble);
interfaces.addInterface(InterfaceType::WiFi, &wifi);
UI ui{&interfaces};
interfaces.enable();
assert(ui.connectedClientLabel() == nullptr);
usb.connected = true;
assert(std::string(ui.connectedClientLabel()) == "USB");
ble.connected = true;
assert(std::string(ui.connectedClientLabel()) == "BLE + USB");
usb.disable();
assert(std::string(ui.connectedClientLabel()) == "BLUETOOTH");
ble.connected = false;
wifi.connected = true;
assert(std::string(ui.connectedClientLabel()) == "TCP");
interfaces.disable();
assert(ui.connectedClientLabel() == nullptr);
}
'''
self.compile_and_run(source)
def test_home_history_pending_and_connections_fit(self):
source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
first_page = extract_braced(source, "if (_page == HomePage::FIRST)")
first_page = first_page.rsplit("#if UI_MESSAGES_HOME_PAGE == 1", 1)[0] + "}"
first_page = first_page.replace("if (_page == HomePage::FIRST)", "")
preamble = PREAMBLE.replace("int getMsgCount() const { return 0; }", UI_MEMBERS)
preamble = preamble.replace("bool getChannel(int channel", "uint32_t getBLEPin() const { return 123456; }\n bool getChannel(int channel")
implementation = r'''
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#define PRESS_LABEL "Press"
int UITask::getPreviewCount() const { return 12; }
const char* UITask::inboxTitle() const { return "HISTORY"; }
void renderHome(DisplayDriver& display, UITask* _task) {
char tmp[80];
const int body_top = 14;
'''+ first_page + r'''
}
struct MeasuredDisplay : Display {
int size = 1;
MeasuredDisplay(int w, int h) { setDimensions(w, h); }
void setTextSize(int value) override { size = value; }
uint16_t getTextWidth(const char* text) override { return strlen(text) * 6 * size; }
void print(const char* text) override {
assert(y >= 0 && y + 8 * size <= height());
for (const auto& line : lines) {
// Home text owns distinct horizontal bands; every text run is centered.
assert(y >= line.y + line.color || y + 8 * size <= line.y);
}
Display::print(text);
lines.back().color = 8 * size; // Save glyph height for overlap detection.
}
};
int main() {
for (const auto dims : {std::pair<int,int>{240,135}, {135,240}, {128,64}, {160,80}}) {
for (const char* client : {"USB", "BLUETOOTH", "BLE + USB", "TCP"}) {
MeasuredDisplay display(dims.first, dims.second);
UITask task(display);
task._msgcount = 0; task.connected = true; task.client = client;
renderHome(display, &task);
assert(display.contains("HISTORY: 12"));
assert(display.contains("Pending: 0"));
if (task.hasBluetoothConnection()) {
assert(display.contains("CONNECTED"));
if (dims.second >= 135) assert(display.contains(client));
} else {
// A wired/TCP connection must not conceal an unpaired BLE PIN.
assert(display.contains("123456"));
assert(!display.contains("CONNECTED"));
}
}
}
}
'''
self.compile_and_run(preamble + implementation)
def compile_and_run(self, source, reader_enabled=0):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory(prefix="mesh-inbox-test-") as directory:
executable = Path(directory) / "inbox.exe"
result = subprocess.run([
compiler, "-std=c++17", f"-DCOMPANION_FEATURE_READER={reader_enabled}",
"-I" + str(ROOT / "src"), "-I" + str(ROOT / "test/mocks"),
"-x", "c++", "-", str(ROOT / "src/helpers/ui/MomentaryButton.cpp"),
str(ROOT / "src/helpers/ui/DisplayDriver.cpp"), "-o", str(executable),
], input=source, text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(executable)], text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_real_ui_and_delivery_queue(self):
ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
preamble = PREAMBLE.replace("int getMsgCount() const { return 0; }", UI_MEMBERS)
implementation = extract_braced(ui, "class MsgPreviewScreen :") + ";\n"
for signature in ("void UITask::newMsg(", "void UITask::msgRead(",
"void UITask::syncMessageQueue(", "int UITask::getPreviewCount(",
"const char* UITask::inboxTitle("):
implementation += extract_braced(ui, signature) + "\n"
implementation += QUEUE
for signature in ("bool MyMesh::addToOfflineQueue(", "int MyMesh::getFromOfflineQueue("):
implementation += extract_braced(mesh, signature) + "\n"
for reader_enabled in (0, 1):
with self.subTest(reader_enabled=reader_enabled):
self.compile_and_run(preamble + implementation + SCENARIOS, reader_enabled)
def test_summary_shows_newest_visible_message_per_thread(self):
ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
implementation = extract_braced(ui, "class MsgPreviewScreen :") + ";\n"
scenarios = r'''
struct SummaryDisplay : Display {
SummaryDisplay() { setDimensions(160, 160); }
};
int main() {
using namespace mesh::ui;
SummaryDisplay display;
UITask task(display);
MsgPreviewScreen messages(&task);
messages.addPreview(1, "Alice", "old public", 0, "Public", 0);
messages.addPreview(1, "Bob", "pending public", 0, "Public", 1);
messages.addPreview(1, "Alice", "delivered public", 0, "Public");
messages.addPreview(1, "Carol", "second channel", 2, "Second", 2);
messages.addPreview(0xFF, "Dan", "old direct", -1, nullptr, 3);
messages.addPreview(0xFF, "Dan", "new direct", -1, nullptr, 4);
for (auto mode : {DisplayInboxMode::History, DisplayInboxMode::Pending,
DisplayInboxMode::Unread}) {
displayPowerPrefs().inbox = mode;
display.clear();
messages.renderSummary(display);
assert(display.rectangles.size() == 3); // One divider per thread.
assert(display.contains("Public") && display.contains("Second"));
assert(display.contains("Direct: Dan"));
assert(display.contains("second channel") && display.contains("new direct"));
assert(!display.contains("old public") && !display.contains("old direct"));
// A delivered newer entry cannot hide the newest pending entry.
const bool pending = mode == DisplayInboxMode::Pending;
assert(display.contains("pending public") == pending);
assert(display.contains("delivered public") == !pending);
}
}
'''
self.compile_and_run(PREAMBLE + implementation + scenarios)
def test_summary_uses_newest_visible_message_per_thread(self):
ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
preamble = PREAMBLE.replace("int getMsgCount() const { return 0; }", UI_MEMBERS)
implementation = extract_braced(ui, "class MsgPreviewScreen :") + ";\n"
scenarios = r'''
int main() {
using namespace mesh::ui;
resetArduinoMock();
displayPowerPrefs().battery.mode = DisplayMode::On;
Display display;
display.servicePower(false);
UITask task(display);
MsgPreviewScreen messages(&task);
task.msg_preview = task.curr = &messages;
messages.addPreview(1, "Alice", "older pending", 0, "Public", 0);
messages.addPreview(1, "Bob", "newer delivered", 0, "Public");
auto expect = [&](DisplayInboxMode mode, const char* wanted, const char* hidden) {
displayPowerPrefs().inbox = mode;
display.clear(); messages.renderSummary(display);
if (!display.contains(wanted) || display.contains(hidden)) {
fprintf(stderr, "summary mode=%u expected=%s hidden=%s\n", unsigned(mode), wanted, hidden);
for (const auto& line : display.lines) fprintf(stderr, "rendered: %s\n", line.text.c_str());
}
assert(display.contains(wanted) && !display.contains(hidden));
};
expect(DisplayInboxMode::History, "newer delivered", "older pending");
expect(DisplayInboxMode::Pending, "older pending", "newer delivered");
// Unread changes the count; read previews remain browseable as documented.
displayPowerPrefs().inbox = DisplayInboxMode::History;
display.clear(); messages.render(display);
expect(DisplayInboxMode::Unread, "newer delivered", "older pending");
assert(messages.messageCount() == 1);
messages.queueRemoved(0);
expect(DisplayInboxMode::Pending, "No messages heard", "older pending");
expect(DisplayInboxMode::History, "newer delivered", "older pending");
}
'''
self.compile_and_run(preamble + implementation + scenarios)
if __name__ == "__main__":
unittest.main()