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.
This commit is contained in:
swaits committed 2026-04-13 12:12:12 -06:00
1 parent 7a9820e495
commit adbad81e19
1 file changed
+366 -4
+366 -4
View File
@@ -166,9 +166,73 @@ fn arb_command() -> impl Strategy<Value = Command> {
]
}
// ── Properties ──────────────────────────────────────────────────────
/// 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]
@@ -204,9 +268,9 @@ proptest! {
prop_assert_ne!(r.preamble_len, PREAMBLE_DEFAULT);
}
/// `Command::from_bytes` never panics on arbitrary input. This is the
/// cheap version of the fuzz target — proptest runs it on every
/// `cargo test` with ~256 iterations; cargo-fuzz runs it for hours.
/// `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);
@@ -218,6 +282,76 @@ proptest! {
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.)
@@ -331,4 +465,232 @@ proptest! {
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);
}