//! Targeted tests that exercise specific branches of the codec so //! surviving mutants surface quickly. //! //! These complement `vectors.rs` (canonical wire bytes) and //! `proptest.rs` (random round-trips) by pinning narrow edge cases that //! random strategies tend to miss — e.g. off-by-one comparisons, CRC //! byte order, COBS boundary cases, reserved-bit validation. #![allow(clippy::unwrap_used, clippy::panic, clippy::bool_assert_comparison)] use donglora_protocol::{ Command, CommandEncodeError, CommandParseError, DeviceMessage, DeviceMessageParseError, ErrorCode, FrameDecodeError, FrameDecoder, FrameResult, Info, InfoParseError, LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode, MAX_MCU_UID_LEN, MAX_OK_PAYLOAD, MAX_OTA_PAYLOAD, MAX_PAYLOAD_FIELD, MAX_PRE_COBS_FRAME, MAX_RADIO_UID_LEN, MAX_SETCONFIG_OK_PAYLOAD, MAX_WIRE_FRAME, Modulation, OkPayload, Owner, RxOrigin, RxPayload, SetConfigResult, SetConfigResultCode, TxDonePayload, TxFlags, TxResult, cap, commands, crc::crc16, encode_frame, events, }; use heapless::Vec as HVec; // ── CRC boundary vectors ─────────────────────────────────────────── #[test] fn crc16_check_value_is_0x29b1() { // Spec Appendix B — the canonical CRC-16/CCITT-FALSE check value. // Any accidental variant switch (KERMIT, XMODEM, reflected, different // init) breaks this before it reaches the wire layer. assert_eq!(crc16(b"123456789"), 0x29B1); } #[test] fn crc16_single_null_byte_not_zero() { // A naive "init = 0" implementation would yield 0 for a single null // input. The correct CCITT-FALSE init 0xFFFF yields 0xE1F0. assert_eq!(crc16(&[0x00]), 0xE1F0); } #[test] fn crc16_empty_input_is_initial_value() { assert_eq!(crc16(&[]), 0xFFFF); } // ── Frame CRC trailer positioning ────────────────────────────────── #[test] fn encode_then_decode_flipped_crc_byte_fails() { // Target: swapping CRC byte order or indexing off-by-one in the // CRC trailer placement. Take a valid frame, flip one CRC byte, and // confirm the decoder rejects. let mut wire = [0u8; MAX_WIRE_FRAME]; let n = encode_frame(commands::TYPE_PING, 0x0042, &[], &mut wire).unwrap(); // Last byte is 0x00 sentinel; the byte before that is part of the // COBS-encoded CRC. Flip it to inject CRC corruption. wire[n - 2] ^= 0x01; let mut decoder = FrameDecoder::new(); let mut saw_err = false; decoder.feed(&wire[..n], |res| { if matches!(res, FrameResult::Err(_)) { saw_err = true; } }); assert!(saw_err, "CRC-corrupted frame must produce FrameResult::Err"); } #[test] fn decoder_rejects_frame_exactly_4_bytes_post_cobs() { // Min valid post-COBS frame is 5 bytes (type + tag + crc). A frame // that decodes to exactly 4 bytes post-COBS must be flagged TooShort // — catches off-by-one on the minimum-length check. // COBS of [0xAA, 0xBB, 0xCC, 0xDD] is [0x05, 0xAA, 0xBB, 0xCC, 0xDD]. let wire = [0x05, 0xAA, 0xBB, 0xCC, 0xDD, 0x00]; let mut decoder = FrameDecoder::new(); let mut saw_too_short = false; decoder.feed(&wire, |res| { if matches!(res, FrameResult::Err(FrameDecodeError::TooShort)) { saw_too_short = true; } }); assert!(saw_too_short); } #[test] fn decoder_accepts_frame_exactly_5_bytes_post_cobs() { // The 5-byte boundary must succeed (empty payload: type + tag + crc). let mut wire = [0u8; MAX_WIRE_FRAME]; let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap(); let mut decoder = FrameDecoder::new(); let mut saw_ok = false; decoder.feed(&wire[..n], |res| { if matches!(res, FrameResult::Ok { .. }) { saw_ok = true; } }); assert!(saw_ok); } // ── TX flag boundary ─────────────────────────────────────────────── #[test] fn tx_flags_reserved_bits_each_rejected() { // Target: `b & !0b0000_0001 != 0` mutated to `== 0` or flipped // elsewhere. Verify every single reserved bit position triggers // rejection. for bit in 1..8 { let byte = 1u8 << bit; assert!( matches!( TxFlags::from_byte(byte), Err(CommandParseError::ReservedBitSet) ), "bit {bit} must be rejected", ); } // Bit 0 alone is allowed (that's the skip_cad flag). assert_eq!(TxFlags::from_byte(0b0000_0001).unwrap().skip_cad, true); } // ── Empty-TX rejection ───────────────────────────────────────────── #[test] fn tx_parse_rejects_only_flags_byte() { // Spec §6.4: TX with zero data bytes must be rejected. assert!(matches!( Command::parse(commands::TYPE_TX, &[0x00]), Err(CommandParseError::WrongLength) )); } #[test] fn tx_parse_accepts_one_data_byte() { // Minimum valid TX: flags byte + exactly one data byte. let got = Command::parse(commands::TYPE_TX, &[0x00, 0x42]).unwrap(); match got { Command::Tx { flags, data } => { assert_eq!(flags.skip_cad, false); assert_eq!(data.as_slice(), &[0x42]); } other => panic!("expected Tx, got {:?}", other), } } #[test] fn tx_parse_rejects_payload_exceeding_max_ota() { // Craft MAX_OTA_PAYLOAD + 1 bytes of data (plus the flags byte). let mut bytes: HVec = HVec::new(); bytes.push(0x00).unwrap(); // flags for _ in 0..=MAX_OTA_PAYLOAD { bytes.push(0x55).unwrap(); } assert!(matches!( Command::parse(commands::TYPE_TX, &bytes), Err(CommandParseError::WrongLength) )); } // ── COBS run-length boundaries ───────────────────────────────────── #[test] fn cobs_254_byte_nonzero_run() { // A long run of nonzero payload bytes forces COBS to emit a 0xFF // code byte and start a fresh run — the classic boundary case // from `PROTOCOL.md §C.8.3`. We pick a run large enough that the // type + tag + payload + CRC span exceeds 254 bytes without any // interior zero to break it up. let mut big: HVec = HVec::new(); for i in 1..=253u8 { big.push(i).unwrap(); } let mut wire = [0u8; MAX_WIRE_FRAME]; let n = encode_frame(0x04, 0x0101, &big, &mut wire).unwrap(); let mut decoder = FrameDecoder::new(); let mut seen: Option<(u8, u16, HVec)> = None; decoder.feed(&wire[..n], |res| { if let FrameResult::Ok { type_id, tag, payload, } = res { let mut p = HVec::new(); p.extend_from_slice(payload).unwrap(); seen = Some((type_id, tag, p)); } }); let (ty, tag, payload) = seen.unwrap(); assert_eq!(ty, 0x04); assert_eq!(tag, 0x0101); assert_eq!(payload.len(), big.len()); assert_eq!(payload.as_slice(), big.as_slice()); } #[test] fn decoder_handles_split_feed() { // Feed one full frame across two `feed()` calls. Accumulator must // preserve the partial state, not reset on each call. let mut wire = [0u8; MAX_WIRE_FRAME]; let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap(); assert!(n >= 3); let mut decoder = FrameDecoder::new(); let mut count = 0; decoder.feed(&wire[..n / 2], |_| { count += 1; }); assert_eq!(count, 0, "no 0x00 in first chunk → no frame yet"); decoder.feed(&wire[n / 2..n], |res| { if matches!(res, FrameResult::Ok { .. }) { count += 1; } }); assert_eq!(count, 1, "second chunk contains the 0x00 sentinel"); } // ── Tag little-endian byte order ─────────────────────────────────── #[test] fn tag_byte_order_is_le() { // tag=0x0102 → bytes 02 01 (LE). Any mutant swapping the tag // bytes would fail here. let mut wire = [0u8; MAX_WIRE_FRAME]; let n = encode_frame(0x01, 0x0102, &[], &mut wire).unwrap(); let mut decoder = FrameDecoder::new(); let mut seen_tag = None; decoder.feed(&wire[..n], |res| { if let FrameResult::Ok { tag, .. } = res { seen_tag = Some(tag); } }); assert_eq!(seen_tag, Some(0x0102)); } // ── Size-constant pins (lib.rs) ───────────────────────────────────── // // The `+` in `MAX_PRE_COBS_FRAME = FRAME_HEADER_SIZE + MAX_PAYLOAD_FIELD + // FRAME_TRAILER_SIZE` etc. is a prime mutation target. Buffers sized // from the mutated constant still work (bigger ≠ failure, smaller // masked by existing slack), so exact-value tests are the canonical // kill. #[test] fn max_pre_cobs_frame_pins_exactly_280() { assert_eq!(MAX_PRE_COBS_FRAME, 280); } #[test] fn max_wire_frame_pins_exactly_284() { // 280 pre-COBS + 3 worst-case COBS overhead + 1 sentinel. assert_eq!(MAX_WIRE_FRAME, 284); } #[test] fn max_ok_payload_pins_exactly_85() { // 37 fixed + 32 mcu_uid + 16 radio_uid. Any mutation that flips // `+` to `*` or `-` crashes this. assert_eq!(MAX_OK_PAYLOAD, 85); } #[test] fn max_setconfig_ok_payload_pins_exactly_27() { // 2 (result + owner) + 1 (modulation_id) + 24 (FSK with 8B sync). assert_eq!(MAX_SETCONFIG_OK_PAYLOAD, 27); } // ── Command::encode_payload / parse boundaries (commands.rs) ──────── fn tx_cmd(data_len: usize) -> Command { let mut d: HVec = HVec::new(); for i in 0..data_len { d.push(i as u8).unwrap(); } Command::Tx { flags: TxFlags::default(), data: d, } } #[test] fn tx_encode_accepts_exactly_max_ota_payload() { // Kills `> MAX_OTA_PAYLOAD` → `>=` (the boundary-inclusive mutant). let cmd = tx_cmd(MAX_OTA_PAYLOAD); let mut buf = [0u8; MAX_OTA_PAYLOAD + 8]; assert!(cmd.encode_payload(&mut buf).is_ok()); } #[test] fn tx_encode_rejects_max_ota_payload_plus_one() { // Kills `> MAX_OTA_PAYLOAD` → `==` — a heapless::Vec can't hold // more than MAX_OTA_PAYLOAD, so we reach the limit via the parse // path with an oversized slice. let mut bytes: HVec = HVec::new(); bytes.push(0x00).unwrap(); for _ in 0..=MAX_OTA_PAYLOAD { bytes.push(0x42).unwrap(); } assert!(matches!( Command::parse(commands::TYPE_TX, &bytes), Err(CommandParseError::WrongLength) )); } #[test] fn tx_encode_rejects_buffer_exactly_one_byte_too_small() { // Kills `buf.len() < total` → `==` and `<=`. let cmd = tx_cmd(5); // total = 1 (flags) + 5 (data) = 6 let mut exact = [0u8; 6]; assert!(cmd.encode_payload(&mut exact).is_ok()); let mut too_small = [0u8; 5]; assert!(matches!( cmd.encode_payload(&mut too_small), Err(CommandEncodeError::BufferTooSmall) )); } #[test] fn tx_parse_body_exactly_max_ota_payload() { // Kills `body.len() > MAX_OTA_PAYLOAD` → `==`. let mut bytes: HVec = HVec::new(); bytes.push(0x00).unwrap(); for _ in 0..MAX_OTA_PAYLOAD { bytes.push(0x42).unwrap(); } let cmd = Command::parse(commands::TYPE_TX, &bytes).unwrap(); match cmd { Command::Tx { data, .. } => assert_eq!(data.len(), MAX_OTA_PAYLOAD), _ => panic!("expected Tx"), } } // ── SetConfigResultCode / Owner enum coverage (events.rs) ─────────── #[test] fn set_config_result_code_as_u8_exact() { // Kills `SetConfigResultCode::as_u8 -> u8 with 0` by pinning each // non-zero variant's discriminant. assert_eq!(SetConfigResultCode::Applied.as_u8(), 0); assert_eq!(SetConfigResultCode::AlreadyMatched.as_u8(), 1); assert_eq!(SetConfigResultCode::LockedMismatch.as_u8(), 2); } #[test] fn set_config_result_code_from_u8_exact() { // Kills match-arm-deletion mutants (1, 2) by asserting each maps. assert_eq!( SetConfigResultCode::from_u8(0), Some(SetConfigResultCode::Applied), ); assert_eq!( SetConfigResultCode::from_u8(1), Some(SetConfigResultCode::AlreadyMatched), ); assert_eq!( SetConfigResultCode::from_u8(2), Some(SetConfigResultCode::LockedMismatch), ); assert_eq!(SetConfigResultCode::from_u8(3), None); } #[test] fn owner_as_u8_exact() { // Kills `Owner::as_u8 -> u8 with 1`. assert_eq!(Owner::None.as_u8(), 0); assert_eq!(Owner::Mine.as_u8(), 1); assert_eq!(Owner::Other.as_u8(), 2); } #[test] fn owner_from_u8_exact() { // Kills match-arm-deletion mutants (0, 2). assert_eq!(Owner::from_u8(0), Some(Owner::None)); assert_eq!(Owner::from_u8(1), Some(Owner::Mine)); assert_eq!(Owner::from_u8(2), Some(Owner::Other)); assert_eq!(Owner::from_u8(3), None); } // ── SetConfigResult encode/decode length boundaries ──────────────── fn sample_lora_for_scr() -> LoRaConfig { LoRaConfig { freq_hz: 915_000_000, sf: 7, bw: LoRaBandwidth::Khz125, cr: LoRaCodingRate::Cr4_5, preamble_len: 8, sync_word: 0x1424, tx_power_dbm: 14, header_mode: LoRaHeaderMode::Explicit, payload_crc: true, iq_invert: false, } } #[test] fn set_config_result_encode_rejects_exactly_one_byte_buf() { // Kills `buf.len() < 2` → `==` / `<=`. let r = SetConfigResult { result: SetConfigResultCode::Applied, owner: Owner::Mine, current: Modulation::LoRa(sample_lora_for_scr()), }; let mut one = [0u8; 1]; assert!(r.encode(&mut one).is_err()); let mut enough = [0u8; 32]; assert!(r.encode(&mut enough).is_ok()); } #[test] fn set_config_result_decode_rejects_exactly_one_byte_buf() { // Kills `buf.len() < 2` → `==` / `<=` on the decode side. assert!(matches!( SetConfigResult::decode(&[]), Err(DeviceMessageParseError::TooShort) )); assert!(matches!( SetConfigResult::decode(&[0x00]), Err(DeviceMessageParseError::TooShort) )); } // ── OkPayload::parse_for dispatches each command cleanly ─────────── #[test] fn ok_payload_parses_get_info() { // Kills `delete match arm commands::TYPE_GET_INFO`. let mut info_buf = [0u8; 128]; let info = sample_info(); let n = info.encode(&mut info_buf).unwrap(); let parsed = OkPayload::parse_for(commands::TYPE_GET_INFO, &info_buf[..n]).unwrap(); assert!(matches!(parsed, OkPayload::Info(_))); } #[test] fn ok_payload_parses_set_config() { // Kills `delete match arm commands::TYPE_SET_CONFIG`. let r = SetConfigResult { result: SetConfigResultCode::Applied, owner: Owner::Mine, current: Modulation::LoRa(sample_lora_for_scr()), }; let mut buf = [0u8; 64]; let n = r.encode(&mut buf).unwrap(); let parsed = OkPayload::parse_for(commands::TYPE_SET_CONFIG, &buf[..n]).unwrap(); assert!(matches!(parsed, OkPayload::SetConfig(_))); } // ── DeviceMessage::parse covers each type_id ─────────────────────── #[test] fn device_message_parses_err() { // Kills `delete match arm TYPE_ERR` in DeviceMessage::parse. let mut err_buf = [0u8; 2]; events::encode_err_payload(ErrorCode::EBusy, &mut err_buf).unwrap(); let parsed = DeviceMessage::parse(events::TYPE_ERR, &err_buf, None).unwrap(); assert!(matches!(parsed, DeviceMessage::Err(ErrorCode::EBusy))); } #[test] fn device_message_parses_tx_done() { // Kills `delete match arm TYPE_TX_DONE`. let td = TxDonePayload { result: TxResult::Transmitted, airtime_us: 42, }; let mut buf = [0u8; 8]; let n = td.encode(&mut buf).unwrap(); let parsed = DeviceMessage::parse(events::TYPE_TX_DONE, &buf[..n], None).unwrap(); assert!(matches!( parsed, DeviceMessage::TxDone(TxDonePayload { airtime_us: 42, .. }) )); } // ── RxPayload::decode length math (events.rs:255) ────────────────── #[test] fn rx_payload_decode_subtracts_metadata_size() { // `n = buf.len() - METADATA_SIZE` — kills `- -> /`. If `-` is // replaced with `/`, the resulting n would be `buf.len() / 20` // which for a 21-byte buffer yields 1 (matches the truth) — but // for a 40-byte buffer yields 2 (wrong; truth is 20). Use a // buffer length that makes the two calculations disagree. let mut meta = [0u8; 20]; meta[0..2].copy_from_slice(&(-100i16).to_le_bytes()); meta[16] = 1; // crc_valid meta[19] = 0; // origin Ota let mut buf: HVec = HVec::new(); buf.extend_from_slice(&meta).unwrap(); buf.extend_from_slice(&[0x01u8; 20]).unwrap(); let decoded = RxPayload::decode(&buf).unwrap(); assert_eq!(decoded.data.len(), 20); } // ── FrameDecoder reset actually clears state ────────────────────── #[test] fn frame_decoder_reset_clears_accumulator() { // Kills `replace FrameDecoder::reset with ()`. let mut decoder = FrameDecoder::new(); // Feed a half-frame: bytes without a 0x00 terminator. decoder.feed(&[0xAA, 0xBB, 0xCC, 0xDD], |_| {}); decoder.reset(); // Now feed a complete, valid frame. The accumulator must not carry // the stale bytes — if `reset` is a no-op, the decoder prepends // them and the CRC / COBS decode fails. let mut wire = [0u8; MAX_WIRE_FRAME]; let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap(); let mut saw_ok = false; decoder.feed(&wire[..n], |res| { if matches!(res, FrameResult::Ok { .. }) { saw_ok = true; } }); assert!(saw_ok, "reset() must clear stale bytes"); } // ── Frame decoder overflow || vs && branch (frame.rs:111) ────────── #[test] fn frame_decoder_stray_sentinel_does_not_emit() { // `self.overflowed || self.len == 0` mutated to `&&` would emit a // spurious error for a stray 0x00 (both `overflowed` and `len == 0` // must be true under `&&`, but on a naked `0x00` only `len == 0` // holds — so the branch stops triggering and the decoder tries to // COBS-decode an empty buffer). let mut decoder = FrameDecoder::new(); let mut saw_any = false; decoder.feed(&[0x00], |_| { saw_any = true; }); assert!( !saw_any, "stray 0x00 with empty buffer must not produce any frame" ); } // ── Capability bit values (info.rs) ──────────────────────────────── // // Kills the 14 `<< -> >>` mutants on the cap:: constants by pinning // each bit's expected u64 value. #[test] fn cap_bit_values_pinned() { assert_eq!(cap::LORA, 1); assert_eq!(cap::FSK, 2); assert_eq!(cap::GFSK, 4); assert_eq!(cap::LR_FHSS, 8); assert_eq!(cap::FLRC, 16); assert_eq!(cap::MSK, 32); assert_eq!(cap::GMSK, 64); assert_eq!(cap::BLE_COMPATIBLE, 128); assert_eq!(cap::CAD_BEFORE_TX, 0x0001_0000); assert_eq!(cap::IQ_INVERSION, 0x0002_0000); assert_eq!(cap::RANGING, 0x0004_0000); assert_eq!(cap::GNSS_SCAN, 0x0008_0000); assert_eq!(cap::WIFI_MAC_SCAN, 0x0010_0000); assert_eq!(cap::SPECTRAL_SCAN, 0x0020_0000); assert_eq!(cap::FULL_DUPLEX, 0x0040_0000); assert_eq!(cap::MULTI_CLIENT, 0x0000_0001_0000_0000); } // ── Info::encode / decode boundaries ─────────────────────────────── fn sample_info() -> Info { let mut mcu = [0u8; MAX_MCU_UID_LEN]; mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]); Info { proto_major: 1, proto_minor: 0, fw_major: 0, fw_minor: 1, fw_patch: 0, radio_chip_id: 0x0002, capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX, supported_sf_bitmap: 0x1FE0, supported_bw_bitmap: 0x03FF, max_payload_bytes: 255, rx_queue_capacity: 32, tx_queue_capacity: 1, freq_min_hz: 150_000_000, freq_max_hz: 960_000_000, tx_power_min_dbm: -9, tx_power_max_dbm: 22, mcu_uid_len: 8, mcu_uid: mcu, radio_uid_len: 0, radio_uid: [0u8; MAX_RADIO_UID_LEN], } } #[test] fn info_encode_rejects_buffer_exactly_one_byte_too_small() { // Kills `buf.len() < total` → `==` / `<=` in Info::encode. let i = sample_info(); // 37 fixed + 8 mcu_uid + 0 radio_uid = 45 bytes required. let mut exact = [0u8; 45]; assert!(i.encode(&mut exact).is_ok()); let mut too_small = [0u8; 44]; assert!(i.encode(&mut too_small).is_err()); } #[test] fn info_decode_rejects_mcu_uid_len_overrun() { // Kills the `<` boundary checks and `+` arithmetic in Info::decode // by constructing an `mcu_uid_len` that extends past the buffer. let i = sample_info(); let mut buf = [0u8; 128]; let n = i.encode(&mut buf).unwrap(); // Pretend mcu_uid_len is 30 so radio_len_idx is 66, beyond n=45. buf[35] = 30; assert!(matches!( Info::decode(&buf[..n]), Err(donglora_protocol::InfoParseError::TooShort) )); } #[test] fn info_decode_offsets_are_correct() { // Implicitly kills the `+ -> -` and `+ -> *` mutants in // Info::decode's offset calculations by roundtripping a // non-zero radio_uid_len AND non-zero mcu_uid_len — any arithmetic // bug flips the data between the two UIDs. let mut i = sample_info(); let mut radio_uid = [0u8; MAX_RADIO_UID_LEN]; radio_uid[..4].copy_from_slice(&[0xCA, 0xFE, 0xBA, 0xBE]); i.radio_uid_len = 4; i.radio_uid = radio_uid; let mut buf = [0u8; 128]; let n = i.encode(&mut buf).unwrap(); let decoded = Info::decode(&buf[..n]).unwrap(); assert_eq!( &decoded.mcu_uid[..8], &[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67] ); assert_eq!(&decoded.radio_uid[..4], &[0xCA, 0xFE, 0xBA, 0xBE]); } #[test] fn info_min_wire_size_pinned() { // Kills mutants touching the fixed prefix size. assert_eq!(Info::MIN_WIRE_SIZE, 37); } // ── FskConfig::encode buffer-size boundary ───────────────────────── #[test] fn fsk_encode_rejects_buffer_exactly_one_byte_too_small() { // Kills `buf.len() < total` → `==` / `<=` in FskConfig::encode. let mut cfg = donglora_protocol::FskConfig { freq_hz: 0, bitrate_bps: 0, freq_dev_hz: 0, rx_bw: 0, preamble_len: 0, sync_word_len: 4, sync_word: [0u8; donglora_protocol::MAX_SYNC_WORD_LEN], }; cfg.sync_word[..4].copy_from_slice(&[1, 2, 3, 4]); // total = 16 + 4 = 20 let mut exact = [0u8; 20]; assert!(cfg.encode(&mut exact).is_ok()); let mut too_small = [0u8; 19]; assert!(cfg.encode(&mut too_small).is_err()); } // ── Frame encode buffer boundary (frame.rs:68) ───────────────────── #[test] fn encode_frame_rejects_output_buffer_exactly_one_byte_too_small() { // Kills `out.len() < total` → `<=` in encode_frame. // // We need to know the exact encoded size for our chosen payload. let mut generous = [0u8; MAX_WIRE_FRAME]; let n = encode_frame(0x01, 0x0001, b"test", &mut generous).unwrap(); assert!(n >= 3); let mut exact = [0u8; MAX_WIRE_FRAME]; assert!(encode_frame(0x01, 0x0001, b"test", &mut exact[..n]).is_ok()); assert!(encode_frame(0x01, 0x0001, b"test", &mut exact[..n - 1]).is_err()); } // ── frame.rs MAX_COBS_ENCODED arithmetic (frame.rs:38) ───────────── #[test] fn max_wire_frame_minus_one_leaves_room_for_sentinel() { // Indirectly kills `MAX_COBS_ENCODED = MAX_WIRE_FRAME - 1` mutants: // encoding a maximum-payload frame must fit in exactly // MAX_WIRE_FRAME bytes, and no fewer. let big = [0x42u8; MAX_PAYLOAD_FIELD]; let mut wire = [0u8; MAX_WIRE_FRAME]; let n = encode_frame(0xC0, 0x0000, &big, &mut wire).unwrap(); assert!(n <= MAX_WIRE_FRAME); assert!(n > MAX_PAYLOAD_FIELD); } // ── Unknown command type produces UnknownType, not WrongLength ───── #[test] fn parse_unknown_type_with_empty_payload() { // 0x10 is reserved (not a defined H→D command type). The parser // must surface UnknownType so the caller can emit EUNKNOWN_CMD. assert!(matches!( Command::parse(0x10, &[]), Err(CommandParseError::UnknownType) )); } // ── SetConfigResult::encode boundary (events.rs:103) ────────────── // // `buf.len() < 2` — the guard clamps writes to the result + owner // bytes. A test that just flips the boundary to `is_err()` can't tell // `<` from `<=` because the downstream `Modulation::encode` also fails. // We distinguish by checking that buf[0] / buf[1] ARE written before // the modulation encode fails: unmutated writes them; the `<=` mutant // returns early and leaves the buffer untouched. #[test] fn set_config_result_encode_writes_header_before_modulation_fails() { let r = SetConfigResult { result: SetConfigResultCode::LockedMismatch, owner: Owner::Other, current: Modulation::LoRa(sample_lora_for_scr()), }; let mut buf = [0xFFu8; 2]; let res = r.encode(&mut buf); assert!(res.is_err(), "modulation encode needs more than 2 bytes"); assert_eq!( buf[0], SetConfigResultCode::LockedMismatch.as_u8(), "result byte must be written before modulation encode fails", ); assert_eq!( buf[1], Owner::Other.as_u8(), "owner byte must be written before modulation encode fails", ); } // ── SetConfigResult::decode boundary (events.rs:116) ─────────────── // // Unmutated: `buf.len() < 2` passes for len==2, then `Modulation::decode(&[])` // returns `TooShort` → mapped to `WrongLength`. Mutated `<=`: guard trips, // returns `TooShort`. Distinguish via the error variant. #[test] fn set_config_result_decode_two_byte_buf_is_wrong_length_not_too_short() { let res = SetConfigResult::decode(&[0x00, 0x01]); assert!( matches!(res, Err(DeviceMessageParseError::WrongLength)), "decode of a 2-byte buffer must surface WrongLength from the inner \ Modulation::decode call, not TooShort from the guard", ); } // ── RxPayload::decode uses `n` for the data slice (events.rs:255) ─ // // After the refactor, `n = buf.len() - METADATA_SIZE` is load-bearing: // the data slice is `&buf[METADATA_SIZE..METADATA_SIZE + n]`. A mutant // that swaps `-` for `/` yields a smaller n and a shorter data slice, // which the round-trip observes. #[test] fn rx_payload_encode_accepts_exactly_max_ota_payload() { // Kills `data.len() > MAX_OTA_PAYLOAD` → `==` in RxPayload::encode. // At exactly MAX_OTA_PAYLOAD, the `==` mutant trips // PayloadTooLarge where the unmutated path encodes cleanly. let mut data: HVec = HVec::new(); for i in 0..MAX_OTA_PAYLOAD { data.push(i as u8).unwrap(); } let rx = RxPayload { rssi_tenths_dbm: -735, snr_tenths_db: 95, freq_err_hz: 0, timestamp_us: 0, crc_valid: true, packets_dropped: 0, origin: RxOrigin::Ota, data, }; let mut buf = [0u8; 20 + MAX_OTA_PAYLOAD]; let n = rx.encode(&mut buf).unwrap(); assert_eq!(n, 20 + MAX_OTA_PAYLOAD); } #[test] fn rx_payload_encode_accepts_buffer_exactly_equal_to_total() { // Kills `buf.len() < total` → `==` in RxPayload::encode. With the // `==` mutant, an exactly-sized buffer trips BufferTooSmall instead // of succeeding. let mut data: HVec = HVec::new(); data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap(); let rx = RxPayload { rssi_tenths_dbm: -735, snr_tenths_db: 95, freq_err_hz: -125, timestamp_us: 42_000_000, crc_valid: true, packets_dropped: 0, origin: RxOrigin::Ota, data, }; // total = 20 metadata + 4 data = 24. Exact fit. let mut buf = [0u8; 24]; let n = rx.encode(&mut buf).unwrap(); assert_eq!(n, 24); } #[test] fn rx_payload_decode_accepts_exactly_max_ota_payload() { // Kills `n > MAX_OTA_PAYLOAD` → `==` in RxPayload::decode. A buffer // with exactly 20 + MAX_OTA_PAYLOAD bytes means n == MAX_OTA_PAYLOAD; // the `==` mutant trips and returns WrongLength, unmutated decodes // cleanly with full-length data. let mut buf = [0u8; 20 + MAX_OTA_PAYLOAD]; buf[16] = 1; // crc_valid buf[19] = 0; // origin Ota for (i, b) in buf[20..].iter_mut().enumerate() { *b = i as u8; } let decoded = RxPayload::decode(&buf).unwrap(); assert_eq!(decoded.data.len(), MAX_OTA_PAYLOAD); assert_eq!(decoded.data[0], 0); assert_eq!( decoded.data[MAX_OTA_PAYLOAD - 1], (MAX_OTA_PAYLOAD - 1) as u8 ); } #[test] fn rx_payload_decode_accepts_exactly_metadata_size_no_data() { // Kills both `buf.len() < METADATA_SIZE` → `==` / `<=` mutants on // `RxPayload::decode`. A 20-byte buffer (metadata only, zero data) // must succeed: unmutated returns an RxEvent with empty `data`; // either mutant trips the guard and returns TooShort. let mut buf = [0u8; 20]; buf[16] = 1; // crc_valid buf[19] = 0; // origin Ota let decoded = RxPayload::decode(&buf).unwrap(); assert_eq!(decoded.data.len(), 0); } #[test] fn rx_payload_decode_rejects_one_byte_below_metadata_size() { // Complements the above: a 19-byte buffer must fail regardless of // how the `<` boundary is mutated, since the data slice // `buf[METADATA_SIZE..]` would otherwise index out of bounds. let buf = [0u8; 19]; assert!(matches!( RxPayload::decode(&buf), Err(DeviceMessageParseError::TooShort), )); } #[test] fn rx_payload_decode_n_is_used_for_data_slice() { let mut buf = [0u8; 40]; buf[16] = 1; // crc_valid buf[19] = 0; // origin Ota for (i, b) in buf[20..40].iter_mut().enumerate() { *b = i as u8; } let decoded = RxPayload::decode(&buf).unwrap(); // Unmutated: n = 40 - 20 = 20 → data slice is all 20 bytes. // Mutated `-` → `/`: n = 40 / 20 = 2 → data slice is only 2 bytes. assert_eq!(decoded.data.len(), 20); assert_eq!(decoded.data[0], 0); assert_eq!(decoded.data[19], 19); } // ── Info sample for the tests below ──────────────────────────────── fn info_sample() -> Info { let mut mcu = [0u8; MAX_MCU_UID_LEN]; mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]); Info { proto_major: 1, proto_minor: 0, fw_major: 0, fw_minor: 1, fw_patch: 0, radio_chip_id: 0x0002, capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX, supported_sf_bitmap: 0x1FE0, supported_bw_bitmap: 0x03FF, max_payload_bytes: 255, rx_queue_capacity: 32, tx_queue_capacity: 1, freq_min_hz: 150_000_000, freq_max_hz: 960_000_000, tx_power_min_dbm: -9, tx_power_max_dbm: 22, mcu_uid_len: 8, mcu_uid: mcu, radio_uid_len: 0, radio_uid: [0u8; MAX_RADIO_UID_LEN], } } // ── Info::decode min-size boundary (info.rs:132) ─────────────────── // // A zero-UID Info encodes to exactly MIN_WIRE_SIZE (37) bytes. Decode // of a 37-byte buffer must succeed. `<=` mutant makes it return // TooShort. #[test] fn info_decode_accepts_exactly_min_wire_size() { let mut i = info_sample(); i.mcu_uid_len = 0; i.radio_uid_len = 0; let mut buf = [0u8; 64]; let n = i.encode(&mut buf).unwrap(); assert_eq!(n, 37); let decoded = Info::decode(&buf[..n]).unwrap(); assert_eq!(decoded.mcu_uid_len, 0); assert_eq!(decoded.radio_uid_len, 0); } // ── Info::decode `radio_len_idx + 1` guard (info.rs:141) ────────── // // The `+ 1` reserves the byte that holds `radio_uid_len`. A 44-byte // buffer with mcu_uid_len=8 has radio_len_idx=44 — the check needs 45 // bytes, so 44 must produce TooShort. Mutants `+ → -` or `+ → *` // either relax the guard and then panic reading buf[44], or simply // allow the read to succeed with garbage data. #[test] fn info_decode_radio_len_idx_plus_one_guard_is_active() { let mut buf = [0u8; 44]; buf[35] = 8; // mcu_uid_len so radio_len_idx lands at 44 assert!(matches!(Info::decode(&buf), Err(InfoParseError::TooShort),)); } // ── Info::decode `expected_total` uses both + (info.rs:149) ─────── // // `Self::MIN_WIRE_SIZE + mcu_n + radio_n` — a mutation to `-` at // either `+` produces a smaller expected_total that passes the guard // at buf.len()==45 (which unmutated correctly rejects as TooShort). // With the guard bypassed, decode panics reading radio_uid from a // slice that extends past the buffer. #[test] fn info_decode_expected_total_uses_both_plus_signs() { let mut i = info_sample(); i.radio_uid_len = 4; let mut big = [0u8; 128]; let n = i.encode(&mut big).unwrap(); assert_eq!(n, 49); assert!(matches!( Info::decode(&big[..45]), Err(InfoParseError::TooShort), )); } // ── Info::decode tolerates trailing bytes (info.rs:150) ─────────── // // Per spec §16.4, minor-version extensions may append fields. The // decoder's final length guard is `buf.len() < expected_total`; a // mutant to `>` trips when buf.len() exceeds expected_total, which // breaks forward-compatibility. #[test] fn info_decode_tolerates_trailing_bytes() { let i = info_sample(); let mut buf = [0u8; 64]; let n = i.encode(&mut buf).unwrap(); assert_eq!(n, 45); // Simulate 10 bytes of a future-minor-version extension. for b in &mut buf[45..55] { *b = 0xAA; } let decoded = Info::decode(&buf[..55]).unwrap(); assert_eq!(decoded, i); }