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}