Add native pyxis test suite + CI

Standalone C++ tests of pyxis-unique code (BLE fragmenter/reassembler,
peer manager, GATT op queue, LXST ring buffers, audio filters, HDLC
framing) plus Python tests of the patch_nimble.py build script.

Each C++ test is compiled directly by clang++/g++ with shims in
tests/native/ (Bytes.h, Log.h, Utilities/OS.h) so pyxis sources can build
without microReticulum's full Arduino/MsgPack dep tree. A pytest wrapper
per test compiles, runs, and parses the summary line — the whole suite
is one command: `pytest tests/build_scripts tests/native -v`.

Total: 13 pytest tests, ~72 underlying C++ assertions, 3.4s.

Surfaced an HPF-formula bug in lxst_audio (mirrored upstream in
LXST-kt/native_audio_filters.cpp) — filed as LXST-kt#13 and tracked
in the corresponding test with a TODO link.

CI workflow runs the pyxis pytest suite plus the clean-passing
microReticulum native17 unit tests (94/114 of the existing fork
test/* suites) on push and PR.
This commit is contained in:
torlando-tech
2026-05-04 14:50:16 -04:00
parent fd5e9316f9
commit 14f937cf8a
19 changed files with 2577 additions and 0 deletions
+333
View File
@@ -0,0 +1,333 @@
// Native unit tests for BLEOperationQueue.
//
// The queue serializes GATT ops (BLE stacks don't queue internally) and is
// driven from the BLE loop task. The deferred-disconnect path documented
// in pyxis MEMORY routes through this — operations get cancelled with
// DISCONNECTED when a peer drops, and timeouts complete with TIMEOUT.
//
// Tests:
// - enqueue increments depth
// - process dispatches to executeOperation and sets isBusy
// - complete invokes the op callback with result+data and clears busy
// - complete() with no current op is a no-op (just warns)
// - process while busy returns false and does not start a new op
// - executeOperation returning false → callback fires with ERROR, busy clears
// - FIFO ordering: enqueue order == execute order
// - timeout fires after default+per-op timeout
// - clearForConnection cancels matching pending + current ops, leaves others
// - clear() cancels all
// - GATTOperationBuilder fluent API populates fields correctly
#include "../../lib/ble_interface/BLEOperationQueue.h"
#include "Utilities/OS.h"
#include <cstdint>
#include <cstdio>
#include <stdexcept>
#include <string>
#include <vector>
using RNS::Bytes;
using RNS::BLE::BLEOperationQueue;
using RNS::BLE::GATTOperation;
using RNS::BLE::GATTOperationBuilder;
using RNS::BLE::OperationResult;
using RNS::BLE::OperationType;
// ── minimal test framework ──
static int g_pass = 0;
static int g_fail = 0;
#define EXPECT_EQ(actual, expected) \
do { \
auto _a = (actual); \
auto _e = (expected); \
if (!(_a == _e)) { \
char buf[256]; \
std::snprintf(buf, sizeof(buf), "%s:%d: %s != %s", \
__FILE__, __LINE__, #actual, #expected); \
throw std::runtime_error(buf); \
} \
} while (0)
#define EXPECT_TRUE(cond) \
do { \
if (!(cond)) { \
char buf[256]; \
std::snprintf(buf, sizeof(buf), "%s:%d: expected %s", \
__FILE__, __LINE__, #cond); \
throw std::runtime_error(buf); \
} \
} while (0)
#define RUN(name) \
do { \
try { \
name(); \
++g_pass; \
std::printf("PASS %s\n", #name); \
} catch (const std::exception& e) { \
++g_fail; \
std::printf("FAIL %s: %s\n", #name, e.what()); \
} \
} while (0)
// Test fixture: subclass that records executeOperation calls and lets the
// test choose whether to "succeed" or "fail" each one.
class TestQueue : public BLEOperationQueue {
public:
std::vector<OperationType> executed_types;
std::vector<uint16_t> executed_handles;
bool fail_next_execute = false;
bool executeOperation(const GATTOperation& op) override {
executed_types.push_back(op.type);
executed_handles.push_back(op.conn_handle);
if (fail_next_execute) {
fail_next_execute = false;
return false;
}
return true;
}
};
// Helper to build a basic READ op with a callback that records its result.
struct CallbackRecord {
bool fired = false;
OperationResult result = OperationResult::SUCCESS;
Bytes data;
};
static GATTOperation make_read_op(uint16_t conn_handle,
uint16_t char_handle,
CallbackRecord* rec) {
return GATTOperationBuilder()
.read(conn_handle, char_handle)
.withCallback([rec](OperationResult r, const Bytes& d) {
rec->fired = true;
rec->result = r;
rec->data = d;
})
.build();
}
// ── tests ──
static void enqueue_increments_depth() {
TestQueue q;
EXPECT_EQ(q.depth(), (size_t)0);
EXPECT_TRUE(!q.isBusy());
CallbackRecord rec;
q.enqueue(make_read_op(1, 0x100, &rec));
EXPECT_EQ(q.depth(), (size_t)1);
EXPECT_TRUE(!q.isBusy()); // not busy until process()
}
static void process_starts_operation_and_marks_busy() {
TestQueue q;
CallbackRecord rec;
q.enqueue(make_read_op(1, 0x100, &rec));
EXPECT_TRUE(q.process());
EXPECT_TRUE(q.isBusy());
EXPECT_EQ(q.depth(), (size_t)0);
EXPECT_EQ(q.executed_types.size(), (size_t)1);
EXPECT_EQ(q.executed_types[0], OperationType::READ);
}
static void complete_fires_callback_and_clears_busy() {
TestQueue q;
CallbackRecord rec;
q.enqueue(make_read_op(1, 0x100, &rec));
q.process();
Bytes payload;
payload.append((uint8_t)0xAA); payload.append((uint8_t)0xBB);
q.complete(OperationResult::SUCCESS, payload);
EXPECT_TRUE(rec.fired);
EXPECT_EQ(rec.result, OperationResult::SUCCESS);
EXPECT_EQ(rec.data.size(), (size_t)2);
EXPECT_EQ(rec.data.data()[0], (uint8_t)0xAA);
EXPECT_TRUE(!q.isBusy());
}
static void complete_with_no_current_op_is_noop() {
TestQueue q;
// Should not crash, should not fire any callback (none registered).
q.complete(OperationResult::SUCCESS, Bytes());
EXPECT_TRUE(!q.isBusy());
}
static void process_while_busy_does_not_start_another() {
TestQueue q;
CallbackRecord rec1, rec2;
q.enqueue(make_read_op(1, 0x100, &rec1));
q.enqueue(make_read_op(1, 0x101, &rec2));
EXPECT_TRUE(q.process());
EXPECT_TRUE(q.isBusy());
EXPECT_EQ(q.executed_types.size(), (size_t)1);
// Second process must not start the queued op.
EXPECT_TRUE(!q.process());
EXPECT_EQ(q.executed_types.size(), (size_t)1);
// Complete the first; next process should start the second.
q.complete(OperationResult::SUCCESS, Bytes());
EXPECT_TRUE(q.process());
EXPECT_EQ(q.executed_types.size(), (size_t)2);
}
static void execute_returning_false_fires_error_and_clears_busy() {
TestQueue q;
CallbackRecord rec;
q.fail_next_execute = true;
q.enqueue(make_read_op(1, 0x100, &rec));
EXPECT_TRUE(!q.process()); // start failed
EXPECT_TRUE(rec.fired);
EXPECT_EQ(rec.result, OperationResult::ERROR);
EXPECT_TRUE(!q.isBusy());
}
static void fifo_ordering_preserved() {
TestQueue q;
CallbackRecord r1, r2, r3;
q.enqueue(make_read_op(1, 0x100, &r1));
q.enqueue(make_read_op(2, 0x200, &r2));
q.enqueue(make_read_op(3, 0x300, &r3));
q.process(); q.complete(OperationResult::SUCCESS, Bytes());
q.process(); q.complete(OperationResult::SUCCESS, Bytes());
q.process(); q.complete(OperationResult::SUCCESS, Bytes());
EXPECT_EQ(q.executed_handles.size(), (size_t)3);
EXPECT_EQ(q.executed_handles[0], (uint16_t)1);
EXPECT_EQ(q.executed_handles[1], (uint16_t)2);
EXPECT_EQ(q.executed_handles[2], (uint16_t)3);
}
static void timeout_completes_with_timeout_result() {
TestQueue q;
// GATTOperation::timeout_ms defaults to 5000 in the struct, so setTimeout()
// alone won't apply. Build the op with an explicit per-op timeout.
CallbackRecord rec;
auto op = GATTOperationBuilder()
.read(1, 0x100)
.withTimeout(500) // 0.5s
.withCallback([&rec](OperationResult r, const Bytes& d) {
rec.fired = true; rec.result = r; rec.data = d;
})
.build();
RNS::Utilities::OS::set_fake_time(0.0);
q.enqueue(std::move(op));
q.process();
EXPECT_TRUE(q.isBusy());
// Advance past timeout; process() should call checkTimeout and fire complete.
RNS::Utilities::OS::set_fake_time(1.0);
q.process();
EXPECT_TRUE(rec.fired);
EXPECT_EQ(rec.result, OperationResult::TIMEOUT);
EXPECT_TRUE(!q.isBusy());
RNS::Utilities::OS::clear_fake_time();
}
static void clear_for_connection_cancels_matching_pending() {
TestQueue q;
CallbackRecord r_a, r_b1, r_b2;
q.enqueue(make_read_op(0xA, 0x100, &r_a));
q.enqueue(make_read_op(0xB, 0x200, &r_b1));
q.enqueue(make_read_op(0xB, 0x201, &r_b2));
EXPECT_EQ(q.depth(), (size_t)3);
q.clearForConnection(0xB);
EXPECT_EQ(q.depth(), (size_t)1); // only conn 0xA remains
EXPECT_TRUE(r_b1.fired);
EXPECT_EQ(r_b1.result, OperationResult::DISCONNECTED);
EXPECT_TRUE(r_b2.fired);
EXPECT_TRUE(!r_a.fired); // unrelated peer untouched
}
static void clear_for_connection_cancels_current_op_if_matching() {
TestQueue q;
CallbackRecord rec;
q.enqueue(make_read_op(0xC, 0x100, &rec));
q.process();
EXPECT_TRUE(q.isBusy());
q.clearForConnection(0xC);
EXPECT_TRUE(rec.fired);
EXPECT_EQ(rec.result, OperationResult::DISCONNECTED);
EXPECT_TRUE(!q.isBusy());
}
static void clear_cancels_everything() {
TestQueue q;
CallbackRecord r1, r2, r3;
q.enqueue(make_read_op(1, 0x100, &r1));
q.enqueue(make_read_op(2, 0x200, &r2));
q.process(); // r1 becomes current
q.enqueue(make_read_op(3, 0x300, &r3));
q.clear();
EXPECT_EQ(q.depth(), (size_t)0);
EXPECT_TRUE(!q.isBusy());
EXPECT_TRUE(r1.fired);
EXPECT_TRUE(r2.fired);
EXPECT_TRUE(r3.fired);
EXPECT_EQ(r1.result, OperationResult::DISCONNECTED);
EXPECT_EQ(r2.result, OperationResult::DISCONNECTED);
EXPECT_EQ(r3.result, OperationResult::DISCONNECTED);
}
static void builder_populates_write_op_correctly() {
Bytes data;
data.append((uint8_t)0xDE); data.append((uint8_t)0xAD);
GATTOperation op = GATTOperationBuilder()
.write(7, 0x500, data)
.withTimeout(3000)
.build();
EXPECT_EQ(op.type, OperationType::WRITE);
EXPECT_EQ(op.conn_handle, (uint16_t)7);
EXPECT_EQ(op.char_handle, (uint16_t)0x500);
EXPECT_EQ(op.data.size(), (size_t)2);
EXPECT_EQ(op.timeout_ms, (uint32_t)3000);
}
static void builder_mtu_request_encodes_big_endian() {
GATTOperation op = GATTOperationBuilder()
.requestMTU(1, 517) // 517 = 0x0205
.build();
EXPECT_EQ(op.type, OperationType::MTU_REQUEST);
EXPECT_EQ(op.data.size(), (size_t)2);
EXPECT_EQ(op.data.data()[0], (uint8_t)0x02);
EXPECT_EQ(op.data.data()[1], (uint8_t)0x05);
}
int main() {
RUN(enqueue_increments_depth);
RUN(process_starts_operation_and_marks_busy);
RUN(complete_fires_callback_and_clears_busy);
RUN(complete_with_no_current_op_is_noop);
RUN(process_while_busy_does_not_start_another);
RUN(execute_returning_false_fires_error_and_clears_busy);
RUN(fifo_ordering_preserved);
RUN(timeout_completes_with_timeout_result);
RUN(clear_for_connection_cancels_matching_pending);
RUN(clear_for_connection_cancels_current_op_if_matching);
RUN(clear_cancels_everything);
RUN(builder_populates_write_op_correctly);
RUN(builder_mtu_request_encodes_big_endian);
std::printf("\n%d passed, %d failed\n", g_pass, g_fail);
return g_fail == 0 ? 0 : 1;
}