[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:
@@ -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()))
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user