Files
pyxis/tests/native/test_ring_buffers.cpp
T
torlando-tech a135c761e6 chore(greptile): iter 1 — applied 4 (3 ACCEPT + 1 MODIFY), rejected 1
Greptile review feedback on PR #21:

ACCEPT:
- test_patch_nimble.py:151 (P1) — replace dead `if False else True`
  ternary with a real assertion that "already applied" is absent on
  the first run.
- test_patch_nimble.py:247 (P1) — invoke the shim subprocess via
  `sys.executable` instead of hardcoded `/usr/bin/python3` so CI's
  setup-python interpreter is used consistently.
- workflows/test.yml:50 (P2) — include hash of
  deps/microReticulum/platformio.ini in PlatformIO cache key so the
  cache invalidates when dependencies change.

MODIFY (narrowed):
- test_ring_buffers.cpp:209 (P2) — keep both `write(data, 0)` and
  `write(data, -1)` assertions, but add a comment clarifying that
  EncodedRingBuffer::write() takes signed `int length` (not size_t),
  so -1 hits the `length <= 0` branch — same as 0. Greptile's
  premise (size_t wrap to SIZE_MAX) does not apply to this codebase.
  The two assertions lock the contract in case the param is ever
  migrated to size_t.

REJECT (silently — no public reply per agent policy):
- test_audio_filters.cpp:237 (P1) — VoiceFilterChain::process()
  takes `numSamples = frames * channels` per the documented
  contract in audio_filters.h:33-40, and the implementation does
  `numFrames = numSamples / channels_` (audio_filters.cpp:63). The
  multichannel test correctly passes `(int)samples.size() = 8000`
  (4000 frames * 2 channels). No out-of-bounds read occurs.
2026-05-04 15:43:14 -04:00

306 lines
11 KiB
C++

