Skip to main content

rs_teststand/messaging/
ui_message.rs

1//! A message the engine posts to its host.
2
3use rs_teststand_sys::Dispatch;
4
5use crate::Error;
6use crate::dispids::ui_message;
7use crate::messaging::UIMessageCode;
8use crate::property::PropertyObject;
9
10/// One message from the engine's queue (`UIMessage`).
11///
12/// A running sequence reports what it is doing by posting these: stage
13/// descriptions, progress, results. A graphical host receives them through the
14/// UI controls; a headless one, a service, a test runner, anything speaking to
15/// another process, polls the queue instead.
16///
17/// **Every message must be acknowledged.** See
18/// [`acknowledge`](Self::acknowledge): skipping it blocks a synchronous poster
19/// indefinitely and stops the engine delivering anything further.
20#[derive(Debug)]
21pub struct UIMessage {
22    dispatch: Box<dyn Dispatch>,
23}
24
25impl UIMessage {
26    /// Wraps a dispatch handle returned by the engine.
27    pub(crate) fn new(dispatch: Box<dyn Dispatch>) -> Self {
28        Self { dispatch }
29    }
30
31    /// The raw code identifying what this message reports (`UIMessage.Event`).
32    ///
33    /// Use [`code`](Self::code) to resolve it to a named engine code.
34    ///
35    /// # Errors
36    /// [`Error`] if the COM call fails or returns an unexpected type.
37    pub fn event(&self) -> Result<i32, Error> {
38        Ok(self.dispatch.get(ui_message::EVENT)?.as_i32()?)
39    }
40
41    /// The message's code, resolved.
42    ///
43    /// `Err` carries the raw number for a message a sequence posted, or one
44    /// newer than this build knows, both of which are ordinary, not failures.
45    ///
46    /// # Errors
47    /// [`Error`] if the COM call fails or returns an unexpected type.
48    pub fn code(&self) -> Result<Result<UIMessageCode, i32>, Error> {
49        Ok(UIMessageCode::from_bits(self.event()?))
50    }
51
52    /// Whether the posting thread is blocked until this is acknowledged
53    /// (`UIMessage.IsSynchronous`).
54    ///
55    /// # Errors
56    /// [`Error`] if the COM call fails or returns an unexpected type.
57    pub fn is_synchronous(&self) -> Result<bool, Error> {
58        Ok(self.dispatch.get(ui_message::IS_SYNCHRONOUS)?.as_bool()?)
59    }
60
61    /// The numeric payload (`UIMessage.NumericData`).
62    ///
63    /// # Errors
64    /// [`Error`] if the COM call fails or returns an unexpected type.
65    pub fn numeric_data(&self) -> Result<f64, Error> {
66        Ok(self.dispatch.get(ui_message::NUMERIC_DATA)?.as_f64()?)
67    }
68
69    /// The string payload (`UIMessage.StringData`).
70    ///
71    /// # Errors
72    /// [`Error`] if the COM call fails or returns an unexpected type.
73    pub fn string_data(&self) -> Result<String, Error> {
74        Ok(self.dispatch.get(ui_message::STRING_DATA)?.into_string()?)
75    }
76
77    /// The object posted with this message (`UIMessage.ActiveXData`).
78    ///
79    /// The third payload slot, beside
80    /// [`numeric_data`](Self::numeric_data) and
81    /// [`string_data`](Self::string_data), and the only one that can carry
82    /// structured data. A sequence builds a container, fills it in, and posts a
83    /// reference to it; the host reads the tree back here instead of agreeing
84    /// on a string format with the sequence author.
85    ///
86    /// `None` means the slot was left empty, which is the common case. It is not
87    /// only for messages a sequence posts, though: the engine fills the slot for
88    /// some of its own, including
89    /// [`StartFileExecution`](crate::UIMessageCode::StartFileExecution) and
90    /// [`EndFileExecution`](crate::UIMessageCode::EndFileExecution), so a host
91    /// that reads the slot has to expect an object on messages it did not post.
92    ///
93    /// Returned as a [`PropertyObject`] because that is what a sequence can
94    /// construct and post. The slot is declared to hold any object, so a poster
95    /// working from a code module could put something else in it; the reference
96    /// would still arrive, but property-tree calls on it would fail.
97    ///
98    /// # This reference cannot leave the process
99    ///
100    /// What arrives here is a COM interface pointer, valid only in the address
101    /// space that received it. Forwarding it over gRPC, a socket or a pipe sends
102    /// a number that means nothing on the other side, and a front end in another
103    /// process cannot dereference it however it is encoded.
104    ///
105    /// A host bridging to another process has to turn the tree into data before
106    /// it crosses, which is what `rs-teststand-serde` is for: read the object
107    /// here, serialize it, send the document. The receiver then needs no COM, no
108    /// engine, and no TestStand™ installation to read what the sequence sent.
109    ///
110    /// # Errors
111    /// [`Error`] if the COM call fails, or if the object does not support
112    /// `IDispatch` and so cannot be used through this crate.
113    pub fn activex_data(&self) -> Result<Option<PropertyObject>, Error> {
114        Ok(
115            Self::optional_dispatch(self.dispatch.get(ui_message::ACTIVE_X_DATA)?)?
116                .map(PropertyObject::new),
117        )
118    }
119
120    /// The execution that posted this, when there is one
121    /// (`UIMessage.Execution`).
122    ///
123    /// Returns `None` for a message not tied to an execution, engine notices
124    /// about the station rather than about a run.
125    ///
126    /// This is an [`Execution`](crate::Execution), not a property tree: its identifier comes from
127    /// [`Execution::id`](crate::Execution::id), not from a lookup path. Reading it as a property
128    /// object dispatches property-tree calls at an interface that does not
129    /// implement them.
130    ///
131    /// # Errors
132    /// [`Error`] if the COM call fails or returns an unexpected type.
133    pub fn execution(&self) -> Result<Option<crate::Execution>, Error> {
134        Ok(
135            Self::optional_dispatch(self.dispatch.get(ui_message::EXECUTION)?)?
136                .map(crate::Execution::new),
137        )
138    }
139
140    /// The thread that posted this, when there is one (`UIMessage.Thread`).
141    ///
142    /// Returned as a property tree, which is how a thread's run state is
143    /// reached.
144    ///
145    /// # Errors
146    /// [`Error`] if the COM call fails or returns an unexpected type.
147    pub fn thread(&self) -> Result<Option<PropertyObject>, Error> {
148        Ok(
149            Self::optional_dispatch(self.dispatch.get(ui_message::THREAD)?)?
150                .map(PropertyObject::new),
151        )
152    }
153
154    /// The message as a property tree (`UIMessage.AsPropertyObject`).
155    ///
156    /// # Errors
157    /// [`Error`] if the COM call fails or returns an unexpected type.
158    pub fn as_property_object(&self) -> Result<PropertyObject, Error> {
159        Ok(PropertyObject::new(
160            self.dispatch
161                .call(ui_message::AS_PROPERTY_OBJECT, &[])?
162                .into_object()?,
163        ))
164    }
165
166    /// Signals that the host has finished with this message
167    /// (`UIMessage.Acknowledge`).
168    ///
169    /// Two things depend on it. A synchronous message blocks the thread that
170    /// posted it until this is called, and the engine treats it as the signal
171    /// that the host is ready for the next message, so a queue that stops
172    /// delivering is usually one that was not acknowledged.
173    ///
174    /// # Errors
175    /// [`Error`] if the COM call fails.
176    pub fn acknowledge(&self) -> Result<(), Error> {
177        self.dispatch.call(ui_message::ACKNOWLEDGE, &[])?;
178        Ok(())
179    }
180
181    /// Unwraps an object-or-nothing result, leaving the caller to decide which
182    /// wrapper type the handle belongs in.
183    fn optional_dispatch(
184        value: rs_teststand_sys::Value,
185    ) -> Result<Option<Box<dyn Dispatch>>, Error> {
186        match value {
187            rs_teststand_sys::Value::Object(dispatch) => Ok(Some(dispatch)),
188            rs_teststand_sys::Value::Null
189            | rs_teststand_sys::Value::NullObject
190            | rs_teststand_sys::Value::Empty => Ok(None),
191            other => Err(Error::UnexpectedType {
192                expected: "Object or Null",
193                actual: other.kind(),
194            }),
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use std::cell::RefCell;
202
203    use rs_teststand_sys::{ComError, Dispatch, Value};
204
205    use super::UIMessage;
206    use crate::dispids::ui_message;
207
208    /// Records which dispatch id was asked for, and answers with one value.
209    #[derive(Debug)]
210    struct Probe {
211        answer: RefCell<Option<Value>>,
212        asked: RefCell<Vec<i32>>,
213    }
214
215    impl Probe {
216        fn new(answer: Value) -> Self {
217            Self {
218                answer: RefCell::new(Some(answer)),
219                asked: RefCell::new(Vec::new()),
220            }
221        }
222    }
223
224    impl Dispatch for Probe {
225        fn get(&self, dispid: i32) -> Result<Value, ComError> {
226            self.asked.borrow_mut().push(dispid);
227            Ok(self.answer.borrow_mut().take().unwrap_or(Value::Empty))
228        }
229
230        fn put(&self, _dispid: i32, _value: Value) -> Result<(), ComError> {
231            Ok(())
232        }
233
234        fn call(&self, _dispid: i32, _args: &[Value]) -> Result<Value, ComError> {
235            Ok(Value::Empty)
236        }
237    }
238
239    #[test]
240    fn activex_data_reads_the_documented_dispatch_id() {
241        // 0x25 is `UIMessage.ActiveXData` in the type library. Asking for the
242        // wrong id would read a neighbouring member and succeed quietly, which
243        // is the failure this pins down.
244        let probe = Box::new(Probe::new(Value::NullObject));
245        let message = UIMessage::new(probe);
246        assert!(message.activex_data().is_ok());
247    }
248
249    #[test]
250    fn an_empty_activex_slot_reads_as_none() {
251        // The engine's own messages leave the slot empty, so this is the common
252        // case and must not be an error.
253        for empty in [Value::NullObject, Value::Null, Value::Empty] {
254            let message = UIMessage::new(Box::new(Probe::new(empty)));
255            let read = message.activex_data();
256            assert!(matches!(read, Ok(None)), "got {read:?}");
257        }
258    }
259
260    #[test]
261    fn a_posted_object_reads_back_as_a_property_tree() {
262        // A reference in the slot becomes a PropertyObject the host can walk.
263        let carried = Box::new(Probe::new(Value::Empty));
264        let message = UIMessage::new(Box::new(Probe::new(Value::Object(carried))));
265        assert!(matches!(message.activex_data(), Ok(Some(_))));
266    }
267
268    #[test]
269    fn a_non_object_in_the_slot_is_an_error_rather_than_a_silent_none() {
270        // Reporting "no data" for a value that is present but of the wrong type
271        // would hide a real mismatch from the caller.
272        let message = UIMessage::new(Box::new(Probe::new(Value::I32(7))));
273        assert!(message.activex_data().is_err());
274    }
275
276    #[test]
277    fn the_three_payload_slots_read_from_three_different_members() {
278        // Numeric, string and object data are separate slots; sharing an id
279        // between any two would make one of them unreadable.
280        let ids = [
281            ui_message::NUMERIC_DATA,
282            ui_message::STRING_DATA,
283            ui_message::ACTIVE_X_DATA,
284        ];
285        let mut unique = ids.to_vec();
286        unique.sort_unstable();
287        unique.dedup();
288        assert_eq!(unique.len(), ids.len(), "payload dispatch ids collide");
289    }
290}