rs_teststand/engine.rs
1//! The `Engine`, root of the object model and factory for everything else.
2
3use rs_teststand_sys::{Dispatch, Value, create_dispatch};
4
5#[path = "engine_startup.rs"]
6mod startup;
7
8use rs_teststand_sys::DialogInfo;
9
10use crate::dispids::engine as dispid;
11use crate::error::Error;
12
13/// ProgID of the version-independent engine coclass. Resolves to the active
14/// installation's `teapi.dll` via the registry.
15const ENGINE_PROG_ID: &str = "TestStand.Engine";
16
17/// The TestStand™ Engine.
18///
19/// Constructing an `Engine` creates the underlying COM object; dropping it
20/// releases it. It is the entry point of the API:
21///
22/// ```no_run
23/// use rs_teststand::Engine;
24///
25/// let engine = Engine::new()?;
26/// println!("TestStand {}", engine.version_string()?);
27/// # Ok::<(), rs_teststand::Error>(())
28/// ```
29#[derive(Debug)]
30pub struct Engine {
31 dispatch: Box<dyn Dispatch>,
32 /// Dialogs closed while this engine was being created. See
33 /// [`startup_dialogs`](Engine::startup_dialogs).
34 startup_dialogs: Vec<DialogInfo>,
35}
36
37impl Engine {
38 /// Creates the engine (STA COM apartment plus the `TestStand.Engine` object).
39 ///
40 /// # Errors
41 /// [`Error::Com`] if COM cannot be initialized or the engine class
42 /// cannot be created (e.g. no TestStand™ installation is registered).
43 pub fn new() -> Result<Self, Error> {
44 // Creating the engine can itself raise a dialog, before any option can
45 // be set, see the `startup` module. The sweeper has to be running
46 // before the call, because the call is what blocks.
47 let sweeper = startup::Sweeper::start();
48 let dispatch = create_dispatch(ENGINE_PROG_ID);
49 let startup_dialogs = sweeper.stop();
50 let engine = Self {
51 dispatch: Box::new(dispatch?),
52 startup_dialogs,
53 };
54 engine.suppress_modal_dialogs();
55 engine.load_type_palettes();
56 Ok(engine)
57 }
58
59 /// Dialogs that were closed while this engine was being created.
60 ///
61 /// A non-empty list is worth logging: it is the only record that something
62 /// asked a question and was answered by closing the window.
63 ///
64 /// Empty means nothing *owned by this process* was found, which is not the
65 /// same as no dialog having appeared. Detection cannot see another
66 /// process's windows, and whether the engine's unreleased-files warning is
67 /// raised in-process has not been established. Do not treat an empty list
68 /// as proof that startup was clean.
69 #[must_use]
70 pub fn startup_dialogs(&self) -> &[DialogInfo] {
71 &self.startup_dialogs
72 }
73
74 /// Loads the station's type palettes.
75 ///
76 /// Step types live in the palettes, so without this
77 /// [`new_step`](Self::new_step) fails with `TS_Err_StepTypeNotFound` for
78 /// every built-in type. The sequence editor does this as it starts; an
79 /// engine created directly over COM does not, so the crate does it here.
80 ///
81 /// Conflicts are resolved by failing rather than prompting, and a failure
82 /// is ignored for the same reason the dialog settings are: a station with
83 /// no palettes configured is still a usable engine.
84 fn load_type_palettes(&self) {
85 let _ = self.load_type_palette_files_ex(crate::ConflictHandler::Error, 0);
86 }
87
88 /// Loads the type palette files (`Engine.LoadTypePaletteFilesEx`).
89 ///
90 /// Called during construction; exposed for a caller that reconfigures the
91 /// palette list and needs to reload.
92 ///
93 /// # Errors
94 /// [`Error`] if the COM call fails.
95 pub fn load_type_palette_files_ex(
96 &self,
97 handler: crate::ConflictHandler,
98 options: i32,
99 ) -> Result<(), Error> {
100 self.dispatch.call(
101 dispid::LOAD_TYPE_PALETTE_FILES_EX,
102 &[Value::I32(handler.bits()), Value::I32(options)],
103 )?;
104 Ok(())
105 }
106
107 /// Loads the type palette files (`Engine.LoadTypePaletteFiles`).
108 ///
109 /// The older form, without conflict handling. Kept because it is the member
110 /// available on engines from TestStand 2016.
111 ///
112 /// # Errors
113 /// [`Error`] if the COM call fails.
114 pub fn load_type_palette_files(&self) -> Result<(), Error> {
115 self.dispatch.call(dispid::LOAD_TYPE_PALETTE_FILES, &[])?;
116 Ok(())
117 }
118
119 /// Unloads the type palette files (`Engine.UnloadTypePaletteFiles`).
120 ///
121 /// # Errors
122 /// [`Error`] if the COM call fails.
123 pub fn unload_type_palette_files(&self) -> Result<(), Error> {
124 self.dispatch.call(dispid::UNLOAD_TYPE_PALETTE_FILES, &[])?;
125 Ok(())
126 }
127
128 /// Points the station's dialog-raising options at their non-interactive
129 /// settings for this session.
130 ///
131 /// A modal dialog is fatal to an unattended host: no one is there to
132 /// dismiss it, so the call blocks forever. That is unacceptable for CI,
133 /// provisioning, or a long-lived service, so engine construction always
134 /// applies these.
135 ///
136 /// Failures are deliberately ignored: an engine that cannot be configured
137 /// is still usable, and construction must not fail over a hardening step.
138 ///
139 /// One gap is worth knowing about. Automatic login uses the operating
140 /// system identity and skips password authentication, but only if that
141 /// account is also a known engine user; if it is not, the engine falls back
142 /// to asking, which is the one dialog this method cannot rule out. A
143 /// station that runs headless should therefore have its service account
144 /// present in the user file. Guard the first calls with a
145 /// [`Watchdog`](crate::Watchdog) if that cannot be guaranteed.
146 ///
147 /// Tracing is left alone. Only the execution bits that halt and wait for a
148 /// person are cleared, so a host keeps whatever tracing the station was
149 /// configured for, see [`ExecutionMask`](crate::ExecutionMask).
150 fn suppress_modal_dialogs(&self) {
151 let Ok(options) = self.station_options() else {
152 return;
153 };
154
155 // A run-time error must resolve itself rather than prompt.
156 let _ = options.set_rte_option(crate::RunTimeErrorOption::Abort);
157
158 // Every prompt the engine can raise while loading or editing files.
159 let _ = options.set_prompt_to_find_files(false);
160 let _ = options.set_type_version_auto_increment_prompt_opt(false);
161 let _ = options.set_use_dialog_for_check_out(false);
162 let _ = options.set_prompt_when_adding_files_to_sc(false);
163 let _ = options.set_check_out_files_when_edited(false);
164
165 // Logging in must never stop on a dialog. Privilege checking off and
166 // login not required means no gate; auto-login uses the operating
167 // system identity, which the engine accepts without asking for a
168 // password. The residual risk is documented on this method.
169 let _ = options.set_enable_user_privilege_checking(false);
170 let _ = options.set_require_user_login(false);
171 let _ = options.set_auto_login_system_user(true);
172
173 // Debug features are a debugging aid with a cost, and two of them raise
174 // a dialog at shutdown. Nothing here is useful to an unattended host.
175 let _ = options.set_debug_options(crate::DebugOptions::NONE);
176
177 // Read-modify-write: keep the station's tracing choices, drop only the
178 // break bits, which suspend an execution until an operator acts.
179 if let Ok(current) = options.execution_mask() {
180 let running = current.difference(crate::ExecutionMask::BREAKS);
181 let _ = options.set_execution_mask(running);
182 }
183 }
184
185 /// The engine's major version number (`Engine.MajorVersion`): the two-digit
186 /// major, so TestStand™ 2026 reports `26` and 2016 reports `16`.
187 ///
188 /// # Errors
189 /// [`Error`] if the COM call fails or returns an unexpected type.
190 pub fn major_version(&self) -> Result<i32, Error> {
191 Ok(self.dispatch.get(dispid::MAJOR_VERSION)?.as_i32()?)
192 }
193
194 /// The engine's minor version number (`Engine.MinorVersion`).
195 ///
196 /// # Errors
197 /// [`Error`] if the COM call fails or returns an unexpected type.
198 pub fn minor_version(&self) -> Result<i32, Error> {
199 Ok(self.dispatch.get(dispid::MINOR_VERSION)?.as_i32()?)
200 }
201
202 /// The engine's revision version number (`Engine.RevisionVersion`).
203 ///
204 /// # Errors
205 /// [`Error`] if the COM call fails or returns an unexpected type.
206 pub fn revision_version(&self) -> Result<i32, Error> {
207 Ok(self.dispatch.get(dispid::REVISION_VERSION)?.as_i32()?)
208 }
209
210 /// The engine's build version number (`Engine.BuildVersion`).
211 ///
212 /// # Errors
213 /// [`Error`] if the COM call fails or returns an unexpected type.
214 pub fn build_version(&self) -> Result<i32, Error> {
215 Ok(self.dispatch.get(dispid::BUILD_VERSION)?.as_i32()?)
216 }
217
218 /// The engine's full version string (`Engine.VersionString`).
219 ///
220 /// # Errors
221 /// [`Error`] if the COM call fails or returns an unexpected type.
222 pub fn version_string(&self) -> Result<String, Error> {
223 Ok(self.dispatch.get(dispid::VERSION_STRING)?.into_string()?)
224 }
225
226 /// Returns `true` if the TestStand™ engine is running as a 64-bit process (`Engine.Is64Bit`).
227 ///
228 /// # Errors
229 /// [`Error`] if the COM call fails or returns an unexpected type.
230 pub fn is_64bit(&self) -> Result<bool, Error> {
231 Ok(self.dispatch.get(dispid::IS_64BIT)?.as_bool()?)
232 }
233
234 /// The path to the TestStand™ root directory (`Engine.TestStandDirectory`).
235 ///
236 /// # Errors
237 /// [`Error`] if the COM call fails or returns an unexpected type.
238 pub fn teststand_directory(&self) -> Result<String, Error> {
239 Ok(self
240 .dispatch
241 .get(dispid::TESTSTAND_DIRECTORY)?
242 .into_string()?)
243 }
244
245 /// The path to the TestStand™ `Bin` directory (`Engine.BinDirectory`).
246 ///
247 /// # Errors
248 /// [`Error`] if the COM call fails or returns an unexpected type.
249 pub fn bin_directory(&self) -> Result<String, Error> {
250 Ok(self.dispatch.get(dispid::BIN_DIRECTORY)?.into_string()?)
251 }
252
253 /// The path to the TestStand™ `Cfg` directory (`Engine.ConfigDirectory`).
254 ///
255 /// # Errors
256 /// [`Error`] if the COM call fails or returns an unexpected type.
257 pub fn config_directory(&self) -> Result<String, Error> {
258 Ok(self.dispatch.get(dispid::CONFIG_DIRECTORY)?.into_string()?)
259 }
260
261 /// Accesses the station's configuration settings (`Engine.StationOptions`).
262 ///
263 /// # Errors
264 /// [`Error`] if the COM call fails or returns an unexpected type.
265 pub fn station_options(&self) -> Result<crate::station::StationOptions, Error> {
266 let dispatch = self.dispatch.get(dispid::STATION_OPTIONS)?.into_object()?;
267 Ok(crate::station::StationOptions::new(dispatch))
268 }
269
270 /// Creates an empty sequence file (`Engine.NewSequenceFile`).
271 ///
272 /// The file exists only in memory until it is saved.
273 ///
274 /// # Errors
275 /// [`Error`] if the COM call fails or returns an unexpected type.
276 pub fn new_sequence_file(&self) -> Result<crate::SequenceFile, Error> {
277 Ok(crate::SequenceFile::new(
278 self.dispatch
279 .call(dispid::NEW_SEQUENCE_FILE, &[])?
280 .into_object()?,
281 ))
282 }
283
284 /// Starts a sequence running (`Engine.NewExecution`).
285 ///
286 /// The execution begins immediately.
287 ///
288 /// Pass `None` for `process_model` to run the sequence directly; supply one
289 /// to run a process-model entry point instead. `execution_type_mask` is
290 /// normally `0`.
291 ///
292 /// # Errors
293 /// [`Error`] if the sequence cannot be started or the COM call fails.
294 pub fn new_execution(
295 &self,
296 sequence_file: &crate::SequenceFile,
297 sequence_name: &str,
298 process_model: Option<&crate::SequenceFile>,
299 break_at_first_step: bool,
300 execution_type_mask: i32,
301 ) -> Result<crate::Execution, Error> {
302 let file = sequence_file
303 .duplicate_dispatch()
304 .ok_or(Error::UnexpectedType {
305 expected: "a live sequence file",
306 actual: "a test fake with no COM identity",
307 })?;
308 // "No process model" is a null object reference, not a null variant.
309 let model = process_model
310 .and_then(crate::SequenceFile::duplicate_dispatch)
311 .map_or(Value::NullObject, Value::Object);
312
313 Ok(crate::Execution::new(
314 self.dispatch
315 .call(
316 dispid::NEW_EXECUTION,
317 &[
318 Value::Object(file),
319 Value::Str(sequence_name.to_owned()),
320 model,
321 Value::Bool(break_at_first_step),
322 Value::I32(execution_type_mask),
323 ],
324 )?
325 .into_object()?,
326 ))
327 }
328
329 /// Posts a message on behalf of an execution (`Engine.PostUIMessage`).
330 ///
331 /// The counterpart to
332 /// [`Thread::post_ui_message_ex`](crate::Thread::post_ui_message_ex), for
333 /// code that is not itself running inside the sequence and therefore has no
334 /// current thread to post from. Because there is no implied context, the
335 /// execution and thread the message belongs to are given explicitly.
336 ///
337 /// `activex_data` carries structured data, read back by the host from
338 /// [`UIMessage::activex_data`](crate::UIMessage::activex_data). Pass `None`
339 /// to leave the slot empty.
340 ///
341 /// Pass `synchronous = true` in the ordinary case; see
342 /// [`Thread::post_ui_message_ex`](crate::Thread::post_ui_message_ex) for why
343 /// the blocking form is the safe default.
344 ///
345 /// # Errors
346 /// [`Error`] if the COM call fails, or if a wrapper has no COM identity.
347 #[allow(
348 clippy::too_many_arguments,
349 reason = "mirrors Engine.PostUIMessage's parameter list and order, which is the point of a twin API: grouping them into a struct would make the Rust call unpredictable from the COM documentation"
350 )]
351 pub fn post_ui_message(
352 &self,
353 execution: &crate::Execution,
354 thread: &crate::Thread,
355 event_code: i32,
356 numeric_data: f64,
357 string_data: &str,
358 activex_data: Option<&crate::PropertyObject>,
359 synchronous: bool,
360 ) -> Result<(), Error> {
361 let missing = || Error::UnexpectedType {
362 expected: "a live execution and thread",
363 actual: "a test fake with no COM identity",
364 };
365 let execution_handle = execution.duplicate_dispatch().ok_or_else(missing)?;
366 let thread_handle = thread.duplicate_dispatch().ok_or_else(missing)?;
367 self.dispatch.call(
368 dispid::POST_UI_MESSAGE,
369 &[
370 Value::Object(execution_handle),
371 Value::Object(thread_handle),
372 Value::I32(event_code),
373 Value::F64(numeric_data),
374 Value::Str(string_data.to_owned()),
375 crate::execution::thread::object_argument(activex_data)?,
376 Value::Bool(synchronous),
377 ],
378 )?;
379 Ok(())
380 }
381
382 /// Logs a user in, or logs the current one out (`Engine.CurrentUser`).
383 ///
384 /// `Some(user)` makes that user current; `None` clears it, which the engine
385 /// documents as logging out.
386 ///
387 /// **This does not check the password.** Setting the property is the act of
388 /// logging in, not an authentication step: a host that cares must call
389 /// [`User::validate_password`](crate::User::validate_password) first and
390 /// refuse on `false`. Written this way because the engine draws the same
391 /// line, and hiding a check inside a setter would make it unclear which one
392 /// a caller had actually performed.
393 ///
394 /// A host built on the `ActiveX` UI controls should use their own login
395 /// method instead, so the controls raise the event they expect; this is the
396 /// headless path.
397 ///
398 /// # Errors
399 /// [`Error`] if the COM call fails, or if `user` has no COM identity.
400 pub fn set_current_user(&self, user: Option<&crate::users::User>) -> Result<(), Error> {
401 let value = match user {
402 None => Value::NullObject,
403 Some(user) => {
404 user.duplicate_dispatch()
405 .map(Value::Object)
406 .ok_or(Error::UnexpectedType {
407 expected: "a live user",
408 actual: "a test fake with no COM identity",
409 })?
410 }
411 };
412 self.dispatch.put(dispid::CURRENT_USER, value)?;
413 Ok(())
414 }
415
416 /// Asks every execution to stop (`Engine.TerminateAll`).
417 ///
418 /// Termination, not abort: cleanup groups still run, so hardware is left in
419 /// a safe state. Like [`Execution::terminate`](crate::Execution::terminate)
420 /// it is a request, and returns before the runs have finished unwinding. A
421 /// caller that needs them stopped must then wait for
422 /// [`UIMessageCode::EndExecution`](crate::UIMessageCode::EndExecution).
423 ///
424 /// # Errors
425 /// [`Error`] if the COM call fails.
426 pub fn terminate_all(&self) -> Result<(), Error> {
427 self.dispatch.call(dispid::TERMINATE_ALL, &[])?;
428 Ok(())
429 }
430
431 /// Stops every execution without running cleanup (`Engine.AbortAll`).
432 ///
433 /// The blunt counterpart to [`terminate_all`](Self::terminate_all). Cleanup
434 /// groups do **not** run, so anything a sequence would have switched off
435 /// stays on. Prefer terminating unless the point is to stop now.
436 ///
437 /// # Errors
438 /// [`Error`] if the COM call fails.
439 pub fn abort_all(&self) -> Result<(), Error> {
440 self.dispatch.call(dispid::ABORT_ALL, &[])?;
441 Ok(())
442 }
443
444 /// The license the engine is currently using (`Engine.LicenseType`).
445 ///
446 /// **Using, not holding.** A freshly created engine has acquired nothing
447 /// and reports [`LicenseType::NoLicense`](crate::LicenseType::NoLicense)
448 /// even on a fully licensed station; the answer only becomes meaningful
449 /// after something acquires. Use
450 /// [`require_license`](Self::require_license) to ask whether the station
451 /// can license this host.
452 ///
453 /// Reads state, so it acquires nothing and raises no dialog.
454 ///
455 /// # Errors
456 /// [`Error`] if the COM call fails, or [`Error::UnknownLicenseType`] if the
457 /// engine reports a type this build does not name.
458 pub fn license_type(&self) -> Result<crate::LicenseType, Error> {
459 let raw = self.dispatch.get(dispid::LICENSE_TYPE)?.as_i32()?;
460 crate::LicenseType::from_bits(raw).map_err(|bits| Error::UnknownLicenseType { bits })
461 }
462
463 /// Acquires a license, or fails if the station cannot grant one.
464 ///
465 /// The check a headless host should make before anything else, and the
466 /// object it should keep alive while it runs.
467 ///
468 /// Acquiring is what makes a license real.
469 /// [`license_type`](Self::license_type) reports the license the engine is
470 /// *using*, and a freshly created engine is using none, measured on a
471 /// station with a valid development system license, it reads `NoLicense`
472 /// until something acquires. So reading before acquiring answers the wrong
473 /// question, and this method acquires first.
474 ///
475 /// The request is [`ApplicationLicense::Unspecified`](crate::ApplicationLicense), which lets the engine
476 /// grant whatever it has. Naming a kind can be refused even when the
477 /// station is properly licensed: on a development system station,
478 /// [`ApplicationLicense::OperatorInterface`](crate::ApplicationLicense) is turned down while
479 /// unspecified succeeds. Ask for a specific kind through
480 /// [`acquire_license`](Self::acquire_license) only when the host genuinely
481 /// requires that one.
482 ///
483 /// The startup dialog is suppressed, so an unlicensed station returns an
484 /// error rather than opening a window nobody will close.
485 ///
486 /// **Refusal is retried for a few seconds before it is believed.** The
487 /// licensing subsystem is not ready the instant the engine object exists:
488 /// measured on a properly licensed station, acquiring immediately after
489 /// construction is refused, while the same call half a second later
490 /// succeeds. A host that trusted the first answer would report an
491 /// unlicensed station to its operator and stop. So a refusal is retried
492 /// until it stops changing, which costs an unlicensed station a few seconds
493 /// once, at startup.
494 ///
495 /// Success is the handle, not the type.
496 /// [`HeldLicense::kind`](crate::HeldLicense::kind) reports what the engine
497 /// says it is using and can still read
498 /// [`NoLicense`](crate::LicenseType::NoLicense) after an unspecified
499 /// request was granted, so treat it as information rather than as the
500 /// verdict.
501 ///
502 /// # Errors
503 /// [`Error::NoLicense`] if no license can be acquired, or [`Error`] if the
504 /// COM call fails.
505 pub fn require_license(&self) -> Result<crate::HeldLicense<'_>, Error> {
506 /// Longest to keep asking before calling the station unlicensed.
507 const PATIENCE: core::time::Duration = core::time::Duration::from_secs(3);
508 /// Gap between attempts.
509 const RETRY_INTERVAL: core::time::Duration = core::time::Duration::from_millis(100);
510
511 let started = std::time::Instant::now();
512 let handle = loop {
513 match self.acquire_license(
514 crate::ApplicationLicense::Unspecified,
515 crate::AcquireLicenseOptions::SUPPRESS_STARTUP_DIALOG,
516 ) {
517 Ok(handle) => break handle,
518 Err(Error::NoLicense) if started.elapsed() < PATIENCE => {
519 std::thread::sleep(RETRY_INTERVAL);
520 }
521 Err(other) => return Err(other),
522 }
523 };
524 // The grant is the handle. `LicenseType` is informational and does not
525 // always follow an unspecified request: measured on a licensed station,
526 // acquiring unspecified returns a handle while the type still reads
527 // `NoLicense`, and only a named request such as a sequence editor makes
528 // it report `DevelopmentSystem`. So the type is recorded, not gated on.
529 let kind = self.license_type()?;
530 Ok(crate::HeldLicense::new(self, handle, kind))
531 }
532
533 /// A description of the current license (`Engine.GetLicenseDescription`).
534 ///
535 /// Free text meant for a person, so log it rather than branch on it; use
536 /// [`license_type`](Self::license_type) for decisions.
537 ///
538 /// # Errors
539 /// [`Error`] if the COM call fails or returns an unexpected type.
540 pub fn get_license_description(&self) -> Result<String, Error> {
541 // The engine declares one reserved parameter, documented as always
542 // zero.
543 Ok(self
544 .dispatch
545 .call(dispid::GET_LICENSE_DESCRIPTION, &[Value::I32(0)])?
546 .into_string()?)
547 }
548
549 /// The license this application requested (`Engine.ApplicationLicense`).
550 ///
551 /// # Errors
552 /// [`Error`] if the COM call fails, or [`Error::UnknownLicenseType`] if the
553 /// engine reports a value this build does not name.
554 pub fn application_license(&self) -> Result<crate::ApplicationLicense, Error> {
555 let raw = self.dispatch.get(dispid::APPLICATION_LICENSE)?.as_i32()?;
556 crate::ApplicationLicense::from_bits(raw).map_err(|bits| Error::UnknownLicenseType { bits })
557 }
558
559 /// Acquires a license and returns its handle (`Engine.AcquireLicense`).
560 ///
561 /// Release it with [`release_license`](Self::release_license); the license
562 /// is held until every handle for it is released.
563 ///
564 /// **Pass [`AcquireLicenseOptions::SUPPRESS_STARTUP_DIALOG`](crate::AcquireLicenseOptions) on any station
565 /// without a person at it.** Without it, an engine that cannot acquire the
566 /// license opens a window offering to evaluate, activate or buy, and waits.
567 /// A headless host stops there until something kills it. With it, the same
568 /// situation returns an error this method propagates.
569 ///
570 /// Prefer
571 /// [`ApplicationLicense::Unspecified`](crate::ApplicationLicense),
572 /// which lets the engine grant whatever it has. Naming a kind is a
573 /// constraint, not a preference, and a smaller request is not a safer one:
574 /// on a station licensed for a development system,
575 /// [`OperatorInterface`](crate::ApplicationLicense::OperatorInterface) is
576 /// refused while unspecified succeeds. Name a kind only when the host truly
577 /// requires it.
578 ///
579 /// Most callers want [`require_license`](Self::require_license) instead,
580 /// which acquires and hands back a guard that releases on drop.
581 ///
582 /// # Errors
583 /// [`Error::NoLicense`] if the license was not granted, or [`Error`] if the
584 /// COM call fails.
585 ///
586 /// A handle of zero is treated as refusal. The reference says this member
587 /// returns an error when it cannot acquire the license; measured against an
588 /// unlicensed station it succeeds and hands back zero instead. A caller
589 /// that trusted the documented behavior would carry on unlicensed, so the
590 /// zero is turned into the error the caller was promised.
591 pub fn acquire_license(
592 &self,
593 license: crate::ApplicationLicense,
594 options: crate::AcquireLicenseOptions,
595 ) -> Result<i32, Error> {
596 let handle = self
597 .dispatch
598 .call(
599 dispid::ACQUIRE_LICENSE,
600 &[Value::I32(license.bits()), Value::I32(options.bits())],
601 )?
602 .as_i32()?;
603 if handle == 0 {
604 return Err(Error::NoLicense);
605 }
606 Ok(handle)
607 }
608
609 /// Releases a license handle (`Engine.ReleaseLicense`).
610 ///
611 /// # Errors
612 /// [`Error`] if the COM call fails.
613 pub fn release_license(&self, handle: i32) -> Result<(), Error> {
614 // Second parameter is reserved and documented as zero.
615 self.dispatch.call(
616 dispid::RELEASE_LICENSE,
617 &[Value::I32(handle), Value::I32(0)],
618 )?;
619 Ok(())
620 }
621
622 /// Whether the station licenses an add-on feature
623 /// (`Engine.HasAddonLicense`).
624 ///
625 /// # Errors
626 /// [`Error`] if the COM call fails or returns an unexpected type.
627 pub fn has_addon_license(&self, feature_name: &str) -> Result<bool, Error> {
628 Ok(self
629 .dispatch
630 .call(
631 dispid::HAS_ADDON_LICENSE,
632 &[Value::Str(feature_name.to_owned())],
633 )?
634 .as_bool()?)
635 }
636
637 /// Releases every code module the engine has loaded
638 /// (`Engine.UnloadAllModules`).
639 ///
640 /// Loading a sequence file loads its modules, and they stay loaded until
641 /// that file is closed. That is what makes the second run fast, and also
642 /// what holds a DLL open against the build that wants to replace it.
643 /// Unloading here frees them all at once, without closing anything.
644 ///
645 /// Call it between runs, not during one: a module in use by a live
646 /// execution is not a candidate, and the next run reloads whatever it needs.
647 ///
648 /// **State inside a module does not survive.** Anything a module kept in a
649 /// static or a global is gone once it is unloaded, and the reload starts
650 /// from nothing. A station whose modules carry state between steps that way
651 /// should keep that state in the engine instead, or not call this.
652 ///
653 /// # Errors
654 /// [`Error`] if the COM call fails.
655 pub fn unload_all_modules(&self) -> Result<(), Error> {
656 self.dispatch.call(dispid::UNLOAD_ALL_MODULES, &[])?;
657 Ok(())
658 }
659
660 /// Whether breakpoints stop an execution (`Engine.BreakpointsEnabled`).
661 ///
662 /// The master switch. With it off, breakpoints stay set but nothing stops
663 /// on them, which is how a station runs unattended without anyone having to
664 /// strip a sequence file of the breakpoints someone left in it.
665 ///
666 /// Distinct from the station option of the same name, which is the setting
667 /// written to disk. This is the engine's live state.
668 ///
669 /// # Errors
670 /// [`Error`] if the COM call fails or returns an unexpected type.
671 pub fn breakpoints_enabled(&self) -> Result<bool, Error> {
672 Ok(self.dispatch.get(dispid::BREAKPOINTS_ENABLED)?.as_bool()?)
673 }
674
675 /// Turns breakpoints on or off (`Engine.BreakpointsEnabled`).
676 ///
677 /// # Errors
678 /// [`Error`] if the COM call fails.
679 pub fn set_breakpoints_enabled(&self, enabled: bool) -> Result<(), Error> {
680 self.dispatch
681 .put(dispid::BREAKPOINTS_ENABLED, Value::Bool(enabled))?;
682 Ok(())
683 }
684
685 /// Whether breakpoints survive the file they are set in
686 /// (`Engine.PersistBreakpoints`).
687 ///
688 /// On, the engine remembers them across a close and reopen. A host that
689 /// sets breakpoints on behalf of a remote panel usually wants this off, so
690 /// that a debugging session leaves nothing behind on the station.
691 ///
692 /// # Errors
693 /// [`Error`] if the COM call fails or returns an unexpected type.
694 pub fn persist_breakpoints(&self) -> Result<bool, Error> {
695 Ok(self.dispatch.get(dispid::PERSIST_BREAKPOINTS)?.as_bool()?)
696 }
697
698 /// Chooses whether breakpoints are remembered (`Engine.PersistBreakpoints`).
699 ///
700 /// # Errors
701 /// [`Error`] if the COM call fails.
702 pub fn set_persist_breakpoints(&self, persist: bool) -> Result<(), Error> {
703 self.dispatch
704 .put(dispid::PERSIST_BREAKPOINTS, Value::Bool(persist))?;
705 Ok(())
706 }
707
708 /// Runs a .NET garbage collection now
709 /// (`Engine.DoDotNetGarbageCollection`).
710 ///
711 /// Only relevant to a station whose steps call .NET code. Collection is
712 /// otherwise periodic, on the
713 /// [interval](Self::dot_net_garbage_collection_interval); this forces one,
714 /// which is worth doing between runs on a long-lived host rather than
715 /// during a measurement, since collection pauses the runtime.
716 ///
717 /// # Errors
718 /// [`Error`] if the COM call fails.
719 pub fn do_dot_net_garbage_collection(&self) -> Result<(), Error> {
720 // The engine declares one reserved parameter, optional and defaulting
721 // to zero. Supplying it keeps the call correct if the default ever
722 // stops being applied.
723 self.dispatch
724 .call(dispid::DO_DOT_NET_GARBAGE_COLLECTION, &[Value::I32(0)])?;
725 Ok(())
726 }
727
728 /// How often the engine collects .NET garbage, in milliseconds
729 /// (`Engine.DotNetGarbageCollectionInterval`).
730 ///
731 /// Zero or less means automatic collection is off. A host built on this
732 /// crate will normally read `-1`, and that is correct rather than broken:
733 /// the three-second default belongs to applications built on the UI
734 /// control, and a headless host does not create one. Nothing collects on a
735 /// timer unless this is set to a positive interval, so a long-lived host
736 /// that runs .NET steps should either set one or call
737 /// [`do_dot_net_garbage_collection`](Self::do_dot_net_garbage_collection)
738 /// between runs.
739 ///
740 /// # Errors
741 /// [`Error`] if the COM call fails or returns an unexpected type.
742 pub fn dot_net_garbage_collection_interval(&self) -> Result<i32, Error> {
743 Ok(self
744 .dispatch
745 .get(dispid::DOT_NET_GARBAGE_COLLECTION_INTERVAL)?
746 .as_i32()?)
747 }
748
749 /// Sets the .NET collection interval, in milliseconds
750 /// (`Engine.DotNetGarbageCollectionInterval`).
751 ///
752 /// Zero or less switches automatic collection off.
753 ///
754 /// # Errors
755 /// [`Error`] if the COM call fails.
756 pub fn set_dot_net_garbage_collection_interval(&self, milliseconds: i32) -> Result<(), Error> {
757 self.dispatch.put(
758 dispid::DOT_NET_GARBAGE_COLLECTION_INTERVAL,
759 Value::I32(milliseconds),
760 )?;
761 Ok(())
762 }
763
764 /// The .NET runtime version the engine loaded (`Engine.DotNetCLRVersion`).
765 ///
766 /// Empty on a station where nothing has pulled the runtime in yet, so treat
767 /// an empty string as "not loaded" rather than as an error.
768 ///
769 /// # Errors
770 /// [`Error`] if the COM call fails or returns an unexpected type.
771 pub fn dot_net_clr_version(&self) -> Result<String, Error> {
772 Ok(self
773 .dispatch
774 .get(dispid::DOT_NET_CLR_VERSION)?
775 .into_string()?)
776 }
777
778 /// The station's user list, as a file (`Engine.UsersFile`).
779 ///
780 /// The users the engine loaded at startup, and the only route to writing
781 /// them back. [`new_user`](Self::new_user) builds a user in memory; without
782 /// saving through this file the station is unchanged once the process
783 /// exits.
784 ///
785 /// # Errors
786 /// [`Error`] if the COM call fails or returns an unexpected type.
787 pub fn users_file(&self) -> Result<crate::UsersFile, Error> {
788 Ok(crate::UsersFile::new(
789 self.dispatch.get(dispid::USERS_FILE)?.into_object()?,
790 ))
791 }
792
793 /// Whether the host polls for messages (`Engine.UIMessagePollingEnabled`).
794 ///
795 /// # Errors
796 /// [`Error`] if the COM call fails or returns an unexpected type.
797 pub fn ui_message_polling_enabled(&self) -> Result<bool, Error> {
798 Ok(self
799 .dispatch
800 .get(dispid::UI_MESSAGE_POLLING_ENABLED)?
801 .as_bool()?)
802 }
803
804 /// Turns message polling on or off (`Engine.UIMessagePollingEnabled`).
805 ///
806 /// Off by default. A headless host must turn it on before anything appears
807 /// in the queue, without it the queue stays empty however much a sequence
808 /// posts.
809 ///
810 /// # Errors
811 /// [`Error`] if the COM call fails.
812 pub fn set_ui_message_polling_enabled(&self, enabled: bool) -> Result<(), Error> {
813 self.dispatch
814 .put(dispid::UI_MESSAGE_POLLING_ENABLED, Value::Bool(enabled))?;
815 Ok(())
816 }
817
818 /// Whether the message queue is empty (`Engine.IsUIMessageQueueEmpty`).
819 ///
820 /// # Errors
821 /// [`Error`] if the COM call fails or returns an unexpected type.
822 pub fn is_ui_message_queue_empty(&self) -> Result<bool, Error> {
823 Ok(self
824 .dispatch
825 .get(dispid::IS_UI_MESSAGE_QUEUE_EMPTY)?
826 .as_bool()?)
827 }
828
829 /// Takes the next message from the queue (`Engine.GetUIMessage`).
830 ///
831 /// Check [`is_ui_message_queue_empty`](Self::is_ui_message_queue_empty)
832 /// first. The message must be acknowledged once handled, see
833 /// [`UIMessage::acknowledge`](crate::UIMessage::acknowledge).
834 ///
835 /// # Errors
836 /// [`Error`] if the COM call fails or returns an unexpected type.
837 pub fn get_ui_message(&self) -> Result<crate::UIMessage, Error> {
838 Ok(crate::UIMessage::new(
839 self.dispatch
840 .call(dispid::GET_UI_MESSAGE, &[])?
841 .into_object()?,
842 ))
843 }
844
845 /// Creates a step (`Engine.NewStep`).
846 ///
847 /// `adapter_key_name` selects the code-module adapter, see
848 /// [`AdapterKeyName`](crate::AdapterKeyName). `step_type_name` names the
849 /// step type, for example `NumericLimitTest` or `Action`.
850 ///
851 /// An empty key does **not** mean "no code module". It means the step type
852 /// chooses, falling back to the station's `DefaultAdapter` when the type
853 /// designates none, so an empty key on an `Action` yields whatever adapter
854 /// the station happens to default to. Pass
855 /// [`AdapterKeyName::NoneAdapter`](crate::AdapterKeyName::NoneAdapter) to
856 /// actually mean no code module.
857 ///
858 /// The step is not part of any sequence until it is inserted.
859 ///
860 /// # Errors
861 /// [`Error`] if the step type is unknown or the COM call fails.
862 pub fn new_step(
863 &self,
864 adapter_key_name: &str,
865 step_type_name: &str,
866 ) -> Result<crate::Step, Error> {
867 Ok(crate::Step::new(
868 self.dispatch
869 .call(
870 dispid::NEW_STEP,
871 &[
872 Value::Str(adapter_key_name.to_owned()),
873 Value::Str(step_type_name.to_owned()),
874 ],
875 )?
876 .into_object()?,
877 ))
878 }
879
880 /// Creates a sequence (`Engine.NewSequence`).
881 ///
882 /// The sequence is not part of any file until it is inserted.
883 ///
884 /// # Errors
885 /// [`Error`] if the COM call fails or returns an unexpected type.
886 pub fn new_sequence(&self) -> Result<crate::Sequence, Error> {
887 Ok(crate::Sequence::new(
888 self.dispatch
889 .call(dispid::NEW_SEQUENCE, &[])?
890 .into_object()?,
891 ))
892 }
893
894 /// Creates a user account object (`Engine.NewUser`).
895 ///
896 /// Pass an existing user as `profile` to inherit its privileges; the new
897 /// user does **not** join any group the profile belongs to. Pass `None` for
898 /// a user with no privileges.
899 ///
900 /// The result exists only in memory, nothing is written to the station's
901 /// users file by creating one.
902 ///
903 /// # Errors
904 /// [`Error`] if the COM call fails or returns an unexpected type.
905 pub fn new_user(
906 &self,
907 profile: Option<&crate::users::User>,
908 ) -> Result<crate::users::User, Error> {
909 // The engine reads the profile's privileges, so it needs a real
910 // handle; a null means "no privileges to inherit".
911 // The profile is required, and "no profile" is a null object
912 // reference, a VT_DISPATCH holding nothing. VT_NULL and VT_EMPTY are
913 // both refused here, and omitting the argument reports it as missing.
914 let argument = profile
915 .and_then(crate::users::User::duplicate_dispatch)
916 .map_or(Value::NullObject, Value::Object);
917 Ok(crate::users::User::new(
918 self.dispatch
919 .call(dispid::NEW_USER, &[argument])?
920 .into_object()?,
921 ))
922 }
923
924 /// Finds a user by login name (`Engine.GetUser`).
925 ///
926 /// Returns `None` when no user has that name, rather than erroring.
927 ///
928 /// # Errors
929 /// [`Error`] if the COM call fails or returns an unexpected type.
930 pub fn get_user(&self, login_name: &str) -> Result<Option<crate::users::User>, Error> {
931 match self
932 .dispatch
933 .call(dispid::GET_USER, &[Value::Str(login_name.to_owned())])?
934 {
935 Value::Object(dispatch) => Ok(Some(crate::users::User::new(dispatch))),
936 Value::Null | Value::Empty => Ok(None),
937 other => Err(Error::UnexpectedType {
938 expected: "Object or Null",
939 actual: other.kind(),
940 }),
941 }
942 }
943
944 /// Whether a login name is already taken (`Engine.UserNameExists`).
945 ///
946 /// # Errors
947 /// [`Error`] if the COM call fails or returns an unexpected type.
948 pub fn user_name_exists(&self, login_name: &str) -> Result<bool, Error> {
949 Ok(self
950 .dispatch
951 .call(
952 dispid::USER_NAME_EXISTS,
953 &[Value::Str(login_name.to_owned())],
954 )?
955 .as_bool()?)
956 }
957
958 /// The user currently logged in (`Engine.CurrentUser`).
959 ///
960 /// Returns `None` when nobody is logged in, which is the normal state on a
961 /// station that does not require a login.
962 ///
963 /// # Errors
964 /// [`Error`] if the COM call fails or returns an unexpected type.
965 pub fn current_user(&self) -> Result<Option<crate::users::User>, Error> {
966 match self.dispatch.get(dispid::CURRENT_USER)? {
967 Value::Object(dispatch) => Ok(Some(crate::users::User::new(dispatch))),
968 Value::Null | Value::Empty => Ok(None),
969 other => Err(Error::UnexpectedType {
970 expected: "Object or Null",
971 actual: other.kind(),
972 }),
973 }
974 }
975
976 /// Whether the logged-in user holds a privilege
977 /// (`Engine.CurrentUserHasPrivilege`).
978 ///
979 /// # Errors
980 /// [`Error`] if the COM call fails or returns an unexpected type.
981 pub fn current_user_has_privilege(
982 &self,
983 privilege: crate::users::UserPrivilege,
984 ) -> Result<bool, Error> {
985 Ok(self
986 .dispatch
987 .call(
988 dispid::CURRENT_USER_HAS_PRIVILEGE,
989 &[Value::Str(privilege.name().to_owned())],
990 )?
991 .as_bool()?)
992 }
993
994 /// Creates a standalone `PropertyObject` (`Engine.NewPropertyObject`).
995 ///
996 /// The object belongs to no sequence file or station; it is useful as the
997 /// root of a tree you build in memory. Pass a type name only when
998 /// `value_type` is `NamedType`.
999 ///
1000 /// # Errors
1001 /// [`Error`] if the COM call fails or returns an unexpected type.
1002 pub fn new_property_object(
1003 &self,
1004 value_type: crate::PropValType,
1005 as_array: bool,
1006 type_name: &str,
1007 options: i32,
1008 ) -> Result<crate::property::PropertyObject, Error> {
1009 Ok(crate::property::PropertyObject::new(
1010 self.dispatch
1011 .call(
1012 dispid::NEW_PROPERTY_OBJECT,
1013 &[
1014 Value::I32(value_type as i32),
1015 Value::Bool(as_array),
1016 Value::Str(type_name.to_owned()),
1017 Value::I32(options),
1018 ],
1019 )?
1020 .into_object()?,
1021 ))
1022 }
1023
1024 /// Shuts the engine down and leaves this thread's COM apartment.
1025 ///
1026 /// For a host that owns the engine on a **spawned** thread. Such a thread
1027 /// really does detach when it ends, so the apartment it initialized has to
1028 /// be closed or the COM runtime is left believing a live thread still owns
1029 /// one. The process's main thread does not need this: it is ending anyway.
1030 ///
1031 /// Consuming `self` is what makes the ordering safe, the engine is
1032 /// released before the apartment closes, and no caller can hold a reference
1033 /// across the boundary.
1034 ///
1035 /// # Errors
1036 /// [`Error`] if a COM call during shutdown fails. The apartment is closed
1037 /// either way.
1038 pub fn close(self, timeout: std::time::Duration) -> Result<bool, Error> {
1039 let confirmed = self.shutdown(timeout);
1040 rs_teststand_sys::close_apartment(self.dispatch);
1041 confirmed
1042 }
1043
1044 /// Closes files, terminates executions, and waits for the engine to say it
1045 /// is done (`Engine.ShutDown`).
1046 ///
1047 /// `ShutDown` is **asynchronous**. It returns as soon as the request is
1048 /// accepted, having only *started* terminating executions and closing
1049 /// files; the engine reports completion later by posting
1050 /// [`UIMessageCode::ShutDownComplete`](crate::UIMessageCode::ShutDownComplete)
1051 /// to its message queue. So a caller that simply calls it and drops the
1052 /// engine tears down COM underneath work that is still running.
1053 ///
1054 /// This does the whole protocol: enables message polling, asks the engine
1055 /// to shut down, then pumps and drains until the engine confirms or
1056 /// `timeout` elapses.
1057 ///
1058 /// Returns `true` when the engine confirmed. `false` means the timeout came
1059 /// first, or the engine posted
1060 /// [`ShutDownCanceled`](crate::UIMessageCode::ShutDownCanceled), which a
1061 /// sequence can cause, for instance by refusing to terminate. Either way the
1062 /// wait is **bounded**: an unattended host must not be able to hang here.
1063 ///
1064 /// Shutting down twice is harmless; the second call simply finds nothing to
1065 /// do and returns once the engine answers.
1066 ///
1067 /// # Errors
1068 /// [`Error`] if a COM call fails.
1069 pub fn shutdown(&self, timeout: std::time::Duration) -> Result<bool, Error> {
1070 // Without polling the completion message goes to an event sink that a
1071 // headless caller does not have, and the wait could never end.
1072 self.set_ui_message_polling_enabled(true)?;
1073 self.dispatch
1074 .call(dispid::SHUT_DOWN, &[Value::Bool(true)])?;
1075
1076 let started = std::time::Instant::now();
1077 while started.elapsed() < timeout {
1078 if crate::pump_thread_messages() {
1079 return Ok(false);
1080 }
1081 while !self.is_ui_message_queue_empty()? {
1082 let message = self.get_ui_message()?;
1083 let code = crate::UIMessageCode::from_bits(message.event()?);
1084 // Acknowledge before deciding: an unacknowledged synchronous
1085 // message would hold up the very shutdown being waited on.
1086 message.acknowledge()?;
1087 match code {
1088 Ok(crate::UIMessageCode::ShutDownComplete) => return Ok(true),
1089 Ok(crate::UIMessageCode::ShutDownCanceled) => return Ok(false),
1090 _ => {}
1091 }
1092 }
1093 }
1094 Ok(false)
1095 }
1096
1097 /// The station's templates file (`Engine.GetTemplatesFile`).
1098 ///
1099 /// Holds the variable, step and sequence prototypes the editor offers when
1100 /// inserting. It is a station-wide file, so it is empty until someone adds
1101 /// templates to it, an empty one is the normal state, not a failure.
1102 ///
1103 /// A template is an ordinary [`PropertyObject`](crate::PropertyObject), not
1104 /// a type of its own, so a program is free to keep its own prototypes in a
1105 /// container it builds itself rather than in this file.
1106 ///
1107 /// # Errors
1108 /// [`Error`] if the COM call fails or returns an unexpected type.
1109 pub fn get_templates_file(
1110 &self,
1111 options: crate::GetTemplatesFileOptions,
1112 ) -> Result<crate::property::PropertyObjectFile, Error> {
1113 Ok(crate::property::PropertyObjectFile::new(
1114 self.dispatch
1115 .call(dispid::GET_TEMPLATES_FILE, &[Value::I32(options.bits())])?
1116 .into_object()?,
1117 ))
1118 }
1119
1120 /// Opens a sequence file, or returns the already-loaded one
1121 /// (`Engine.GetSequenceFileEx`).
1122 ///
1123 /// The engine caches the file and counts load references, so every
1124 /// successful call must be paired with
1125 /// [`release_sequence_file_ex`](Self::release_sequence_file_ex).
1126 ///
1127 /// Both option arguments matter on an unattended host:
1128 /// [`crate::sequence::GetSeqFileOptions::DO_NOT_RUN_LOAD_CALLBACK`] suppresses a load
1129 /// callback that could raise a dialog, and [`crate::sequence::ConflictHandler::Error`]
1130 /// fails the load instead of prompting.
1131 ///
1132 /// # Errors
1133 /// [`Error`] if the file cannot be opened or the COM call fails.
1134 pub fn get_sequence_file_ex(
1135 &self,
1136 path: &str,
1137 options: crate::sequence::GetSeqFileOptions,
1138 handler: crate::sequence::ConflictHandler,
1139 ) -> Result<crate::sequence::SequenceFile, Error> {
1140 let dispatch = self
1141 .dispatch
1142 .call(
1143 dispid::GET_SEQUENCE_FILE_EX,
1144 &[
1145 Value::Str(path.to_owned()),
1146 Value::I32(options.bits()),
1147 Value::I32(handler.bits()),
1148 ],
1149 )?
1150 .into_object()?;
1151 Ok(crate::sequence::SequenceFile::new(dispatch))
1152 }
1153
1154 /// Drops one load reference on a sequence file
1155 /// (`Engine.ReleaseSequenceFileEx`).
1156 ///
1157 /// Returns `true` when that was the last reference and the engine has
1158 /// discarded the file. `false` means something else still holds it open,
1159 /// so the file stays loaded, which is why only the `true` case also
1160 /// releases the wrapper's own COM reference.
1161 ///
1162 /// # Errors
1163 /// [`Error`] if the COM call fails.
1164 pub fn release_sequence_file_ex(
1165 &self,
1166 sequence_file: crate::sequence::SequenceFile,
1167 options: i32,
1168 ) -> Result<bool, Error> {
1169 let released = self
1170 .dispatch
1171 .call(
1172 dispid::RELEASE_SEQUENCE_FILE_EX,
1173 &[
1174 Value::Object(sequence_file.into_dispatch()),
1175 Value::I32(options),
1176 ],
1177 )?
1178 .as_bool()?;
1179 Ok(released)
1180 }
1181
1182 /// Accesses the collection of search directories (`Engine.SearchDirectories`).
1183 ///
1184 /// # Errors
1185 /// [`Error`] if the COM call fails or returns an unexpected type.
1186 pub fn search_directories(&self) -> Result<crate::station::SearchDirectories, Error> {
1187 let dispatch = self
1188 .dispatch
1189 .get(dispid::SEARCH_DIRECTORIES)?
1190 .into_object()?;
1191 Ok(crate::station::SearchDirectories::new(dispatch))
1192 }
1193
1194 /// Accesses the station global variables container (`Engine.Globals`).
1195 ///
1196 /// # Errors
1197 /// [`Error`] if the COM call fails or returns an unexpected type.
1198 pub fn globals(&self) -> Result<crate::property::PropertyObject, Error> {
1199 let dispatch = self.dispatch.get(dispid::GLOBALS)?.into_object()?;
1200 Ok(crate::property::PropertyObject::new(dispatch))
1201 }
1202
1203 /// Creates a new workspace file object (`Engine.NewWorkspaceFile`).
1204 ///
1205 /// # Errors
1206 /// [`Error`] if the COM call fails or returns an unexpected type.
1207 pub fn new_workspace_file(&self) -> Result<crate::workspace::WorkspaceFile, Error> {
1208 let dispatch = self
1209 .dispatch
1210 .call(dispid::NEW_WORKSPACE_FILE, &[])?
1211 .into_object()?;
1212 Ok(crate::workspace::WorkspaceFile::new(dispatch))
1213 }
1214
1215 /// Opens an existing workspace file (`Engine.OpenWorkspaceFile`).
1216 ///
1217 /// # Errors
1218 /// [`Error`] if the COM call fails or returns an unexpected type.
1219 pub fn open_workspace_file(
1220 &self,
1221 path: &str,
1222 read_only: bool,
1223 options: i32,
1224 ) -> Result<crate::workspace::WorkspaceFile, Error> {
1225 let dispatch = self
1226 .dispatch
1227 .call(
1228 dispid::OPEN_WORKSPACE_FILE,
1229 &[
1230 Value::Str(path.to_string()),
1231 Value::Bool(read_only),
1232 Value::I32(options),
1233 ],
1234 )?
1235 .into_object()?;
1236 Ok(crate::workspace::WorkspaceFile::new(dispatch))
1237 }
1238
1239 /// Flushes modified station globals and configuration to disk (`Engine.CommitGlobalsToDisk`).
1240 ///
1241 /// # Errors
1242 /// [`Error`] if the COM call fails.
1243 pub fn commit_globals_to_disk(&self, prompt_on_save_conflicts: bool) -> Result<(), Error> {
1244 self.dispatch.call(
1245 dispid::COMMIT_GLOBALS_TO_DISK,
1246 &[Value::Bool(prompt_on_save_conflicts)],
1247 )?;
1248 Ok(())
1249 }
1250
1251 /// Builds an engine over a caller-supplied dispatch handle. Test-only seam
1252 /// for exercising wrapper logic against a fake, with no live COM.
1253 #[cfg(test)]
1254 pub(crate) fn from_dispatch(dispatch: Box<dyn Dispatch>) -> Self {
1255 Self {
1256 dispatch,
1257 // Nothing was created, so nothing could have asked anything.
1258 startup_dialogs: Vec::new(),
1259 }
1260 }
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265 use std::collections::HashMap;
1266
1267 use rs_teststand_sys::{ComError, Value};
1268
1269 use super::{Engine, dispid};
1270 use crate::error::Error;
1271
1272 /// A scripted response for one dispatch id, so the fake needs no COM and no
1273 /// `Clone` on `Value`.
1274 #[derive(Debug, Clone)]
1275 enum Scripted {
1276 Bool(bool),
1277 I32(i32),
1278 Str(&'static str),
1279 Fail(i32),
1280 }
1281
1282 #[derive(Debug)]
1283 struct FakeDispatch {
1284 responses: HashMap<i32, Scripted>,
1285 /// Every `put` and `call` in order, so a test can assert what a wrapper
1286 /// sent rather than only what it read back.
1287 written: Written,
1288 }
1289
1290 /// Shared with the test, because `Engine` takes the dispatch by value.
1291 type Written = std::rc::Rc<core::cell::RefCell<Vec<(i32, Value)>>>;
1292
1293 impl FakeDispatch {
1294 fn new(entries: impl IntoIterator<Item = (i32, Scripted)>, written: Written) -> Self {
1295 Self {
1296 responses: entries.into_iter().collect(),
1297 written,
1298 }
1299 }
1300 }
1301
1302 impl rs_teststand_sys::Dispatch for FakeDispatch {
1303 fn get(&self, dispid: i32) -> Result<Value, ComError> {
1304 match self.responses.get(&dispid) {
1305 Some(Scripted::Bool(value)) => Ok(Value::Bool(*value)),
1306 Some(Scripted::I32(value)) => Ok(Value::I32(*value)),
1307 Some(Scripted::Str(value)) => Ok(Value::Str((*value).to_owned())),
1308 Some(Scripted::Fail(code)) => Err(ComError::hresult(*code, "fake")),
1309 None => Err(ComError::hresult(0, "fake: unscripted dispid")),
1310 }
1311 }
1312
1313 fn put(&self, dispid: i32, value: Value) -> Result<(), ComError> {
1314 self.written.borrow_mut().push((dispid, value));
1315 Ok(())
1316 }
1317
1318 fn call(&self, dispid: i32, args: &[Value]) -> Result<Value, ComError> {
1319 let first = match args.first() {
1320 Some(Value::I32(value)) => Value::I32(*value),
1321 _ => Value::Empty,
1322 };
1323 self.written.borrow_mut().push((dispid, first));
1324 Ok(Value::Empty)
1325 }
1326 }
1327
1328 fn engine_with(entries: impl IntoIterator<Item = (i32, Scripted)>) -> Engine {
1329 engine_recording(entries).0
1330 }
1331
1332 /// An engine plus the log of everything it writes.
1333 fn engine_recording(entries: impl IntoIterator<Item = (i32, Scripted)>) -> (Engine, Written) {
1334 let written: Written = std::rc::Rc::default();
1335 let dispatch = FakeDispatch::new(entries, std::rc::Rc::clone(&written));
1336 (Engine::from_dispatch(Box::new(dispatch)), written)
1337 }
1338
1339 /// What a wrapper sent, reduced to the shapes these members use.
1340 ///
1341 /// `Value` carries COM payloads that have no meaningful equality, so it does
1342 /// not implement `PartialEq`. Comparing the handful of scalar cases here is
1343 /// enough and keeps that out of the public type.
1344 #[derive(Debug, PartialEq, Eq)]
1345 enum Sent {
1346 Empty,
1347 Bool(bool),
1348 I32(i32),
1349 Other,
1350 }
1351
1352 impl From<&Value> for Sent {
1353 fn from(value: &Value) -> Self {
1354 match *value {
1355 Value::Empty => Self::Empty,
1356 Value::Bool(flag) => Self::Bool(flag),
1357 Value::I32(number) => Self::I32(number),
1358 _ => Self::Other,
1359 }
1360 }
1361 }
1362
1363 /// Whether the log holds exactly this one entry.
1364 fn wrote(written: &Written, dispid: i32, expected: &Sent) -> bool {
1365 let log = written.borrow();
1366 matches!(log.as_slice(), [(id, sent)] if *id == dispid && Sent::from(sent) == *expected)
1367 }
1368
1369 #[test]
1370 fn major_version_reads_i4_property() -> Result<(), Error> {
1371 let engine = engine_with([(dispid::MAJOR_VERSION, Scripted::I32(26))]);
1372 assert_eq!(engine.major_version()?, 26);
1373 Ok(())
1374 }
1375
1376 #[test]
1377 fn version_string_reads_bstr_property() -> Result<(), Error> {
1378 let engine = engine_with([(dispid::VERSION_STRING, Scripted::Str("26.0.0.123"))]);
1379 assert_eq!(engine.version_string()?, "26.0.0.123");
1380 Ok(())
1381 }
1382
1383 #[test]
1384 fn is_64bit_reads_bool_property() -> Result<(), Error> {
1385 let engine = engine_with([(dispid::IS_64BIT, Scripted::Bool(true))]);
1386 assert!(engine.is_64bit()?);
1387 Ok(())
1388 }
1389
1390 #[test]
1391 fn directories_read_bstr_properties() -> Result<(), Error> {
1392 let engine = engine_with([
1393 (dispid::TESTSTAND_DIRECTORY, Scripted::Str("T:\\TestStand")),
1394 (dispid::BIN_DIRECTORY, Scripted::Str("T:\\TestStand\\Bin")),
1395 (
1396 dispid::CONFIG_DIRECTORY,
1397 Scripted::Str("T:\\TestStand\\Cfg"),
1398 ),
1399 ]);
1400 assert_eq!(engine.teststand_directory()?, "T:\\TestStand");
1401 assert_eq!(engine.bin_directory()?, "T:\\TestStand\\Bin");
1402 assert_eq!(engine.config_directory()?, "T:\\TestStand\\Cfg");
1403 Ok(())
1404 }
1405
1406 #[test]
1407 fn unload_all_modules_calls_the_method_with_no_arguments() -> Result<(), Error> {
1408 let (engine, written) = engine_recording([]);
1409 engine.unload_all_modules()?;
1410 assert!(
1411 wrote(&written, dispid::UNLOAD_ALL_MODULES, &Sent::Empty),
1412 "expected one argument-free call, got {written:?}",
1413 );
1414 Ok(())
1415 }
1416
1417 #[test]
1418 fn breakpoints_enabled_round_trips() -> Result<(), Error> {
1419 let engine = engine_with([(dispid::BREAKPOINTS_ENABLED, Scripted::Bool(true))]);
1420 assert!(engine.breakpoints_enabled()?);
1421
1422 let (engine, written) = engine_recording([]);
1423 engine.set_breakpoints_enabled(false)?;
1424 assert!(
1425 wrote(&written, dispid::BREAKPOINTS_ENABLED, &Sent::Bool(false)),
1426 "expected the flag to be written as a bool, got {written:?}",
1427 );
1428 Ok(())
1429 }
1430
1431 #[test]
1432 fn persist_breakpoints_round_trips() -> Result<(), Error> {
1433 let engine = engine_with([(dispid::PERSIST_BREAKPOINTS, Scripted::Bool(false))]);
1434 assert!(!engine.persist_breakpoints()?);
1435
1436 let (engine, written) = engine_recording([]);
1437 engine.set_persist_breakpoints(true)?;
1438 assert!(
1439 wrote(&written, dispid::PERSIST_BREAKPOINTS, &Sent::Bool(true)),
1440 "expected the flag to be written as a bool, got {written:?}",
1441 );
1442 Ok(())
1443 }
1444
1445 #[test]
1446 fn dot_net_collection_passes_the_reserved_argument() -> Result<(), Error> {
1447 // The engine declares the parameter optional with a zero default. Send
1448 // it explicitly so the call stays correct if the default is dropped.
1449 let (engine, written) = engine_recording([]);
1450 engine.do_dot_net_garbage_collection()?;
1451 assert!(
1452 wrote(
1453 &written,
1454 dispid::DO_DOT_NET_GARBAGE_COLLECTION,
1455 &Sent::I32(0)
1456 ),
1457 "expected the reserved argument to be sent as zero, got {written:?}",
1458 );
1459 Ok(())
1460 }
1461
1462 #[test]
1463 fn dot_net_collection_interval_round_trips() -> Result<(), Error> {
1464 let engine = engine_with([(
1465 dispid::DOT_NET_GARBAGE_COLLECTION_INTERVAL,
1466 Scripted::I32(30_000),
1467 )]);
1468 assert_eq!(engine.dot_net_garbage_collection_interval()?, 30_000);
1469
1470 let (engine, written) = engine_recording([]);
1471 engine.set_dot_net_garbage_collection_interval(5_000)?;
1472 assert!(
1473 wrote(
1474 &written,
1475 dispid::DOT_NET_GARBAGE_COLLECTION_INTERVAL,
1476 &Sent::I32(5_000)
1477 ),
1478 "expected the interval to be written as an i4, got {written:?}",
1479 );
1480 Ok(())
1481 }
1482
1483 #[test]
1484 fn dot_net_clr_version_is_empty_when_the_runtime_is_not_loaded() -> Result<(), Error> {
1485 // Documented behavior: empty means "not loaded", not "failed".
1486 let engine = engine_with([(dispid::DOT_NET_CLR_VERSION, Scripted::Str(""))]);
1487 assert_eq!(engine.dot_net_clr_version()?, "");
1488 Ok(())
1489 }
1490
1491 #[test]
1492 fn com_failure_propagates_as_typed_error() {
1493 // 0x8004_2001 stands in for an engine HRESULT; the exact code must survive.
1494 let engine = engine_with([(dispid::MAJOR_VERSION, Scripted::Fail(-2_147_209_215))]);
1495 let result = engine.major_version();
1496 assert!(
1497 matches!(result, Err(Error::Com { hresult, .. }) if hresult == -2_147_209_215),
1498 "expected Com error carrying the HRESULT, got {result:?}",
1499 );
1500 }
1501
1502 #[test]
1503 fn wrong_variant_type_is_reported_not_coerced() {
1504 // Property answers with a string where the wrapper wants an i32.
1505 let engine = engine_with([(dispid::MAJOR_VERSION, Scripted::Str("not a number"))]);
1506 let result = engine.major_version();
1507 assert!(
1508 matches!(
1509 result,
1510 Err(Error::UnexpectedType {
1511 expected: "I32",
1512 ..
1513 })
1514 ),
1515 "expected a type-mismatch error, got {result:?}",
1516 );
1517 }
1518}