1use 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
18pub const MAX_SCENARIO_STEPS: usize = 10_000;
20pub const EXPECT_POLL_MS: u64 = 20;
22
23#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
44 #[cfg_attr(feature = "ts", ts(optional))]
45 pub name: Option<String>,
46 pub steps: Vec<Step>,
48}
49
50#[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 #[serde(rename_all = "camelCase")]
57 Scan {
58 device: DeviceId,
60 code: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 #[cfg_attr(feature = "ts", ts(optional))]
65 terminator: Option<Terminator>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 #[cfg_attr(feature = "ts", ts(optional))]
69 delay_ms: Option<u64>,
70 },
71 Tap {
73 device: DeviceId,
75 uid: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 #[cfg_attr(feature = "ts", ts(optional))]
80 records: Option<Vec<NdefRecord>>,
81 },
82 Wait {
84 ms: u64,
86 },
87 SetFault {
89 device: DeviceId,
91 fault: FaultId,
93 #[serde(default)]
95 #[cfg_attr(
96 feature = "ts",
97 ts(as = "std::collections::HashMap<String, serde_json::Value>")
98 )]
99 options: FaultOptions,
100 },
101 ClearFault {
103 device: DeviceId,
105 fault: FaultId,
107 },
108 #[serde(rename_all = "camelCase")]
111 Expect {
112 device: DeviceId,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
117 #[cfg_attr(feature = "ts", ts(optional))]
118 state: Option<Value>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 #[cfg_attr(feature = "ts", ts(optional))]
122 receipt_contains: Option<String>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 #[cfg_attr(feature = "ts", ts(optional))]
126 jobs: Option<u32>,
127 #[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 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#[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 Passed,
155 Failed,
157 Skipped,
159}
160
161#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
163#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
164#[serde(rename_all = "camelCase")]
165pub struct StepResult {
166 pub index: u32,
168 pub step: String,
170 pub status: StepStatus,
172 pub at_ms: u64,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 #[cfg_attr(feature = "ts", ts(optional))]
177 pub message: Option<String>,
178}
179
180#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
182#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
183#[serde(rename_all = "camelCase")]
184pub struct ScenarioReport {
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 #[cfg_attr(feature = "ts", ts(optional))]
188 pub name: Option<String>,
189 pub ok: bool,
191 pub steps: Vec<StepResult>,
193 pub duration_ms: u64,
195}
196
197#[derive(Clone, Debug, PartialEq)]
199pub enum Progress {
200 WaitUntil(Millis),
202 Done(ScenarioReport),
204}
205
206#[derive(Clone, Debug)]
208pub struct ScenarioRun {
209 scenario: Scenario,
210 index: usize,
211 started: Option<Millis>,
212 until: Option<Millis>,
214 results: Vec<StepResult>,
215}
216
217impl ScenarioRun {
218 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 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 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
360fn 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 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
454fn 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}