Skip to main content

instrument_core/
mock.rs

1use crate::address::{InterfaceKind, ResourceAddress};
2use crate::error::{Error, Result};
3use crate::identity::Idn;
4use crate::kind::InstrumentKind;
5use crate::transport::{Transport, TransportIdentity};
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use std::collections::VecDeque;
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11
12/// A single step in a mock transport script.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(tag = "op", rename_all = "snake_case")]
15pub enum ScriptStep {
16    Write { data: String },
17    Read { data: String },
18    Clear,
19}
20
21/// Scripted request/response transport for deterministic CI.
22#[derive(Debug, Clone)]
23pub struct MockTransport {
24    script: Vec<ScriptStep>,
25    steps: Arc<Mutex<Vec<ScriptStep>>>,
26    step_index: Arc<Mutex<usize>>,
27    identity: TransportIdentity,
28    fail_writes_remaining: Arc<Mutex<u32>>,
29    fail_reads_remaining: Arc<Mutex<u32>>,
30    pending_read: Arc<Mutex<VecDeque<u8>>>,
31}
32
33impl MockTransport {
34    pub fn from_script(steps: Vec<ScriptStep>) -> Self {
35        Self {
36            script: steps.clone(),
37            steps: Arc::new(Mutex::new(steps)),
38            step_index: Arc::new(Mutex::new(0)),
39            identity: TransportIdentity::default(),
40            fail_writes_remaining: Arc::new(Mutex::new(0)),
41            fail_reads_remaining: Arc::new(Mutex::new(0)),
42            pending_read: Arc::new(Mutex::new(VecDeque::new())),
43        }
44    }
45
46    /// Returns a fresh transport replaying the same script (for new sessions).
47    pub fn reopen(&self) -> Self {
48        let mut t = Self::from_script(self.script.clone());
49        t.identity = self.identity.clone();
50        *t.fail_writes_remaining.lock().unwrap() = *self.fail_writes_remaining.lock().unwrap();
51        *t.fail_reads_remaining.lock().unwrap() = *self.fail_reads_remaining.lock().unwrap();
52        t
53    }
54
55    pub fn script(&self) -> &[ScriptStep] {
56        &self.script
57    }
58
59    pub fn with_identity(mut self, identity: TransportIdentity) -> Self {
60        self.identity = identity;
61        self
62    }
63
64    /// Fails the next N write attempts with Timeout (for retry testing).
65    pub fn fail_writes(self, count: u32) -> Self {
66        *self.fail_writes_remaining.lock().unwrap() = count;
67        self
68    }
69
70    /// Fails the next N read attempts with Timeout without consuming a script step.
71    pub fn fail_reads(self, count: u32) -> Self {
72        *self.fail_reads_remaining.lock().unwrap() = count;
73        self
74    }
75
76    fn peek_step(&self) -> Result<ScriptStep> {
77        let idx = self.step_index.lock().unwrap();
78        let steps = self.steps.lock().unwrap();
79        if *idx >= steps.len() {
80            return Err(Error::MockExhausted);
81        }
82        Ok(steps[*idx].clone())
83    }
84
85    fn next_step(&self) -> Result<ScriptStep> {
86        let mut idx = self.step_index.lock().unwrap();
87        let steps = self.steps.lock().unwrap();
88        if *idx >= steps.len() {
89            return Err(Error::MockExhausted);
90        }
91        let step = steps[*idx].clone();
92        *idx += 1;
93        Ok(step)
94    }
95}
96
97impl Transport for MockTransport {
98    fn write(&mut self, data: &[u8]) -> Result<()> {
99        let mut fails = self.fail_writes_remaining.lock().unwrap();
100        if *fails > 0 {
101            *fails -= 1;
102            return Err(Error::Timeout);
103        }
104        drop(fails);
105
106        match self.next_step()? {
107            ScriptStep::Write { data: expected } => {
108                let actual = String::from_utf8_lossy(data).to_string();
109                if normalize_cmd(&actual) != normalize_cmd(&expected) {
110                    return Err(Error::MockMismatch { expected, actual });
111                }
112                Ok(())
113            }
114            other => Err(Error::MockMismatch {
115                expected: format!("write, got {other:?}"),
116                actual: String::from_utf8_lossy(data).to_string(),
117            }),
118        }
119    }
120
121    fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
122        let mut fails = self.fail_reads_remaining.lock().unwrap();
123        if *fails > 0 {
124            *fails -= 1;
125            return Err(Error::Timeout);
126        }
127        drop(fails);
128
129        if buf.is_empty() {
130            return Ok(0);
131        }
132        let mut pending = self.pending_read.lock().unwrap();
133        if pending.is_empty() {
134            if !matches!(self.peek_step()?, ScriptStep::Read { .. }) {
135                return Err(Error::Timeout);
136            }
137            if let ScriptStep::Read { data } = self.next_step()? {
138                pending.extend(data.into_bytes());
139            }
140        }
141        let n = pending.len().min(buf.len());
142        for byte in &mut buf[..n] {
143            *byte = pending.pop_front().unwrap();
144        }
145        Ok(n)
146    }
147
148    fn clear(&mut self) -> Result<()> {
149        match self.next_step()? {
150            ScriptStep::Clear => Ok(()),
151            other => Err(Error::MockMismatch {
152                expected: "clear".into(),
153                actual: format!("{other:?}"),
154            }),
155        }
156    }
157
158    fn set_read_timeout(&mut self, _timeout: Duration) -> Result<()> {
159        Ok(())
160    }
161
162    fn identity(&self) -> TransportIdentity {
163        self.identity.clone()
164    }
165}
166
167#[cfg(feature = "async")]
168impl crate::async_transport::AsyncTransport for MockTransport {
169    fn write<'a>(
170        &'a mut self,
171        data: &'a [u8],
172    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
173        Box::pin(async move { Transport::write(self, data) })
174    }
175
176    fn read<'a>(
177        &'a mut self,
178        buf: &'a mut [u8],
179    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<usize>> + Send + 'a>> {
180        Box::pin(async move { Transport::read(self, buf) })
181    }
182
183    fn clear<'a>(
184        &'a mut self,
185    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
186        Box::pin(async move { Transport::clear(self) })
187    }
188
189    fn set_read_timeout<'a>(
190        &'a mut self,
191        timeout: Duration,
192    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
193        Box::pin(async move { Transport::set_read_timeout(self, timeout) })
194    }
195
196    fn apply_read_timeout(&mut self, timeout: Duration) -> Result<()> {
197        Transport::set_read_timeout(self, timeout)
198    }
199
200    fn identity(&self) -> TransportIdentity {
201        Transport::identity(self)
202    }
203}
204
205fn normalize_cmd(s: &str) -> String {
206    s.trim().to_uppercase()
207}
208
209/// High-level fixture builder for common instrument patterns.
210#[derive(Debug, Default)]
211pub struct ScriptedFixture {
212    idn: Idn,
213    kinds: Vec<InstrumentKind>,
214    query_handlers: IndexMap<String, String>,
215    write_handlers: Vec<String>,
216}
217
218impl ScriptedFixture {
219    pub fn builder() -> ScriptedFixtureBuilder {
220        ScriptedFixtureBuilder::default()
221    }
222
223    pub fn into_transport(self) -> MockTransport {
224        let mut steps = Vec::new();
225
226        for cmd in &self.write_handlers {
227            let data = if cmd.ends_with('\n') {
228                cmd.clone()
229            } else {
230                format!("{cmd}\n")
231            };
232            steps.push(ScriptStep::Write { data });
233        }
234
235        for (query, response) in &self.query_handlers {
236            steps.push(ScriptStep::Write {
237                data: format!("{query}\n"),
238            });
239            let resp = if response.ends_with('\n') {
240                response.clone()
241            } else {
242                format!("{response}\n")
243            };
244            steps.push(ScriptStep::Read { data: resp });
245        }
246
247        let identity = TransportIdentity {
248            manufacturer: Some(self.idn.manufacturer.clone()),
249            model: Some(self.idn.model.clone()),
250            serial: Some(self.idn.serial.clone()),
251            interface: InterfaceKind::Unknown,
252            manf_id: None,
253            model_code: None,
254        };
255
256        MockTransport::from_script(steps).with_identity(identity)
257    }
258
259    pub fn kinds(&self) -> &[InstrumentKind] {
260        &self.kinds
261    }
262
263    pub fn idn(&self) -> &Idn {
264        &self.idn
265    }
266}
267
268#[derive(Debug, Default)]
269pub struct ScriptedFixtureBuilder {
270    idn: Idn,
271    kinds: Vec<InstrumentKind>,
272    query_handlers: IndexMap<String, String>,
273    write_handlers: Vec<String>,
274}
275
276impl ScriptedFixtureBuilder {
277    pub fn idn(mut self, manufacturer: &str, model: &str, serial: &str, firmware: &str) -> Self {
278        self.idn = Idn {
279            manufacturer: manufacturer.into(),
280            model: model.into(),
281            serial: serial.into(),
282            firmware: firmware.into(),
283        };
284        self
285    }
286
287    pub fn kinds(mut self, kinds: impl IntoIterator<Item = InstrumentKind>) -> Self {
288        self.kinds = kinds.into_iter().collect();
289        self
290    }
291
292    pub fn on_query(mut self, query: &str, response: &str) -> Self {
293        self.query_handlers
294            .insert(query.to_string(), response.to_string());
295        self
296    }
297
298    /// Expects a write-only command (no response read).
299    pub fn on_write(mut self, command: &str) -> Self {
300        self.write_handlers.push(command.to_string());
301        self
302    }
303
304    /// Registers `*IDN?` response for discovery probing.
305    pub fn with_idn_probe(mut self) -> Self {
306        self.query_handlers
307            .insert("*IDN?".into(), self.idn.format_response());
308        self
309    }
310
311    pub fn build(self) -> ScriptedFixture {
312        ScriptedFixture {
313            idn: self.idn,
314            kinds: self.kinds,
315            query_handlers: self.query_handlers,
316            write_handlers: self.write_handlers,
317        }
318    }
319}
320
321/// Builds a mock address for fixture-based catalogs.
322pub fn mock_address(name: &str) -> Result<ResourceAddress> {
323    ResourceAddress::parse(&format!("mock://{name}"))
324}
325
326/// Serializable transcript for fixture storage and replay.
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct Transcript {
329    pub steps: Vec<ScriptStep>,
330}
331
332impl Transcript {
333    pub fn from_steps(steps: Vec<ScriptStep>) -> Self {
334        Self { steps }
335    }
336
337    pub fn to_json(&self) -> Result<String> {
338        serde_json::to_string_pretty(self).map_err(|e| Error::Parse(e.to_string()))
339    }
340
341    pub fn from_json(json: &str) -> Result<Self> {
342        serde_json::from_str(json).map_err(|e| Error::Parse(e.to_string()))
343    }
344}