rs-teststand 0.0.1

Community Rust bindings (twin API) for the National Instruments TestStand™ COM API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! Live-engine tests for observing and controlling a running execution.
//!
//! This is the layer a host service reports from. A front end does not poll an
//! engine object — it is handed updates keyed by execution id — so what matters
//! here is that the identity, status and control members behave predictably
//! while a sequence is actually running.
//!
//! Requires a registered engine:
//! `cargo test --features live-engine --test live_execution -- --ignored --test-threads=1`

#![cfg(feature = "live-engine")]

use std::path::PathBuf;
use std::time::{Duration, Instant};

use rs_teststand::{
    AdapterKeyName, ConflictHandler, Engine, Error, GetSeqFileOptions, SequenceFile, StepGroup,
    UIMessageCode, pump_thread_messages,
};

const INSERT_IF_MISSING: i32 = 1;
const NO_OPTIONS: i32 = 0;

/// Builds a file whose `MainSequence` does a little work, so an execution
/// exists long enough to be observed.
fn runnable_file(engine: &Engine) -> Result<SequenceFile, Error> {
    let sequence_file = engine.new_sequence_file()?;
    let main_sequence = sequence_file.get_sequence_by_name("MainSequence")?;
    for index in 0..3 {
        let step = engine.new_step(AdapterKeyName::NoneAdapter.as_str(), "Statement")?;
        step.set_name(&format!("Work {index}"))?;
        step.as_property_object()?.set_val_string(
            "TS.PostExpr",
            INSERT_IF_MISSING,
            &format!("Locals.Counter = {index}"),
        )?;
        main_sequence.insert_step(&step, index, StepGroup::Main)?;
    }
    main_sequence
        .locals()?
        .set_val_number("Counter", INSERT_IF_MISSING, 0.0)?;
    Ok(sequence_file)
}

/// Runs an execution to completion by draining the message queue.
///
/// Two obligations, and missing either one hangs: **pump** the thread's Windows
/// messages so COM can deliver into this apartment, and **drain** the engine's
/// queue so a synchronous poster is released. Spinning on the queue alone burns
/// a core and never sees `EndExecution` — measured, not theorised.
///
/// Deliberately not `WaitForEnd`, which pumps but does not drain.
fn run_to_end(engine: &Engine, deadline: Duration) -> Result<bool, Error> {
    let started = Instant::now();
    while started.elapsed() < deadline {
        if pump_thread_messages() {
            // A quit message means the host is going away; stop rather than
            // wait out the deadline on a thread that is shutting down.
            return Ok(false);
        }
        while !engine.is_ui_message_queue_empty()? {
            let message = engine.get_ui_message()?;
            let ended = matches!(
                UIMessageCode::from_bits(message.event()?),
                Ok(UIMessageCode::EndExecution)
            );
            message.acknowledge()?;
            if ended {
                return Ok(true);
            }
        }
    }
    Ok(false)
}

