kenwood_thd75/transport/
mock.rs

1//! Mock transport for testing without real hardware.
2
3use std::collections::VecDeque;
4use std::path::Path;
5
6use crate::error::TransportError;
7
8use super::Transport;
9
10/// Mock transport for testing. Programs expected command/response exchanges.
11#[derive(Debug)]
12pub struct MockTransport {
13    exchanges: VecDeque<(Vec<u8>, Vec<u8>)>,
14    pending_response: Option<Vec<u8>>,
15    accept_any_write: bool,
16}
17
18impl MockTransport {
19    /// Create a new empty mock transport with no expected exchanges.
20    #[must_use]
21    pub const fn new() -> Self {
22        Self {
23            exchanges: VecDeque::new(),
24            pending_response: None,
25            accept_any_write: false,
26        }
27    }
28
29    /// Queue an expected command/response exchange.
30    ///
31    /// When `write()` is called with `command`, the corresponding `response`
32    /// will be returned by the next `read()`.
33    pub fn expect(&mut self, command: &[u8], response: &[u8]) {
34        self.exchanges
35            .push_back((command.to_vec(), response.to_vec()));
36    }
37
38    /// Load expected exchanges from a fixture file.
39    ///
40    /// The file format uses `> ` prefixed lines for commands and `< ` prefixed
41    /// lines for responses. Literal `\r` sequences are converted to `0x0D`.
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if the file cannot be read.
46    pub fn from_fixture(path: &Path) -> Result<Self, std::io::Error> {
47        let content = std::fs::read_to_string(path)?;
48        let mut mock = Self::new();
49        let mut current_command: Option<Vec<u8>> = None;
50
51        for line in content.lines() {
52            if let Some(cmd) = line.strip_prefix("> ") {
53                let bytes = cmd.replace("\\r", "\r").into_bytes();
54                current_command = Some(bytes);
55            } else if let Some(resp) = line.strip_prefix("< ") {
56                let bytes = resp.replace("\\r", "\r").into_bytes();
57                if let Some(cmd) = current_command.take() {
58                    mock.exchanges.push_back((cmd, bytes));
59                }
60            }
61        }
62
63        Ok(mock)
64    }
65
66    /// Queue data to be returned by the next `read()` without requiring
67    /// a preceding `write()`.
68    ///
69    /// This is useful for testing unsolicited incoming data (e.g., MMDVM
70    /// frames received from the radio without a prior command).
71    pub fn queue_read(&mut self, data: &[u8]) {
72        self.pending_response = Some(data.to_vec());
73    }
74
75    /// Accept any subsequent `write()` calls without validation.
76    ///
77    /// When enabled, writes succeed without checking against expected
78    /// exchanges and no response is queued.
79    pub const fn expect_any_write(&mut self) {
80        self.accept_any_write = true;
81    }
82
83    /// Panic if any expected exchanges remain unconsumed.
84    ///
85    /// # Panics
86    ///
87    /// Panics if there are remaining exchanges that were not exercised.
88    pub fn assert_complete(&self) {
89        assert!(
90            self.exchanges.is_empty(),
91            "MockTransport has {} unconsumed exchange(s)",
92            self.exchanges.len()
93        );
94    }
95}
96
97impl Default for MockTransport {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103impl Transport for MockTransport {
104    async fn write(&mut self, data: &[u8]) -> Result<(), TransportError> {
105        tracing::debug!(bytes = data.len(), "mock: write");
106
107        if self.accept_any_write && self.exchanges.is_empty() {
108            tracing::debug!("mock: accepting any write (no response queued)");
109            return Ok(());
110        }
111
112        let (expected_cmd, response) = self.exchanges.pop_front().ok_or_else(|| {
113            TransportError::Write(std::io::Error::new(
114                std::io::ErrorKind::InvalidData,
115                format!(
116                    "no more expected exchanges, but got write: {:?}",
117                    String::from_utf8_lossy(data)
118                ),
119            ))
120        })?;
121
122        if data != expected_cmd {
123            return Err(TransportError::Write(std::io::Error::new(
124                std::io::ErrorKind::InvalidData,
125                format!(
126                    "expected command {:?}, got {:?}",
127                    String::from_utf8_lossy(&expected_cmd),
128                    String::from_utf8_lossy(data)
129                ),
130            )));
131        }
132
133        tracing::debug!(bytes = response.len(), "mock: read response queued");
134        self.pending_response = Some(response);
135        Ok(())
136    }
137
138    async fn read(&mut self, buf: &mut [u8]) -> Result<usize, TransportError> {
139        let response = self.pending_response.take().ok_or_else(|| {
140            TransportError::Read(std::io::Error::new(
141                std::io::ErrorKind::WouldBlock,
142                "no pending response — call write() first",
143            ))
144        })?;
145
146        let len = response.len().min(buf.len());
147        buf[..len].copy_from_slice(&response[..len]);
148        tracing::debug!(bytes = len, "mock: read");
149        Ok(len)
150    }
151
152    async fn close(&mut self) -> Result<(), TransportError> {
153        tracing::debug!("mock: closing transport");
154        self.exchanges.clear();
155        self.pending_response = None;
156        Ok(())
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[tokio::test]
165    async fn basic_exchange() {
166        let mut mock = MockTransport::new();
167        mock.expect(b"ID\r", b"ID TH-D75\r");
168        mock.write(b"ID\r").await.unwrap();
169        let mut buf = [0u8; 64];
170        let n = mock.read(&mut buf).await.unwrap();
171        assert_eq!(&buf[..n], b"ID TH-D75\r");
172        mock.assert_complete();
173    }
174
175    #[tokio::test]
176    async fn unexpected_command() {
177        let mut mock = MockTransport::new();
178        mock.expect(b"ID\r", b"ID TH-D75\r");
179        let result = mock.write(b"FV\r").await;
180        assert!(result.is_err());
181    }
182
183    #[tokio::test]
184    async fn multiple_exchanges() {
185        let mut mock = MockTransport::new();
186        mock.expect(b"ID\r", b"ID TH-D75\r");
187        mock.expect(b"FV\r", b"FV 1.03.000\r");
188
189        mock.write(b"ID\r").await.unwrap();
190        let mut buf = [0u8; 64];
191        let n = mock.read(&mut buf).await.unwrap();
192        assert_eq!(&buf[..n], b"ID TH-D75\r");
193
194        mock.write(b"FV\r").await.unwrap();
195        let n = mock.read(&mut buf).await.unwrap();
196        assert_eq!(&buf[..n], b"FV 1.03.000\r");
197
198        mock.assert_complete();
199    }
200
201    #[tokio::test]
202    async fn from_fixture_file() {
203        let mut mock =
204            MockTransport::from_fixture(Path::new("tests/fixtures/identify.txt")).unwrap();
205
206        mock.write(b"ID\r").await.unwrap();
207        let mut buf = [0u8; 64];
208        let n = mock.read(&mut buf).await.unwrap();
209        assert_eq!(&buf[..n], b"ID TH-D75\r");
210
211        mock.write(b"FV\r").await.unwrap();
212        let n = mock.read(&mut buf).await.unwrap();
213        assert_eq!(&buf[..n], b"FV 1.03.000\r");
214
215        mock.assert_complete();
216    }
217
218    #[tokio::test]
219    async fn read_without_write_errors() {
220        let mut mock = MockTransport::new();
221        let mut buf = [0u8; 64];
222        let result = mock.read(&mut buf).await;
223        assert!(result.is_err());
224    }
225
226    #[tokio::test]
227    async fn write_with_no_exchanges_errors() {
228        let mut mock = MockTransport::new();
229        let result = mock.write(b"ID\r").await;
230        assert!(result.is_err());
231    }
232
233    #[tokio::test]
234    async fn default_creates_empty() {
235        let mock = MockTransport::default();
236        mock.assert_complete();
237    }
238
239    #[tokio::test]
240    async fn close_clears_state() {
241        let mut mock = MockTransport::new();
242        mock.expect(b"ID\r", b"ID TH-D75\r");
243        mock.close().await.unwrap();
244        mock.assert_complete();
245    }
246}