Skip to main content

jugar_probar/playbook/
runner.rs

1//! Playbook runner with full setup/steps/teardown execution.
2//!
3//! Implements:
4//! - Setup/teardown lifecycle (teardown runs even on failure)
5//! - Variable capture and substitution
6//! - Forbidden transition checking
7//! - Path and output assertions
8//! - Execution trace recording
9
10use super::executor::{ActionExecutor, ExecutorError, PlaybookExecutor};
11use super::schema::{OutputAssertion, PathAssertion, Playbook, PlaybookAction, PlaybookStep};
12use std::collections::HashMap;
13use std::time::{Duration, Instant};
14
15/// Result of running a playbook.
16#[derive(Debug)]
17pub struct PlaybookRunResult {
18    /// Whether the playbook passed
19    pub passed: bool,
20    /// Captured variables
21    pub variables: HashMap<String, String>,
22    /// Execution trace (state path taken)
23    pub state_path: Vec<String>,
24    /// Individual step results
25    pub step_results: Vec<StepResult>,
26    /// Assertion results
27    pub assertion_results: Vec<AssertionCheckResult>,
28    /// Total execution time
29    pub total_time: Duration,
30    /// Error message if failed
31    pub error: Option<String>,
32}
33
34/// Result of executing a single step.
35#[derive(Debug, Clone)]
36pub struct StepResult {
37    /// Step name
38    pub name: String,
39    /// Whether step passed
40    pub passed: bool,
41    /// Step execution time
42    pub duration: Duration,
43    /// Captured variables from this step
44    pub captured: HashMap<String, String>,
45    /// Error message if failed
46    pub error: Option<String>,
47}
48
49/// Result of checking an assertion.
50#[derive(Debug, Clone)]
51pub struct AssertionCheckResult {
52    /// Assertion description
53    pub description: String,
54    /// Whether assertion passed
55    pub passed: bool,
56    /// Error message if failed
57    pub error: Option<String>,
58}
59
60/// Playbook runner that manages the full execution lifecycle.
61pub struct PlaybookRunner<E: ActionExecutor> {
62    playbook: Playbook,
63    #[allow(dead_code)] // Will be used when action execution is implemented
64    executor: PlaybookExecutor<E>,
65    variables: HashMap<String, String>,
66    state_path: Vec<String>,
67}
68
69impl<E: ActionExecutor> PlaybookRunner<E> {
70    /// Create a new runner for the given playbook.
71    pub fn new(playbook: Playbook, executor: E) -> Self {
72        let initial = playbook.machine.initial.clone();
73        let pb_executor = PlaybookExecutor::new(playbook.clone(), executor);
74
75        Self {
76            playbook,
77            executor: pb_executor,
78            variables: HashMap::new(),
79            state_path: vec![initial],
80        }
81    }
82
83    /// Run the complete playbook.
84    pub fn run(&mut self) -> PlaybookRunResult {
85        let start = Instant::now();
86        let mut step_results = Vec::new();
87        let mut passed = true;
88        let mut error_msg: Option<String> = None;
89
90        // Get playbook steps (if defined)
91        let steps = self.playbook.playbook.clone().unwrap_or_default();
92
93        // Run setup
94        if let Err(e) = self.run_setup(&steps.setup) {
95            error_msg = Some(format!("Setup failed: {}", e));
96            passed = false;
97        }
98
99        // Run steps if setup succeeded
100        if passed {
101            if let Some(err) = self.run_steps(&steps.steps, &mut step_results) {
102                passed = false;
103                error_msg = err;
104            }
105        }
106
107        // Run teardown (always, even on failure)
108        let _ = self.run_teardown(&steps.teardown);
109
110        // Check assertions
111        let assertion_results = self.check_assertions();
112        if assertion_results.iter().any(|a| !a.passed) {
113            passed = false;
114            if error_msg.is_none() {
115                error_msg = Some("Assertions failed".to_string());
116            }
117        }
118
119        PlaybookRunResult {
120            passed,
121            variables: self.variables.clone(),
122            state_path: self.state_path.clone(),
123            step_results,
124            assertion_results,
125            total_time: start.elapsed(),
126            error: error_msg,
127        }
128    }
129
130    /// Run setup actions.
131    fn run_setup(&self, setup: &[PlaybookAction]) -> Result<(), ExecutorError> {
132        for action in setup {
133            self.run_action(action)?;
134        }
135        Ok(())
136    }
137
138    /// Run teardown actions.
139    fn run_teardown(&self, teardown: &[PlaybookAction]) -> Result<(), ExecutorError> {
140        for action in teardown {
141            if action.ignore_errors {
142                let _ = self.run_action(action);
143            } else {
144                self.run_action(action)?;
145            }
146        }
147        Ok(())
148    }
149
150    /// Run steps until one fails, appending every attempted step to
151    /// `step_results`. Returns `Some(error)` for the first failure — the error
152    /// itself may be `None`, mirroring `StepResult::error` — or `None` if every
153    /// step passed.
154    fn run_steps(
155        &mut self,
156        steps: &[PlaybookStep],
157        step_results: &mut Vec<StepResult>,
158    ) -> Option<Option<String>> {
159        for step in steps {
160            match self.run_step(step) {
161                Ok(result) => {
162                    let failure = (!result.passed).then(|| result.error.clone());
163                    step_results.push(result);
164                    if let Some(error) = failure {
165                        return Some(error);
166                    }
167                }
168                Err(e) => {
169                    step_results.push(StepResult {
170                        name: step.name.clone(),
171                        passed: false,
172                        duration: Duration::ZERO,
173                        captured: HashMap::new(),
174                        error: Some(e.to_string()),
175                    });
176                    return Some(Some(e.to_string()));
177                }
178            }
179        }
180        None
181    }
182
183    /// Run a single action.
184    fn run_action(&self, _action: &PlaybookAction) -> Result<(), ExecutorError> {
185        // Deferred (PMAT-760): Execute WASM action via executor
186        Ok(())
187    }
188
189    /// Run a single step.
190    fn run_step(&mut self, step: &PlaybookStep) -> Result<StepResult, ExecutorError> {
191        let start = Instant::now();
192        let mut captured = HashMap::new();
193
194        // Execute transitions for this step
195        for transition_id in &step.transitions {
196            // Find the transition by ID
197            let transition = self
198                .playbook
199                .machine
200                .transitions
201                .iter()
202                .find(|t| &t.id == transition_id);
203
204            if let Some(t) = transition {
205                // Check if this is a forbidden transition
206                if let Some(err) = self.check_forbidden(&t.from, &t.to) {
207                    return Ok(StepResult {
208                        name: step.name.clone(),
209                        passed: false,
210                        duration: start.elapsed(),
211                        captured,
212                        error: Some(err),
213                    });
214                }
215
216                // Record state path
217                self.state_path.push(t.to.clone());
218            }
219        }
220
221        // Capture variables
222        for capture in &step.capture {
223            // Deferred (PMAT-761): Actually evaluate the expression
224            let value = self.substitute_variables(&capture.from);
225            captured.insert(capture.var.clone(), value.clone());
226            self.variables.insert(capture.var.clone(), value);
227        }
228
229        Ok(StepResult {
230            name: step.name.clone(),
231            passed: true,
232            duration: start.elapsed(),
233            captured,
234            error: None,
235        })
236    }
237
238    /// Check if a transition is forbidden.
239    fn check_forbidden(&self, from: &str, to: &str) -> Option<String> {
240        for forbidden in &self.playbook.machine.forbidden {
241            if forbidden.from == from && forbidden.to == to {
242                return Some(format!(
243                    "Forbidden transition: {} -> {} ({})",
244                    from, to, forbidden.reason
245                ));
246            }
247        }
248        None
249    }
250
251    /// Substitute ${var} patterns in a string.
252    fn substitute_variables(&self, input: &str) -> String {
253        let mut result = input.to_string();
254        for (key, value) in &self.variables {
255            let pattern = format!("${{{}}}", key);
256            result = result.replace(&pattern, value);
257        }
258        result
259    }
260
261    /// Check all assertions.
262    fn check_assertions(&self) -> Vec<AssertionCheckResult> {
263        let mut results = Vec::new();
264
265        if let Some(assertions) = &self.playbook.assertions {
266            // Check path assertion
267            if let Some(path) = &assertions.path {
268                results.push(self.check_path_assertion(path));
269            }
270
271            // Check output assertions
272            for output in &assertions.output {
273                results.push(self.check_output_assertion(output));
274            }
275        }
276
277        results
278    }
279
280    /// Check path assertion.
281    fn check_path_assertion(&self, path: &PathAssertion) -> AssertionCheckResult {
282        let actual_path: Vec<&str> = self.state_path.iter().map(|s| s.as_str()).collect();
283        let expected_path: Vec<&str> = path.expected.iter().map(|s| s.as_str()).collect();
284
285        if actual_path == expected_path {
286            AssertionCheckResult {
287                description: "Path matches expected sequence".to_string(),
288                passed: true,
289                error: None,
290            }
291        } else {
292            AssertionCheckResult {
293                description: "Path matches expected sequence".to_string(),
294                passed: false,
295                error: Some(format!(
296                    "Expected path {:?}, got {:?}",
297                    expected_path, actual_path
298                )),
299            }
300        }
301    }
302
303    /// Check output assertion.
304    fn check_output_assertion(&self, output: &OutputAssertion) -> AssertionCheckResult {
305        let value = self.variables.get(&output.var);
306
307        // Check not_empty
308        if let Some(failure) = Self::assert_not_empty(output, value) {
309            return failure;
310        }
311
312        // Check matches regex
313        if let Some(failure) = Self::assert_matches(output, value) {
314            return failure;
315        }
316
317        // Check less_than
318        if let Some(failure) = Self::assert_less_than(output, value) {
319            return failure;
320        }
321
322        // Check greater_than
323        if let Some(failure) = Self::assert_greater_than(output, value) {
324            return failure;
325        }
326
327        // Check equals
328        if let Some(failure) = Self::assert_equals(output, value) {
329            return failure;
330        }
331
332        AssertionCheckResult {
333            description: format!("Variable '{}' assertion", output.var),
334            passed: true,
335            error: None,
336        }
337    }
338
339    /// `not_empty`: fails when the variable is undefined or the empty string.
340    fn assert_not_empty(
341        output: &OutputAssertion,
342        value: Option<&String>,
343    ) -> Option<AssertionCheckResult> {
344        if output.not_empty == Some(true) && value.map_or(true, String::is_empty) {
345            return Some(AssertionCheckResult {
346                description: format!("Variable '{}' is not empty", output.var),
347                passed: false,
348                error: Some(format!("Variable '{}' is empty or undefined", output.var)),
349            });
350        }
351        None
352    }
353
354    /// `matches`: fails when the variable is undefined, or is defined and does not
355    /// match. A pattern that fails to compile is ignored, as before.
356    fn assert_matches(
357        output: &OutputAssertion,
358        value: Option<&String>,
359    ) -> Option<AssertionCheckResult> {
360        let pattern = output.matches.as_ref()?;
361        let Some(val) = value else {
362            return Some(AssertionCheckResult {
363                description: format!("Variable '{}' matches '{}'", output.var, pattern),
364                passed: false,
365                error: Some(format!("Variable '{}' is undefined", output.var)),
366            });
367        };
368        let re = regex::Regex::new(pattern).ok()?;
369        if re.is_match(val) {
370            return None;
371        }
372        Some(AssertionCheckResult {
373            description: format!("Variable '{}' matches '{}'", output.var, pattern),
374            passed: false,
375            error: Some(format!(
376                "Value '{}' does not match pattern '{}'",
377                val, pattern
378            )),
379        })
380    }
381
382    /// `less_than`: fails when the variable parses as an integer that is not less
383    /// than the bound. A value that is undefined or unparseable is ignored, as before.
384    fn assert_less_than(
385        output: &OutputAssertion,
386        value: Option<&String>,
387    ) -> Option<AssertionCheckResult> {
388        let max = output.less_than?;
389        let num = value?.parse::<i64>().ok()?;
390        if num < max {
391            return None;
392        }
393        Some(AssertionCheckResult {
394            description: format!("Variable '{}' < {}", output.var, max),
395            passed: false,
396            error: Some(format!("{} is not less than {}", num, max)),
397        })
398    }
399
400    /// `greater_than`: fails when the variable parses as an integer that is not
401    /// greater than the bound. A value that is undefined or unparseable is ignored.
402    fn assert_greater_than(
403        output: &OutputAssertion,
404        value: Option<&String>,
405    ) -> Option<AssertionCheckResult> {
406        let min = output.greater_than?;
407        let num = value?.parse::<i64>().ok()?;
408        if num > min {
409            return None;
410        }
411        Some(AssertionCheckResult {
412            description: format!("Variable '{}' > {}", output.var, min),
413            passed: false,
414            error: Some(format!("{} is not greater than {}", num, min)),
415        })
416    }
417
418    /// `equals`: fails when the variable differs from the expected value.
419    fn assert_equals(
420        output: &OutputAssertion,
421        value: Option<&String>,
422    ) -> Option<AssertionCheckResult> {
423        let expected = output.equals.as_ref()?;
424        if value == Some(expected) {
425            return None;
426        }
427        Some(AssertionCheckResult {
428            description: format!("Variable '{}' equals '{}'", output.var, expected),
429            passed: false,
430            error: Some(format!(
431                "Expected '{}', got '{}'",
432                expected,
433                value.map_or("undefined", String::as_str)
434            )),
435        })
436    }
437
438    /// Export execution trace as JSON.
439    pub fn export_trace_json(&self) -> String {
440        serde_json::json!({
441            "playbook": self.playbook.name,
442            "state_path": self.state_path,
443            "variables": self.variables,
444        })
445        .to_string()
446    }
447}
448
449/// Convert a state machine to SVG format.
450pub fn to_svg(playbook: &Playbook) -> String {
451    let dot = super::state_machine::to_dot(playbook);
452
453    // Generate SVG header
454    let mut svg = String::from(
455        r##"<?xml version="1.0" encoding="UTF-8"?>
456<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">
457  <style>
458    .state { fill: #e0e0e0; stroke: #333; stroke-width: 2; }
459    .state-final { fill: #c8e6c9; }
460    .transition { stroke: #333; stroke-width: 1.5; fill: none; marker-end: url(#arrow); }
461    .label { font-family: sans-serif; font-size: 12px; }
462  </style>
463  <defs>
464    <marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
465      <polygon points="0 0, 10 3.5, 0 7" fill="#333"/>
466    </marker>
467  </defs>
468  <text x="10" y="20" class="label">State Machine: "##,
469    );
470
471    svg.push_str(&playbook.machine.id);
472    svg.push_str("</text>\n");
473
474    // Add states as circles (simplified layout)
475    let mut y_offset = 100;
476    for (id, state) in &playbook.machine.states {
477        let class = if state.final_state {
478            "state state-final"
479        } else {
480            "state"
481        };
482        svg.push_str(&format!(
483            r#"  <ellipse cx="400" cy="{}" rx="60" ry="30" class="{}"/>
484  <text x="400" y="{}" text-anchor="middle" class="label">{}</text>
485"#,
486            y_offset,
487            class,
488            y_offset + 5,
489            id
490        ));
491        y_offset += 100;
492    }
493
494    // Add comment about DOT source
495    svg.push_str(&format!(
496        "\n  <!-- DOT source:\n{}\n  -->\n",
497        dot.lines()
498            .map(|l| format!("       {}", l))
499            .collect::<Vec<_>>()
500            .join("\n")
501    ));
502
503    svg.push_str("</svg>");
504    svg
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::playbook::schema::Playbook;
511
512    struct MockExecutor;
513
514    impl ActionExecutor for MockExecutor {
515        fn click(&mut self, _: &str) -> Result<(), ExecutorError> {
516            Ok(())
517        }
518        fn type_text(&mut self, _: &str, _: &str) -> Result<(), ExecutorError> {
519            Ok(())
520        }
521        fn wait(
522            &mut self,
523            _: &crate::playbook::schema::WaitCondition,
524        ) -> Result<(), ExecutorError> {
525            Ok(())
526        }
527        fn navigate(&mut self, _: &str) -> Result<(), ExecutorError> {
528            Ok(())
529        }
530        fn execute_script(&mut self, _: &str) -> Result<String, ExecutorError> {
531            Ok(String::new())
532        }
533        fn screenshot(&mut self, _: &str) -> Result<(), ExecutorError> {
534            Ok(())
535        }
536        fn element_exists(&self, _: &str) -> Result<bool, ExecutorError> {
537            Ok(true)
538        }
539        fn get_text(&self, _: &str) -> Result<String, ExecutorError> {
540            Ok(String::new())
541        }
542        fn get_attribute(&self, _: &str, _: &str) -> Result<String, ExecutorError> {
543            Ok(String::new())
544        }
545        fn get_url(&self) -> Result<String, ExecutorError> {
546            Ok(String::new())
547        }
548        fn evaluate(&self, _: &str) -> Result<bool, ExecutorError> {
549            Ok(true)
550        }
551    }
552
553    #[test]
554    fn test_forbidden_transition_detection() {
555        let yaml = r##"
556version: "1.0"
557name: "Test Playbook"
558machine:
559  id: "test"
560  initial: "start"
561  states:
562    start:
563      id: "start"
564    middle:
565      id: "middle"
566    end:
567      id: "end"
568      final_state: true
569  transitions:
570    - id: "t1"
571      from: "start"
572      to: "middle"
573      event: "go"
574    - id: "t2"
575      from: "middle"
576      to: "end"
577      event: "finish"
578  forbidden:
579    - from: "start"
580      to: "end"
581      reason: "Cannot skip middle state"
582"##;
583        let playbook = Playbook::from_yaml(yaml).expect("parse");
584        let runner = PlaybookRunner::new(playbook, MockExecutor);
585
586        // Check forbidden transition
587        let err = runner.check_forbidden("start", "end");
588        assert!(err.is_some());
589        assert!(err
590            .expect("should have error")
591            .contains("Cannot skip middle state"));
592
593        // Check allowed transition
594        let ok = runner.check_forbidden("start", "middle");
595        assert!(ok.is_none());
596    }
597
598    #[test]
599    fn test_variable_substitution() {
600        let yaml = r##"
601version: "1.0"
602machine:
603  id: "test"
604  initial: "start"
605  states:
606    start:
607      id: "start"
608  transitions:
609    - id: "t1"
610      from: "start"
611      to: "start"
612      event: "loop"
613"##;
614        let playbook = Playbook::from_yaml(yaml).expect("parse");
615        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
616
617        runner
618            .variables
619            .insert("name".to_string(), "test".to_string());
620        runner
621            .variables
622            .insert("value".to_string(), "123".to_string());
623
624        let result = runner.substitute_variables("Hello ${name}, value is ${value}");
625        assert_eq!(result, "Hello test, value is 123");
626    }
627
628    #[test]
629    fn test_svg_export() {
630        let yaml = r##"
631version: "1.0"
632machine:
633  id: "test_machine"
634  initial: "start"
635  states:
636    start:
637      id: "start"
638    end:
639      id: "end"
640      final_state: true
641  transitions:
642    - id: "t1"
643      from: "start"
644      to: "end"
645      event: "finish"
646"##;
647        let playbook = Playbook::from_yaml(yaml).expect("parse");
648        let svg = to_svg(&playbook);
649
650        assert!(svg.contains("<svg"));
651        assert!(svg.contains("test_machine"));
652        assert!(svg.contains("</svg>"));
653    }
654
655    #[test]
656    fn test_run_empty_playbook() {
657        let yaml = r##"
658version: "1.0"
659machine:
660  id: "test"
661  initial: "start"
662  states:
663    start:
664      id: "start"
665  transitions:
666    - id: "t_loop"
667      from: "start"
668      to: "start"
669      event: "noop"
670"##;
671        let playbook = Playbook::from_yaml(yaml).expect("parse");
672        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
673        let result = runner.run();
674
675        assert!(result.passed);
676        assert!(result.error.is_none());
677        assert_eq!(result.state_path, vec!["start"]);
678    }
679
680    #[test]
681    fn test_run_with_steps_and_transitions() {
682        let yaml = r##"
683version: "1.0"
684machine:
685  id: "test"
686  initial: "start"
687  states:
688    start:
689      id: "start"
690    middle:
691      id: "middle"
692    end:
693      id: "end"
694      final_state: true
695  transitions:
696    - id: "t1"
697      from: "start"
698      to: "middle"
699      event: "go"
700    - id: "t2"
701      from: "middle"
702      to: "end"
703      event: "finish"
704playbook:
705  setup: []
706  steps:
707    - name: "Go to middle"
708      transitions: ["t1"]
709      capture: []
710    - name: "Go to end"
711      transitions: ["t2"]
712      capture: []
713  teardown: []
714"##;
715        let playbook = Playbook::from_yaml(yaml).expect("parse");
716        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
717        let result = runner.run();
718
719        assert!(result.passed);
720        assert_eq!(result.state_path, vec!["start", "middle", "end"]);
721        assert_eq!(result.step_results.len(), 2);
722    }
723
724    #[test]
725    fn test_run_with_variable_capture() {
726        let yaml = r##"
727version: "1.0"
728machine:
729  id: "test"
730  initial: "start"
731  states:
732    start:
733      id: "start"
734  transitions:
735    - id: "t1"
736      from: "start"
737      to: "start"
738      event: "loop"
739playbook:
740  setup: []
741  steps:
742    - name: "Capture step"
743      transitions: ["t1"]
744      capture:
745        - var: "captured_val"
746          from: "test_value"
747  teardown: []
748"##;
749        let playbook = Playbook::from_yaml(yaml).expect("parse");
750        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
751        let result = runner.run();
752
753        assert!(result.passed);
754        assert_eq!(
755            result.variables.get("captured_val"),
756            Some(&"test_value".to_string())
757        );
758    }
759
760    #[test]
761    fn test_run_forbidden_transition_fails() {
762        let yaml = r##"
763version: "1.0"
764machine:
765  id: "test"
766  initial: "start"
767  states:
768    start:
769      id: "start"
770    end:
771      id: "end"
772      final_state: true
773  transitions:
774    - id: "forbidden_t"
775      from: "start"
776      to: "end"
777      event: "skip"
778  forbidden:
779    - from: "start"
780      to: "end"
781      reason: "Cannot skip"
782playbook:
783  setup: []
784  steps:
785    - name: "Try forbidden"
786      transitions: ["forbidden_t"]
787      capture: []
788  teardown: []
789"##;
790        let playbook = Playbook::from_yaml(yaml).expect("parse");
791        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
792        let result = runner.run();
793
794        assert!(!result.passed);
795        assert!(result.step_results[0]
796            .error
797            .as_ref()
798            .expect("should have error")
799            .contains("Forbidden"));
800    }
801
802    #[test]
803    fn test_path_assertion_pass() {
804        let yaml = r##"
805version: "1.0"
806machine:
807  id: "test"
808  initial: "start"
809  states:
810    start:
811      id: "start"
812    end:
813      id: "end"
814  transitions:
815    - id: "t1"
816      from: "start"
817      to: "end"
818      event: "go"
819playbook:
820  setup: []
821  steps:
822    - name: "Go"
823      transitions: ["t1"]
824      capture: []
825  teardown: []
826assertions:
827  path:
828    expected: ["start", "end"]
829  output: []
830"##;
831        let playbook = Playbook::from_yaml(yaml).expect("parse");
832        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
833        let result = runner.run();
834
835        assert!(result.passed);
836        assert!(result.assertion_results.iter().all(|a| a.passed));
837    }
838
839    #[test]
840    fn test_path_assertion_fail() {
841        let yaml = r##"
842version: "1.0"
843machine:
844  id: "test"
845  initial: "start"
846  states:
847    start:
848      id: "start"
849    end:
850      id: "end"
851  transitions:
852    - id: "t_loop"
853      from: "start"
854      to: "start"
855      event: "noop"
856assertions:
857  path:
858    expected: ["start", "end"]
859  output: []
860"##;
861        let playbook = Playbook::from_yaml(yaml).expect("parse");
862        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
863        let result = runner.run();
864
865        assert!(!result.passed);
866        assert!(result.assertion_results.iter().any(|a| !a.passed));
867    }
868
869    #[test]
870    fn test_output_assertion_not_empty() {
871        let yaml = r##"
872version: "1.0"
873machine:
874  id: "test"
875  initial: "start"
876  states:
877    start:
878      id: "start"
879  transitions:
880    - id: "t1"
881      from: "start"
882      to: "start"
883      event: "loop"
884playbook:
885  setup: []
886  steps:
887    - name: "Capture"
888      transitions: ["t1"]
889      capture:
890        - var: "my_var"
891          from: "some_value"
892  teardown: []
893assertions:
894  output:
895    - var: "my_var"
896      not_empty: true
897"##;
898        let playbook = Playbook::from_yaml(yaml).expect("parse");
899        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
900        let result = runner.run();
901
902        assert!(result.passed);
903    }
904
905    #[test]
906    fn test_output_assertion_not_empty_fails() {
907        let yaml = r##"
908version: "1.0"
909machine:
910  id: "test"
911  initial: "start"
912  states:
913    start:
914      id: "start"
915  transitions:
916    - id: "t_loop"
917      from: "start"
918      to: "start"
919      event: "noop"
920assertions:
921  output:
922    - var: "missing_var"
923      not_empty: true
924"##;
925        let playbook = Playbook::from_yaml(yaml).expect("parse");
926        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
927        let result = runner.run();
928
929        assert!(!result.passed);
930    }
931
932    #[test]
933    fn test_output_assertion_matches() {
934        let yaml = r##"
935version: "1.0"
936machine:
937  id: "test"
938  initial: "start"
939  states:
940    start:
941      id: "start"
942  transitions:
943    - id: "t1"
944      from: "start"
945      to: "start"
946      event: "loop"
947playbook:
948  setup: []
949  steps:
950    - name: "Capture"
951      transitions: ["t1"]
952      capture:
953        - var: "email"
954          from: "test@example.com"
955  teardown: []
956assertions:
957  output:
958    - var: "email"
959      matches: ".*@.*\\.com"
960"##;
961        let playbook = Playbook::from_yaml(yaml).expect("parse");
962        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
963        let result = runner.run();
964
965        assert!(result.passed);
966    }
967
968    #[test]
969    fn test_output_assertion_matches_fails() {
970        let yaml = r##"
971version: "1.0"
972machine:
973  id: "test"
974  initial: "start"
975  states:
976    start:
977      id: "start"
978  transitions:
979    - id: "t1"
980      from: "start"
981      to: "start"
982      event: "loop"
983playbook:
984  setup: []
985  steps:
986    - name: "Capture"
987      transitions: ["t1"]
988      capture:
989        - var: "value"
990          from: "abc"
991  teardown: []
992assertions:
993  output:
994    - var: "value"
995      matches: "^[0-9]+$"
996"##;
997        let playbook = Playbook::from_yaml(yaml).expect("parse");
998        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
999        let result = runner.run();
1000
1001        assert!(!result.passed);
1002    }
1003
1004    #[test]
1005    fn test_output_assertion_matches_undefined() {
1006        let yaml = r##"
1007version: "1.0"
1008machine:
1009  id: "test"
1010  initial: "start"
1011  states:
1012    start:
1013      id: "start"
1014  transitions:
1015    - id: "t_loop"
1016      from: "start"
1017      to: "start"
1018      event: "noop"
1019assertions:
1020  output:
1021    - var: "undefined_var"
1022      matches: ".*"
1023"##;
1024        let playbook = Playbook::from_yaml(yaml).expect("parse");
1025        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1026        let result = runner.run();
1027
1028        assert!(!result.passed);
1029    }
1030
1031    #[test]
1032    fn test_output_assertion_less_than() {
1033        let yaml = r##"
1034version: "1.0"
1035machine:
1036  id: "test"
1037  initial: "start"
1038  states:
1039    start:
1040      id: "start"
1041  transitions:
1042    - id: "t1"
1043      from: "start"
1044      to: "start"
1045      event: "loop"
1046playbook:
1047  setup: []
1048  steps:
1049    - name: "Capture"
1050      transitions: ["t1"]
1051      capture:
1052        - var: "count"
1053          from: "5"
1054  teardown: []
1055assertions:
1056  output:
1057    - var: "count"
1058      less_than: 10
1059"##;
1060        let playbook = Playbook::from_yaml(yaml).expect("parse");
1061        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1062        let result = runner.run();
1063
1064        assert!(result.passed);
1065    }
1066
1067    #[test]
1068    fn test_output_assertion_less_than_fails() {
1069        let yaml = r##"
1070version: "1.0"
1071machine:
1072  id: "test"
1073  initial: "start"
1074  states:
1075    start:
1076      id: "start"
1077  transitions:
1078    - id: "t1"
1079      from: "start"
1080      to: "start"
1081      event: "loop"
1082playbook:
1083  setup: []
1084  steps:
1085    - name: "Capture"
1086      transitions: ["t1"]
1087      capture:
1088        - var: "count"
1089          from: "15"
1090  teardown: []
1091assertions:
1092  output:
1093    - var: "count"
1094      less_than: 10
1095"##;
1096        let playbook = Playbook::from_yaml(yaml).expect("parse");
1097        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1098        let result = runner.run();
1099
1100        assert!(!result.passed);
1101    }
1102
1103    #[test]
1104    fn test_output_assertion_greater_than() {
1105        let yaml = r##"
1106version: "1.0"
1107machine:
1108  id: "test"
1109  initial: "start"
1110  states:
1111    start:
1112      id: "start"
1113  transitions:
1114    - id: "t1"
1115      from: "start"
1116      to: "start"
1117      event: "loop"
1118playbook:
1119  setup: []
1120  steps:
1121    - name: "Capture"
1122      transitions: ["t1"]
1123      capture:
1124        - var: "count"
1125          from: "100"
1126  teardown: []
1127assertions:
1128  output:
1129    - var: "count"
1130      greater_than: 50
1131"##;
1132        let playbook = Playbook::from_yaml(yaml).expect("parse");
1133        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1134        let result = runner.run();
1135
1136        assert!(result.passed);
1137    }
1138
1139    #[test]
1140    fn test_output_assertion_greater_than_fails() {
1141        let yaml = r##"
1142version: "1.0"
1143machine:
1144  id: "test"
1145  initial: "start"
1146  states:
1147    start:
1148      id: "start"
1149  transitions:
1150    - id: "t1"
1151      from: "start"
1152      to: "start"
1153      event: "loop"
1154playbook:
1155  setup: []
1156  steps:
1157    - name: "Capture"
1158      transitions: ["t1"]
1159      capture:
1160        - var: "count"
1161          from: "10"
1162  teardown: []
1163assertions:
1164  output:
1165    - var: "count"
1166      greater_than: 50
1167"##;
1168        let playbook = Playbook::from_yaml(yaml).expect("parse");
1169        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1170        let result = runner.run();
1171
1172        assert!(!result.passed);
1173    }
1174
1175    #[test]
1176    fn test_output_assertion_equals() {
1177        let yaml = r##"
1178version: "1.0"
1179machine:
1180  id: "test"
1181  initial: "start"
1182  states:
1183    start:
1184      id: "start"
1185  transitions:
1186    - id: "t1"
1187      from: "start"
1188      to: "start"
1189      event: "loop"
1190playbook:
1191  setup: []
1192  steps:
1193    - name: "Capture"
1194      transitions: ["t1"]
1195      capture:
1196        - var: "result"
1197          from: "success"
1198  teardown: []
1199assertions:
1200  output:
1201    - var: "result"
1202      equals: "success"
1203"##;
1204        let playbook = Playbook::from_yaml(yaml).expect("parse");
1205        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1206        let result = runner.run();
1207
1208        assert!(result.passed);
1209    }
1210
1211    #[test]
1212    fn test_output_assertion_equals_fails() {
1213        let yaml = r##"
1214version: "1.0"
1215machine:
1216  id: "test"
1217  initial: "start"
1218  states:
1219    start:
1220      id: "start"
1221  transitions:
1222    - id: "t1"
1223      from: "start"
1224      to: "start"
1225      event: "loop"
1226playbook:
1227  setup: []
1228  steps:
1229    - name: "Capture"
1230      transitions: ["t1"]
1231      capture:
1232        - var: "result"
1233          from: "failure"
1234  teardown: []
1235assertions:
1236  output:
1237    - var: "result"
1238      equals: "success"
1239"##;
1240        let playbook = Playbook::from_yaml(yaml).expect("parse");
1241        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1242        let result = runner.run();
1243
1244        assert!(!result.passed);
1245    }
1246
1247    #[test]
1248    fn test_export_trace_json() {
1249        let yaml = r##"
1250version: "1.0"
1251name: "Trace Test"
1252machine:
1253  id: "test"
1254  initial: "start"
1255  states:
1256    start:
1257      id: "start"
1258    end:
1259      id: "end"
1260  transitions:
1261    - id: "t1"
1262      from: "start"
1263      to: "end"
1264      event: "go"
1265playbook:
1266  setup: []
1267  steps:
1268    - name: "Go"
1269      transitions: ["t1"]
1270      capture:
1271        - var: "test_var"
1272          from: "test_value"
1273  teardown: []
1274"##;
1275        let playbook = Playbook::from_yaml(yaml).expect("parse");
1276        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1277        runner.run();
1278
1279        let json = runner.export_trace_json();
1280        assert!(json.contains("Trace Test"));
1281        assert!(json.contains("state_path"));
1282        assert!(json.contains("test_var"));
1283    }
1284
1285    #[test]
1286    fn test_teardown_with_ignore_errors() {
1287        let yaml = r##"
1288version: "1.0"
1289machine:
1290  id: "test"
1291  initial: "start"
1292  states:
1293    start:
1294      id: "start"
1295  transitions:
1296    - id: "t_loop"
1297      from: "start"
1298      to: "start"
1299      event: "noop"
1300playbook:
1301  setup: []
1302  steps: []
1303  teardown:
1304    - action:
1305        wasm: "cleanup"
1306        args: []
1307      ignore_errors: true
1308"##;
1309        let playbook = Playbook::from_yaml(yaml).expect("parse");
1310        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1311        let result = runner.run();
1312
1313        assert!(result.passed);
1314    }
1315
1316    #[test]
1317    fn test_run_step_with_nonexistent_transition() {
1318        let yaml = r##"
1319version: "1.0"
1320machine:
1321  id: "test"
1322  initial: "start"
1323  states:
1324    start:
1325      id: "start"
1326  transitions:
1327    - id: "t_loop"
1328      from: "start"
1329      to: "start"
1330      event: "noop"
1331playbook:
1332  setup: []
1333  steps:
1334    - name: "Bad transition"
1335      transitions: ["nonexistent"]
1336      capture: []
1337  teardown: []
1338"##;
1339        let playbook = Playbook::from_yaml(yaml).expect("parse");
1340        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1341        let result = runner.run();
1342
1343        // Should still pass, just no state change
1344        assert!(result.passed);
1345    }
1346
1347    #[test]
1348    fn test_step_with_multiple_transitions() {
1349        let yaml = r##"
1350version: "1.0"
1351machine:
1352  id: "test"
1353  initial: "a"
1354  states:
1355    a:
1356      id: "a"
1357    b:
1358      id: "b"
1359    c:
1360      id: "c"
1361      final_state: true
1362  transitions:
1363    - id: "t1"
1364      from: "a"
1365      to: "b"
1366      event: "step1"
1367    - id: "t2"
1368      from: "b"
1369      to: "c"
1370      event: "step2"
1371playbook:
1372  setup: []
1373  steps:
1374    - name: "Multi-transition step"
1375      transitions: ["t1", "t2"]
1376      capture: []
1377  teardown: []
1378"##;
1379        let playbook = Playbook::from_yaml(yaml).expect("parse");
1380        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1381        let result = runner.run();
1382
1383        assert!(result.passed);
1384        assert_eq!(result.state_path, vec!["a", "b", "c"]);
1385    }
1386
1387    #[test]
1388    fn test_variable_substitution_with_captured_variables() {
1389        let yaml = r##"
1390version: "1.0"
1391machine:
1392  id: "test"
1393  initial: "start"
1394  states:
1395    start:
1396      id: "start"
1397    next:
1398      id: "next"
1399  transitions:
1400    - id: "t1"
1401      from: "start"
1402      to: "next"
1403      event: "go"
1404playbook:
1405  setup: []
1406  steps:
1407    - name: "First capture"
1408      transitions: ["t1"]
1409      capture:
1410        - var: "prefix"
1411          from: "hello"
1412    - name: "Use captured"
1413      transitions: []
1414      capture:
1415        - var: "message"
1416          from: "${prefix}_world"
1417  teardown: []
1418"##;
1419        let playbook = Playbook::from_yaml(yaml).expect("parse");
1420        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1421        let result = runner.run();
1422
1423        assert!(result.passed);
1424        assert_eq!(result.variables.get("prefix"), Some(&"hello".to_string()));
1425        assert_eq!(
1426            result.variables.get("message"),
1427            Some(&"hello_world".to_string())
1428        );
1429    }
1430
1431    #[test]
1432    fn test_output_assertion_not_empty_with_empty_string() {
1433        let yaml = r##"
1434version: "1.0"
1435machine:
1436  id: "test"
1437  initial: "start"
1438  states:
1439    start:
1440      id: "start"
1441  transitions:
1442    - id: "t1"
1443      from: "start"
1444      to: "start"
1445      event: "loop"
1446playbook:
1447  setup: []
1448  steps:
1449    - name: "Capture empty"
1450      transitions: ["t1"]
1451      capture:
1452        - var: "empty_var"
1453          from: ""
1454  teardown: []
1455assertions:
1456  output:
1457    - var: "empty_var"
1458      not_empty: true
1459"##;
1460        let playbook = Playbook::from_yaml(yaml).expect("parse");
1461        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1462        let result = runner.run();
1463
1464        assert!(!result.passed);
1465        assert!(result.assertion_results.iter().any(|a| !a.passed
1466            && a.error
1467                .as_ref()
1468                .is_some_and(|e| e.contains("empty or undefined"))));
1469    }
1470
1471    #[test]
1472    fn test_output_assertion_less_than_non_numeric() {
1473        let yaml = r##"
1474version: "1.0"
1475machine:
1476  id: "test"
1477  initial: "start"
1478  states:
1479    start:
1480      id: "start"
1481  transitions:
1482    - id: "t1"
1483      from: "start"
1484      to: "start"
1485      event: "loop"
1486playbook:
1487  setup: []
1488  steps:
1489    - name: "Capture non-numeric"
1490      transitions: ["t1"]
1491      capture:
1492        - var: "text_val"
1493          from: "not_a_number"
1494  teardown: []
1495assertions:
1496  output:
1497    - var: "text_val"
1498      less_than: 100
1499"##;
1500        let playbook = Playbook::from_yaml(yaml).expect("parse");
1501        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1502        let result = runner.run();
1503
1504        // Should pass because the parse fails silently and assertion defaults to pass
1505        assert!(result.passed);
1506    }
1507
1508    #[test]
1509    fn test_output_assertion_greater_than_non_numeric() {
1510        let yaml = r##"
1511version: "1.0"
1512machine:
1513  id: "test"
1514  initial: "start"
1515  states:
1516    start:
1517      id: "start"
1518  transitions:
1519    - id: "t1"
1520      from: "start"
1521      to: "start"
1522      event: "loop"
1523playbook:
1524  setup: []
1525  steps:
1526    - name: "Capture non-numeric"
1527      transitions: ["t1"]
1528      capture:
1529        - var: "text_val"
1530          from: "not_a_number"
1531  teardown: []
1532assertions:
1533  output:
1534    - var: "text_val"
1535      greater_than: 0
1536"##;
1537        let playbook = Playbook::from_yaml(yaml).expect("parse");
1538        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1539        let result = runner.run();
1540
1541        // Should pass because the parse fails silently and assertion defaults to pass
1542        assert!(result.passed);
1543    }
1544
1545    #[test]
1546    fn test_output_assertion_equals_undefined() {
1547        let yaml = r##"
1548version: "1.0"
1549machine:
1550  id: "test"
1551  initial: "start"
1552  states:
1553    start:
1554      id: "start"
1555  transitions:
1556    - id: "t_loop"
1557      from: "start"
1558      to: "start"
1559      event: "noop"
1560assertions:
1561  output:
1562    - var: "missing"
1563      equals: "expected"
1564"##;
1565        let playbook = Playbook::from_yaml(yaml).expect("parse");
1566        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1567        let result = runner.run();
1568
1569        assert!(!result.passed);
1570        assert!(result
1571            .assertion_results
1572            .iter()
1573            .any(|a| !a.passed && a.error.as_ref().is_some_and(|e| e.contains("undefined"))));
1574    }
1575
1576    #[test]
1577    fn test_output_assertion_less_than_undefined() {
1578        let yaml = r##"
1579version: "1.0"
1580machine:
1581  id: "test"
1582  initial: "start"
1583  states:
1584    start:
1585      id: "start"
1586  transitions:
1587    - id: "t_loop"
1588      from: "start"
1589      to: "start"
1590      event: "noop"
1591assertions:
1592  output:
1593    - var: "missing"
1594      less_than: 100
1595"##;
1596        let playbook = Playbook::from_yaml(yaml).expect("parse");
1597        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1598        let result = runner.run();
1599
1600        // Should pass because undefined value is None and the branch skips
1601        assert!(result.passed);
1602    }
1603
1604    #[test]
1605    fn test_output_assertion_greater_than_undefined() {
1606        let yaml = r##"
1607version: "1.0"
1608machine:
1609  id: "test"
1610  initial: "start"
1611  states:
1612    start:
1613      id: "start"
1614  transitions:
1615    - id: "t_loop"
1616      from: "start"
1617      to: "start"
1618      event: "noop"
1619assertions:
1620  output:
1621    - var: "missing"
1622      greater_than: 0
1623"##;
1624        let playbook = Playbook::from_yaml(yaml).expect("parse");
1625        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1626        let result = runner.run();
1627
1628        // Should pass because undefined value is None and the branch skips
1629        assert!(result.passed);
1630    }
1631
1632    #[test]
1633    fn test_teardown_runs_after_step_failure() {
1634        let yaml = r##"
1635version: "1.0"
1636machine:
1637  id: "test"
1638  initial: "start"
1639  states:
1640    start:
1641      id: "start"
1642    end:
1643      id: "end"
1644  transitions:
1645    - id: "forbidden_t"
1646      from: "start"
1647      to: "end"
1648      event: "skip"
1649  forbidden:
1650    - from: "start"
1651      to: "end"
1652      reason: "Cannot skip"
1653playbook:
1654  setup: []
1655  steps:
1656    - name: "Fail with forbidden"
1657      transitions: ["forbidden_t"]
1658      capture: []
1659  teardown:
1660    - action:
1661        wasm: "cleanup"
1662        args: []
1663      ignore_errors: false
1664"##;
1665        let playbook = Playbook::from_yaml(yaml).expect("parse");
1666        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1667        let result = runner.run();
1668
1669        // Teardown should have run even though step failed
1670        assert!(!result.passed);
1671    }
1672
1673    #[test]
1674    fn test_svg_export_with_final_state() {
1675        let yaml = r##"
1676version: "1.0"
1677machine:
1678  id: "svg_test"
1679  initial: "start"
1680  states:
1681    start:
1682      id: "start"
1683    middle:
1684      id: "middle"
1685    end:
1686      id: "end"
1687      final_state: true
1688  transitions:
1689    - id: "t1"
1690      from: "start"
1691      to: "middle"
1692      event: "go"
1693    - id: "t2"
1694      from: "middle"
1695      to: "end"
1696      event: "finish"
1697"##;
1698        let playbook = Playbook::from_yaml(yaml).expect("parse");
1699        let svg = to_svg(&playbook);
1700
1701        assert!(svg.contains("<svg"));
1702        assert!(svg.contains("svg_test"));
1703        assert!(svg.contains("state-final")); // Final state should have this class
1704        assert!(svg.contains("</svg>"));
1705        assert!(svg.contains("DOT source")); // Comment with DOT source
1706    }
1707
1708    #[test]
1709    fn test_no_assertions_section() {
1710        let yaml = r##"
1711version: "1.0"
1712machine:
1713  id: "test"
1714  initial: "start"
1715  states:
1716    start:
1717      id: "start"
1718  transitions:
1719    - id: "t_loop"
1720      from: "start"
1721      to: "start"
1722      event: "noop"
1723"##;
1724        let playbook = Playbook::from_yaml(yaml).expect("parse");
1725        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1726        let result = runner.run();
1727
1728        assert!(result.passed);
1729        assert!(result.assertion_results.is_empty());
1730    }
1731
1732    #[test]
1733    fn test_step_result_fields() {
1734        let yaml = r##"
1735version: "1.0"
1736machine:
1737  id: "test"
1738  initial: "start"
1739  states:
1740    start:
1741      id: "start"
1742    end:
1743      id: "end"
1744  transitions:
1745    - id: "t1"
1746      from: "start"
1747      to: "end"
1748      event: "go"
1749playbook:
1750  setup: []
1751  steps:
1752    - name: "Test Step"
1753      transitions: ["t1"]
1754      capture:
1755        - var: "step_var"
1756          from: "step_value"
1757  teardown: []
1758"##;
1759        let playbook = Playbook::from_yaml(yaml).expect("parse");
1760        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1761        let result = runner.run();
1762
1763        assert!(result.passed);
1764        assert_eq!(result.step_results.len(), 1);
1765        let step = &result.step_results[0];
1766        assert_eq!(step.name, "Test Step");
1767        assert!(step.passed);
1768        assert!(step.error.is_none());
1769        assert_eq!(
1770            step.captured.get("step_var"),
1771            Some(&"step_value".to_string())
1772        );
1773    }
1774
1775    #[test]
1776    fn test_playbook_run_result_fields() {
1777        let yaml = r##"
1778version: "1.0"
1779name: "Result Test Playbook"
1780machine:
1781  id: "test"
1782  initial: "start"
1783  states:
1784    start:
1785      id: "start"
1786    end:
1787      id: "end"
1788  transitions:
1789    - id: "t1"
1790      from: "start"
1791      to: "end"
1792      event: "go"
1793playbook:
1794  setup: []
1795  steps:
1796    - name: "Go"
1797      transitions: ["t1"]
1798      capture:
1799        - var: "test_var"
1800          from: "test_value"
1801  teardown: []
1802assertions:
1803  path:
1804    expected: ["start", "end"]
1805  output:
1806    - var: "test_var"
1807      equals: "test_value"
1808"##;
1809        let playbook = Playbook::from_yaml(yaml).expect("parse");
1810        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1811        let result = runner.run();
1812
1813        assert!(result.passed);
1814        assert!(result.error.is_none());
1815        assert_eq!(result.state_path, vec!["start", "end"]);
1816        assert_eq!(
1817            result.variables.get("test_var"),
1818            Some(&"test_value".to_string())
1819        );
1820        assert!(!result.total_time.is_zero() || result.total_time == std::time::Duration::ZERO);
1821        assert_eq!(result.step_results.len(), 1);
1822        assert_eq!(result.assertion_results.len(), 2); // path + output
1823        assert!(result.assertion_results.iter().all(|a| a.passed));
1824    }
1825
1826    #[test]
1827    fn test_assertion_result_error_formats() {
1828        let yaml = r##"
1829version: "1.0"
1830machine:
1831  id: "test"
1832  initial: "start"
1833  states:
1834    start:
1835      id: "start"
1836  transitions:
1837    - id: "t_loop"
1838      from: "start"
1839      to: "start"
1840      event: "noop"
1841assertions:
1842  path:
1843    expected: ["start", "wrong", "path"]
1844  output:
1845    - var: "missing"
1846      not_empty: true
1847"##;
1848        let playbook = Playbook::from_yaml(yaml).expect("parse");
1849        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1850        let result = runner.run();
1851
1852        assert!(!result.passed);
1853        assert!(result
1854            .error
1855            .as_ref()
1856            .is_some_and(|e| e.contains("Assertions failed")));
1857
1858        // Check path assertion error format
1859        let path_result = result
1860            .assertion_results
1861            .iter()
1862            .find(|a| a.description.contains("Path"));
1863        assert!(path_result.is_some());
1864        let path_err = path_result.and_then(|p| p.error.as_ref());
1865        assert!(path_err.is_some_and(|e| e.contains("Expected path")));
1866    }
1867
1868    #[test]
1869    fn test_less_than_boundary_value() {
1870        let yaml = r##"
1871version: "1.0"
1872machine:
1873  id: "test"
1874  initial: "start"
1875  states:
1876    start:
1877      id: "start"
1878  transitions:
1879    - id: "t1"
1880      from: "start"
1881      to: "start"
1882      event: "loop"
1883playbook:
1884  setup: []
1885  steps:
1886    - name: "Capture"
1887      transitions: ["t1"]
1888      capture:
1889        - var: "count"
1890          from: "10"
1891  teardown: []
1892assertions:
1893  output:
1894    - var: "count"
1895      less_than: 10
1896"##;
1897        let playbook = Playbook::from_yaml(yaml).expect("parse");
1898        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1899        let result = runner.run();
1900
1901        // 10 is not less than 10
1902        assert!(!result.passed);
1903    }
1904
1905    #[test]
1906    fn test_greater_than_boundary_value() {
1907        let yaml = r##"
1908version: "1.0"
1909machine:
1910  id: "test"
1911  initial: "start"
1912  states:
1913    start:
1914      id: "start"
1915  transitions:
1916    - id: "t1"
1917      from: "start"
1918      to: "start"
1919      event: "loop"
1920playbook:
1921  setup: []
1922  steps:
1923    - name: "Capture"
1924      transitions: ["t1"]
1925      capture:
1926        - var: "count"
1927          from: "50"
1928  teardown: []
1929assertions:
1930  output:
1931    - var: "count"
1932      greater_than: 50
1933"##;
1934        let playbook = Playbook::from_yaml(yaml).expect("parse");
1935        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1936        let result = runner.run();
1937
1938        // 50 is not greater than 50
1939        assert!(!result.passed);
1940    }
1941
1942    #[test]
1943    fn test_multiple_output_assertions_on_same_var() {
1944        let yaml = r##"
1945version: "1.0"
1946machine:
1947  id: "test"
1948  initial: "start"
1949  states:
1950    start:
1951      id: "start"
1952  transitions:
1953    - id: "t1"
1954      from: "start"
1955      to: "start"
1956      event: "loop"
1957playbook:
1958  setup: []
1959  steps:
1960    - name: "Capture"
1961      transitions: ["t1"]
1962      capture:
1963        - var: "count"
1964          from: "50"
1965  teardown: []
1966assertions:
1967  output:
1968    - var: "count"
1969      not_empty: true
1970    - var: "count"
1971      greater_than: 40
1972    - var: "count"
1973      less_than: 60
1974"##;
1975        let playbook = Playbook::from_yaml(yaml).expect("parse");
1976        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
1977        let result = runner.run();
1978
1979        assert!(result.passed);
1980        assert_eq!(result.assertion_results.len(), 3);
1981    }
1982
1983    #[test]
1984    fn test_step_fails_early_remaining_steps_skipped() {
1985        let yaml = r##"
1986version: "1.0"
1987machine:
1988  id: "test"
1989  initial: "start"
1990  states:
1991    start:
1992      id: "start"
1993    end:
1994      id: "end"
1995  transitions:
1996    - id: "forbidden_t"
1997      from: "start"
1998      to: "end"
1999      event: "skip"
2000    - id: "t_loop"
2001      from: "start"
2002      to: "start"
2003      event: "loop"
2004  forbidden:
2005    - from: "start"
2006      to: "end"
2007      reason: "Cannot skip"
2008playbook:
2009  setup: []
2010  steps:
2011    - name: "First (fails)"
2012      transitions: ["forbidden_t"]
2013      capture: []
2014    - name: "Second (should be skipped)"
2015      transitions: ["t_loop"]
2016      capture:
2017        - var: "should_not_exist"
2018          from: "value"
2019  teardown: []
2020"##;
2021        let playbook = Playbook::from_yaml(yaml).expect("parse");
2022        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
2023        let result = runner.run();
2024
2025        assert!(!result.passed);
2026        // Only one step should have been executed
2027        assert_eq!(result.step_results.len(), 1);
2028        // Variable from second step should not exist
2029        assert!(!result.variables.contains_key("should_not_exist"));
2030    }
2031
2032    #[test]
2033    fn test_forbidden_check_multiple_forbidden_rules() {
2034        let yaml = r##"
2035version: "1.0"
2036machine:
2037  id: "test"
2038  initial: "start"
2039  states:
2040    start:
2041      id: "start"
2042    middle:
2043      id: "middle"
2044    end:
2045      id: "end"
2046  transitions:
2047    - id: "t1"
2048      from: "start"
2049      to: "middle"
2050      event: "go"
2051    - id: "t2"
2052      from: "middle"
2053      to: "end"
2054      event: "finish"
2055  forbidden:
2056    - from: "start"
2057      to: "end"
2058      reason: "Cannot skip middle from start"
2059    - from: "middle"
2060      to: "start"
2061      reason: "Cannot go backwards"
2062"##;
2063        let playbook = Playbook::from_yaml(yaml).expect("parse");
2064        let runner = PlaybookRunner::new(playbook, MockExecutor);
2065
2066        // First forbidden rule
2067        let err1 = runner.check_forbidden("start", "end");
2068        assert!(err1.is_some());
2069        assert!(err1
2070            .as_ref()
2071            .is_some_and(|e| e.contains("Cannot skip middle from start")));
2072
2073        // Second forbidden rule
2074        let err2 = runner.check_forbidden("middle", "start");
2075        assert!(err2.is_some());
2076        assert!(err2
2077            .as_ref()
2078            .is_some_and(|e| e.contains("Cannot go backwards")));
2079
2080        // Allowed transition
2081        let ok = runner.check_forbidden("start", "middle");
2082        assert!(ok.is_none());
2083    }
2084
2085    #[test]
2086    fn test_substitute_variables_no_match() {
2087        let yaml = r##"
2088version: "1.0"
2089machine:
2090  id: "test"
2091  initial: "start"
2092  states:
2093    start:
2094      id: "start"
2095  transitions:
2096    - id: "t_loop"
2097      from: "start"
2098      to: "start"
2099      event: "noop"
2100"##;
2101        let playbook = Playbook::from_yaml(yaml).expect("parse");
2102        let runner = PlaybookRunner::new(playbook, MockExecutor);
2103
2104        // No variables set, so pattern should remain unchanged
2105        let result = runner.substitute_variables("No ${vars} here ${at_all}");
2106        assert_eq!(result, "No ${vars} here ${at_all}");
2107    }
2108
2109    #[test]
2110    fn test_substitute_variables_partial_match() {
2111        let yaml = r##"
2112version: "1.0"
2113machine:
2114  id: "test"
2115  initial: "start"
2116  states:
2117    start:
2118      id: "start"
2119  transitions:
2120    - id: "t_loop"
2121      from: "start"
2122      to: "start"
2123      event: "noop"
2124"##;
2125        let playbook = Playbook::from_yaml(yaml).expect("parse");
2126        let mut runner = PlaybookRunner::new(playbook, MockExecutor);
2127
2128        runner
2129            .variables
2130            .insert("found".to_string(), "YES".to_string());
2131
2132        let result = runner.substitute_variables("${found} but ${not_found}");
2133        assert_eq!(result, "YES but ${not_found}");
2134    }
2135
2136    #[test]
2137    fn test_assertion_check_result_clone() {
2138        let result = AssertionCheckResult {
2139            description: "Test".to_string(),
2140            passed: true,
2141            error: None,
2142        };
2143        let cloned = result;
2144        assert_eq!(cloned.description, "Test");
2145        assert!(cloned.passed);
2146        assert!(cloned.error.is_none());
2147    }
2148
2149    #[test]
2150    fn test_step_result_clone() {
2151        let result = StepResult {
2152            name: "Test Step".to_string(),
2153            passed: false,
2154            duration: std::time::Duration::from_millis(100),
2155            captured: HashMap::new(),
2156            error: Some("Test error".to_string()),
2157        };
2158        let cloned = result;
2159        assert_eq!(cloned.name, "Test Step");
2160        assert!(!cloned.passed);
2161        assert_eq!(cloned.duration, std::time::Duration::from_millis(100));
2162        assert_eq!(cloned.error, Some("Test error".to_string()));
2163    }
2164}