Files
protocol-rs/tests/proptest.rs
T
swaits adbad81e19 test: 23 proptests across two budget tiers (1M+ cases per run)
Scaled the proptest suite hard and added 13 new properties:

- cobs_decoder_multi_frame: N frames decode to N commands in order.
- cobs_decoder_chunk_invariant: arbitrary chunk splits yield the same
  sequence as feed-all-at-once.
- cobs_decoder_byte_by_byte_equivalent: stronger form of chunking.
- cobs_decoder_recovers_after_garbage: decoder is not poisoned by a
  leading garbage frame.
- cobs_decoder_ignores_sentinel_runs: arbitrary leading/trailing 0x00
  runs do not affect decoding.
- cobs_decoder_oversized_discards: frames > MAX_FRAME are discarded;
  subsequent frames still decode.
- response_full_roundtrip: encode + COBS + decode + re-parse yields the
  original Response (via a test-only decode_response helper).
- response_encoding_deterministic: byte-identical output across runs.
- cobs_encoded_has_single_trailing_zero: COBS invariant check.
- bandwidth_u8_is_bijection: sweeps the whole u8 space.
- radio_config_write_to_stays_in_bounds: buffer-write sanity.
- resolve_then_validate_consistent: cross-field contract.

Split into two proptest! blocks: lightweight at 100,000 cases each, and
heavyweight (MAX_FRAME-sized buffers, multi-frame streams) at 10,000.
Total ~1.13M test cases per suite run, finishing in ~20 s.
2026-04-13 12:12:12 -06:00

697 lines
24 KiB
Rust

