use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::device::{ControlCommand, DeviceEvent, Terminator};
use crate::fault::{FaultId, FaultOptions};
use crate::ids::DeviceId;
use crate::nfc::NdefRecord;
use crate::registry::Registry;
use crate::time::Millis;
pub const MAX_SCENARIO_STEPS: usize = 10_000;
pub const EXPECT_POLL_MS: u64 = 20;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Scenario {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
pub name: Option<String>,
pub steps: Vec<Step>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Step {
#[serde(rename_all = "camelCase")]
Scan {
device: DeviceId,
code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
terminator: Option<Terminator>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
delay_ms: Option<u64>,
},
Tap {
device: DeviceId,
uid: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
records: Option<Vec<NdefRecord>>,
},
Wait {
ms: u64,
},
SetFault {
device: DeviceId,
fault: FaultId,
#[serde(default)]
#[cfg_attr(
feature = "ts",
ts(as = "std::collections::HashMap<String, serde_json::Value>")
)]
options: FaultOptions,
},
ClearFault {
device: DeviceId,
fault: FaultId,
},
#[serde(rename_all = "camelCase")]
Expect {
device: DeviceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
state: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
receipt_contains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
jobs: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
within_ms: Option<u64>,
},
}
impl Step {
pub fn name(&self) -> &'static str {
match self {
Step::Scan { .. } => "scan",
Step::Tap { .. } => "tap",
Step::Wait { .. } => "wait",
Step::SetFault { .. } => "setFault",
Step::ClearFault { .. } => "clearFault",
Step::Expect { .. } => "expect",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "lowercase")]
pub enum StepStatus {
Passed,
Failed,
Skipped,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "camelCase")]
pub struct StepResult {
pub index: u32,
pub step: String,
pub status: StepStatus,
pub at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
pub message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "camelCase")]
pub struct ScenarioReport {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
pub name: Option<String>,
pub ok: bool,
pub steps: Vec<StepResult>,
pub duration_ms: u64,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Progress {
WaitUntil(Millis),
Done(ScenarioReport),
}
#[derive(Clone, Debug)]
pub struct ScenarioRun {
scenario: Scenario,
index: usize,
started: Option<Millis>,
until: Option<Millis>,
results: Vec<StepResult>,
}
impl ScenarioRun {
pub fn new(scenario: Scenario) -> Self {
Self {
scenario,
index: 0,
started: None,
until: None,
results: Vec::new(),
}
}
pub fn advance(&mut self, registry: &mut Registry) -> (Progress, Vec<(DeviceId, DeviceEvent)>) {
let now = registry.now();
let started = *self.started.get_or_insert(now);
let mut transport = Vec::new();
while let Some(step) = self.scenario.steps.get(self.index).cloned() {
let outcome = match &step {
Step::Wait { ms } => {
let until = *self.until.get_or_insert(now + *ms);
if now < until {
return (Progress::WaitUntil(until), transport);
}
Ok(())
}
Step::Expect {
device,
state,
receipt_contains,
jobs,
within_ms,
} => match check(
registry,
device,
state.as_ref(),
receipt_contains.as_deref(),
*jobs,
) {
Ok(()) => Ok(()),
Err(message) => {
let deadline = *self.until.get_or_insert(now + within_ms.unwrap_or(0));
if now < deadline {
let next = (now + EXPECT_POLL_MS).min(deadline);
return (Progress::WaitUntil(next), transport);
}
Err(message)
}
},
Step::Scan {
device,
code,
terminator,
delay_ms,
} => control(
registry,
device,
ControlCommand::Scan {
code: code.clone(),
terminator: *terminator,
delay_ms: *delay_ms,
},
&mut transport,
),
Step::Tap {
device,
uid,
records,
} => control(
registry,
device,
ControlCommand::Tap {
uid: uid.clone(),
records: records.clone(),
},
&mut transport,
),
Step::SetFault {
device,
fault,
options,
} => fault_step(
registry,
device,
fault,
true,
options.clone(),
&mut transport,
),
Step::ClearFault { device, fault } => fault_step(
registry,
device,
fault,
false,
FaultOptions::new(),
&mut transport,
),
};
self.until = None;
let at_ms = now.since(started);
let failed = outcome.is_err();
self.results.push(StepResult {
index: index_u32(self.index),
step: step.name().to_owned(),
status: if failed {
StepStatus::Failed
} else {
StepStatus::Passed
},
at_ms,
message: outcome.err(),
});
self.index += 1;
if failed {
break;
}
}
for (i, step) in self.scenario.steps.iter().enumerate().skip(self.index) {
self.results.push(StepResult {
index: index_u32(i),
step: step.name().to_owned(),
status: StepStatus::Skipped,
at_ms: now.since(started),
message: None,
});
}
self.index = self.scenario.steps.len();
let report = ScenarioReport {
name: self.scenario.name.clone(),
ok: self.results.iter().all(|r| r.status == StepStatus::Passed),
steps: std::mem::take(&mut self.results),
duration_ms: now.since(started),
};
(Progress::Done(report), transport)
}
}
fn index_u32(index: usize) -> u32 {
u32::try_from(index).unwrap_or(u32::MAX)
}
fn for_transport(event: &DeviceEvent) -> bool {
matches!(
event,
DeviceEvent::ToHost(_)
| DeviceEvent::Wedge(_)
| DeviceEvent::Nfc(_)
| DeviceEvent::Connected
| DeviceEvent::Disconnected
)
}
fn control(
registry: &mut Registry,
device: &DeviceId,
command: ControlCommand,
transport: &mut Vec<(DeviceId, DeviceEvent)>,
) -> Result<(), String> {
let events = registry
.control(device, command)
.map_err(|e| e.to_string())?;
if let Some(DeviceEvent::Log(entry)) = events
.iter()
.find(|e| matches!(e, DeviceEvent::Log(entry) if entry.level >= crate::LogLevel::Warn))
{
return Err(entry.message.clone());
}
transport.extend(
events
.into_iter()
.filter(for_transport)
.map(|e| (device.clone(), e)),
);
Ok(())
}
fn fault_step(
registry: &mut Registry,
device: &DeviceId,
fault: &FaultId,
active: bool,
options: FaultOptions,
transport: &mut Vec<(DeviceId, DeviceEvent)>,
) -> Result<(), String> {
let events = registry
.set_fault(device, fault.clone(), active, options)
.map_err(|e| e.to_string())?;
transport.extend(
events
.into_iter()
.filter(for_transport)
.map(|e| (device.clone(), e)),
);
Ok(())
}
fn check(
registry: &Registry,
device: &DeviceId,
state: Option<&Value>,
receipt_contains: Option<&str>,
jobs: Option<u32>,
) -> Result<(), String> {
let summary = registry
.summary(device)
.ok_or_else(|| format!("device `{device}` not found"))?;
if let Some(expected) = state {
if !subset(expected, &summary.state) {
return Err(format!("state {} does not match {expected}", summary.state));
}
}
if let Some(count) = jobs {
let held = registry.jobs(device).map(Iterator::count).unwrap_or(0);
if held != count as usize {
return Err(format!("{held} jobs, expected {count}"));
}
}
if let Some(text) = receipt_contains {
let last = registry.last_job(device).ok().flatten();
match last {
Some(job) if job.text.contains(text) => {}
Some(job) => {
return Err(format!(
"the latest receipt does not contain {text:?}: {:?}",
job.text
))
}
None => return Err(format!("no receipt yet (expected one containing {text:?})")),
}
}
Ok(())
}
fn subset(expected: &Value, actual: &Value) -> bool {
match (expected, actual) {
(Value::Object(want), Value::Object(have)) => want
.iter()
.all(|(k, v)| have.get(k).is_some_and(|h| subset(v, h))),
_ => expected == actual,
}
}
#[cfg(test)]
mod tests {
use super::subset;
use serde_json::json;
#[test]
fn subset_matches_nested_keys() {
let actual = json!({ "a": 1, "b": { "c": 2, "d": 3 }, "e": [1, 2] });
assert!(subset(&json!({ "b": { "c": 2 } }), &actual));
assert!(subset(&json!({ "e": [1, 2] }), &actual));
assert!(!subset(&json!({ "e": [1] }), &actual));
assert!(!subset(&json!({ "z": null }), &actual));
assert!(!subset(&json!({ "a": "1" }), &actual));
}
}