#[test]
#[ignore = "requires a live engine"]
fn an_execution_identifies_itself_the_way_a_front_end_needs() -> Result<(), Error> {
    // A host keys everything by execution id — that is how the reference user
    // interfaces route updates — so identity has to be readable immediately,
    // not only once the run finishes.
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let sequence_file = runnable_file(&engine)?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;

    let id = execution.id()?;
    let display_name = execution.display_name()?;
    let threads = execution.num_threads()?;
    println!("  id={id}, name={display_name:?}, threads={threads}");

    assert!(id > 0, "an execution should have a positive id");
    assert!(!display_name.is_empty(), "a front end needs a name to show");
    assert!(threads >= 1, "an execution always has at least one thread");

    assert!(run_to_end(&engine, Duration::from_secs(20))?);
    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn the_result_status_settles_once_the_run_is_over() -> Result<(), Error> {
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let sequence_file = runnable_file(&engine)?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;

    assert!(run_to_end(&engine, Duration::from_secs(20))?);

    let status = execution.result_status()?;
    println!("  final status: {status:?}");
    assert!(
        !status.is_empty(),
        "a finished execution should report a status"
    );
    // Kept as the engine's own string rather than an enum: a sequence is free
    // to set a status this crate has never heard of.
    assert!(
        ["Passed", "Done", "Failed", "Terminated", "Error"].contains(&status.as_str()),
        "unexpected status {status:?} — worth reading, not a failure of the API"
    );

    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn an_execution_reports_the_file_it_is_running() -> Result<(), Error> {
    // A host serving several executions has to tell a client which file each
    // one came from, and the execution knows without being told.
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let path = std::env::temp_dir().join("rs_teststand_execution_probe.seq");
    let path = path.to_string_lossy().into_owned();

    let sequence_file = runnable_file(&engine)?;
    sequence_file.save(&path)?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;

    let reported = execution.sequence_file_path()?;
    println!("  running: {reported}");
    assert!(
        reported.eq_ignore_ascii_case(&path),
        "expected {path}, got {reported}"
    );

    assert!(run_to_end(&engine, Duration::from_secs(20))?);
    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    let _ = std::fs::remove_file(&path);
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn timings_are_available_while_the_run_is_in_progress() -> Result<(), Error> {
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let sequence_file = runnable_file(&engine)?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;

    assert!(run_to_end(&engine, Duration::from_secs(20))?);

    let executing = execution.seconds_executing()?;
    let suspended = execution.seconds_suspended()?;
    println!("  executing={executing}s, suspended={suspended}s");
    assert!(executing >= 0.0, "elapsed time cannot be negative");
    assert!(
        suspended >= 0.0,
        "a run that never broke should report no suspended time"
    );

    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn a_thread_is_reachable_both_by_index_and_as_the_foreground_one() -> Result<(), Error> {
    // Two routes to the same thread. A host uses the foreground one to follow
    // what an operator would see; indexing is how it enumerates the rest.
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let sequence_file = runnable_file(&engine)?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;

    let by_index = execution.get_thread(0)?;
    let foreground = execution.foreground_thread()?;
    // Both must be usable as property trees; a wrong DISPID here would abort
    // the process rather than fail, so reaching this line is the assertion.
    assert!(by_index.as_property_object().is_ok());
    assert!(foreground.as_property_object().is_ok());

    assert!(run_to_end(&engine, Duration::from_secs(20))?);
    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn an_execution_exposes_its_own_property_tree_and_error_object() -> Result<(), Error> {
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let sequence_file = runnable_file(&engine)?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;

    assert!(run_to_end(&engine, Duration::from_secs(20))?);

    // The error object exists whether or not anything went wrong; a host reads
    // its fields to decide, rather than treating its absence as success.
    let error_object = execution.error_object()?;
    let occurred = error_object.get_val_bool("Occurred", NO_OPTIONS)?;
    println!("  error occurred: {occurred}");
    assert!(!occurred, "this sequence does nothing that can fail");

    assert!(execution.as_property_object().is_ok());
    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn terminating_a_run_is_asked_for_rather_than_immediate() -> Result<(), Error> {
    // Termination is a request: cleanup still runs. A host that assumes the
    // execution is gone the moment terminate returns would report a state the
    // engine has not reached.
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let sequence_file = runnable_file(&engine)?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;

    execution.terminate()?;
    let ended = run_to_end(&engine, Duration::from_secs(20))?;
    assert!(ended, "the execution should still report its end");

    let status = execution.result_status()?;
    println!("  status after terminate: {status:?}");

    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn a_thread_identifies_itself_and_reaches_its_context() -> Result<(), Error> {
    // Every DISPID on Thread was verified against the type library after a
    // guessed one aborted the process here; this is what keeps them honest.
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let sequence_file = runnable_file(&engine)?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;

    let thread = execution.foreground_thread()?;
    let id = thread.id()?;
    let unique = thread.unique_thread_id()?;
    let name = thread.display_name()?;
    let depth = thread.call_stack_size()?;
    println!("  thread id={id}, unique={unique:?}, name={name:?}, stack={depth}");

    assert!(
        !unique.is_empty(),
        "a host keys on the unique id across runs"
    );
    assert!(depth >= 1, "a running thread has at least one frame");
    assert_eq!(
        thread.execution()?.id()?,
        execution.id()?,
        "a thread should lead back to its own execution"
    );

    assert!(run_to_end(&engine, Duration::from_secs(20))?);
    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn station_globals_outlive_a_run_but_file_globals_do_not() -> Result<(), Error> {
    // The lifetime rule this crate documents, checked rather than quoted.
    // NI states StationGlobals exists before and persists after an execution,
    // while FileGlobals is the run's own copy. Getting this wrong is how a host
    // ends up holding a reference into a finished run.
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let sequence_file = engine.new_sequence_file()?;
    let main_sequence = sequence_file.get_sequence_by_name("MainSequence")?;

    // A file global with a known default, and a step that changes it at run time.
    sequence_file
        .file_globals_default_values()?
        .set_val_string("Marker", INSERT_IF_MISSING, "default")?;
    let step = engine.new_step(AdapterKeyName::NoneAdapter.as_str(), "Statement")?;
    step.set_name("Touch Globals")?;
    step.as_property_object()?.set_val_string(
        "TS.PostExpr",
        INSERT_IF_MISSING,
        "FileGlobals.Marker = \"changed by the run\"",
    )?;
    main_sequence.insert_step(&step, 0, StepGroup::Main)?;

    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;
    assert!(run_to_end(&engine, Duration::from_secs(20))?);

    // The run wrote to its own copy; the file's stored default is untouched.
    assert_eq!(
        sequence_file
            .file_globals_default_values()?
            .get_val_string("Marker", NO_OPTIONS)?,
        "default",
        "a run must not write through to the file's stored defaults"
    );

    // Station globals are reachable with no execution at all, which is the
    // route a host should use for anything that must outlive a run.
    let globals = engine.globals()?;
    globals.set_val_string("RsTestStandProbe", INSERT_IF_MISSING, "kept")?;
    assert_eq!(
        globals.get_val_string("RsTestStandProbe", NO_OPTIONS)?,
        "kept"
    );
    globals.delete_sub_property("RsTestStandProbe", NO_OPTIONS)?;

    let _ = execution.id()?;
    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn every_control_member_reaches_the_member_it_names() -> Result<(), Error> {
    // This test exists because four Execution dispatch identifiers were once
    // guessed rather than read from the type library, and two of them landed on
    // the wrong member — `abort` invoked CancelTermination, `cancel_termination`
    // invoked ClearExtraResultList. Neither failed loudly. Calling each control
    // member on a real execution is what makes such a mix-up visible.
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let sequence_file = runnable_file(&engine)?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;

    // Only the members that do not change run state; suspending is a race and
    // gets a test of its own.
    execution.as_property_object()?;
    execution.get_sequence_file()?;
    execution.cancel_termination()?;

    assert!(run_to_end(&engine, Duration::from_secs(20))?);
    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn aborting_stops_a_run_without_its_cleanup() -> Result<(), Error> {
    // Abort is the blunt one, and it must be the member it claims to be: an
    // identifier that silently selected CancelTermination instead would leave a
    // run going when a host believed it had stopped it.
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let sequence_file = runnable_file(&engine)?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;

    execution.abort()?;
    assert!(
        run_to_end(&engine, Duration::from_secs(20))?,
        "an aborted run should still report its end"
    );
    println!("  status after abort: {:?}", execution.result_status()?);

    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn suspending_takes_effect_before_a_resume_is_safe() -> Result<(), Error> {
    // Every control member is a request, not an action. Suspending and then
    // resuming straight away races: the resume can be processed before the
    // suspend takes hold, and the run then stays stopped for ever — which is
    // exactly how this test first failed. Waiting for the engine to confirm is
    // what makes the pair safe, and ExternallySuspended is the confirmation.
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;
    let sequence_file = runnable_file(&engine)?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;
    let thread = execution.foreground_thread()?;

    execution.suspend()?;
    // Short: this run is only a few statements, so if the suspend has not
    // landed within a moment the run has already finished, which the assertion
    // below treats as a legitimate outcome. Waiting longer buys nothing and
    // used to burn thirty seconds of the suite.
    let deadline = Instant::now() + Duration::from_secs(2);
    let mut suspended = false;
    while Instant::now() < deadline {
        let _ = pump_thread_messages();
        if thread.externally_suspended()? {
            suspended = true;
            break;
        }
        // Drain so a synchronous poster is never left waiting while we watch.
        while !engine.is_ui_message_queue_empty()? {
            engine.get_ui_message()?.acknowledge()?;
        }
    }

    // A run this short can finish before the suspend lands; that is a legitimate
    // outcome and is reported rather than asserted away.
    println!("  suspend observed: {suspended}");
    execution.resume()?;

    assert!(
        run_to_end(&engine, Duration::from_secs(20))?,
        "the run should finish once resumed"
    );
    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn shutting_down_is_confirmed_by_the_engine_and_bounded() -> Result<(), Error> {
    // ShutDown is asynchronous: it returns as soon as the request is accepted
    // and reports completion later on the message queue. A host that skips the
    // wait tears COM down underneath work still in progress. The wait must also
    // be bounded — an unattended station cannot be allowed to hang here.
    let engine = Engine::new()?;
    let sequence_file = runnable_file(&engine)?;
    let _execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;

    let started = Instant::now();
    let confirmed = engine.shutdown(Duration::from_secs(30))?;
    let waited = started.elapsed();
    println!("  confirmed={confirmed} after {waited:?}");

    assert!(
        waited < Duration::from_secs(30),
        "the wait must be bounded, not merely finite"
    );
    assert!(
        confirmed,
        "the engine should confirm shutdown for a run this simple"
    );
    Ok(())
}

#[test]
#[ignore = "requires a live engine"]
fn results_parse_from_a_sequence_file_authored_in_the_editor() -> Result<(), Error> {
    // Building a sequence in code and running it proves the walk handles what
    // this crate itself produced. A file authored in the editor is the case a
    // host actually meets, and it carries things code-built files tend not to:
    // real step types, a step with recording switched off, editor defaults.
    let engine = Engine::new()?;
    engine.set_ui_message_polling_enabled(true)?;

    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join("ResultListParse.seq");
    assert!(
        path.is_file(),
        "the fixture is committed and must be present: {}",
        path.display()
    );

    let sequence_file = engine.get_sequence_file_ex(
        &path.to_string_lossy(),
        GetSeqFileOptions::DO_NOT_RUN_LOAD_CALLBACK,
        ConflictHandler::Error,
    )?;
    let execution = engine.new_execution(&sequence_file, "MainSequence", None, false, 0)?;
    assert!(run_to_end(&engine, Duration::from_secs(20))?);

    let parsed = execution.result_list()?.parse()?;
    for result in &parsed {
        println!(
            "  {} ({}) -> {} {:?}",
            result.name, result.step_type, result.status, result.value
        );
    }

    assert!(
        !parsed.is_empty(),
        "an authored sequence should record something"
    );
    // Every entry names the step it came from; a blank name means the walk read
    // the wrong property rather than that the step was anonymous.
    assert!(
        parsed.iter().all(|result| !result.status.is_empty()),
        "every recorded result carries a status"
    );

    engine.release_sequence_file_ex(sequence_file, NO_OPTIONS)?;
    Ok(())
}