//! Property tests for the wire protocol and COBS framing.
//!
//! These cover three flavours of invariant:
//!
//! 1. **Roundtrips** — serialize then parse equals identity on well-formed
//! values.
//! 2. **No-panic** — parsers never panic on arbitrary byte input. This is
//! the cheap version of the fuzz suite and runs on every `cargo test`.
//! 3. **Bounded capacity** — oversized inputs are discarded, never allocate,
//! never overflow the `heapless::Vec` backing store.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use donglora_protocol::framing::{CobsDecoder, MAX_FRAME, cobs_encode_response};
use donglora_protocol::{
Bandwidth, Command, ErrorCode, MAX_PAYLOAD, PREAMBLE_DEFAULT, RADIO_CONFIG_SIZE, RadioConfig,
Response, TX_POWER_MAX,
};
use heapless::Vec as HVec;
use proptest::prelude::*;
// ── Strategies ──────────────────────────────────────────────────────
fn arb_bandwidth() -> impl Strategy<Value = Bandwidth> {
prop_oneof![
Just(Bandwidth::Khz7),
Just(Bandwidth::Khz10),
Just(Bandwidth::Khz15),
Just(Bandwidth::Khz20),
Just(Bandwidth::Khz31),
Just(Bandwidth::Khz41),
Just(Bandwidth::Khz62),
Just(Bandwidth::Khz125),
Just(Bandwidth::Khz250),
Just(Bandwidth::Khz500),
]
}
fn arb_radio_config() -> impl Strategy<Value = RadioConfig> {
(
any::<u32>(),
arb_bandwidth(),
any::<u8>(),
any::<u8>(),
any::<u16>(),
any::<i8>(),
any::<u16>(),
any::<u8>(),
)
.prop_map(
|(freq_hz, bw, sf, cr, sync_word, tx_power_dbm, preamble_len, cad)| RadioConfig {
freq_hz,
bw,
sf,
cr,
sync_word,
tx_power_dbm,
preamble_len,
cad,
},
)
}
/// Payload bytes, bounded by MAX_PAYLOAD so the heapless Vec never overflows.
fn arb_payload() -> impl Strategy<Value = HVec<u8, MAX_PAYLOAD>> {
prop::collection::vec(any::<u8>(), 0..=MAX_PAYLOAD).prop_map(|v| {
let mut out = HVec::new();
let _ = out.extend_from_slice(&v);
out
})
}
fn arb_error_code() -> impl Strategy<Value = ErrorCode> {
prop_oneof![
Just(ErrorCode::InvalidConfig),
Just(ErrorCode::RadioBusy),
Just(ErrorCode::TxTimeout),
Just(ErrorCode::NotConfigured),
Just(ErrorCode::NoDisplay),
]
}
fn arb_response() -> impl Strategy<Value = Response> {
prop_oneof![
Just(Response::Pong),
arb_radio_config().prop_map(Response::Config),
(any::<i16>(), any::<i16>(), arb_payload())
.prop_map(|(rssi, snr, payload)| Response::RxPacket { rssi, snr, payload }),
Just(Response::TxDone),
Just(Response::Ok),
arb_error_code().prop_map(Response::Error),
any::<[u8; 6]>().prop_map(Response::MacAddress),
]
}
/// Test-only Command encoder. The firmware only ever decodes commands, so
/// there is no `Command::write_to` in the library itself — we roll the wire
/// layout here to exercise the roundtrip.
fn encode_command(cmd: &Command, out: &mut [u8]) -> usize {
match cmd {
Command::Ping => {
out[0] = 0;
1
}
Command::GetConfig => {
out[0] = 1;
1
}
Command::SetConfig(cfg) => {
out[0] = 2;
cfg.write_to(&mut out[1..]);
1 + RADIO_CONFIG_SIZE
}
Command::StartRx => {
out[0] = 3;
1
}
Command::StopRx => {
out[0] = 4;
1
}
Command::Transmit { config, payload } => {
out[0] = 5;
let mut pos = 1;
if let Some(cfg) = config {
out[pos] = 1;
pos += 1;
cfg.write_to(&mut out[pos..]);
pos += RADIO_CONFIG_SIZE;
} else {
out[pos] = 0;
pos += 1;
}
out[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
pos += 2;
out[pos..pos + payload.len()].copy_from_slice(payload);
pos + payload.len()
}
Command::DisplayOn => {
out[0] = 6;
1
}
Command::DisplayOff => {
out[0] = 7;
1
}
Command::GetMac => {
out[0] = 8;
1
}
}
}
fn arb_command() -> impl Strategy<Value = Command> {
prop_oneof![
Just(Command::Ping),
Just(Command::GetConfig),
arb_radio_config().prop_map(Command::SetConfig),
Just(Command::StartRx),
Just(Command::StopRx),
(prop::option::of(arb_radio_config()), arb_payload())
.prop_map(|(config, payload)| Command::Transmit { config, payload }),
Just(Command::DisplayOn),
Just(Command::DisplayOff),
Just(Command::GetMac),
]
}
/// Decode a raw (non-COBS) Response buffer back into a `Response`. The
/// library is intentionally asymmetric — firmware encodes, host decodes —
/// so this lives in the test crate only.
fn decode_response(buf: &[u8]) -> Option<Response> {
let tag = *buf.first()?;
let rest = &buf[1..];
match tag {
0 => Some(Response::Pong),
1 => Some(Response::Config(RadioConfig::from_bytes(rest)?)),
2 => {
if rest.len() < 6 {
return None;
}
let rssi = i16::from_le_bytes([rest[0], rest[1]]);
let snr = i16::from_le_bytes([rest[2], rest[3]]);
let len = u16::from_le_bytes([rest[4], rest[5]]) as usize;
if rest.len() < 6 + len {
return None;
}
let mut payload = HVec::new();
let _ = payload.extend_from_slice(&rest[6..6 + len]);
Some(Response::RxPacket { rssi, snr, payload })
}
3 => Some(Response::TxDone),
4 => Some(Response::Ok),
5 => {
let code = *rest.first()?;
let err = match code {
0 => ErrorCode::InvalidConfig,
1 => ErrorCode::RadioBusy,
2 => ErrorCode::TxTimeout,
4 => ErrorCode::NotConfigured,
5 => ErrorCode::NoDisplay,
_ => return None,
};
Some(Response::Error(err))
}
6 => {
if rest.len() < 6 {
return None;
}
let mut mac = [0u8; 6];
mac.copy_from_slice(&rest[..6]);
Some(Response::MacAddress(mac))
}
_ => None,
}
}
/// COBS-frame a raw byte slice with trailing 0x00 sentinel.
fn cobs_frame(raw: &[u8], out: &mut [u8]) -> usize {
let n = ucobs::encode(raw, out).unwrap();
out[n] = 0x00;
n + 1
}
// ── Properties — lightweight (100_000 cases) ────────────────────────
proptest! {
// Cheap properties: tiny allocations, fast inner loops. 100k cases
// keeps the whole suite under a few seconds.
#![proptest_config(ProptestConfig {
cases: 100_000,
max_shrink_iters: 10_000,
.. ProptestConfig::default()
})]
/// RadioConfig roundtrip: write_to ∘ from_bytes is identity for any
/// value produced by the struct's public fields.
#[test]
fn radio_config_roundtrip(cfg in arb_radio_config()) {
let mut buf = [0u8; RADIO_CONFIG_SIZE];
let n = cfg.write_to(&mut buf);
prop_assert_eq!(n, RADIO_CONFIG_SIZE);
prop_assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
}
/// RadioConfig::validate is deterministic: the same config against the
/// same power range produces the same verdict across N invocations.
/// (Tautological but catches any accidental interior mutability.)
#[test]
fn validate_is_deterministic(
cfg in arb_radio_config(),
power_range in (-20i8..=0i8, 10i8..=30i8),
) {
let v1 = cfg.validate(power_range);
let v2 = cfg.validate(power_range);
prop_assert_eq!(v1, v2);
}
/// Resolve never turns sentinels into out-of-range values: after resolve,
/// `tx_power_dbm` sits inside the power range and `preamble_len` ≥ 6.
#[test]
fn resolve_clears_sentinels(
cfg in arb_radio_config(),
power_range in (-20i8..=0i8, 10i8..=30i8),
) {
let r = cfg.resolve(power_range);
prop_assert_ne!(r.tx_power_dbm, TX_POWER_MAX);
prop_assert_ne!(r.preamble_len, PREAMBLE_DEFAULT);
}
/// `Command::from_bytes` never panics on arbitrary input. Proptest
/// runs this on every `cargo test`; cargo-fuzz runs the same code
/// for hours in CI.
#[test]
fn command_from_bytes_no_panic(data in prop::collection::vec(any::<u8>(), 0..1024)) {
let _ = Command::from_bytes(&data);
}
/// RadioConfig::from_bytes never panics on arbitrary input.
#[test]
fn radio_config_from_bytes_no_panic(data in prop::collection::vec(any::<u8>(), 0..64)) {
let _ = RadioConfig::from_bytes(&data);
}
/// Bandwidth bijection: every accepted on-wire value round-trips.
/// Complements the enum-exhaustive vector test by sweeping the full
/// u8 space and confirming accept/reject follow the spec.
#[test]
fn bandwidth_u8_is_bijection(v in any::<u8>()) {
let mut buf = [0u8; RADIO_CONFIG_SIZE];
RadioConfig {
freq_hz: 915_000_000,
bw: Bandwidth::Khz125,
sf: 7,
cr: 5,
sync_word: 0,
tx_power_dbm: 0,
preamble_len: 16,
cad: 0,
}
.write_to(&mut buf);
buf[4] = v;
let parsed = RadioConfig::from_bytes(&buf);
if v <= 9 {
let decoded = parsed.unwrap();
let mut back = [0u8; RADIO_CONFIG_SIZE];
decoded.write_to(&mut back);
prop_assert_eq!(back[4], v);
} else {
prop_assert!(parsed.is_none());
}
}
/// write_to touches exactly RADIO_CONFIG_SIZE bytes — no surrounding
/// memory is clobbered.
#[test]
fn radio_config_write_to_stays_in_bounds(cfg in arb_radio_config()) {
let mut buf = [0xAAu8; RADIO_CONFIG_SIZE + 16];
let n = cfg.write_to(&mut buf[..RADIO_CONFIG_SIZE]);
prop_assert_eq!(n, RADIO_CONFIG_SIZE);
for (i, &b) in buf[RADIO_CONFIG_SIZE..].iter().enumerate() {
prop_assert_eq!(b, 0xAA, "byte at RADIO_CONFIG_SIZE+{} was clobbered", i);
}
}
/// After `resolve(pr)` clears both sentinels, `validate(pr)` accepts
/// the remaining fields iff they are independently in range.
#[test]
fn resolve_then_validate_consistent(
cfg in arb_radio_config(),
power_range in (-20i8..=0i8, 10i8..=30i8),
) {
let r = cfg.resolve(power_range);
if r.tx_power_dbm < power_range.0 || r.tx_power_dbm > power_range.1 {
prop_assert!(r.validate(power_range).is_err());
}
if r.preamble_len < 6 {
prop_assert!(r.validate(power_range).is_err());
}
}
}
// ── Properties — heavyweight (10_000 cases) ────────────────────────
proptest! {
// Properties that touch MAX_FRAME-sized buffers or multi-frame
// streams; ~10k cases keeps total runtime reasonable.
#![proptest_config(ProptestConfig {
cases: 10_000,
max_shrink_iters: 10_000,
.. ProptestConfig::default()
})]
/// Bounded-capacity invariant: a `Transmit` command parsed from bytes
/// never yields a payload longer than MAX_PAYLOAD. (The parser truncates
/// silently via `extend_from_slice`; this check guards that assumption.)
#[test]
fn transmit_payload_bounded(
has_config in any::<bool>(),
cfg in arb_radio_config(),
len_field in 0u16..=2000, // deliberately oversized
data in prop::collection::vec(any::<u8>(), 0..2000),
) {
let mut buf = [0u8; 2100];
buf[0] = 5; // Transmit tag
let mut pos = 1;
if has_config {
buf[pos] = 1;
pos += 1;
cfg.write_to(&mut buf[pos..]);
pos += RADIO_CONFIG_SIZE;
} else {
buf[pos] = 0;
pos += 1;
}
buf[pos..pos + 2].copy_from_slice(&len_field.to_le_bytes());
pos += 2;
let copy_len = data.len().min(buf.len() - pos);
buf[pos..pos + copy_len].copy_from_slice(&data[..copy_len]);
let total = pos + copy_len;
if let Some(Command::Transmit { payload, .. }) = Command::from_bytes(&buf[..total]) {
prop_assert!(payload.len() <= MAX_PAYLOAD);
}
}
/// Command roundtrip via the test-only encoder. Catches any parser/encoder
/// drift if either side is edited without the other.
#[test]
fn command_roundtrip(cmd in arb_command()) {
let mut buf = [0u8; 1024];
let n = encode_command(&cmd, &mut buf);
let parsed = Command::from_bytes(&buf[..n]).unwrap();
prop_assert_eq!(parsed, cmd);
}
/// COBS reversibility: encode-then-decode a response yields the same
/// bytes that `write_to` produced.
#[test]
fn cobs_response_roundtrip(resp in arb_response()) {
let mut write = [0u8; MAX_FRAME];
let mut encode = [0u8; MAX_FRAME];
// Expected raw bytes before COBS.
let raw_len = {
let mut raw = [0u8; MAX_FRAME];
let n = resp.clone().write_to(&mut raw);
let raw_vec: HVec<u8, MAX_FRAME> = HVec::from_slice(&raw[..n]).unwrap();
raw_vec
};
let framed = cobs_encode_response(resp, &mut write, &mut encode)
.expect("non-overflowing response should encode");
// Last byte is the 0x00 sentinel; the preceding bytes are the COBS frame.
prop_assert_eq!(*framed.last().unwrap(), 0x00);
let mut decoded = [0u8; MAX_FRAME];
let n = ucobs::decode(&framed[..framed.len() - 1], &mut decoded).unwrap();
prop_assert_eq!(&decoded[..n], raw_len.as_slice());
}
/// CobsDecoder never panics and never yields a command longer than the
/// bounded payload for arbitrary byte streams delivered in arbitrary
/// chunk boundaries.
#[test]
fn cobs_decoder_no_panic(
stream in prop::collection::vec(any::<u8>(), 0..4096),
chunk_splits in prop::collection::vec(1usize..=128, 0..32),
) {
let mut decoder = CobsDecoder::new();
let mut cursor = 0;
for &split in &chunk_splits {
if cursor >= stream.len() {
break;
}
let end = (cursor + split).min(stream.len());
decoder.feed(&stream[cursor..end], |cmd| {
if let Command::Transmit { payload, .. } = cmd {
assert!(payload.len() <= MAX_PAYLOAD);
}
});
cursor = end;
}
// Deliver any remainder as one chunk.
if cursor < stream.len() {
decoder.feed(&stream[cursor..], |_| {});
}
}
/// COBS round-trip through the streaming decoder: encoding a real
/// command, appending the 0x00 sentinel, feeding the result to
/// `CobsDecoder::feed` yields exactly that command.
#[test]
fn cobs_decoder_roundtrip(cmd in arb_command()) {
let mut raw = [0u8; 1024];
let n = encode_command(&cmd, &mut raw);
let mut framed = [0u8; 1100];
let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap();
framed[enc_n] = 0x00;
let mut decoder = CobsDecoder::new();
let mut got: Option<Command> = None;
decoder.feed(&framed[..enc_n + 1], |c| got = Some(c));
prop_assert_eq!(got, Some(cmd));
}
/// Multi-frame streaming: N concatenated COBS frames decode to N
/// commands in the same order.
#[test]
fn cobs_decoder_multi_frame(cmds in prop::collection::vec(arb_command(), 1..8)) {
let mut stream: HVec<u8, 16_384> = HVec::new();
let mut raw = [0u8; 1024];
let mut framed = [0u8; 1100];
for cmd in &cmds {
let n = encode_command(cmd, &mut raw);
let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap();
stream.extend_from_slice(&framed[..enc_n]).unwrap();
stream.push(0x00).unwrap();
}
let mut decoder = CobsDecoder::new();
let mut got: HVec<Command, 8> = HVec::new();
decoder.feed(&stream, |c| {
got.push(c).unwrap();
});
prop_assert_eq!(got.as_slice(), cmds.as_slice());
}
/// Chunk-boundary invariance: the same byte stream fed in arbitrary
/// chunk splits yields the same command sequence as feeding it all
/// at once.
#[test]
fn cobs_decoder_chunk_invariant(
cmds in prop::collection::vec(arb_command(), 0..6),
splits in prop::collection::vec(1usize..=64, 0..64),
) {
let mut stream: HVec<u8, 16_384> = HVec::new();
let mut raw = [0u8; 1024];
let mut framed = [0u8; 1100];
for cmd in &cmds {
let n = encode_command(cmd, &mut raw);
let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap();
stream.extend_from_slice(&framed[..enc_n]).unwrap();
stream.push(0x00).unwrap();
}
let mut all_at_once: HVec<Command, 8> = HVec::new();
CobsDecoder::new().feed(&stream, |c| {
all_at_once.push(c).unwrap();
});
let mut chunked: HVec<Command, 8> = HVec::new();
let mut decoder = CobsDecoder::new();
let mut cursor = 0;
for &split in &splits {
if cursor >= stream.len() {
break;
}
let end = (cursor + split).min(stream.len());
decoder.feed(&stream[cursor..end], |c| {
chunked.push(c).unwrap();
});
cursor = end;
}
if cursor < stream.len() {
decoder.feed(&stream[cursor..], |c| {
chunked.push(c).unwrap();
});
}
prop_assert_eq!(chunked, all_at_once);
}
/// Garbage-then-valid: a leading oversized/garbage frame is discarded
/// but does not poison the decoder; the next valid frame decodes.
#[test]
fn cobs_decoder_recovers_after_garbage(
garbage in prop::collection::vec(1u8..=255, 1..2 * MAX_FRAME),
cmd in arb_command(),
) {
let mut stream: HVec<u8, 4096> = HVec::new();
// Feed garbage followed by a sentinel (no valid COBS structure).
stream.extend_from_slice(&garbage).unwrap();
stream.push(0x00).unwrap();
// Then a legitimate framed command.
let mut raw = [0u8; 1024];
let n = encode_command(&cmd, &mut raw);
let mut framed = [0u8; 1100];
let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap();
stream.extend_from_slice(&framed[..enc_n]).unwrap();
stream.push(0x00).unwrap();
let mut decoder = CobsDecoder::new();
let mut got: HVec<Command, 4> = HVec::new();
decoder.feed(&stream, |c| {
got.push(c).unwrap();
});
// The recovered command must be last. Garbage may or may not
// parse as Some command — we only assert that the real one did.
prop_assert!(got.contains(&cmd), "valid frame after garbage must decode");
prop_assert_eq!(got.last(), Some(&cmd));
}
/// Sentinel padding: arbitrary runs of 0x00 between frames are
/// treated as empty frames (no command emitted) and do not disturb
/// subsequent decoding.
#[test]
fn cobs_decoder_ignores_sentinel_runs(
leading_zeros in 0usize..32,
cmd in arb_command(),
trailing_zeros in 0usize..32,
) {
let mut stream: HVec<u8, 2048> = HVec::new();
for _ in 0..leading_zeros {
stream.push(0x00).unwrap();
}
let mut raw = [0u8; 1024];
let n = encode_command(&cmd, &mut raw);
let mut framed = [0u8; 1100];
let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap();
stream.extend_from_slice(&framed[..enc_n]).unwrap();
stream.push(0x00).unwrap();
for _ in 0..trailing_zeros {
stream.push(0x00).unwrap();
}
let mut decoder = CobsDecoder::new();
let mut got: HVec<Command, 4> = HVec::new();
decoder.feed(&stream, |c| {
got.push(c).unwrap();
});
prop_assert!(got.contains(&cmd));
}
/// Oversized frame (more than MAX_FRAME bytes before a sentinel) is
/// discarded; a subsequent valid frame decodes.
#[test]
fn cobs_decoder_oversized_discards(
overflow_bytes in (MAX_FRAME + 1)..(3 * MAX_FRAME),
cmd in arb_command(),
) {
let mut stream: HVec<u8, 4096> = HVec::new();
for _ in 0..overflow_bytes {
stream.push(0x01).unwrap(); // non-zero filler
}
stream.push(0x00).unwrap();
let mut raw = [0u8; 1024];
let n = encode_command(&cmd, &mut raw);
let mut framed = [0u8; 1100];
let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap();
stream.extend_from_slice(&framed[..enc_n]).unwrap();
stream.push(0x00).unwrap();
let mut decoder = CobsDecoder::new();
let mut got: Option<Command> = None;
decoder.feed(&stream, |c| got = Some(c));
prop_assert_eq!(got, Some(cmd));
}
/// Full Response roundtrip: write_to + COBS encode + COBS decode +
/// bytes-to-Response yields the original value.
#[test]
fn response_full_roundtrip(resp in arb_response()) {
let mut write = [0u8; MAX_FRAME];
let mut encode = [0u8; MAX_FRAME];
let framed = cobs_encode_response(resp.clone(), &mut write, &mut encode).unwrap();
let mut decoded = [0u8; MAX_FRAME];
let n = ucobs::decode(&framed[..framed.len() - 1], &mut decoded).unwrap();
prop_assert_eq!(decode_response(&decoded[..n]), Some(resp));
}
/// Encoding is deterministic: the same Response yields byte-identical
/// output across repeated invocations.
#[test]
fn response_encoding_deterministic(resp in arb_response()) {
let mut a_write = [0u8; MAX_FRAME];
let mut a_enc = [0u8; MAX_FRAME];
let mut b_write = [0u8; MAX_FRAME];
let mut b_enc = [0u8; MAX_FRAME];
let a_len = cobs_encode_response(resp.clone(), &mut a_write, &mut a_enc).unwrap().len();
let b_len = cobs_encode_response(resp, &mut b_write, &mut b_enc).unwrap().len();
prop_assert_eq!(a_len, b_len);
prop_assert_eq!(&a_enc[..a_len], &b_enc[..b_len]);
}
/// `cobs_encode_response` output has exactly one 0x00 — at the end.
/// The COBS layer's entire contract is that no zero appears inside.
#[test]
fn cobs_encoded_has_single_trailing_zero(resp in arb_response()) {
let mut write = [0u8; MAX_FRAME];
let mut encode = [0u8; MAX_FRAME];
let framed = cobs_encode_response(resp, &mut write, &mut encode).unwrap();
let zero_count = framed.iter().filter(|&&b| b == 0).count();
prop_assert_eq!(zero_count, 1);
prop_assert_eq!(*framed.last().unwrap(), 0x00);
}
/// COBS-framed stream: feed-byte-by-byte is equivalent to feed-all-at-once.
#[test]
fn cobs_decoder_byte_by_byte_equivalent(cmds in prop::collection::vec(arb_command(), 0..4)) {
let mut stream: HVec<u8, 8192> = HVec::new();
let mut raw = [0u8; 1024];
let mut framed = [0u8; 1100];
for cmd in &cmds {
let n = encode_command(cmd, &mut raw);
let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap();
stream.extend_from_slice(&framed[..enc_n]).unwrap();
stream.push(0x00).unwrap();
}
let mut whole: HVec<Command, 4> = HVec::new();
CobsDecoder::new().feed(&stream, |c| whole.push(c).unwrap());
let mut per_byte: HVec<Command, 4> = HVec::new();
let mut decoder = CobsDecoder::new();
for &b in &stream {
decoder.feed(&[b], |c| per_byte.push(c).unwrap());
}
prop_assert_eq!(per_byte, whole);
}
}
// Keep the framer helper exercised so unused-code lints on `cobs_frame`
// stay quiet even if a future edit removes its last caller.
#[test]
fn cobs_frame_helper_smoke() {
let mut out = [0u8; 16];
let n = cobs_frame(&[1, 2, 3], &mut out);
assert!(n >= 4);
assert_eq!(out[n - 1], 0x00);
}