From 71cd8078d93dfbda225d37fad508235ff61e6a33 Mon Sep 17 00:00:00 2001 From: Alex van de Sandt Date: Thu, 7 May 2026 09:11:55 -0500 Subject: [PATCH] Add codec unit tests --- .clippy.toml | 1 + Cargo.lock | 7 ++ Cargo.toml | 3 + src/codec.rs | 196 ++++++++++++++++++++++++++++++++++++++++++ src/command/macros.rs | 13 ++- src/command/mod.rs | 2 +- 6 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 .clippy.toml diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..154626e --- /dev/null +++ b/.clippy.toml @@ -0,0 +1 @@ +allow-unwrap-in-tests = true diff --git a/Cargo.lock b/Cargo.lock index ab7a179..519bc3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "arctictis" version = "0.1.0" dependencies = [ + "claims", "futures-util", "itoa", "thiserror", @@ -44,6 +45,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "claims" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18" + [[package]] name = "core-foundation" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index 6c30e85..9d1eda4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,3 +47,6 @@ used_underscore_binding = "warn" unwrap_used = "warn" +[dev-dependencies] +claims = "0.8.0" + diff --git a/src/codec.rs b/src/codec.rs index 91d65c1..3acf16b 100644 --- a/src/codec.rs +++ b/src/codec.rs @@ -56,6 +56,7 @@ pub enum ResponseError { InvalidFields(#[from] E), } +#[derive(Clone, Debug)] pub struct RawResponse { cmd: Bytes, raw_values: Vec, @@ -95,6 +96,8 @@ impl Decoder for Codec { type Error = DecoderError; fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + // internal `AnyDelimiterCodec` is responsible for managing the buffer per the semantics of + // `Decoder` let Some(output) = self.decoder.decode(src)? else { return Ok(None); }; @@ -110,3 +113,196 @@ impl Decoder for Codec { Ok(Some(RawResponse { cmd, raw_values })) } } + +#[cfg(test)] +mod tests { + use claims::{assert_matches, assert_none, assert_ok}; + use tokio_util::{ + bytes::{Bytes, BytesMut}, + codec::{AnyDelimiterCodec, AnyDelimiterCodecError, Decoder, Encoder}, + }; + + use crate::{ + Command, DecoderError, OkResponseError, ResponseError, + codec::{Codec, RETURN_CODE, RawResponse}, + command::{OkResponse, command, range_param, range_response}, + }; + + #[derive(Debug, thiserror::Error)] + pub enum ParamError { + #[error("invalid UTF-8 bytes")] + Utf8Error(#[from] std::str::Utf8Error), + #[error(transparent)] + Parse(#[from] std::num::ParseIntError), + #[error("invalid value, got `{0}`")] + Invalid(u8), + } + + range_param!(RangeParam(0..=15): u8); + range_response!(RangeParam => ParamError : Invalid); + + command!(b"CMD": SimpleCommand); + command!(b"CMD": ResponseCommand => RangeParam); + command!(b"CMD": ParamCommand(RangeParam) => OkResponse); + + mod encode { + use super::*; + + #[test] + fn simple_command() { + let buf = encode(SimpleCommand); + + assert_eq!(buf.as_ref(), b"CMD\r"); + } + + #[test] + fn single_param() { + let buf = encode(ParamCommand(RangeParam::new(0).unwrap())); + + assert_eq!(buf.as_ref(), b"CMD,0\r"); + } + + fn encode(cmd: C) -> BytesMut { + let mut buf = BytesMut::new(); + Codec::new().encode(cmd, &mut buf).unwrap(); + buf + } + } + + mod decode { + use super::*; + + #[test] + fn ok_response() { + let item = decode(b"CMD,OK\r").unwrap().unwrap(); + + assert_eq!(item.cmd.as_ref(), b"CMD"); + assert_eq!(item.raw_values, [Bytes::from(b"OK".as_slice())]); + } + + #[test] + fn multi_param_response() { + let item = decode(b"CMD,1,2,3\r").unwrap().unwrap(); + + assert_eq!(item.cmd.as_ref(), b"CMD"); + assert_eq!( + item.raw_values, + [ + Bytes::from(b"1".as_slice()), + Bytes::from(b"2".as_slice()), + Bytes::from(b"3".as_slice()) + ], + ); + } + + #[test] + fn incomplete_response_returns_none() { + let (response, result) = decode_buf(b"CMD,"); + + assert_none!(assert_ok!(result)); + // buffer should not be changed + assert_eq!(response.as_ref(), b"CMD,"); + } + + #[test] + fn clears_buffer_on_complete_response() { + let (response, result) = decode_buf(b"CMD,OK\r"); + + assert_eq!(result.unwrap().unwrap().cmd.as_ref(), b"CMD"); + // buffer should be cleared + assert!(response.is_empty()); + } + + #[test] + fn partially_consumes_buffer_on_complete_response() { + let (response, result) = decode_buf(b"CMD,OK\rCMD2"); + + assert_eq!(result.unwrap().unwrap().cmd.as_ref(), b"CMD"); + // buffer should have single complete command removed + assert_eq!(response.as_ref(), b"CMD2"); + } + + #[test] + fn malformed_response() { + let err = decode(b"\r").unwrap_err(); + + assert_matches!(err, DecoderError::Malformed); + } + + #[test] + fn delimiter_error() { + let mut codec = Codec::new(); + // reduce the max length of the internal delimiter decoder + codec.decoder = + AnyDelimiterCodec::new_with_max_length(vec![RETURN_CODE], vec![RETURN_CODE], 1); + let mut response = BytesMut::from(b"CMD,OK\r".as_slice()); + + let err = codec.decode(&mut response).unwrap_err(); + assert_matches!( + err, + DecoderError::DelimiterError(AnyDelimiterCodecError::MaxChunkLengthExceeded) + ); + } + + fn decode(raw_response: &[u8]) -> Result, DecoderError> { + decode_buf(raw_response).1 + } + + fn decode_buf( + raw_response: &[u8], + ) -> (BytesMut, Result, DecoderError>) { + let mut raw_response = BytesMut::from(raw_response); + let result = Codec::new().decode(&mut raw_response); + + (raw_response, result) + } + } + + mod deserialize { + use super::*; + + #[test] + fn ok_response() { + decode(b"CMD,OK\r").deserialize::().unwrap(); + // nothing to assert + } + + #[test] + fn single_param_response() { + let response = decode(b"CMD,1\r").deserialize::().unwrap(); + assert_eq!(response.value(), 1); + } + + #[test] + fn wrong_command() { + let result = decode(b"FOO,OK\r") + .deserialize::() + .unwrap_err(); + assert_matches!(result, ResponseError::WrongCommand); + } + + #[test] + fn wrong_number_of_fields() { + let result = decode(b"CMD,OK,OK\r") + .deserialize::() + .unwrap_err(); + assert_matches!(result, ResponseError::WrongNumberOfFields); + } + + #[test] + fn invalid_fields() { + let result = decode(b"CMD,FOO\r") + .deserialize::() + .unwrap_err(); + assert_matches!( + result, + ResponseError::InvalidFields(OkResponseError::UnexpectedValue) + ); + } + + fn decode(raw: &[u8]) -> RawResponse { + let mut raw = BytesMut::from(raw); + Codec::new().decode(&mut raw).unwrap().unwrap() + } + } +} diff --git a/src/command/macros.rs b/src/command/macros.rs index ab65b56..88183b6 100644 --- a/src/command/macros.rs +++ b/src/command/macros.rs @@ -43,13 +43,24 @@ macro_rules! command { macro_rules! range_param { ($name:ident($range:expr): $type:ty) => { - #[derive(::core::clone::Clone, ::core::marker::Copy, ::core::fmt::Debug)] + #[derive( + ::core::clone::Clone, + ::core::marker::Copy, + ::core::fmt::Debug, + ::core::cmp::PartialEq, + ::core::cmp::Eq, + )] pub struct $name($type); impl $name { pub fn new(value: $type) -> ::core::option::Option { ($range).contains(&value).then_some(Self(value)) } + + #[allow(unused)] + pub fn value(&self) -> $type { + self.0 + } } impl crate::command::Params for $name { diff --git a/src/command/mod.rs b/src/command/mod.rs index 900a581..ac2e44b 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -41,7 +41,7 @@ impl<'a> ParamBuffer<'a> { Self(bytes) } - fn serialize_param(&mut self, bytes: &[u8]) { + pub fn serialize_param(&mut self, bytes: &[u8]) { self.0.put_u8(PARAM_DELIMITER); self.0.extend_from_slice(bytes); } -- 2.51.2