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}