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:
1 file changed
+366
-4
+366
-4
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user