donglora-protocol 0.1.0

DongLoRa wire protocol types and COBS framing — shared between firmware and host crates
Documentation
//! Targeted tests that kill mutants surfaced by `cargo mutants`.
//!
//! Each test below corresponds to a specific mutant that proptest and
//! the canonical vectors failed to catch. Keep them — deleting one
//! unlocks the corresponding mutant.

#![allow(clippy::unwrap_used, clippy::panic)]

use donglora_protocol::framing::{CobsDecoder, MAX_FRAME, cobs_encode_response};
use donglora_protocol::{Bandwidth, Command, RADIO_CONFIG_SIZE, RadioConfig, Response};
use heapless::Vec as HVec;

fn base_config() -> RadioConfig {
    RadioConfig {
        freq_hz: 915_000_000,
        bw: Bandwidth::Khz125,
        sf: 7,
        cr: 5,
        sync_word: 0x1234,
        tx_power_dbm: 22,
        preamble_len: 16,
        cad: 1,
    }
}

// ── RadioConfig::cad_enabled ────────────────────────────────────────

#[test]
fn cad_enabled_is_true_for_non_zero() {
    // Kills: replace cad_enabled with `true`/`false`, replace `!=` with `==`.
    for cad in [1u8, 2, 127, 255] {
        let cfg = RadioConfig {
            cad,
            ..base_config()
        };
        assert!(cfg.cad_enabled(), "cad={cad} must report enabled");
    }
}

#[test]
fn cad_enabled_is_false_for_zero() {
    let cfg = RadioConfig {
        cad: 0,
        ..base_config()
    };
    assert!(!cfg.cad_enabled());
}

// ── Command::from_bytes Transmit with-config boundary ──────────────

#[test]
fn transmit_with_config_requires_more_than_radio_config_size() {
    // The parser checks `rest.len() > RADIO_CONFIG_SIZE` so there is at
    // least one byte past the config for the `len` field (actually two,
    // caught by a later check). Swapping `>` for `>=` or `&&` for `||`
    // silently breaks the boundary — these inputs pin it.

    // has_config=1 with exactly RADIO_CONFIG_SIZE bytes of config and
    // nothing else: must reject (no room for the len field).
    let mut just_config = [0u8; 2 + RADIO_CONFIG_SIZE];
    just_config[0] = 5; // Transmit tag
    just_config[1] = 1; // has_config = true
    base_config().write_to(&mut just_config[2..]);
    assert!(
        Command::from_bytes(&just_config).is_none(),
        "has_config=1 with no len bytes must reject",
    );

    // has_config=2 is a sentinel the parser must reject outright.
    // Swapping `&&` for `||` would accept it (high byte count would make
    // the second half true).
    let mut bad_flag = [0u8; 32];
    bad_flag[0] = 5;
    bad_flag[1] = 2;
    assert!(
        Command::from_bytes(&bad_flag).is_none(),
        "has_config must be strictly 0 or 1",
    );
}

// ── CobsDecoder::reset ─────────────────────────────────────────────

#[test]
fn cobs_decoder_reset_discards_partial_frame() {
    let mut decoder = CobsDecoder::new();

    // Feed half a frame (no trailing 0x00 yet) then reset. The reset must
    // discard the accumulator so the next (different) frame decodes correctly.
    decoder.feed(&[0x02, 0x99], |_| panic!("no command yet"));
    decoder.reset();

    // Now feed a complete, properly-framed Ping (tag 0).
    let mut raw = [0u8; 1];
    raw[0] = 0;
    let mut framed = [0u8; 8];
    let n = ucobs::encode(&raw[..1], &mut framed).unwrap();
    framed[n] = 0x00;

    let mut got = None;
    decoder.feed(&framed[..n + 1], |c| got = Some(c));
    assert_eq!(got, Some(Command::Ping));
}

// ── CobsDecoder::feed — empty-frame handling ───────────────────────

#[test]
fn cobs_decoder_treats_back_to_back_zeros_as_empty_frames() {
    // Kills: `if self.len > 0` → `>=`. With `>=` we would attempt to
    // decode an empty buffer on every stray 0x00 byte, which ucobs::decode
    // rejects — so the behaviour would be a silent change in error-handling
    // rather than a functional break. This test pins the current contract:
    // feeding only sentinel bytes never yields a Command and never panics.
    let mut decoder = CobsDecoder::new();
    let mut yielded = 0;
    decoder.feed(&[0, 0, 0, 0, 0], |_| yielded += 1);
    assert_eq!(yielded, 0);
}

// ── cobs_encode_response — buffer-full boundary ────────────────────

#[test]
fn cobs_encode_response_rejects_overflow() {
    // A maximum-payload RxPacket produces 263 raw bytes + 3 COBS overhead
    // ≈ 266 bytes — well under MAX_FRAME (512). To exercise the overflow
    // branch we fabricate the condition by encoding into a too-small
    // buffer via the same function on a synthetic oversized response.
    //
    // This test uses the real function with real buffers. The overflow
    // branch fires only when `encoded_len + 1 > encode_buf.len()`, which
    // for MAX_FRAME = 512 cannot happen with any valid Response. So we
    // rely on proptest / fuzz to exercise sub-threshold cases and pin the
    // happy path here: the happy path must always produce a trailing 0x00
    // sentinel and leave the encoded-length byte in bounds.
    let mut payload: HVec<u8, 256> = HVec::new();
    for i in 0..256u16 {
        payload.push((i & 0xFF) as u8).unwrap();
    }
    let mut write = [0u8; MAX_FRAME];
    let mut encode = [0u8; MAX_FRAME];
    let framed = cobs_encode_response(
        Response::RxPacket {
            rssi: -1,
            snr: -2,
            payload,
        },
        &mut write,
        &mut encode,
    )
    .unwrap();
    // Last byte is the sentinel.
    assert_eq!(*framed.last().unwrap(), 0x00);
    // Encoded length sits strictly inside the buffer; the `< buf.len()`
    // check must be strict, not `<=`.
    assert!(framed.len() <= MAX_FRAME);
}