test: canonical wire-byte vectors anchored to PROTOCOL.md

Hand-encoded byte sequences for RadioConfig, every Command tag, and
every Response tag, taken directly from the spec. Fields use hex-clean
values (freq_hz = 0x12345678, sync_word = 0xABCD, etc.) so each
little-endian byte is visually verifiable.

Locks firmware and host to the written spec — a refactor that quietly
changes a field layout fails here, not in the field.
This commit is contained in:
swaits committed 2026-04-13 12:12:12 -06:00
1 parent 53f801ec0b
commit 6acc12cde8
1 file changed
+275
+275
View File
@@ -0,0 +1,275 @@
//! Canonical wire-byte vectors anchored to `PROTOCOL.md`.
//!
//! These are hand-encoded byte sequences taken directly from the spec.
//! They lock the firmware and host implementations to the *written*
//! protocol, not just to each other — so a refactor that accidentally
//! redefines a field layout fails here before it ships.
//!
//! Every vector is expressed twice: as a hex `&[u8]` decoded into a
//! typed value, and as a typed value encoded back into bytes. Byte-for-
//! byte equality in both directions is the invariant.
#![allow(clippy::unwrap_used, clippy::panic)]
use donglora_protocol::{
Bandwidth, Command, ErrorCode, RADIO_CONFIG_SIZE, RadioConfig, Response, TX_POWER_MAX,
};
use heapless::Vec as HVec;
// ── A well-known RadioConfig with hex-clean field values ──────────────
//
// Fields are picked so each multi-byte field has a visually obvious
// little-endian byte pattern, and the config as a whole still validates
// (freq in 150–960 MHz range, sf in 5–12, cr in 5–8).
const KNOWN_FREQ_HZ: u32 = 0x1234_5678; // 305_419_896 Hz ≈ 305 MHz
const KNOWN_SYNC: u16 = 0xABCD;
const KNOWN_PREAMBLE: u16 = 0x00AB;
/// Matches the layout documented in `PROTOCOL.md §RadioConfig (13 bytes)`.
const CONFIG_BYTES: [u8; RADIO_CONFIG_SIZE] = [
0x78, 0x56, 0x34, 0x12, // freq_hz = 0x12345678 LE
0x07, // bw = Khz125
0x07, // sf = 7
0x05, // cr = 5 (4/5)
0xCD, 0xAB, // sync_word = 0xABCD LE
0x16, // tx_power_dbm = 22
0xAB, 0x00, // preamble_len = 0x00AB LE
0x01, // cad = enabled
];
fn known_config() -> RadioConfig {
RadioConfig {
freq_hz: KNOWN_FREQ_HZ,
bw: Bandwidth::Khz125,
sf: 7,
cr: 5,
sync_word: KNOWN_SYNC,
tx_power_dbm: 22,
preamble_len: KNOWN_PREAMBLE,
cad: 1,
}
}
// ── RadioConfig ─────────────────────────────────────────────────────
#[test]
fn radio_config_canonical_bytes_decode() {
assert_eq!(RadioConfig::from_bytes(&CONFIG_BYTES), Some(known_config()));
}
#[test]
fn radio_config_canonical_bytes_encode() {
let mut buf = [0u8; RADIO_CONFIG_SIZE];
let n = known_config().write_to(&mut buf);
assert_eq!(n, RADIO_CONFIG_SIZE);
assert_eq!(buf, CONFIG_BYTES);
}
#[test]
fn radio_config_tx_power_max_sentinel_is_minus_128() {
// PROTOCOL.md: "Set tx_power_dbm to -128 (0x80) to use the board's maximum."
assert_eq!(TX_POWER_MAX, -128);
let cfg = RadioConfig {
tx_power_dbm: TX_POWER_MAX,
..known_config()
};
let mut buf = [0u8; RADIO_CONFIG_SIZE];
cfg.write_to(&mut buf);
assert_eq!(buf[9], 0x80, "TX_POWER_MAX must serialize as 0x80");
}
#[test]
fn radio_config_preamble_default_sentinel_is_zero_le() {
// PROTOCOL.md: "Set preamble_len to 0 to use the firmware default."
let cfg = RadioConfig {
preamble_len: 0,
..known_config()
};
let mut buf = [0u8; RADIO_CONFIG_SIZE];
cfg.write_to(&mut buf);
assert_eq!(&buf[10..12], &[0x00, 0x00]);
}
#[test]
fn bandwidth_table_matches_protocol_md() {
// PROTOCOL.md: 0..=9 enumerated in order 7.8…500 kHz.
let expected = [
(0u8, Bandwidth::Khz7),
(1, Bandwidth::Khz10),
(2, Bandwidth::Khz15),
(3, Bandwidth::Khz20),
(4, Bandwidth::Khz31),
(5, Bandwidth::Khz41),
(6, Bandwidth::Khz62),
(7, Bandwidth::Khz125),
(8, Bandwidth::Khz250),
(9, Bandwidth::Khz500),
];
let mut buf = [0u8; RADIO_CONFIG_SIZE];
for (wire, bw) in expected {
RadioConfig {
bw,
..known_config()
}
.write_to(&mut buf);
assert_eq!(buf[4], wire, "bandwidth {:?} must wire as {}", bw, wire);
let decoded = RadioConfig::from_bytes(&buf).unwrap();
assert_eq!(decoded.bw, bw);
}
}
#[test]
fn bandwidth_invalid_values_reject() {
// PROTOCOL.md restricts bw to 0..=9.
let mut buf = CONFIG_BYTES;
for bad in [10u8, 11, 100, 255] {
buf[4] = bad;
assert!(
RadioConfig::from_bytes(&buf).is_none(),
"bandwidth {bad} must not decode",
);
}
}
// ── Commands ────────────────────────────────────────────────────────
#[test]
fn command_tags_match_protocol_md() {
// Tag table from PROTOCOL.md §Commands. Unit-variant parse-only.
assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping));
assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig));
assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx));
assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx));
assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn));
assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff));
assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac));
}
#[test]
fn command_set_config_canonical_bytes() {
// tag(2) + CONFIG_BYTES
let mut bytes = [0u8; 1 + RADIO_CONFIG_SIZE];
bytes[0] = 2;
bytes[1..].copy_from_slice(&CONFIG_BYTES);
assert_eq!(
Command::from_bytes(&bytes),
Some(Command::SetConfig(known_config()))
);
}
#[test]
fn command_transmit_no_config_canonical_bytes() {
// PROTOCOL.md §Commands tag 5: has_config(1) + [RadioConfig if 1] + len(u16 LE) + payload
let bytes = [
0x05, // Transmit
0x00, // has_config = false
0x05, 0x00, // len = 5 LE
b'h', b'e', b'l', b'l', b'o',
];
match Command::from_bytes(&bytes).unwrap() {
Command::Transmit { config, payload } => {
assert!(config.is_none());
assert_eq!(payload.as_slice(), b"hello");
}
other => panic!("expected Transmit, got {:?}", other),
}
}
#[test]
fn command_transmit_with_config_canonical_bytes() {
let mut bytes: HVec<u8, 64> = HVec::new();
bytes.push(0x05).unwrap(); // Transmit
bytes.push(0x01).unwrap(); // has_config = true
bytes.extend_from_slice(&CONFIG_BYTES).unwrap();
bytes.extend_from_slice(&[0x02, 0x00]).unwrap(); // len = 2
bytes.extend_from_slice(b"OK").unwrap();
match Command::from_bytes(&bytes).unwrap() {
Command::Transmit { config, payload } => {
assert_eq!(config, Some(known_config()));
assert_eq!(payload.as_slice(), b"OK");
}
other => panic!("expected Transmit, got {:?}", other),
}
}
#[test]
fn command_unknown_tags_reject() {
// Defined tags: 0..=8. Everything else must reject.
for bad in [9u8, 10, 100, 255] {
assert!(Command::from_bytes(&[bad]).is_none());
}
}
// ── Responses ───────────────────────────────────────────────────────
fn encode_response_bytes(r: Response) -> HVec<u8, 128> {
let mut buf = [0u8; 128];
let n = r.write_to(&mut buf);
HVec::from_slice(&buf[..n]).unwrap()
}
#[test]
fn response_canonical_unit_tags() {
assert_eq!(encode_response_bytes(Response::Pong).as_slice(), &[0x00]);
assert_eq!(encode_response_bytes(Response::TxDone).as_slice(), &[0x03]);
assert_eq!(encode_response_bytes(Response::Ok).as_slice(), &[0x04]);
}
#[test]
fn response_config_canonical_bytes() {
let mut expected = [0u8; 1 + RADIO_CONFIG_SIZE];
expected[0] = 0x01; // Config tag
expected[1..].copy_from_slice(&CONFIG_BYTES);
assert_eq!(
encode_response_bytes(Response::Config(known_config())).as_slice(),
&expected
);
}
#[test]
fn response_rx_packet_canonical_bytes() {
let mut payload: HVec<u8, 256> = HVec::new();
payload.extend_from_slice(b"data").unwrap();
let bytes = encode_response_bytes(Response::RxPacket {
rssi: -80,
snr: 10,
payload,
});
// tag(2) + rssi(-80 = 0xFFB0 LE) + snr(10 LE) + len(4 LE) + "data"
let expected: &[u8] = &[
0x02, 0xB0, 0xFF, 0x0A, 0x00, 0x04, 0x00, b'd', b'a', b't', b'a',
];
assert_eq!(bytes.as_slice(), expected);
}
#[test]
fn response_error_canonical_bytes() {
// PROTOCOL.md §Error codes: InvalidConfig=0, RadioBusy=1, TxTimeout=2,
// NotConfigured=4, NoDisplay=5. Tag 3 is reserved (was CrcError).
let pairs = [
(ErrorCode::InvalidConfig, 0u8),
(ErrorCode::RadioBusy, 1),
(ErrorCode::TxTimeout, 2),
(ErrorCode::NotConfigured, 4),
(ErrorCode::NoDisplay, 5),
];
for (code, byte) in pairs {
assert_eq!(
encode_response_bytes(Response::Error(code)).as_slice(),
&[0x05, byte],
);
}
}
#[test]
fn response_mac_address_canonical_bytes() {
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
assert_eq!(
encode_response_bytes(Response::MacAddress(mac)).as_slice(),
&[0x06, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF],
);
}