// Native unit tests for PCM and encoded SPSC ring buffers.
//
// LXST tuning numbers (PCM_RING_FRAMES=50, PREBUFFER_FRAMES=15,
// ENCODED_RING_SLOTS=128) live in code that consumes these buffers, but
// correctness of overflow/underrun/wraparound behavior is what makes those
// numbers meaningful. Tests:
//
// PacketRingBuffer (fixed frame size):
// - empty read returns false
// - write+read preserves samples bit-exact
// - wrong-size write/read rejected
// - capacity is maxFrames - 1 (one slot reserved as the empty/full flag)
// - full ring rejects writes; partial drain frees a slot
// - wraparound across the buffer end preserves data
// - availableFrames consistent across operations
// - reset clears producer + consumer
// - SPSC stress: producer thread + consumer thread, no loss/reorder
//
// EncodedRingBuffer (variable-length slots):
// - length=0 rejected
// - length > maxBytesPerSlot rejected
// - round-trip preserves payload + length
// - read with too-small dest advances read cursor and returns false
// - wraparound
#include "../../lib/lxst_audio/packet_ring_buffer.h"
#include "../../lib/lxst_audio/encoded_ring_buffer.h"
#include <atomic>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <thread>
#include <vector>
// ── 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)
// ── PacketRingBuffer tests ──
static std::vector<int16_t> make_frame(int n, int seed) {
std::vector<int16_t> f(n);
for (int i = 0; i < n; ++i) f[i] = (int16_t)((i * 31) ^ seed);
return f;
}
static void prb_empty_read_returns_false() {
PacketRingBuffer rb(4, 8);
int16_t out[8];
EXPECT_TRUE(!rb.read(out, 8));
EXPECT_EQ(rb.availableFrames(), 0);
}
static void prb_write_read_round_trip() {
PacketRingBuffer rb(4, 8);
auto f = make_frame(8, 0xAA);
EXPECT_TRUE(rb.write(f.data(), 8));
EXPECT_EQ(rb.availableFrames(), 1);
int16_t out[8] = {0};
EXPECT_TRUE(rb.read(out, 8));
for (int i = 0; i < 8; ++i) EXPECT_EQ(out[i], f[i]);
EXPECT_EQ(rb.availableFrames(), 0);
}
static void prb_wrong_size_rejected() {
PacketRingBuffer rb(4, 8);
auto f = make_frame(8, 0x55);
int16_t out[8];
EXPECT_TRUE(!rb.write(f.data(), 7)); // wrong count
EXPECT_TRUE(!rb.write(f.data(), 9));
rb.write(f.data(), 8);
EXPECT_TRUE(!rb.read(out, 7));
EXPECT_TRUE(!rb.read(out, 9));
}
static void prb_capacity_is_max_minus_one() {
// SPSC with one-slot-reserved-for-empty: capacity is maxFrames - 1.
const int N = 4;
PacketRingBuffer rb(N, 4);
auto f = make_frame(4, 0x11);
int wrote = 0;
while (rb.write(f.data(), 4)) ++wrote;
EXPECT_EQ(wrote, N - 1); // 3 writes succeed before full
EXPECT_EQ(rb.availableFrames(), N - 1);
}
static void prb_partial_drain_frees_slots() {
PacketRingBuffer rb(4, 4);
auto f = make_frame(4, 0x22);
while (rb.write(f.data(), 4)) {}
EXPECT_EQ(rb.availableFrames(), 3);
int16_t out[4];
EXPECT_TRUE(rb.read(out, 4));
EXPECT_EQ(rb.availableFrames(), 2);
EXPECT_TRUE(rb.write(f.data(), 4)); // slot freed up
EXPECT_EQ(rb.availableFrames(), 3);
}
static void prb_wraparound_preserves_data() {
PacketRingBuffer rb(4, 4);
int16_t out[4];
// Cycle 10 distinct frames through; if wraparound is broken, data
// corrupts at the buffer-end boundary.
for (int i = 0; i < 10; ++i) {
auto f = make_frame(4, 0x40 + i);
EXPECT_TRUE(rb.write(f.data(), 4));
EXPECT_TRUE(rb.read(out, 4));
for (int j = 0; j < 4; ++j) EXPECT_EQ(out[j], f[j]);
}
}
static void prb_reset_clears_state() {
PacketRingBuffer rb(4, 4);
auto f = make_frame(4, 0x33);
rb.write(f.data(), 4);
rb.write(f.data(), 4);
EXPECT_EQ(rb.availableFrames(), 2);
rb.reset();
EXPECT_EQ(rb.availableFrames(), 0);
}
// SPSC stress: separate producer and consumer threads exchange a million
// frames. Verifies acquire/release ordering on writeIndex_/readIndex_ keeps
// data intact (no torn frame, no reordering, no loss when sized big enough).
static void prb_spsc_threaded_stress() {
const int FRAMES = 8;
PacketRingBuffer rb(64, FRAMES);
const int TOTAL = 100000;
std::atomic<bool> producer_done{false};
std::thread producer([&]{
for (int i = 0; i < TOTAL; ++i) {
std::vector<int16_t> f(FRAMES, (int16_t)(i & 0x7FFF));
while (!rb.write(f.data(), FRAMES)) {
std::this_thread::yield();
}
}
producer_done.store(true, std::memory_order_release);
});
int received = 0;
int failures = 0;
while (received < TOTAL) {
int16_t out[FRAMES];
if (rb.read(out, FRAMES)) {
int16_t expected = (int16_t)(received & 0x7FFF);
for (int j = 0; j < FRAMES; ++j) {
if (out[j] != expected) ++failures;
}
++received;
} else {
std::this_thread::yield();
if (producer_done.load(std::memory_order_acquire) &&
rb.availableFrames() == 0) break;
}
}
producer.join();
EXPECT_EQ(received, TOTAL);
EXPECT_EQ(failures, 0);
}
// ── EncodedRingBuffer tests ──
static void erb_zero_length_rejected() {
EncodedRingBuffer eb(4, 64);
uint8_t data[1] = {0xAA};
// EncodedRingBuffer::write() takes `int length` (signed) and rejects
// `length <= 0`. Both 0 and -1 hit the same branch — we exercise both
// to lock that contract in case the param ever migrates to size_t.
EXPECT_TRUE(!eb.write(data, 0));
EXPECT_TRUE(!eb.write(data, -1));
}
static void erb_oversize_rejected() {
EncodedRingBuffer eb(4, 16);
uint8_t big[32]; std::memset(big, 0xCC, sizeof(big));
EXPECT_TRUE(!eb.write(big, 17));
EXPECT_TRUE(eb.write(big, 16));
}
static void erb_round_trip_variable_lengths() {
EncodedRingBuffer eb(8, 64);
std::vector<std::vector<uint8_t>> sent;
for (int i = 0; i < 5; ++i) {
std::vector<uint8_t> v;
int len = 1 + i * 7;
for (int j = 0; j < len; ++j) v.push_back((uint8_t)((j ^ i) * 31));
sent.push_back(v);
EXPECT_TRUE(eb.write(v.data(), len));
}
EXPECT_EQ(eb.availableSlots(), 5);
for (size_t i = 0; i < sent.size(); ++i) {
uint8_t out[128] = {0};
int actual = -1;
EXPECT_TRUE(eb.read(out, 128, &actual));
EXPECT_EQ(actual, (int)sent[i].size());
for (int j = 0; j < actual; ++j) EXPECT_EQ(out[j], sent[i][j]);
}
EXPECT_EQ(eb.availableSlots(), 0);
}
static void erb_too_small_dest_advances_cursor() {
// Per impl: if maxLength < stored length, read advances readIndex and
// returns false with actualLength=0. This is intentional drop-on-truncation.
EncodedRingBuffer eb(4, 64);
uint8_t big[40]; std::memset(big, 0x88, sizeof(big));
eb.write(big, 40);
EXPECT_EQ(eb.availableSlots(), 1);
uint8_t small[10];
int actual = -1;
EXPECT_TRUE(!eb.read(small, 10, &actual));
EXPECT_EQ(actual, 0);
EXPECT_EQ(eb.availableSlots(), 0); // cursor advanced, slot consumed
}
static void erb_wraparound() {
EncodedRingBuffer eb(4, 8);
uint8_t buf[8];
for (int i = 0; i < 12; ++i) {
for (int j = 0; j < 8; ++j) buf[j] = (uint8_t)((i + j) * 13);
EXPECT_TRUE(eb.write(buf, 8));
uint8_t out[8] = {0};
int actual = -1;
EXPECT_TRUE(eb.read(out, 8, &actual));
EXPECT_EQ(actual, 8);
for (int j = 0; j < 8; ++j) EXPECT_EQ(out[j], buf[j]);
}
}
static void erb_full_then_drain() {
EncodedRingBuffer eb(4, 8);
uint8_t data[4] = {1,2,3,4};
int wrote = 0;
while (eb.write(data, 4)) ++wrote;
EXPECT_EQ(wrote, 3); // capacity = N - 1
int actual;
uint8_t out[8];
while (eb.read(out, 8, &actual)) {}
EXPECT_EQ(eb.availableSlots(), 0);
}
int main() {
RUN(prb_empty_read_returns_false);
RUN(prb_write_read_round_trip);
RUN(prb_wrong_size_rejected);
RUN(prb_capacity_is_max_minus_one);
RUN(prb_partial_drain_frees_slots);
RUN(prb_wraparound_preserves_data);
RUN(prb_reset_clears_state);
RUN(prb_spsc_threaded_stress);
RUN(erb_zero_length_rejected);
RUN(erb_oversize_rejected);
RUN(erb_round_trip_variable_lengths);
RUN(erb_too_small_dest_advances_cursor);
RUN(erb_wraparound);
RUN(erb_full_then_drain);
std::printf("\n%d passed, %d failed\n", g_pass, g_fail);
return g_fail == 0 ? 0 : 1;
}