[test](trx-core): add Tier 5 unit tests for audio framing helpers

Cover write_audio_msg / read_audio_msg / write_vchan_* / parse_vchan_* with round-trip and edge-case tests over an in-memory buffer (no sockets). Catches regressions in the wire format used by trx-server's audio listener and the trx-client audio reader.

Tests: type+length+payload round-trip, empty payload, consecutive frames, EOF before header / mid-payload, oversize normal frame rejected, history-compressed frame allowed up to its larger cap, history cap still enforced past 16 MiB, vchan UUID + audio frame round-trips, short-payload rejection, AudioStreamInfo JSON round-trip, write_audio_msg_buffered byte-equivalence.

15 new tests in trx-core.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-05-03 20:16:30 +02:00
parent 2e3c36f776
commit 81ec8191a1
+210
View File
@@ -193,3 +193,213 @@ pub fn parse_vchan_uuid_msg(payload: &[u8]) -> std::io::Result<Uuid> {
} }
Ok(Uuid::from_bytes(payload[..16].try_into().unwrap())) Ok(Uuid::from_bytes(payload[..16].try_into().unwrap()))
} }
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::BufReader;
#[tokio::test]
async fn write_then_read_round_trip_preserves_type_and_payload() {
let mut buf: Vec<u8> = Vec::new();
let payload = b"hello, world";
write_audio_msg(&mut buf, AUDIO_MSG_FT8_DECODE, payload)
.await
.unwrap();
// Wire bytes: 1 byte type + 4 bytes BE length + payload.
assert_eq!(buf.len(), 1 + 4 + payload.len());
assert_eq!(buf[0], AUDIO_MSG_FT8_DECODE);
assert_eq!(
u32::from_be_bytes([buf[1], buf[2], buf[3], buf[4]]),
payload.len() as u32
);
assert_eq!(&buf[5..], payload);
let mut reader = BufReader::new(&buf[..]);
let (msg_type, got) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_FT8_DECODE);
assert_eq!(got, payload);
}
#[tokio::test]
async fn write_then_read_handles_empty_payload() {
let mut buf: Vec<u8> = Vec::new();
write_audio_msg(&mut buf, AUDIO_MSG_RX_FRAME, &[])
.await
.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (msg_type, got) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_RX_FRAME);
assert!(got.is_empty());
}
#[tokio::test]
async fn read_audio_msg_decodes_consecutive_frames() {
let mut buf: Vec<u8> = Vec::new();
write_audio_msg(&mut buf, AUDIO_MSG_FT8_DECODE, b"a")
.await
.unwrap();
write_audio_msg(&mut buf, AUDIO_MSG_FT4_DECODE, b"bb")
.await
.unwrap();
write_audio_msg(&mut buf, AUDIO_MSG_AIS_DECODE, b"ccc")
.await
.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (t, p) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!((t, p.as_slice()), (AUDIO_MSG_FT8_DECODE, b"a".as_slice()));
let (t, p) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!((t, p.as_slice()), (AUDIO_MSG_FT4_DECODE, b"bb".as_slice()));
let (t, p) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!((t, p.as_slice()), (AUDIO_MSG_AIS_DECODE, b"ccc".as_slice()));
}
#[tokio::test]
async fn read_audio_msg_eof_before_header_returns_unexpected_eof() {
let buf: Vec<u8> = Vec::new();
let mut reader = BufReader::new(&buf[..]);
let err = read_audio_msg(&mut reader).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
#[tokio::test]
async fn read_audio_msg_eof_mid_payload_returns_unexpected_eof() {
// Header claims 16 bytes; only 4 follow.
let mut buf: Vec<u8> = vec![AUDIO_MSG_FT8_DECODE];
buf.extend_from_slice(&16u32.to_be_bytes());
buf.extend_from_slice(&[0xAA; 4]);
let mut reader = BufReader::new(&buf[..]);
let err = read_audio_msg(&mut reader).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
#[tokio::test]
async fn read_audio_msg_rejects_oversize_normal_frame() {
// Type ≠ HISTORY_COMPRESSED → cap is MAX_PAYLOAD_SIZE (1 MiB). Claim
// 2 MiB and leave the body absent — we should fail before reading.
let mut buf: Vec<u8> = vec![AUDIO_MSG_FT8_DECODE];
buf.extend_from_slice(&(2 * 1024 * 1024u32).to_be_bytes());
let mut reader = BufReader::new(&buf[..]);
let err = read_audio_msg(&mut reader).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn read_audio_msg_history_frame_allows_larger_payload() {
// 2 MiB exceeds MAX_PAYLOAD_SIZE but is below MAX_HISTORY_PAYLOAD_SIZE.
// Reading should succeed when the type is HISTORY_COMPRESSED.
let payload = vec![0xCDu8; 2 * 1024 * 1024];
let mut buf: Vec<u8> = Vec::with_capacity(5 + payload.len());
write_audio_msg(&mut buf, AUDIO_MSG_HISTORY_COMPRESSED, &payload)
.await
.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (msg_type, got) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_HISTORY_COMPRESSED);
assert_eq!(got.len(), payload.len());
}
#[tokio::test]
async fn read_audio_msg_history_frame_rejects_above_history_cap() {
// Claim 32 MiB, body absent. Should still fail (cap enforced even for
// history-compressed type).
let mut buf: Vec<u8> = vec![AUDIO_MSG_HISTORY_COMPRESSED];
buf.extend_from_slice(&(32 * 1024 * 1024u32).to_be_bytes());
let mut reader = BufReader::new(&buf[..]);
let err = read_audio_msg(&mut reader).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn vchan_uuid_msg_round_trip() {
let uuid = Uuid::new_v4();
let mut buf: Vec<u8> = Vec::new();
write_vchan_uuid_msg(&mut buf, AUDIO_MSG_VCHAN_SUB, uuid)
.await
.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (msg_type, payload) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_VCHAN_SUB);
let got = parse_vchan_uuid_msg(&payload).unwrap();
assert_eq!(got, uuid);
}
#[test]
fn parse_vchan_uuid_msg_rejects_short_payload() {
let err = parse_vchan_uuid_msg(&[0u8; 8]).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
// Empty payload also rejected.
let err = parse_vchan_uuid_msg(&[]).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn vchan_audio_frame_round_trip() {
let uuid = Uuid::new_v4();
let opus = b"\x80\x81\x82 fake opus payload";
let mut buf: Vec<u8> = Vec::new();
write_vchan_audio_frame(&mut buf, uuid, opus).await.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (msg_type, payload) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_RX_FRAME_CH);
let (got_uuid, got_opus) = parse_vchan_audio_frame(&payload).unwrap();
assert_eq!(got_uuid, uuid);
assert_eq!(got_opus, opus);
}
#[test]
fn parse_vchan_audio_frame_rejects_short_payload() {
let err = parse_vchan_audio_frame(&[0u8; 8]).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn parse_vchan_audio_frame_handles_empty_opus() {
// Exactly 16 bytes: UUID with no opus payload.
let uuid = Uuid::new_v4();
let payload = uuid.as_bytes().to_vec();
let (got_uuid, got_opus) = parse_vchan_audio_frame(&payload).unwrap();
assert_eq!(got_uuid, uuid);
assert!(got_opus.is_empty());
}
#[tokio::test]
async fn audio_stream_info_serialises_round_trip() {
let info = AudioStreamInfo {
sample_rate: 48_000,
channels: 2,
frame_duration_ms: 20,
bitrate_bps: 64_000,
};
let json = serde_json::to_vec(&info).unwrap();
let mut buf: Vec<u8> = Vec::new();
write_audio_msg(&mut buf, AUDIO_MSG_STREAM_INFO, &json)
.await
.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (msg_type, payload) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_STREAM_INFO);
let parsed: AudioStreamInfo = serde_json::from_slice(&payload).unwrap();
assert_eq!(parsed.sample_rate, info.sample_rate);
assert_eq!(parsed.channels, info.channels);
assert_eq!(parsed.frame_duration_ms, info.frame_duration_ms);
assert_eq!(parsed.bitrate_bps, info.bitrate_bps);
}
#[tokio::test]
async fn write_audio_msg_buffered_does_not_flush() {
// Smoke test that the buffered variant produces equivalent bytes to
// the flushed variant — Vec<u8> doesn't actually "buffer" so this is
// mostly a behavioural check that no extra padding/headers slip in.
let mut a: Vec<u8> = Vec::new();
let mut b: Vec<u8> = Vec::new();
write_audio_msg_buffered(&mut a, AUDIO_MSG_FT8_DECODE, b"x")
.await
.unwrap();
write_audio_msg(&mut b, AUDIO_MSG_FT8_DECODE, b"x")
.await
.unwrap();
assert_eq!(a, b);
}
}