Skip to main content

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}