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