1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Playbook {
12 pub version: String,
14 #[serde(default)]
16 pub name: String,
17 #[serde(default)]
19 pub description: String,
20 pub machine: StateMachine,
22 #[serde(default)]
24 pub performance: PerformanceBudget,
25 #[serde(default)]
27 pub playbook: Option<PlaybookSteps>,
28 #[serde(default)]
30 pub assertions: Option<PlaybookAssertions>,
31 #[serde(default)]
33 pub falsification: Option<FalsificationConfig>,
34 #[serde(default)]
36 pub metadata: HashMap<String, String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct StateMachine {
42 pub id: String,
44 pub initial: String,
46 pub states: HashMap<String, State>,
48 pub transitions: Vec<Transition>,
50 #[serde(default)]
52 pub forbidden: Vec<ForbiddenTransition>,
53 #[serde(default)]
55 pub performance: Option<PerformanceBudget>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ForbiddenTransition {
61 pub from: String,
63 pub to: String,
65 #[serde(default)]
67 pub reason: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct State {
73 pub id: String,
75 #[serde(default)]
77 pub description: String,
78 #[serde(default)]
80 pub on_entry: Vec<Action>,
81 #[serde(default)]
83 pub on_exit: Vec<Action>,
84 #[serde(default)]
86 pub invariants: Vec<Invariant>,
87 #[serde(default)]
89 pub final_state: bool,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct Transition {
95 pub id: String,
97 pub from: String,
99 pub to: String,
101 pub event: String,
103 #[serde(default)]
105 pub guard: Option<String>,
106 #[serde(default)]
108 pub actions: Vec<Action>,
109 #[serde(default)]
111 pub assertions: Vec<Assertion>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(tag = "type")]
117pub enum Action {
118 #[serde(rename = "click")]
120 Click { selector: String },
121 #[serde(rename = "type")]
123 Type { selector: String, text: String },
124 #[serde(rename = "wait")]
126 Wait { condition: WaitCondition },
127 #[serde(rename = "navigate")]
129 Navigate { url: String },
130 #[serde(rename = "script")]
132 Script { code: String },
133 #[serde(rename = "screenshot")]
135 Screenshot { name: String },
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(tag = "type")]
141pub enum WaitCondition {
142 #[serde(rename = "visible")]
144 Visible { selector: String },
145 #[serde(rename = "hidden")]
147 Hidden { selector: String },
148 #[serde(rename = "duration")]
150 Duration { ms: u64 },
151 #[serde(rename = "network_idle")]
153 NetworkIdle,
154 #[serde(rename = "condition")]
156 Condition { expression: String },
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(tag = "type")]
162pub enum Assertion {
163 #[serde(rename = "element_exists")]
165 ElementExists { selector: String },
166 #[serde(rename = "text_equals")]
168 TextEquals { selector: String, expected: String },
169 #[serde(rename = "text_contains")]
171 TextContains { selector: String, substring: String },
172 #[serde(rename = "attribute_equals")]
174 AttributeEquals {
175 selector: String,
176 attribute: String,
177 expected: String,
178 },
179 #[serde(rename = "url_matches")]
181 UrlMatches { pattern: String },
182 #[serde(rename = "script")]
184 Script { expression: String },
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct Invariant {
190 pub description: String,
192 pub condition: String,
194 #[serde(default)]
196 pub severity: InvariantSeverity,
197}
198
199#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
201#[serde(rename_all = "lowercase")]
202pub enum InvariantSeverity {
203 Warning,
205 #[default]
207 Error,
208 Critical,
210}
211
212#[derive(Debug, Clone, Default, Serialize, Deserialize)]
214pub struct PerformanceBudget {
215 #[serde(default)]
217 pub max_transition_time_ms: Option<u64>,
218 #[serde(default)]
220 pub max_total_time_ms: Option<u64>,
221 #[serde(default)]
223 pub max_memory_bytes: Option<u64>,
224 #[serde(default)]
226 pub complexity_class: Option<ComplexityClass>,
227}
228
229#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
231pub enum ComplexityClass {
232 #[serde(rename = "O(1)")]
234 Constant,
235 #[serde(rename = "O(log n)")]
237 Logarithmic,
238 #[serde(rename = "O(n)")]
240 Linear,
241 #[serde(rename = "O(n log n)")]
243 Linearithmic,
244 #[serde(rename = "O(n^2)")]
246 Quadratic,
247}
248
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
251pub struct PlaybookSteps {
252 #[serde(default)]
254 pub setup: Vec<PlaybookAction>,
255 #[serde(default)]
257 pub steps: Vec<PlaybookStep>,
258 #[serde(default)]
260 pub teardown: Vec<PlaybookAction>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct PlaybookAction {
266 pub action: ActionSpec,
268 #[serde(default)]
270 pub description: String,
271 #[serde(default)]
273 pub ignore_errors: bool,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct ActionSpec {
279 #[serde(default)]
281 pub wasm: Option<String>,
282 #[serde(default)]
284 pub args: Vec<String>,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct PlaybookStep {
290 pub name: String,
292 #[serde(default)]
294 pub transitions: Vec<String>,
295 #[serde(default)]
297 pub timeout: Option<String>,
298 #[serde(default)]
300 pub capture: Vec<VariableCapture>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct VariableCapture {
306 pub var: String,
308 pub from: String,
310}
311
312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
314pub struct PlaybookAssertions {
315 #[serde(default)]
317 pub path: Option<PathAssertion>,
318 #[serde(default)]
320 pub output: Vec<OutputAssertion>,
321 #[serde(default)]
323 pub complexity: Option<ComplexityAssertion>,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct PathAssertion {
329 pub expected: Vec<String>,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct OutputAssertion {
336 pub var: String,
338 #[serde(default)]
340 pub not_empty: Option<bool>,
341 #[serde(default)]
343 pub matches: Option<String>,
344 #[serde(default)]
346 pub less_than: Option<i64>,
347 #[serde(default)]
349 pub greater_than: Option<i64>,
350 #[serde(default)]
352 pub equals: Option<String>,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct ComplexityAssertion {
358 pub operation: String,
360 pub measure: String,
362 pub input_var: String,
364 pub expected: ComplexityClass,
366 #[serde(default)]
368 pub tolerance: f64,
369 #[serde(default)]
371 pub sample_sizes: Vec<usize>,
372}
373
374#[derive(Debug, Clone, Default, Serialize, Deserialize)]
376pub struct FalsificationConfig {
377 #[serde(default)]
379 pub mutations: Vec<MutationDef>,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct MutationDef {
385 pub id: String,
387 #[serde(default)]
389 pub description: String,
390 pub mutate: String,
392 pub expected_failure: String,
394}
395
396impl Playbook {
397 pub fn from_yaml(yaml: &str) -> Result<Self, PlaybookError> {
402 let playbook: Playbook =
403 serde_yaml_ng::from_str(yaml).map_err(|e| PlaybookError::ParseError(e.to_string()))?;
404 playbook.validate()?;
405 Ok(playbook)
406 }
407
408 fn validate(&self) -> Result<(), PlaybookError> {
410 if self.version != "1.0" {
412 return Err(PlaybookError::InvalidVersion(self.version.clone()));
413 }
414
415 if !self.machine.states.contains_key(&self.machine.initial) {
417 return Err(PlaybookError::InvalidInitialState(
418 self.machine.initial.clone(),
419 ));
420 }
421
422 for transition in &self.machine.transitions {
424 if !self.machine.states.contains_key(&transition.from) {
425 return Err(PlaybookError::InvalidTransitionSource {
426 transition_id: transition.id.clone(),
427 state_id: transition.from.clone(),
428 });
429 }
430 if !self.machine.states.contains_key(&transition.to) {
431 return Err(PlaybookError::InvalidTransitionTarget {
432 transition_id: transition.id.clone(),
433 state_id: transition.to.clone(),
434 });
435 }
436 }
437
438 let state_ids: Vec<_> = self.machine.states.keys().collect();
440 let unique_ids: std::collections::HashSet<_> = state_ids.iter().collect();
441 if state_ids.len() != unique_ids.len() {
442 return Err(PlaybookError::DuplicateStateIds);
443 }
444
445 let transition_ids: Vec<_> = self.machine.transitions.iter().map(|t| &t.id).collect();
447 let unique_transition_ids: std::collections::HashSet<_> = transition_ids.iter().collect();
448 if transition_ids.len() != unique_transition_ids.len() {
449 return Err(PlaybookError::DuplicateTransitionIds);
450 }
451
452 if self.machine.states.is_empty() {
454 return Err(PlaybookError::EmptyStates);
455 }
456 if self.machine.transitions.is_empty() {
457 return Err(PlaybookError::EmptyTransitions);
458 }
459
460 Ok(())
461 }
462}
463
464#[derive(Debug, Clone, thiserror::Error)]
466pub enum PlaybookError {
467 #[error("Failed to parse YAML: {0}")]
468 ParseError(String),
469
470 #[error("Invalid version '{0}', expected '1.0'")]
471 InvalidVersion(String),
472
473 #[error("Initial state '{0}' does not exist")]
474 InvalidInitialState(String),
475
476 #[error("Transition '{transition_id}' references non-existent source state '{state_id}'")]
477 InvalidTransitionSource {
478 transition_id: String,
479 state_id: String,
480 },
481
482 #[error("Transition '{transition_id}' references non-existent target state '{state_id}'")]
483 InvalidTransitionTarget {
484 transition_id: String,
485 state_id: String,
486 },
487
488 #[error("Duplicate state IDs detected")]
489 DuplicateStateIds,
490
491 #[error("Duplicate transition IDs detected")]
492 DuplicateTransitionIds,
493
494 #[error("States cannot be empty")]
495 EmptyStates,
496
497 #[error("Transitions cannot be empty")]
498 EmptyTransitions,
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 const VALID_PLAYBOOK: &str = r##"
506version: "1.0"
507machine:
508 id: "login_flow"
509 initial: "logged_out"
510 states:
511 logged_out:
512 id: "logged_out"
513 description: "User is not authenticated"
514 invariants:
515 - description: "Login button visible"
516 condition: "document.querySelector('#login-btn') !== null"
517 logged_in:
518 id: "logged_in"
519 description: "User is authenticated"
520 final_state: true
521 transitions:
522 - id: "t1"
523 from: "logged_out"
524 to: "logged_in"
525 event: "login_success"
526 assertions:
527 - type: element_exists
528 selector: "#welcome-message"
529"##;
530
531 #[test]
532 fn test_parse_valid_playbook() {
533 let playbook = Playbook::from_yaml(VALID_PLAYBOOK).expect("Should parse valid playbook");
534 assert_eq!(playbook.version, "1.0");
535 assert_eq!(playbook.machine.id, "login_flow");
536 assert_eq!(playbook.machine.initial, "logged_out");
537 assert_eq!(playbook.machine.states.len(), 2);
538 assert_eq!(playbook.machine.transitions.len(), 1);
539 }
540
541 #[test]
542 fn test_reject_invalid_version() {
543 let yaml = VALID_PLAYBOOK.replace("version: \"1.0\"", "version: \"2.0\"");
544 let result = Playbook::from_yaml(&yaml);
545 assert!(matches!(result, Err(PlaybookError::InvalidVersion(_))));
546 }
547
548 #[test]
549 fn test_reject_invalid_initial_state() {
550 let yaml = VALID_PLAYBOOK.replace("initial: \"logged_out\"", "initial: \"nonexistent\"");
551 let result = Playbook::from_yaml(&yaml);
552 assert!(matches!(result, Err(PlaybookError::InvalidInitialState(_))));
553 }
554
555 #[test]
556 fn test_reject_invalid_transition_source() {
557 let yaml = VALID_PLAYBOOK.replace("from: \"logged_out\"", "from: \"nonexistent\"");
558 let result = Playbook::from_yaml(&yaml);
559 assert!(matches!(
560 result,
561 Err(PlaybookError::InvalidTransitionSource { .. })
562 ));
563 }
564
565 #[test]
566 fn test_reject_invalid_transition_target() {
567 let yaml = VALID_PLAYBOOK.replace("to: \"logged_in\"", "to: \"nonexistent\"");
568 let result = Playbook::from_yaml(&yaml);
569 assert!(matches!(
570 result,
571 Err(PlaybookError::InvalidTransitionTarget { .. })
572 ));
573 }
574
575 #[test]
576 fn test_reject_empty_states() {
577 let yaml = r#"
578version: "1.0"
579machine:
580 id: "test"
581 initial: "start"
582 states: {}
583 transitions:
584 - id: "t1"
585 from: "start"
586 to: "end"
587 event: "go"
588"#;
589 let result = Playbook::from_yaml(yaml);
590 assert!(result.is_err());
592 assert!(matches!(
594 result,
595 Err(PlaybookError::EmptyStates | PlaybookError::InvalidInitialState(_))
596 ));
597 }
598
599 #[test]
600 fn test_reject_empty_transitions() {
601 let yaml = r#"
602version: "1.0"
603machine:
604 id: "test"
605 initial: "start"
606 states:
607 start:
608 id: "start"
609 transitions: []
610"#;
611 let result = Playbook::from_yaml(yaml);
612 assert!(matches!(result, Err(PlaybookError::EmptyTransitions)));
613 }
614
615 #[test]
618 fn test_parse_error_invalid_yaml() {
619 let yaml = "this is not: valid: yaml: {{{{";
620 let result = Playbook::from_yaml(yaml);
621 assert!(matches!(result, Err(PlaybookError::ParseError(_))));
622 }
623
624 #[test]
625 fn test_parse_error_missing_required_field() {
626 let yaml = r#"
627version: "1.0"
628"#;
629 let result = Playbook::from_yaml(yaml);
630 assert!(matches!(result, Err(PlaybookError::ParseError(_))));
631 }
632
633 #[test]
634 fn test_duplicate_transition_ids() {
635 let yaml = r#"
636version: "1.0"
637machine:
638 id: "test"
639 initial: "a"
640 states:
641 a:
642 id: "a"
643 b:
644 id: "b"
645 transitions:
646 - id: "t1"
647 from: "a"
648 to: "b"
649 event: "go"
650 - id: "t1"
651 from: "b"
652 to: "a"
653 event: "back"
654"#;
655 let result = Playbook::from_yaml(yaml);
656 assert!(matches!(result, Err(PlaybookError::DuplicateTransitionIds)));
657 }
658
659 #[test]
660 fn test_playbook_error_display() {
661 let err = PlaybookError::ParseError("test error".to_string());
663 assert!(err.to_string().contains("Failed to parse YAML"));
664
665 let err = PlaybookError::InvalidVersion("2.0".to_string());
666 assert!(err.to_string().contains("Invalid version '2.0'"));
667
668 let err = PlaybookError::InvalidInitialState("missing".to_string());
669 assert!(err.to_string().contains("Initial state 'missing'"));
670
671 let err = PlaybookError::InvalidTransitionSource {
672 transition_id: "t1".to_string(),
673 state_id: "missing".to_string(),
674 };
675 assert!(err.to_string().contains("Transition 't1'"));
676 assert!(err.to_string().contains("source state 'missing'"));
677
678 let err = PlaybookError::InvalidTransitionTarget {
679 transition_id: "t2".to_string(),
680 state_id: "gone".to_string(),
681 };
682 assert!(err.to_string().contains("Transition 't2'"));
683 assert!(err.to_string().contains("target state 'gone'"));
684
685 let err = PlaybookError::DuplicateStateIds;
686 assert!(err.to_string().contains("Duplicate state IDs"));
687
688 let err = PlaybookError::DuplicateTransitionIds;
689 assert!(err.to_string().contains("Duplicate transition IDs"));
690
691 let err = PlaybookError::EmptyStates;
692 assert!(err.to_string().contains("States cannot be empty"));
693
694 let err = PlaybookError::EmptyTransitions;
695 assert!(err.to_string().contains("Transitions cannot be empty"));
696 }
697
698 #[test]
699 fn test_invariant_severity_default() {
700 let severity = InvariantSeverity::default();
701 assert_eq!(severity, InvariantSeverity::Error);
702 }
703
704 #[test]
705 fn test_invariant_severity_parsing() {
706 let yaml = r#"
707version: "1.0"
708machine:
709 id: "test"
710 initial: "start"
711 states:
712 start:
713 id: "start"
714 invariants:
715 - description: "warning"
716 condition: "true"
717 severity: warning
718 - description: "error"
719 condition: "true"
720 severity: error
721 - description: "critical"
722 condition: "true"
723 severity: critical
724 end:
725 id: "end"
726 transitions:
727 - id: "t1"
728 from: "start"
729 to: "end"
730 event: "go"
731"#;
732 let playbook = Playbook::from_yaml(yaml).expect("Should parse");
733 let state = playbook.machine.states.get("start").unwrap();
734 assert_eq!(state.invariants.len(), 3);
735 assert_eq!(state.invariants[0].severity, InvariantSeverity::Warning);
736 assert_eq!(state.invariants[1].severity, InvariantSeverity::Error);
737 assert_eq!(state.invariants[2].severity, InvariantSeverity::Critical);
738 }
739
740 #[test]
741 fn test_complexity_class_parsing() {
742 let yaml = r#"
743version: "1.0"
744machine:
745 id: "test"
746 initial: "start"
747 states:
748 start:
749 id: "start"
750 end:
751 id: "end"
752 transitions:
753 - id: "t1"
754 from: "start"
755 to: "end"
756 event: "go"
757 performance:
758 complexity_class: "O(1)"
759"#;
760 let playbook = Playbook::from_yaml(yaml).expect("Should parse O(1)");
761 assert_eq!(
762 playbook.machine.performance.unwrap().complexity_class,
763 Some(ComplexityClass::Constant)
764 );
765
766 for (class_str, expected) in [
768 ("O(log n)", ComplexityClass::Logarithmic),
769 ("O(n)", ComplexityClass::Linear),
770 ("O(n log n)", ComplexityClass::Linearithmic),
771 ("O(n^2)", ComplexityClass::Quadratic),
772 ] {
773 let yaml = format!(
774 r#"
775version: "1.0"
776machine:
777 id: "test"
778 initial: "start"
779 states:
780 start:
781 id: "start"
782 end:
783 id: "end"
784 transitions:
785 - id: "t1"
786 from: "start"
787 to: "end"
788 event: "go"
789 performance:
790 complexity_class: "{class_str}"
791"#
792 );
793 let playbook = Playbook::from_yaml(&yaml).expect("Should parse");
794 assert_eq!(
795 playbook.machine.performance.unwrap().complexity_class,
796 Some(expected)
797 );
798 }
799 }
800
801 #[test]
802 fn test_action_variants_parsing() {
803 let yaml = r#"
804version: "1.0"
805machine:
806 id: "test"
807 initial: "start"
808 states:
809 start:
810 id: "start"
811 on_entry:
812 - type: click
813 selector: ".btn"
814 - type: type
815 selector: ".input"
816 text: "hello"
817 - type: wait
818 condition:
819 type: visible
820 selector: ".element"
821 - type: navigate
822 url: "https://example.com"
823 - type: script
824 code: "console.log('hi')"
825 - type: screenshot
826 name: "screenshot1"
827 on_exit:
828 - type: click
829 selector: ".logout"
830 end:
831 id: "end"
832 transitions:
833 - id: "t1"
834 from: "start"
835 to: "end"
836 event: "go"
837 actions:
838 - type: wait
839 condition:
840 type: hidden
841 selector: ".loader"
842"#;
843 let playbook = Playbook::from_yaml(yaml).expect("Should parse actions");
844 let state = playbook.machine.states.get("start").unwrap();
845
846 assert_eq!(state.on_entry.len(), 6);
847 assert!(matches!(&state.on_entry[0], Action::Click { selector } if selector == ".btn"));
848 assert!(
849 matches!(&state.on_entry[1], Action::Type { selector, text } if selector == ".input" && text == "hello")
850 );
851 assert!(matches!(&state.on_entry[2], Action::Wait { .. }));
852 assert!(
853 matches!(&state.on_entry[3], Action::Navigate { url } if url == "https://example.com")
854 );
855 assert!(
856 matches!(&state.on_entry[4], Action::Script { code } if code == "console.log('hi')")
857 );
858 assert!(matches!(&state.on_entry[5], Action::Screenshot { name } if name == "screenshot1"));
859
860 assert_eq!(state.on_exit.len(), 1);
861 }
862
863 #[test]
864 fn test_wait_condition_variants() {
865 let yaml = r#"
866version: "1.0"
867machine:
868 id: "test"
869 initial: "start"
870 states:
871 start:
872 id: "start"
873 on_entry:
874 - type: wait
875 condition:
876 type: duration
877 ms: 1000
878 - type: wait
879 condition:
880 type: network_idle
881 - type: wait
882 condition:
883 type: condition
884 expression: "window.ready === true"
885 end:
886 id: "end"
887 transitions:
888 - id: "t1"
889 from: "start"
890 to: "end"
891 event: "go"
892"#;
893 let playbook = Playbook::from_yaml(yaml).expect("Should parse wait conditions");
894 let state = playbook.machine.states.get("start").unwrap();
895
896 assert_eq!(state.on_entry.len(), 3);
897 if let Action::Wait { condition } = &state.on_entry[0] {
898 assert!(matches!(condition, WaitCondition::Duration { ms: 1000 }));
899 } else {
900 panic!("Expected Wait action");
901 }
902 if let Action::Wait { condition } = &state.on_entry[1] {
903 assert!(matches!(condition, WaitCondition::NetworkIdle));
904 } else {
905 panic!("Expected Wait action");
906 }
907 if let Action::Wait { condition } = &state.on_entry[2] {
908 assert!(matches!(condition, WaitCondition::Condition { .. }));
909 } else {
910 panic!("Expected Wait action");
911 }
912 }
913
914 #[test]
915 fn test_assertion_variants() {
916 let yaml = r#"
917version: "1.0"
918machine:
919 id: "test"
920 initial: "start"
921 states:
922 start:
923 id: "start"
924 end:
925 id: "end"
926 transitions:
927 - id: "t1"
928 from: "start"
929 to: "end"
930 event: "go"
931 assertions:
932 - type: element_exists
933 selector: ".elem"
934 - type: text_equals
935 selector: ".text"
936 expected: "Hello"
937 - type: text_contains
938 selector: ".text"
939 substring: "ell"
940 - type: attribute_equals
941 selector: ".elem"
942 attribute: "data-value"
943 expected: "123"
944 - type: url_matches
945 pattern: "^https://.*"
946 - type: script
947 expression: "document.title === 'Test'"
948"#;
949 let playbook = Playbook::from_yaml(yaml).expect("Should parse assertions");
950 let transition = &playbook.machine.transitions[0];
951
952 assert_eq!(transition.assertions.len(), 6);
953 assert!(matches!(
954 &transition.assertions[0],
955 Assertion::ElementExists { .. }
956 ));
957 assert!(matches!(
958 &transition.assertions[1],
959 Assertion::TextEquals { .. }
960 ));
961 assert!(matches!(
962 &transition.assertions[2],
963 Assertion::TextContains { .. }
964 ));
965 assert!(matches!(
966 &transition.assertions[3],
967 Assertion::AttributeEquals { .. }
968 ));
969 assert!(matches!(
970 &transition.assertions[4],
971 Assertion::UrlMatches { .. }
972 ));
973 assert!(matches!(
974 &transition.assertions[5],
975 Assertion::Script { .. }
976 ));
977 }
978
979 #[test]
980 fn test_performance_budget_defaults() {
981 let budget = PerformanceBudget::default();
982 assert!(budget.max_transition_time_ms.is_none());
983 assert!(budget.max_total_time_ms.is_none());
984 assert!(budget.max_memory_bytes.is_none());
985 assert!(budget.complexity_class.is_none());
986 }
987
988 #[test]
989 fn test_performance_budget_full() {
990 let yaml = r#"
991version: "1.0"
992machine:
993 id: "test"
994 initial: "start"
995 states:
996 start:
997 id: "start"
998 end:
999 id: "end"
1000 transitions:
1001 - id: "t1"
1002 from: "start"
1003 to: "end"
1004 event: "go"
1005performance:
1006 max_transition_time_ms: 100
1007 max_total_time_ms: 5000
1008 max_memory_bytes: 10485760
1009 complexity_class: "O(n)"
1010"#;
1011 let playbook = Playbook::from_yaml(yaml).expect("Should parse performance");
1012 assert_eq!(playbook.performance.max_transition_time_ms, Some(100));
1013 assert_eq!(playbook.performance.max_total_time_ms, Some(5000));
1014 assert_eq!(playbook.performance.max_memory_bytes, Some(10485760));
1015 assert_eq!(
1016 playbook.performance.complexity_class,
1017 Some(ComplexityClass::Linear)
1018 );
1019 }
1020
1021 #[test]
1022 fn test_forbidden_transitions() {
1023 let yaml = r#"
1024version: "1.0"
1025machine:
1026 id: "test"
1027 initial: "start"
1028 states:
1029 start:
1030 id: "start"
1031 middle:
1032 id: "middle"
1033 end:
1034 id: "end"
1035 transitions:
1036 - id: "t1"
1037 from: "start"
1038 to: "middle"
1039 event: "go"
1040 - id: "t2"
1041 from: "middle"
1042 to: "end"
1043 event: "finish"
1044 forbidden:
1045 - from: "start"
1046 to: "end"
1047 reason: "Must go through middle state"
1048 - from: "end"
1049 to: "start"
1050"#;
1051 let playbook = Playbook::from_yaml(yaml).expect("Should parse forbidden transitions");
1052 assert_eq!(playbook.machine.forbidden.len(), 2);
1053 assert_eq!(playbook.machine.forbidden[0].from, "start");
1054 assert_eq!(playbook.machine.forbidden[0].to, "end");
1055 assert_eq!(
1056 playbook.machine.forbidden[0].reason,
1057 "Must go through middle state"
1058 );
1059 assert_eq!(playbook.machine.forbidden[1].from, "end");
1060 assert_eq!(playbook.machine.forbidden[1].to, "start");
1061 assert_eq!(playbook.machine.forbidden[1].reason, "");
1062 }
1063
1064 #[test]
1065 fn test_playbook_steps() {
1066 let yaml = r#"
1067version: "1.0"
1068machine:
1069 id: "test"
1070 initial: "start"
1071 states:
1072 start:
1073 id: "start"
1074 end:
1075 id: "end"
1076 transitions:
1077 - id: "t1"
1078 from: "start"
1079 to: "end"
1080 event: "go"
1081playbook:
1082 setup:
1083 - action:
1084 wasm: "init_game"
1085 args: ["--level", "1"]
1086 description: "Initialize game"
1087 ignore_errors: false
1088 steps:
1089 - name: "Step 1"
1090 transitions: ["t1"]
1091 timeout: "30s"
1092 capture:
1093 - var: "score"
1094 from: "game.score"
1095 teardown:
1096 - action:
1097 wasm: "cleanup"
1098 args: []
1099 description: "Cleanup"
1100 ignore_errors: true
1101"#;
1102 let playbook = Playbook::from_yaml(yaml).expect("Should parse playbook steps");
1103 let steps = playbook.playbook.unwrap();
1104
1105 assert_eq!(steps.setup.len(), 1);
1106 assert_eq!(steps.setup[0].action.wasm, Some("init_game".to_string()));
1107 assert_eq!(steps.setup[0].action.args, vec!["--level", "1"]);
1108 assert_eq!(steps.setup[0].description, "Initialize game");
1109 assert!(!steps.setup[0].ignore_errors);
1110
1111 assert_eq!(steps.steps.len(), 1);
1112 assert_eq!(steps.steps[0].name, "Step 1");
1113 assert_eq!(steps.steps[0].transitions, vec!["t1"]);
1114 assert_eq!(steps.steps[0].timeout, Some("30s".to_string()));
1115 assert_eq!(steps.steps[0].capture.len(), 1);
1116 assert_eq!(steps.steps[0].capture[0].var, "score");
1117 assert_eq!(steps.steps[0].capture[0].from, "game.score");
1118
1119 assert_eq!(steps.teardown.len(), 1);
1120 assert!(steps.teardown[0].ignore_errors);
1121 }
1122
1123 #[test]
1124 fn test_playbook_assertions() {
1125 let yaml = r#"
1126version: "1.0"
1127machine:
1128 id: "test"
1129 initial: "start"
1130 states:
1131 start:
1132 id: "start"
1133 middle:
1134 id: "middle"
1135 end:
1136 id: "end"
1137 transitions:
1138 - id: "t1"
1139 from: "start"
1140 to: "middle"
1141 event: "go"
1142 - id: "t2"
1143 from: "middle"
1144 to: "end"
1145 event: "finish"
1146assertions:
1147 path:
1148 expected: ["start", "middle", "end"]
1149 output:
1150 - var: "score"
1151 not_empty: true
1152 - var: "score"
1153 matches: "^\\d+$"
1154 - var: "score"
1155 less_than: 1000
1156 - var: "score"
1157 greater_than: 0
1158 - var: "name"
1159 equals: "Player1"
1160 complexity:
1161 operation: "render"
1162 measure: "render_time"
1163 input_var: "entity_count"
1164 expected: "O(n)"
1165 tolerance: 0.1
1166 sample_sizes: [10, 100, 1000]
1167"#;
1168 let playbook = Playbook::from_yaml(yaml).expect("Should parse assertions");
1169 let assertions = playbook.assertions.unwrap();
1170
1171 assert_eq!(
1172 assertions.path.unwrap().expected,
1173 vec!["start", "middle", "end"]
1174 );
1175
1176 assert_eq!(assertions.output.len(), 5);
1177 assert_eq!(assertions.output[0].var, "score");
1178 assert_eq!(assertions.output[0].not_empty, Some(true));
1179 assert_eq!(assertions.output[1].matches, Some("^\\d+$".to_string()));
1180 assert_eq!(assertions.output[2].less_than, Some(1000));
1181 assert_eq!(assertions.output[3].greater_than, Some(0));
1182 assert_eq!(assertions.output[4].equals, Some("Player1".to_string()));
1183
1184 let complexity = assertions.complexity.unwrap();
1185 assert_eq!(complexity.operation, "render");
1186 assert_eq!(complexity.measure, "render_time");
1187 assert_eq!(complexity.input_var, "entity_count");
1188 assert_eq!(complexity.expected, ComplexityClass::Linear);
1189 assert!((complexity.tolerance - 0.1).abs() < f64::EPSILON);
1190 assert_eq!(complexity.sample_sizes, vec![10, 100, 1000]);
1191 }
1192
1193 #[test]
1194 fn test_falsification_config() {
1195 let yaml = r#"
1196version: "1.0"
1197machine:
1198 id: "test"
1199 initial: "start"
1200 states:
1201 start:
1202 id: "start"
1203 end:
1204 id: "end"
1205 transitions:
1206 - id: "t1"
1207 from: "start"
1208 to: "end"
1209 event: "go"
1210falsification:
1211 mutations:
1212 - id: "mut1"
1213 description: "Remove collision detection"
1214 mutate: "game.collision_enabled = false"
1215 expected_failure: "Player should collide with wall"
1216 - id: "mut2"
1217 mutate: "game.score = -1"
1218 expected_failure: "Score should never be negative"
1219"#;
1220 let playbook = Playbook::from_yaml(yaml).expect("Should parse falsification");
1221 let falsification = playbook.falsification.unwrap();
1222
1223 assert_eq!(falsification.mutations.len(), 2);
1224 assert_eq!(falsification.mutations[0].id, "mut1");
1225 assert_eq!(
1226 falsification.mutations[0].description,
1227 "Remove collision detection"
1228 );
1229 assert_eq!(
1230 falsification.mutations[0].mutate,
1231 "game.collision_enabled = false"
1232 );
1233 assert_eq!(
1234 falsification.mutations[0].expected_failure,
1235 "Player should collide with wall"
1236 );
1237 assert_eq!(falsification.mutations[1].id, "mut2");
1238 assert_eq!(falsification.mutations[1].description, "");
1239 }
1240
1241 #[test]
1242 fn test_metadata() {
1243 let yaml = r#"
1244version: "1.0"
1245machine:
1246 id: "test"
1247 initial: "start"
1248 states:
1249 start:
1250 id: "start"
1251 end:
1252 id: "end"
1253 transitions:
1254 - id: "t1"
1255 from: "start"
1256 to: "end"
1257 event: "go"
1258metadata:
1259 author: "Test Author"
1260 game: "MyGame"
1261 tags: "integration,smoke"
1262"#;
1263 let playbook = Playbook::from_yaml(yaml).expect("Should parse metadata");
1264 assert_eq!(playbook.metadata.get("author"), Some(&"Test Author".into()));
1265 assert_eq!(playbook.metadata.get("game"), Some(&"MyGame".into()));
1266 assert_eq!(
1267 playbook.metadata.get("tags"),
1268 Some(&"integration,smoke".into())
1269 );
1270 }
1271
1272 #[test]
1273 fn test_optional_fields_defaults() {
1274 let yaml = r#"
1275version: "1.0"
1276machine:
1277 id: "minimal"
1278 initial: "start"
1279 states:
1280 start:
1281 id: "start"
1282 end:
1283 id: "end"
1284 transitions:
1285 - id: "t1"
1286 from: "start"
1287 to: "end"
1288 event: "go"
1289"#;
1290 let playbook = Playbook::from_yaml(yaml).expect("Should parse minimal playbook");
1291 assert_eq!(playbook.name, "");
1292 assert_eq!(playbook.description, "");
1293 assert!(playbook.playbook.is_none());
1294 assert!(playbook.assertions.is_none());
1295 assert!(playbook.falsification.is_none());
1296 assert!(playbook.metadata.is_empty());
1297 assert!(playbook.machine.forbidden.is_empty());
1298 assert!(playbook.machine.performance.is_none());
1299 }
1300
1301 #[test]
1302 fn test_state_optional_fields() {
1303 let yaml = r#"
1304version: "1.0"
1305machine:
1306 id: "test"
1307 initial: "start"
1308 states:
1309 start:
1310 id: "start"
1311 end:
1312 id: "end"
1313 transitions:
1314 - id: "t1"
1315 from: "start"
1316 to: "end"
1317 event: "go"
1318"#;
1319 let playbook = Playbook::from_yaml(yaml).expect("Should parse");
1320 let state = playbook.machine.states.get("start").unwrap();
1321 assert_eq!(state.description, "");
1322 assert!(state.on_entry.is_empty());
1323 assert!(state.on_exit.is_empty());
1324 assert!(state.invariants.is_empty());
1325 assert!(!state.final_state);
1326 }
1327
1328 #[test]
1329 fn test_transition_optional_fields() {
1330 let yaml = r#"
1331version: "1.0"
1332machine:
1333 id: "test"
1334 initial: "start"
1335 states:
1336 start:
1337 id: "start"
1338 end:
1339 id: "end"
1340 transitions:
1341 - id: "t1"
1342 from: "start"
1343 to: "end"
1344 event: "go"
1345"#;
1346 let playbook = Playbook::from_yaml(yaml).expect("Should parse");
1347 let transition = &playbook.machine.transitions[0];
1348 assert!(transition.guard.is_none());
1349 assert!(transition.actions.is_empty());
1350 assert!(transition.assertions.is_empty());
1351 }
1352
1353 #[test]
1354 fn test_transition_with_guard() {
1355 let yaml = r#"
1356version: "1.0"
1357machine:
1358 id: "test"
1359 initial: "start"
1360 states:
1361 start:
1362 id: "start"
1363 end:
1364 id: "end"
1365 transitions:
1366 - id: "t1"
1367 from: "start"
1368 to: "end"
1369 event: "go"
1370 guard: "player.health > 0"
1371"#;
1372 let playbook = Playbook::from_yaml(yaml).expect("Should parse");
1373 let transition = &playbook.machine.transitions[0];
1374 assert_eq!(transition.guard, Some("player.health > 0".to_string()));
1375 }
1376
1377 #[test]
1378 fn test_playbook_steps_default() {
1379 let steps = PlaybookSteps::default();
1380 assert!(steps.setup.is_empty());
1381 assert!(steps.steps.is_empty());
1382 assert!(steps.teardown.is_empty());
1383 }
1384
1385 #[test]
1386 fn test_playbook_assertions_default() {
1387 let assertions = PlaybookAssertions::default();
1388 assert!(assertions.path.is_none());
1389 assert!(assertions.output.is_empty());
1390 assert!(assertions.complexity.is_none());
1391 }
1392
1393 #[test]
1394 fn test_falsification_config_default() {
1395 let config = FalsificationConfig::default();
1396 assert!(config.mutations.is_empty());
1397 }
1398
1399 #[test]
1400 fn test_name_and_description() {
1401 let yaml = r#"
1402version: "1.0"
1403name: "My Test Playbook"
1404description: "A comprehensive test"
1405machine:
1406 id: "test"
1407 initial: "start"
1408 states:
1409 start:
1410 id: "start"
1411 end:
1412 id: "end"
1413 transitions:
1414 - id: "t1"
1415 from: "start"
1416 to: "end"
1417 event: "go"
1418"#;
1419 let playbook = Playbook::from_yaml(yaml).expect("Should parse");
1420 assert_eq!(playbook.name, "My Test Playbook");
1421 assert_eq!(playbook.description, "A comprehensive test");
1422 }
1423
1424 #[test]
1425 fn test_playbook_error_clone() {
1426 let err = PlaybookError::InvalidVersion("2.0".to_string());
1427 let cloned = err.clone();
1428 assert_eq!(err.to_string(), cloned.to_string());
1429 }
1430
1431 #[test]
1432 fn test_playbook_error_debug() {
1433 let err = PlaybookError::EmptyStates;
1434 let debug_str = format!("{:?}", err);
1435 assert!(debug_str.contains("EmptyStates"));
1436 }
1437
1438 #[test]
1439 fn test_struct_clone_derive() {
1440 let state = State {
1442 id: "test".to_string(),
1443 description: "desc".to_string(),
1444 on_entry: vec![],
1445 on_exit: vec![],
1446 invariants: vec![],
1447 final_state: false,
1448 };
1449 let _ = state;
1450
1451 let transition = Transition {
1452 id: "t1".to_string(),
1453 from: "a".to_string(),
1454 to: "b".to_string(),
1455 event: "go".to_string(),
1456 guard: None,
1457 actions: vec![],
1458 assertions: vec![],
1459 };
1460 let _ = transition;
1461
1462 let invariant = Invariant {
1463 description: "test".to_string(),
1464 condition: "true".to_string(),
1465 severity: InvariantSeverity::Error,
1466 };
1467 let _ = invariant;
1468
1469 let forbidden = ForbiddenTransition {
1470 from: "a".to_string(),
1471 to: "b".to_string(),
1472 reason: "test".to_string(),
1473 };
1474 let _ = forbidden;
1475 }
1476
1477 #[test]
1478 fn test_action_clone() {
1479 let action = Action::Click {
1480 selector: ".btn".to_string(),
1481 };
1482 let _ = action;
1483
1484 let action = Action::Type {
1485 selector: ".input".to_string(),
1486 text: "hello".to_string(),
1487 };
1488 let _ = action;
1489
1490 let action = Action::Wait {
1491 condition: WaitCondition::NetworkIdle,
1492 };
1493 let _ = action;
1494 }
1495
1496 #[test]
1497 fn test_wait_condition_clone() {
1498 let cond = WaitCondition::Visible {
1499 selector: ".elem".to_string(),
1500 };
1501 let _ = cond;
1502
1503 let cond = WaitCondition::Hidden {
1504 selector: ".elem".to_string(),
1505 };
1506 let _ = cond;
1507
1508 let cond = WaitCondition::Duration { ms: 100 };
1509 let _ = cond;
1510
1511 let cond = WaitCondition::Condition {
1512 expression: "true".to_string(),
1513 };
1514 let _ = cond;
1515 }
1516
1517 #[test]
1518 fn test_assertion_clone() {
1519 let assertion = Assertion::ElementExists {
1520 selector: ".elem".to_string(),
1521 };
1522 let _ = assertion;
1523
1524 let assertion = Assertion::TextEquals {
1525 selector: ".elem".to_string(),
1526 expected: "text".to_string(),
1527 };
1528 let _ = assertion;
1529
1530 let assertion = Assertion::TextContains {
1531 selector: ".elem".to_string(),
1532 substring: "text".to_string(),
1533 };
1534 let _ = assertion;
1535
1536 let assertion = Assertion::AttributeEquals {
1537 selector: ".elem".to_string(),
1538 attribute: "attr".to_string(),
1539 expected: "val".to_string(),
1540 };
1541 let _ = assertion;
1542
1543 let assertion = Assertion::UrlMatches {
1544 pattern: ".*".to_string(),
1545 };
1546 let _ = assertion;
1547
1548 let assertion = Assertion::Script {
1549 expression: "true".to_string(),
1550 };
1551 let _ = assertion;
1552 }
1553
1554 #[test]
1555 fn test_complexity_class_copy() {
1556 let class = ComplexityClass::Constant;
1557 let copied = class;
1558 assert_eq!(class, copied);
1559 }
1560
1561 #[test]
1562 fn test_invariant_severity_copy() {
1563 let severity = InvariantSeverity::Warning;
1564 let copied = severity;
1565 assert_eq!(severity, copied);
1566 }
1567
1568 #[test]
1569 fn test_action_spec_clone() {
1570 let spec = ActionSpec {
1571 wasm: Some("func".to_string()),
1572 args: vec!["arg1".to_string()],
1573 };
1574 let _ = spec;
1575 }
1576
1577 #[test]
1578 fn test_playbook_action_clone() {
1579 let action = PlaybookAction {
1580 action: ActionSpec {
1581 wasm: None,
1582 args: vec![],
1583 },
1584 description: "test".to_string(),
1585 ignore_errors: true,
1586 };
1587 let _ = action;
1588 }
1589
1590 #[test]
1591 fn test_playbook_step_clone() {
1592 let step = PlaybookStep {
1593 name: "step1".to_string(),
1594 transitions: vec!["t1".to_string()],
1595 timeout: Some("10s".to_string()),
1596 capture: vec![VariableCapture {
1597 var: "x".to_string(),
1598 from: "y".to_string(),
1599 }],
1600 };
1601 let _ = step;
1602 }
1603
1604 #[test]
1605 fn test_variable_capture_clone() {
1606 let capture = VariableCapture {
1607 var: "x".to_string(),
1608 from: "y".to_string(),
1609 };
1610 let _ = capture;
1611 }
1612
1613 #[test]
1614 fn test_path_assertion_clone() {
1615 let assertion = PathAssertion {
1616 expected: vec!["a".to_string(), "b".to_string()],
1617 };
1618 let _ = assertion;
1619 }
1620
1621 #[test]
1622 fn test_output_assertion_clone() {
1623 let assertion = OutputAssertion {
1624 var: "x".to_string(),
1625 not_empty: Some(true),
1626 matches: Some(".*".to_string()),
1627 less_than: Some(100),
1628 greater_than: Some(0),
1629 equals: Some("value".to_string()),
1630 };
1631 let _ = assertion;
1632 }
1633
1634 #[test]
1635 fn test_complexity_assertion_clone() {
1636 let assertion = ComplexityAssertion {
1637 operation: "op".to_string(),
1638 measure: "time".to_string(),
1639 input_var: "n".to_string(),
1640 expected: ComplexityClass::Linear,
1641 tolerance: 0.1,
1642 sample_sizes: vec![10, 100],
1643 };
1644 let _ = assertion;
1645 }
1646
1647 #[test]
1648 fn test_mutation_def_clone() {
1649 let mutation = MutationDef {
1650 id: "m1".to_string(),
1651 description: "desc".to_string(),
1652 mutate: "x = 1".to_string(),
1653 expected_failure: "fail".to_string(),
1654 };
1655 let _ = mutation;
1656 }
1657
1658 #[test]
1659 fn test_struct_debug_derive() {
1660 let state = State {
1662 id: "test".to_string(),
1663 description: "desc".to_string(),
1664 on_entry: vec![],
1665 on_exit: vec![],
1666 invariants: vec![],
1667 final_state: false,
1668 };
1669 let _ = format!("{:?}", state);
1670
1671 let action = Action::Click {
1672 selector: ".btn".to_string(),
1673 };
1674 let _ = format!("{:?}", action);
1675
1676 let cond = WaitCondition::NetworkIdle;
1677 let _ = format!("{:?}", cond);
1678
1679 let assertion = Assertion::Script {
1680 expression: "true".to_string(),
1681 };
1682 let _ = format!("{:?}", assertion);
1683
1684 let severity = InvariantSeverity::Critical;
1685 let _ = format!("{:?}", severity);
1686
1687 let class = ComplexityClass::Quadratic;
1688 let _ = format!("{:?}", class);
1689 }
1690
1691 #[test]
1692 fn test_playbook_clone() {
1693 let playbook = Playbook::from_yaml(VALID_PLAYBOOK).expect("Should parse");
1694 let _ = playbook;
1695 }
1696
1697 #[test]
1698 fn test_state_machine_clone() {
1699 let playbook = Playbook::from_yaml(VALID_PLAYBOOK).expect("Should parse");
1700 let _ = playbook.machine;
1701 }
1702}