Skip to main content

hardless_core/
scenario.rs

1//! The scenario runner (FR-04): an ordered list of steps (`scan`, `tap`, `wait`, `setFault`,
2//! `clearFault`, `expect`) run against a [`Registry`].
3//!
4//! Sans-IO like the rest of the core: [`ScenarioRun::advance`] runs every step it can at the
5//! registry's current time and says when to call it again. The server (or a test with a fake
6//! clock) does the waiting.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::device::{ControlCommand, DeviceEvent, Terminator};
12use crate::fault::{FaultId, FaultOptions};
13use crate::ids::DeviceId;
14use crate::nfc::NdefRecord;
15use crate::registry::Registry;
16use crate::time::Millis;
17
18/// Most steps a scenario may have.
19pub const MAX_SCENARIO_STEPS: usize = 10_000;
20/// How often an `expect` with `withinMs` is checked again, in milliseconds.
21pub const EXPECT_POLL_MS: u64 = 20;
22
23/// A scenario: steps run in order, stopping at the first that fails.
24///
25/// ```
26/// use hardless_core::Scenario;
27/// let scenario: Scenario = serde_json::from_value(serde_json::json!({
28///     "name": "paper runs out",
29///     "steps": [
30///         { "type": "scan", "device": "scanner-1", "code": "4006381333931" },
31///         { "type": "wait", "ms": 200 },
32///         { "type": "setFault", "device": "printer-1", "fault": "paper-out" },
33///         { "type": "expect", "device": "printer-1", "state": { "paperOut": true } }
34///     ]
35/// })).unwrap();
36/// assert_eq!(scenario.steps.len(), 4);
37/// ```
38#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
39#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct Scenario {
42    /// A name for reports.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    #[cfg_attr(feature = "ts", ts(optional))]
45    pub name: Option<String>,
46    /// The steps, in order.
47    pub steps: Vec<Step>,
48}
49
50/// One scenario step.
51#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
52#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
53#[serde(tag = "type", rename_all = "camelCase")]
54pub enum Step {
55    /// Scan a barcode on a keyboard-wedge scanner.
56    #[serde(rename_all = "camelCase")]
57    Scan {
58        /// The scanner.
59        device: DeviceId,
60        /// The barcode.
61        code: String,
62        /// Overrides the scanner's terminator.
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        #[cfg_attr(feature = "ts", ts(optional))]
65        terminator: Option<Terminator>,
66        /// Overrides the scanner's inter-key delay.
67        #[serde(default, skip_serializing_if = "Option::is_none")]
68        #[cfg_attr(feature = "ts", ts(optional))]
69        delay_ms: Option<u64>,
70    },
71    /// Tap an NFC card.
72    Tap {
73        /// The NFC reader.
74        device: DeviceId,
75        /// Card UID as hex.
76        uid: String,
77        /// NDEF records; without them the reader uses what it remembers of the card.
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        #[cfg_attr(feature = "ts", ts(optional))]
80        records: Option<Vec<NdefRecord>>,
81    },
82    /// Wait.
83    Wait {
84        /// Milliseconds to wait.
85        ms: u64,
86    },
87    /// Turn a fault on.
88    SetFault {
89        /// The device.
90        device: DeviceId,
91        /// The fault.
92        fault: FaultId,
93        /// Its options.
94        #[serde(default)]
95        #[cfg_attr(
96            feature = "ts",
97            ts(as = "std::collections::HashMap<String, serde_json::Value>")
98        )]
99        options: FaultOptions,
100    },
101    /// Turn a fault off.
102    ClearFault {
103        /// The device.
104        device: DeviceId,
105        /// The fault.
106        fault: FaultId,
107    },
108    /// Check a device. Every given check must hold; with `withinMs`, they may become true
109    /// within that time (e.g. a receipt the app prints in response to a scan).
110    #[serde(rename_all = "camelCase")]
111    Expect {
112        /// The device.
113        device: DeviceId,
114        /// Fields the device state must have (a subset match: nested objects compare by the
115        /// keys given).
116        #[serde(default, skip_serializing_if = "Option::is_none")]
117        #[cfg_attr(feature = "ts", ts(optional))]
118        state: Option<Value>,
119        /// Text the latest job must contain.
120        #[serde(default, skip_serializing_if = "Option::is_none")]
121        #[cfg_attr(feature = "ts", ts(optional))]
122        receipt_contains: Option<String>,
123        /// How many jobs the device must hold.
124        #[serde(default, skip_serializing_if = "Option::is_none")]
125        #[cfg_attr(feature = "ts", ts(optional))]
126        jobs: Option<u32>,
127        /// How long the checks may take to become true, in milliseconds (default 0).
128        #[serde(default, skip_serializing_if = "Option::is_none")]
129        #[cfg_attr(feature = "ts", ts(optional))]
130        within_ms: Option<u64>,
131    },
132}
133
134impl Step {
135    /// The step's `type`, e.g. `"setFault"`.
136    pub fn name(&self) -> &'static str {
137        match self {
138            Step::Scan { .. } => "scan",
139            Step::Tap { .. } => "tap",
140            Step::Wait { .. } => "wait",
141            Step::SetFault { .. } => "setFault",
142            Step::ClearFault { .. } => "clearFault",
143            Step::Expect { .. } => "expect",
144        }
145    }
146}
147
148/// How a step went.
149#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
150#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
151#[serde(rename_all = "lowercase")]
152pub enum StepStatus {
153    /// It ran and its checks held.
154    Passed,
155    /// It failed; the scenario stopped here.
156    Failed,
157    /// It did not run because an earlier step failed.
158    Skipped,
159}
160
161/// The result of one step.
162#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
163#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
164#[serde(rename_all = "camelCase")]
165pub struct StepResult {
166    /// Position in the scenario, from 0.
167    pub index: u32,
168    /// The step's `type`.
169    pub step: String,
170    /// How it went.
171    pub status: StepStatus,
172    /// When it finished, in milliseconds from the start of the run.
173    pub at_ms: u64,
174    /// Why it failed.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    #[cfg_attr(feature = "ts", ts(optional))]
177    pub message: Option<String>,
178}
179
180/// The result of a whole run (`POST /scenarios/run`).
181#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
182#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
183#[serde(rename_all = "camelCase")]
184pub struct ScenarioReport {
185    /// The scenario's name.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    #[cfg_attr(feature = "ts", ts(optional))]
188    pub name: Option<String>,
189    /// Whether every step passed.
190    pub ok: bool,
191    /// One result per step.
192    pub steps: Vec<StepResult>,
193    /// How long the run took, in milliseconds.
194    pub duration_ms: u64,
195}
196
197/// What [`ScenarioRun::advance`] wants next.
198#[derive(Clone, Debug, PartialEq)]
199pub enum Progress {
200    /// Call `advance` again at or after this time.
201    WaitUntil(Millis),
202    /// The run is over.
203    Done(ScenarioReport),
204}
205
206/// A scenario being run.
207#[derive(Clone, Debug)]
208pub struct ScenarioRun {
209    scenario: Scenario,
210    index: usize,
211    started: Option<Millis>,
212    /// The end of the current `wait`, or the deadline of the current `expect`.
213    until: Option<Millis>,
214    results: Vec<StepResult>,
215}
216
217impl ScenarioRun {
218    /// A run that has not started.
219    pub fn new(scenario: Scenario) -> Self {
220        Self {
221            scenario,
222            index: 0,
223            started: None,
224            until: None,
225            results: Vec::new(),
226        }
227    }
228
229    /// Runs every step it can now. Device events that need a transport (wedge scans, bytes
230    /// for the app, connects and disconnects) are returned for the caller to deliver.
231    pub fn advance(&mut self, registry: &mut Registry) -> (Progress, Vec<(DeviceId, DeviceEvent)>) {
232        let now = registry.now();
233        let started = *self.started.get_or_insert(now);
234        let mut transport = Vec::new();
235        while let Some(step) = self.scenario.steps.get(self.index).cloned() {
236            let outcome = match &step {
237                Step::Wait { ms } => {
238                    let until = *self.until.get_or_insert(now + *ms);
239                    if now < until {
240                        return (Progress::WaitUntil(until), transport);
241                    }
242                    Ok(())
243                }
244                Step::Expect {
245                    device,
246                    state,
247                    receipt_contains,
248                    jobs,
249                    within_ms,
250                } => match check(
251                    registry,
252                    device,
253                    state.as_ref(),
254                    receipt_contains.as_deref(),
255                    *jobs,
256                ) {
257                    Ok(()) => Ok(()),
258                    Err(message) => {
259                        let deadline = *self.until.get_or_insert(now + within_ms.unwrap_or(0));
260                        if now < deadline {
261                            let next = (now + EXPECT_POLL_MS).min(deadline);
262                            return (Progress::WaitUntil(next), transport);
263                        }
264                        Err(message)
265                    }
266                },
267                Step::Scan {
268                    device,
269                    code,
270                    terminator,
271                    delay_ms,
272                } => control(
273                    registry,
274                    device,
275                    ControlCommand::Scan {
276                        code: code.clone(),
277                        terminator: *terminator,
278                        delay_ms: *delay_ms,
279                    },
280                    &mut transport,
281                ),
282                Step::Tap {
283                    device,
284                    uid,
285                    records,
286                } => control(
287                    registry,
288                    device,
289                    ControlCommand::Tap {
290                        uid: uid.clone(),
291                        records: records.clone(),
292                    },
293                    &mut transport,
294                ),
295                Step::SetFault {
296                    device,
297                    fault,
298                    options,
299                } => fault_step(
300                    registry,
301                    device,
302                    fault,
303                    true,
304                    options.clone(),
305                    &mut transport,
306                ),
307                Step::ClearFault { device, fault } => fault_step(
308                    registry,
309                    device,
310                    fault,
311                    false,
312                    FaultOptions::new(),
313                    &mut transport,
314                ),
315            };
316            self.until = None;
317            let at_ms = now.since(started);
318            let failed = outcome.is_err();
319            self.results.push(StepResult {
320                index: index_u32(self.index),
321                step: step.name().to_owned(),
322                status: if failed {
323                    StepStatus::Failed
324                } else {
325                    StepStatus::Passed
326                },
327                at_ms,
328                message: outcome.err(),
329            });
330            self.index += 1;
331            if failed {
332                break;
333            }
334        }
335        // Whatever is left was skipped.
336        for (i, step) in self.scenario.steps.iter().enumerate().skip(self.index) {
337            self.results.push(StepResult {
338                index: index_u32(i),
339                step: step.name().to_owned(),
340                status: StepStatus::Skipped,
341                at_ms: now.since(started),
342                message: None,
343            });
344        }
345        self.index = self.scenario.steps.len();
346        let report = ScenarioReport {
347            name: self.scenario.name.clone(),
348            ok: self.results.iter().all(|r| r.status == StepStatus::Passed),
349            steps: std::mem::take(&mut self.results),
350            duration_ms: now.since(started),
351        };
352        (Progress::Done(report), transport)
353    }
354}
355
356fn index_u32(index: usize) -> u32 {
357    u32::try_from(index).unwrap_or(u32::MAX)
358}
359
360/// Whether an event needs a transport to reach the app.
361fn for_transport(event: &DeviceEvent) -> bool {
362    matches!(
363        event,
364        DeviceEvent::ToHost(_)
365            | DeviceEvent::Wedge(_)
366            | DeviceEvent::Nfc(_)
367            | DeviceEvent::Connected
368            | DeviceEvent::Disconnected
369    )
370}
371
372fn control(
373    registry: &mut Registry,
374    device: &DeviceId,
375    command: ControlCommand,
376    transport: &mut Vec<(DeviceId, DeviceEvent)>,
377) -> Result<(), String> {
378    let events = registry
379        .control(device, command)
380        .map_err(|e| e.to_string())?;
381    if let Some(DeviceEvent::Log(entry)) = events
382        .iter()
383        .find(|e| matches!(e, DeviceEvent::Log(entry) if entry.level >= crate::LogLevel::Warn))
384    {
385        // The device refused (an empty code, a command it does not support).
386        return Err(entry.message.clone());
387    }
388    transport.extend(
389        events
390            .into_iter()
391            .filter(for_transport)
392            .map(|e| (device.clone(), e)),
393    );
394    Ok(())
395}
396
397fn fault_step(
398    registry: &mut Registry,
399    device: &DeviceId,
400    fault: &FaultId,
401    active: bool,
402    options: FaultOptions,
403    transport: &mut Vec<(DeviceId, DeviceEvent)>,
404) -> Result<(), String> {
405    let events = registry
406        .set_fault(device, fault.clone(), active, options)
407        .map_err(|e| e.to_string())?;
408    transport.extend(
409        events
410            .into_iter()
411            .filter(for_transport)
412            .map(|e| (device.clone(), e)),
413    );
414    Ok(())
415}
416
417fn check(
418    registry: &Registry,
419    device: &DeviceId,
420    state: Option<&Value>,
421    receipt_contains: Option<&str>,
422    jobs: Option<u32>,
423) -> Result<(), String> {
424    let summary = registry
425        .summary(device)
426        .ok_or_else(|| format!("device `{device}` not found"))?;
427    if let Some(expected) = state {
428        if !subset(expected, &summary.state) {
429            return Err(format!("state {} does not match {expected}", summary.state));
430        }
431    }
432    if let Some(count) = jobs {
433        let held = registry.jobs(device).map(Iterator::count).unwrap_or(0);
434        if held != count as usize {
435            return Err(format!("{held} jobs, expected {count}"));
436        }
437    }
438    if let Some(text) = receipt_contains {
439        let last = registry.last_job(device).ok().flatten();
440        match last {
441            Some(job) if job.text.contains(text) => {}
442            Some(job) => {
443                return Err(format!(
444                    "the latest receipt does not contain {text:?}: {:?}",
445                    job.text
446                ))
447            }
448            None => return Err(format!("no receipt yet (expected one containing {text:?})")),
449        }
450    }
451    Ok(())
452}
453
454/// Whether `expected` matches `actual`: objects by the keys `expected` gives, anything else
455/// by equality.
456fn subset(expected: &Value, actual: &Value) -> bool {
457    match (expected, actual) {
458        (Value::Object(want), Value::Object(have)) => want
459            .iter()
460            .all(|(k, v)| have.get(k).is_some_and(|h| subset(v, h))),
461        _ => expected == actual,
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::subset;
468    use serde_json::json;
469
470    #[test]
471    fn subset_matches_nested_keys() {
472        let actual = json!({ "a": 1, "b": { "c": 2, "d": 3 }, "e": [1, 2] });
473        assert!(subset(&json!({ "b": { "c": 2 } }), &actual));
474        assert!(subset(&json!({ "e": [1, 2] }), &actual));
475        assert!(!subset(&json!({ "e": [1] }), &actual));
476        assert!(!subset(&json!({ "z": null }), &actual));
477        assert!(!subset(&json!({ "a": "1" }), &actual));
478    }
479}