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