From 81ec8191a193f0008d1729241380b1fa9026762d Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sun, 3 May 2026 20:16:30 +0200 Subject: [PATCH] [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 Signed-off-by: Stan Grams --- src/trx-core/src/audio.rs | 210 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/src/trx-core/src/audio.rs b/src/trx-core/src/audio.rs index fb3cbb2..912282a 100644 --- a/src/trx-core/src/audio.rs +++ b/src/trx-core/src/audio.rs @@ -193,3 +193,213 @@ pub fn parse_vchan_uuid_msg(payload: &[u8]) -> std::io::Result { } 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 doesn't actually "buffer" so this is + // mostly a behavioural check that no extra padding/headers slip in. + let mut a: Vec = Vec::new(); + let mut b: Vec = 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); + } +}