Skip to main content

rs_teststand/execution/
result_list.rs

1//! Reading an execution's recorded results.
2
3use crate::Error;
4use crate::property::{PropertyObject, PropertyOptions};
5
6/// Where a recorded result keeps the name of the step that produced it.
7const STEP_NAME: &str = "TS.StepName";
8/// Where it keeps that step's type.
9const STEP_TYPE: &str = "TS.StepType";
10/// The result's position in the order the engine recorded them.
11const INDEX: &str = "TS.Index";
12/// How deeply the step was nested, which a tree view indents by.
13const BLOCK_LEVEL: &str = "TS.BlockLevel";
14/// Seconds the step took.
15const TOTAL_TIME: &str = "TS.TotalTime";
16/// The human-readable line the engine recorded for a report.
17const REPORT_TEXT: &str = "ReportText";
18/// Whether the step reported an error at all.
19const ERROR_OCCURRED: &str = "Error.Occurred";
20/// The numeric error code, meaningful only when one occurred.
21const ERROR_CODE: &str = "Error.Code";
22/// The error text, meaningful only when one occurred.
23const ERROR_MESSAGE: &str = "Error.Msg";
24/// The pass/fail or error outcome.
25const STATUS: &str = "Status";
26/// A numeric measurement, present on test steps only.
27const NUMERIC: &str = "Numeric";
28/// A string measurement, present on string value tests.
29const STRING: &str = "String";
30/// The unit a numeric measurement was taken in, a top-level sibling of the
31/// reading rather than part of the limits.
32const UNITS: &str = "Units";
33/// The lower bound a numeric measurement was checked against.
34///
35/// `RawLimits`, not `Limits`: the raw pair is what the engine records on the
36/// result, confirmed against a live run.
37const LIMIT_LOW: &str = "RawLimits.Low";
38/// The upper bound a numeric measurement was checked against.
39const LIMIT_HIGH: &str = "RawLimits.High";
40
41/// One entry from an execution's result list.
42///
43/// Plain data, deliberately: the COM objects behind a result belong to the
44/// execution that produced them, so a host that wants to keep results after the
45/// run must keep values rather than references.
46#[derive(Debug, Clone, PartialEq)]
47#[non_exhaustive]
48pub struct StepResult {
49    /// The step that recorded this result.
50    pub name: String,
51    /// That step's type, for example `NumericLimitTest`.
52    pub step_type: String,
53    /// The outcome the engine recorded: `Passed`, `Failed`, `Done`, `Error`,
54    /// `Skipped`, or a status a sequence set itself.
55    pub status: String,
56    /// The measurement, when the step recorded one.
57    ///
58    /// A numeric limit test yields a number, a string value test a string, and
59    /// an action neither, which is why this is optional rather than defaulted
60    /// to zero or an empty string.
61    pub value: Option<ResultValue>,
62    /// Where this result sits in the order the engine recorded them.
63    pub index: i32,
64    /// How deeply the step was nested, for a view that indents by depth.
65    pub block_level: i32,
66    /// Seconds the step took, as the engine measured it.
67    pub total_time: f64,
68    /// The line the engine recorded for a report, empty when it recorded none.
69    pub report_text: String,
70    /// What went wrong, if anything did.
71    ///
72    /// Always present rather than optional: every result carries the `Error`
73    /// container, and `occurred` is the flag that says whether the rest of it
74    /// means anything.
75    pub error: ResultError,
76    /// The unit the measurement was taken in, when the step recorded one.
77    pub units: Option<String>,
78    /// The range the measurement was checked against.
79    ///
80    /// Only step types that compare against bounds record these, so an action
81    /// or a pass/fail test leaves it `None` rather than reporting a range it
82    /// never had.
83    pub limits: Option<Limits>,
84}
85
86/// What a step reported when something went wrong.
87#[derive(Debug, Clone, PartialEq, Default)]
88#[non_exhaustive]
89pub struct ResultError {
90    /// Whether an error was reported at all. When false, the other two fields
91    /// carry no meaning.
92    pub occurred: bool,
93    /// The engine's numeric code for the failure.
94    pub code: i32,
95    /// The failure text, empty when the engine recorded none.
96    pub message: String,
97}
98
99/// The range a numeric measurement was checked against.
100#[derive(Debug, Clone, PartialEq)]
101#[non_exhaustive]
102pub struct Limits {
103    /// The lower bound.
104    pub low: f64,
105    /// The upper bound.
106    pub high: f64,
107}
108
109/// A measurement carried by a result.
110#[derive(Debug, Clone, PartialEq)]
111pub enum ResultValue {
112    /// A numeric measurement.
113    Number(f64),
114    /// A string measurement.
115    Text(String),
116}
117
118/// An execution's recorded results (`ResultList`).
119///
120/// A thin reader over the array the engine builds as a sequence runs. Only
121/// steps whose recording is enabled appear here, so the list is routinely
122/// shorter than the sequence, and a step that failed early can end a run before
123/// later steps record anything. See
124/// [`ResultRecordingOption`](crate::ResultRecordingOption).
125#[derive(Debug)]
126pub struct ResultList {
127    results: PropertyObject,
128}
129
130impl ResultList {
131    /// Reads results from anything that exposes a `ResultList` property.
132    ///
133    /// Accepts the results tree from
134    /// [`Execution::result_object`](crate::Execution::result_object), which is
135    /// where a headless caller finds them.
136    ///
137    /// # Errors
138    /// [`Error`] if the object holds no `ResultList`, or a COM call fails.
139    pub fn from_result_object(result_object: &PropertyObject) -> Result<Self, Error> {
140        let none = PropertyOptions::NONE.bits();
141        if !result_object.exists("ResultList", none)? {
142            return Err(Error::UnexpectedType {
143                expected: "an object carrying a ResultList",
144                actual: "no ResultList property",
145            });
146        }
147        Ok(Self {
148            results: result_object.get_property_object("ResultList", none)?,
149        })
150    }
151
152    /// How many results were recorded.
153    ///
154    /// # Errors
155    /// [`Error`] if the COM call fails.
156    pub fn len(&self) -> Result<i32, Error> {
157        self.results.get_num_elements()
158    }
159
160    /// Whether nothing was recorded.
161    ///
162    /// # Errors
163    /// [`Error`] if the COM call fails.
164    pub fn is_empty(&self) -> Result<bool, Error> {
165        Ok(self.len()? == 0)
166    }
167
168    /// Reads every result into plain data.
169    ///
170    /// Each field is optional in the tree, and a missing one is normal rather
171    /// than an error: an action step records no measurement, and a result
172    /// written by a custom step type may carry neither name nor type. Missing
173    /// text becomes empty, a missing measurement becomes `None`.
174    ///
175    /// # Errors
176    /// [`Error`] if the list cannot be walked.
177    pub fn parse(&self) -> Result<Vec<StepResult>, Error> {
178        self.parse_from(0)
179    }
180
181    /// Reads the results recorded from `start` onward.
182    ///
183    /// What a streaming host polls with. Re-reading the whole list on every
184    /// tick makes a long run cost quadratic, so a caller keeps the count it has
185    /// already sent and asks for the rest. The list only grows while a run is
186    /// in flight, so results already read do not shift index.
187    ///
188    /// An offset at or past the end yields an empty vector rather than an
189    /// error, because a poll that finds nothing new is the ordinary case. A
190    /// negative offset is treated as zero.
191    ///
192    /// # Errors
193    /// [`Error`] if the list cannot be walked.
194    pub fn parse_from(&self, start: i32) -> Result<Vec<StepResult>, Error> {
195        let none = PropertyOptions::NONE.bits();
196        let mut parsed = Vec::new();
197
198        for index in start.max(0)..self.len()? {
199            let entry = self.results.get_property_object_by_offset(index, none)?;
200            let text = |path: &str| entry.get_val_string(path, none).unwrap_or_default();
201
202            // Numeric first: a numeric limit test carries both a number and, on
203            // some step types, an empty string, and the number is the reading.
204            let value = entry
205                .get_val_number(NUMERIC, none)
206                .ok()
207                .map(ResultValue::Number)
208                .or_else(|| {
209                    entry
210                        .get_val_string(STRING, none)
211                        .ok()
212                        .filter(|found| !found.is_empty())
213                        .map(ResultValue::Text)
214                });
215
216            // Both bounds or neither: a range missing one end is not a range,
217            // and reporting a half-open one as if it were checked would mislead
218            // a panel drawing the limit.
219            let limits = match (
220                entry.get_val_number(LIMIT_LOW, none),
221                entry.get_val_number(LIMIT_HIGH, none),
222            ) {
223                (Ok(low), Ok(high)) => Some(Limits { low, high }),
224                _ => None,
225            };
226
227            // Read as a number, not an integer. These properties are stored as
228            // TestStand numbers, and `GetValInteger64` does not coerce them: it
229            // hands back zero, silently, so every index would read as zero.
230            // Measured on a live engine, not assumed.
231            //
232            // The float-to-int conversion is saturating and these are small
233            // whole counts, so the truncation the lint warns about is the
234            // intended narrowing rather than a hazard.
235            #[expect(
236                clippy::cast_possible_truncation,
237                reason = "counts are small whole numbers and the cast saturates"
238            )]
239            let count = |path: &str| {
240                entry
241                    .get_val_number(path, none)
242                    .ok()
243                    .map_or(0, |found| found as i32)
244            };
245
246            parsed.push(StepResult {
247                name: text(STEP_NAME),
248                step_type: text(STEP_TYPE),
249                status: text(STATUS),
250                value,
251                index: count(INDEX),
252                block_level: count(BLOCK_LEVEL),
253                total_time: entry.get_val_number(TOTAL_TIME, none).unwrap_or_default(),
254                report_text: text(REPORT_TEXT),
255                error: ResultError {
256                    occurred: entry
257                        .get_val_boolean(ERROR_OCCURRED, none)
258                        .unwrap_or_default(),
259                    code: count(ERROR_CODE),
260                    message: text(ERROR_MESSAGE),
261                },
262                units: entry
263                    .get_val_string(UNITS, none)
264                    .ok()
265                    .filter(|found| !found.is_empty()),
266                limits,
267            });
268        }
269        Ok(parsed)
270    }
271
272    /// The underlying array, for a caller that wants to walk it itself.
273    #[must_use]
274    pub const fn as_property_object(&self) -> &PropertyObject {
275        &self.results
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::{ResultError, ResultValue, StepResult};
282
283    #[test]
284    fn a_result_without_a_measurement_is_distinguishable_from_a_zero() {
285        // An action step records no measurement. Defaulting that to 0.0 would
286        // make it indistinguishable from a test that genuinely measured zero.
287        let action = StepResult {
288            name: "Initialize".to_owned(),
289            step_type: "Action".to_owned(),
290            status: "Done".to_owned(),
291            value: None,
292            index: 0,
293            block_level: 0,
294            total_time: 0.0,
295            report_text: String::new(),
296            error: ResultError::default(),
297            units: None,
298            limits: None,
299        };
300        let measured = StepResult {
301            value: Some(ResultValue::Number(0.0)),
302            ..action.clone()
303        };
304        assert_ne!(action.value, measured.value);
305        assert!(action.value.is_none());
306    }
307
308    #[test]
309    fn a_text_measurement_is_kept_as_text() {
310        assert_eq!(
311            ResultValue::Text("SN-001".to_owned()),
312            ResultValue::Text("SN-001".to_owned())
313        );
314        assert_ne!(
315            ResultValue::Text("1.5".to_owned()),
316            ResultValue::Number(1.5)
317        );
318    }
319}