1use serde::{Deserialize, Serialize};
70use std::collections::BTreeMap;
71
72pub const SCENARIO_SCHEMA_VERSION: u32 = 1;
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Scenario {
82 #[serde(default = "default_schema_version")]
84 pub schema_version: u32,
85
86 pub id: String,
88
89 #[serde(default)]
91 pub description: String,
92
93 #[serde(default)]
95 pub lab: LabSection,
96
97 #[serde(default)]
99 pub chaos: ChaosSection,
100
101 #[serde(default)]
103 pub network: NetworkSection,
104
105 #[serde(default)]
107 pub faults: Vec<FaultEvent>,
108
109 #[serde(default)]
111 pub participants: Vec<Participant>,
112
113 #[serde(default = "default_oracles")]
115 pub oracles: Vec<String>,
116
117 #[serde(default)]
119 pub cancellation: Option<CancellationSection>,
120
121 #[serde(default)]
123 pub include: Vec<IncludeRef>,
124
125 #[serde(default)]
127 pub metadata: BTreeMap<String, String>,
128}
129
130fn default_schema_version() -> u32 {
131 SCENARIO_SCHEMA_VERSION
132}
133
134fn default_oracles() -> Vec<String> {
135 vec!["all".to_string()]
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct LabSection {
145 #[serde(default = "default_seed")]
147 pub seed: u64,
148
149 pub entropy_seed: Option<u64>,
151
152 #[serde(default = "default_worker_count")]
154 pub worker_count: usize,
155
156 #[serde(default = "default_trace_capacity")]
158 pub trace_capacity: usize,
159
160 #[serde(default = "default_max_steps")]
162 pub max_steps: Option<u64>,
163
164 #[serde(default = "default_true")]
166 pub panic_on_obligation_leak: bool,
167
168 #[serde(default = "default_true")]
170 pub panic_on_futurelock: bool,
171
172 #[serde(default = "default_futurelock_max_idle")]
174 pub futurelock_max_idle_steps: u64,
175
176 #[serde(default)]
178 pub replay_recording: bool,
179}
180
181impl Default for LabSection {
182 fn default() -> Self {
183 Self {
184 seed: 42,
185 entropy_seed: None,
186 worker_count: 1,
187 trace_capacity: 4096,
188 max_steps: Some(100_000),
189 panic_on_obligation_leak: true,
190 panic_on_futurelock: true,
191 futurelock_max_idle_steps: 10_000,
192 replay_recording: false,
193 }
194 }
195}
196
197fn default_seed() -> u64 {
198 42
199}
200fn default_worker_count() -> usize {
201 1
202}
203fn default_trace_capacity() -> usize {
204 4096
205}
206#[allow(clippy::unnecessary_wraps)]
207fn default_max_steps() -> Option<u64> {
208 Some(100_000)
209}
210fn default_true() -> bool {
211 true
212}
213fn default_futurelock_max_idle() -> u64 {
214 10_000
215}
216
217#[derive(Debug, Clone, Default, Serialize, Deserialize)]
223#[serde(tag = "preset", rename_all = "snake_case")]
224pub enum ChaosSection {
225 #[default]
227 Off,
228 Light,
230 Heavy,
232 Custom {
234 #[serde(default)]
236 cancel_probability: f64,
237 #[serde(default)]
239 delay_probability: f64,
240 #[serde(default)]
242 delay_min_ms: u64,
243 #[serde(default = "default_delay_max_ms")]
245 delay_max_ms: u64,
246 #[serde(default)]
248 io_error_probability: f64,
249 #[serde(default)]
251 wakeup_storm_probability: f64,
252 #[serde(default)]
254 budget_exhaustion_probability: f64,
255 },
256}
257
258fn default_delay_max_ms() -> u64 {
259 10
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct NetworkSection {
269 #[serde(default = "default_network_preset")]
271 pub preset: NetworkPreset,
272
273 #[serde(default)]
275 pub links: BTreeMap<String, LinkConditions>,
276}
277
278impl Default for NetworkSection {
279 fn default() -> Self {
280 Self {
281 preset: NetworkPreset::Ideal,
282 links: BTreeMap::new(),
283 }
284 }
285}
286
287fn default_network_preset() -> NetworkPreset {
288 NetworkPreset::Ideal
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum NetworkPreset {
295 Ideal,
297 Local,
299 Lan,
301 Wan,
303 Satellite,
305 Congested,
307 Lossy,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct LinkConditions {
314 #[serde(default)]
316 pub latency: Option<LatencySpec>,
317 #[serde(default)]
319 pub packet_loss: Option<f64>,
320 #[serde(default)]
322 pub packet_corrupt: Option<f64>,
323 #[serde(default)]
325 pub packet_duplicate: Option<f64>,
326 #[serde(default)]
328 pub packet_reorder: Option<f64>,
329 #[serde(default)]
331 pub bandwidth: Option<u64>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336#[serde(tag = "model", rename_all = "snake_case")]
337pub enum LatencySpec {
338 Fixed {
340 ms: u64,
342 },
343 Uniform {
345 min_ms: u64,
347 max_ms: u64,
349 },
350 Normal {
352 mean_ms: u64,
354 stddev_ms: u64,
356 },
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct FaultEvent {
366 pub at_ms: u64,
368
369 pub action: FaultAction,
371
372 #[serde(default)]
374 pub args: BTreeMap<String, serde_json::Value>,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
379#[serde(rename_all = "snake_case")]
380pub enum FaultAction {
381 Partition,
383 Heal,
385 HostCrash,
387 HostRestart,
389 ClockSkew,
391 ClockReset,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct Participant {
402 pub name: String,
404
405 #[serde(default)]
407 pub role: String,
408
409 #[serde(default)]
411 pub properties: BTreeMap<String, serde_json::Value>,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct CancellationSection {
421 pub strategy: CancellationStrategy,
423
424 #[serde(default)]
426 pub count: Option<usize>,
427
428 #[serde(default)]
430 pub probability: Option<f64>,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case")]
436pub enum CancellationStrategy {
437 Never,
439 AllPoints,
441 RandomSample,
443 FirstN,
445 LastN,
447 EveryNth,
449 Probabilistic,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct IncludeRef {
460 pub path: String,
462}
463
464#[derive(Debug, Clone)]
470pub struct ValidationError {
471 pub field: String,
473 pub message: String,
475}
476
477impl std::fmt::Display for ValidationError {
478 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479 write!(f, "{}: {}", self.field, self.message)
480 }
481}
482
483impl std::error::Error for ValidationError {}
484
485impl Scenario {
486 #[must_use]
490 pub fn validate(&self) -> Vec<ValidationError> {
491 let mut errors = Vec::new();
492 self.validate_header(&mut errors);
493 self.validate_chaos(&mut errors);
494 self.validate_network(&mut errors);
495 self.validate_faults(&mut errors);
496 self.validate_participants(&mut errors);
497 self.validate_cancellation(&mut errors);
498 errors
499 }
500
501 fn validate_header(&self, errors: &mut Vec<ValidationError>) {
502 if self.schema_version != SCENARIO_SCHEMA_VERSION {
503 errors.push(ValidationError {
504 field: "schema_version".into(),
505 message: format!(
506 "unsupported version {}, expected {SCENARIO_SCHEMA_VERSION}",
507 self.schema_version
508 ),
509 });
510 }
511 if self.id.is_empty() {
512 errors.push(ValidationError {
513 field: "id".into(),
514 message: "scenario id must not be empty".into(),
515 });
516 }
517 if self.lab.worker_count == 0 {
518 errors.push(ValidationError {
519 field: "lab.worker_count".into(),
520 message: "worker_count must be >= 1".into(),
521 });
522 }
523 if self.lab.trace_capacity == 0 {
524 errors.push(ValidationError {
525 field: "lab.trace_capacity".into(),
526 message: "trace_capacity must be > 0".into(),
527 });
528 }
529 }
530
531 fn validate_chaos(&self, errors: &mut Vec<ValidationError>) {
532 if let ChaosSection::Custom {
533 cancel_probability,
534 delay_probability,
535 delay_min_ms,
536 delay_max_ms,
537 io_error_probability,
538 wakeup_storm_probability,
539 budget_exhaustion_probability,
540 } = &self.chaos
541 {
542 for (name, val) in [
543 ("chaos.cancel_probability", cancel_probability),
544 ("chaos.delay_probability", delay_probability),
545 ("chaos.io_error_probability", io_error_probability),
546 ("chaos.wakeup_storm_probability", wakeup_storm_probability),
547 (
548 "chaos.budget_exhaustion_probability",
549 budget_exhaustion_probability,
550 ),
551 ] {
552 if !(0.0..=1.0).contains(val) {
553 errors.push(ValidationError {
554 field: name.into(),
555 message: format!("probability must be in [0.0, 1.0], got {val}"),
556 });
557 }
558 }
559 if *delay_min_ms > *delay_max_ms {
560 errors.push(ValidationError {
561 field: "chaos.delay_min_ms".into(),
562 message: format!(
563 "delay_min_ms ({delay_min_ms}) must be <= delay_max_ms ({delay_max_ms})"
564 ),
565 });
566 }
567 }
568 }
569
570 fn validate_network(&self, errors: &mut Vec<ValidationError>) {
571 for (key, link) in &self.network.links {
572 let key_valid = key
573 .split_once("->")
574 .is_some_and(|(from, to)| !from.is_empty() && !to.is_empty() && !to.contains("->"));
575 if !key_valid {
576 errors.push(ValidationError {
577 field: format!("network.links.{key}"),
578 message: "link key must be in format \"from->to\"".into(),
579 });
580 }
581
582 for (name, value) in [
583 ("packet_loss", link.packet_loss),
584 ("packet_corrupt", link.packet_corrupt),
585 ("packet_duplicate", link.packet_duplicate),
586 ("packet_reorder", link.packet_reorder),
587 ] {
588 if let Some(probability) = value {
589 if !probability.is_finite() || !(0.0..=1.0).contains(&probability) {
590 errors.push(ValidationError {
591 field: format!("network.links.{key}.{name}"),
592 message: format!(
593 "probability must be finite and in [0.0, 1.0], got {probability}"
594 ),
595 });
596 }
597 }
598 }
599
600 if let Some(LatencySpec::Uniform { min_ms, max_ms }) = &link.latency {
601 if min_ms > max_ms {
602 errors.push(ValidationError {
603 field: format!("network.links.{key}.latency"),
604 message: format!(
605 "uniform latency min_ms ({min_ms}) must be <= max_ms ({max_ms})"
606 ),
607 });
608 }
609 }
610 }
611 }
612
613 fn validate_faults(&self, errors: &mut Vec<ValidationError>) {
614 for window in self.faults.windows(2) {
615 if window[1].at_ms < window[0].at_ms {
616 errors.push(ValidationError {
617 field: "faults".into(),
618 message: format!(
619 "fault events must be ordered by at_ms: {} comes before {}",
620 window[0].at_ms, window[1].at_ms
621 ),
622 });
623 }
624 }
625 }
626
627 fn validate_participants(&self, errors: &mut Vec<ValidationError>) {
628 let mut seen_names = std::collections::HashSet::new();
629 for p in &self.participants {
630 if !seen_names.insert(&p.name) {
631 errors.push(ValidationError {
632 field: format!("participants.{}", p.name),
633 message: "duplicate participant name".into(),
634 });
635 }
636 }
637 }
638
639 fn validate_cancellation(&self, errors: &mut Vec<ValidationError>) {
640 let Some(ref cancel) = self.cancellation else {
641 return;
642 };
643 match cancel.strategy {
644 CancellationStrategy::RandomSample
645 | CancellationStrategy::FirstN
646 | CancellationStrategy::LastN
647 | CancellationStrategy::EveryNth => {
648 if cancel.count.is_none() {
649 errors.push(ValidationError {
650 field: "cancellation.count".into(),
651 message: format!(
652 "strategy {:?} requires a count parameter",
653 cancel.strategy
654 ),
655 });
656 } else if cancel.count == Some(0) {
657 errors.push(ValidationError {
658 field: "cancellation.count".into(),
659 message: "count must be >= 1".into(),
660 });
661 }
662 }
663 CancellationStrategy::Probabilistic => {
664 if let Some(p) = cancel.probability {
665 if !p.is_finite() || !(0.0..=1.0).contains(&p) {
666 errors.push(ValidationError {
667 field: "cancellation.probability".into(),
668 message: format!("probability must be in [0.0, 1.0], got {p}"),
669 });
670 }
671 } else {
672 errors.push(ValidationError {
673 field: "cancellation.probability".into(),
674 message: "strategy probabilistic requires a probability parameter".into(),
675 });
676 }
677 }
678 CancellationStrategy::Never | CancellationStrategy::AllPoints => {}
679 }
680 }
681
682 #[must_use]
684 pub fn to_lab_config(&self) -> super::config::LabConfig {
685 let mut config = super::config::LabConfig::new(self.lab.seed)
686 .worker_count(self.lab.worker_count)
687 .trace_capacity(self.lab.trace_capacity)
688 .panic_on_leak(self.lab.panic_on_obligation_leak)
689 .panic_on_futurelock(self.lab.panic_on_futurelock)
690 .futurelock_max_idle_steps(self.lab.futurelock_max_idle_steps);
691
692 if let Some(entropy) = self.lab.entropy_seed {
693 config = config.entropy_seed(entropy);
694 }
695
696 if let Some(max) = self.lab.max_steps {
697 config = config.max_steps(max);
698 } else {
699 config = config.no_step_limit();
700 }
701
702 config = match &self.chaos {
704 ChaosSection::Off => config,
705 ChaosSection::Light => config.with_light_chaos(),
706 ChaosSection::Heavy => config.with_heavy_chaos(),
707 ChaosSection::Custom {
708 cancel_probability,
709 delay_probability,
710 delay_min_ms,
711 delay_max_ms,
712 io_error_probability,
713 wakeup_storm_probability,
714 budget_exhaustion_probability,
715 } => {
716 use std::time::Duration;
717 let chaos_seed = self.lab.entropy_seed.unwrap_or(self.lab.seed);
718 let chaos = super::chaos::ChaosConfig::new(chaos_seed)
719 .with_cancel_probability(*cancel_probability)
720 .with_delay_probability(*delay_probability)
721 .with_delay_range(
722 Duration::from_millis(*delay_min_ms)..Duration::from_millis(*delay_max_ms),
723 )
724 .with_io_error_probability(*io_error_probability)
725 .with_wakeup_storm_probability(*wakeup_storm_probability)
726 .with_budget_exhaust_probability(*budget_exhaustion_probability);
727 config.with_chaos(chaos)
728 }
729 };
730
731 if self.lab.replay_recording {
732 config = config.with_default_replay_recording();
733 }
734
735 config
736 }
737
738 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
744 serde_json::from_str(json)
745 }
746
747 pub fn to_json(&self) -> Result<String, serde_json::Error> {
753 serde_json::to_string_pretty(self)
754 }
755}
756
757#[cfg(test)]
762mod tests {
763 use super::*;
764
765 fn minimal_json() -> &'static str {
766 r#"{
767 "id": "test-scenario",
768 "description": "minimal test"
769 }"#
770 }
771
772 #[test]
773 fn parse_minimal_scenario() {
774 let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
775 assert_eq!(s.id, "test-scenario");
776 assert_eq!(s.schema_version, 1);
777 assert_eq!(s.lab.seed, 42);
778 assert_eq!(s.lab.worker_count, 1);
779 assert!(s.faults.is_empty());
780 assert!(s.participants.is_empty());
781 assert_eq!(s.oracles, vec!["all"]);
782 }
783
784 #[test]
785 fn validate_minimal_scenario() {
786 let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
787 let errors = s.validate();
788 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
789 }
790
791 #[test]
792 fn validate_empty_id_rejected() {
793 let json = r#"{"id": "", "description": "bad"}"#;
794 let s: Scenario = serde_json::from_str(json).unwrap();
795 let errors = s.validate();
796 assert!(errors.iter().any(|e| e.field == "id"));
797 }
798
799 #[test]
800 fn validate_bad_schema_version() {
801 let json = r#"{"schema_version": 99, "id": "x"}"#;
802 let s: Scenario = serde_json::from_str(json).unwrap();
803 let errors = s.validate();
804 assert!(errors.iter().any(|e| e.field == "schema_version"));
805 }
806
807 #[test]
808 fn parse_chaos_preset_light() {
809 let json = r#"{"id": "x", "chaos": {"preset": "light"}}"#;
810 let s: Scenario = serde_json::from_str(json).unwrap();
811 assert!(matches!(s.chaos, ChaosSection::Light));
812 }
813
814 #[test]
815 fn parse_chaos_custom() {
816 let json = r#"{
817 "id": "x",
818 "chaos": {
819 "preset": "custom",
820 "cancel_probability": 0.05,
821 "delay_probability": 0.3,
822 "io_error_probability": 0.1
823 }
824 }"#;
825 let s: Scenario = serde_json::from_str(json).unwrap();
826 match s.chaos {
827 ChaosSection::Custom {
828 cancel_probability,
829 delay_probability,
830 io_error_probability,
831 ..
832 } => {
833 assert!((cancel_probability - 0.05).abs() < f64::EPSILON);
834 assert!((delay_probability - 0.3).abs() < f64::EPSILON);
835 assert!((io_error_probability - 0.1).abs() < f64::EPSILON);
836 }
837 other => panic!("expected Custom, got {other:?}"),
838 }
839 }
840
841 #[test]
842 fn validate_chaos_bad_probability() {
843 let json = r#"{
844 "id": "x",
845 "chaos": {"preset": "custom", "cancel_probability": 1.5}
846 }"#;
847 let s: Scenario = serde_json::from_str(json).unwrap();
848 let errors = s.validate();
849 assert!(errors.iter().any(|e| e.field == "chaos.cancel_probability"));
850 }
851
852 #[test]
853 fn parse_network_preset_wan() {
854 let json = r#"{"id": "x", "network": {"preset": "wan"}}"#;
855 let s: Scenario = serde_json::from_str(json).unwrap();
856 assert_eq!(s.network.preset, NetworkPreset::Wan);
857 }
858
859 #[test]
860 fn parse_network_link_override() {
861 let json = r#"{
862 "id": "x",
863 "network": {
864 "preset": "lan",
865 "links": {
866 "alice->bob": { "packet_loss": 0.5 }
867 }
868 }
869 }"#;
870 let s: Scenario = serde_json::from_str(json).unwrap();
871 let link = s.network.links.get("alice->bob").unwrap();
872 assert!((link.packet_loss.unwrap() - 0.5).abs() < f64::EPSILON);
873 }
874
875 #[test]
876 fn validate_bad_link_key() {
877 let json = r#"{
878 "id": "x",
879 "network": {"links": {"alice_bob": {}}}
880 }"#;
881 let s: Scenario = serde_json::from_str(json).unwrap();
882 let errors = s.validate();
883 assert!(errors.iter().any(|e| e.field.contains("network.links")));
884 }
885
886 #[test]
887 fn validate_link_probability_out_of_range() {
888 let json = r#"{
889 "id": "x",
890 "network": {
891 "links": {
892 "alice->bob": { "packet_loss": 1.5 }
893 }
894 }
895 }"#;
896 let s: Scenario = serde_json::from_str(json).unwrap();
897 let errors = s.validate();
898 assert!(
899 errors
900 .iter()
901 .any(|e| e.field == "network.links.alice->bob.packet_loss")
902 );
903 }
904
905 #[test]
906 fn validate_uniform_latency_min_max_order() {
907 let json = r#"{
908 "id": "x",
909 "network": {
910 "links": {
911 "alice->bob": {
912 "latency": { "model": "uniform", "min_ms": 20, "max_ms": 10 }
913 }
914 }
915 }
916 }"#;
917 let s: Scenario = serde_json::from_str(json).unwrap();
918 let errors = s.validate();
919 assert!(
920 errors
921 .iter()
922 .any(|e| e.field == "network.links.alice->bob.latency")
923 );
924 }
925
926 #[test]
927 fn parse_fault_events() {
928 let json = r#"{
929 "id": "x",
930 "faults": [
931 {"at_ms": 100, "action": "partition", "args": {"from": "a", "to": "b"}},
932 {"at_ms": 500, "action": "heal", "args": {"from": "a", "to": "b"}}
933 ]
934 }"#;
935 let s: Scenario = serde_json::from_str(json).unwrap();
936 assert_eq!(s.faults.len(), 2);
937 assert_eq!(s.faults[0].at_ms, 100);
938 assert!(matches!(s.faults[0].action, FaultAction::Partition));
939 assert_eq!(s.faults[1].at_ms, 500);
940 assert!(matches!(s.faults[1].action, FaultAction::Heal));
941 }
942
943 #[test]
944 fn validate_unordered_faults() {
945 let json = r#"{
946 "id": "x",
947 "faults": [
948 {"at_ms": 500, "action": "partition"},
949 {"at_ms": 100, "action": "heal"}
950 ]
951 }"#;
952 let s: Scenario = serde_json::from_str(json).unwrap();
953 let errors = s.validate();
954 assert!(errors.iter().any(|e| e.field == "faults"));
955 }
956
957 #[test]
958 fn parse_participants() {
959 let json = r#"{
960 "id": "x",
961 "participants": [
962 {"name": "alice", "role": "sender"},
963 {"name": "bob", "role": "receiver"}
964 ]
965 }"#;
966 let s: Scenario = serde_json::from_str(json).unwrap();
967 assert_eq!(s.participants.len(), 2);
968 assert_eq!(s.participants[0].name, "alice");
969 assert_eq!(s.participants[1].role, "receiver");
970 }
971
972 #[test]
973 fn validate_duplicate_participant() {
974 let json = r#"{
975 "id": "x",
976 "participants": [
977 {"name": "alice"},
978 {"name": "alice"}
979 ]
980 }"#;
981 let s: Scenario = serde_json::from_str(json).unwrap();
982 let errors = s.validate();
983 assert!(errors.iter().any(|e| e.message.contains("duplicate")));
984 }
985
986 #[test]
987 fn parse_cancellation_strategy() {
988 let json = r#"{
989 "id": "x",
990 "cancellation": {
991 "strategy": "random_sample",
992 "count": 100
993 }
994 }"#;
995 let s: Scenario = serde_json::from_str(json).unwrap();
996 let cancel = s.cancellation.as_ref().unwrap();
997 assert!(matches!(
998 cancel.strategy,
999 CancellationStrategy::RandomSample
1000 ));
1001 assert_eq!(cancel.count, Some(100));
1002 }
1003
1004 #[test]
1005 fn validate_missing_count() {
1006 let json = r#"{
1007 "id": "x",
1008 "cancellation": {"strategy": "random_sample"}
1009 }"#;
1010 let s: Scenario = serde_json::from_str(json).unwrap();
1011 let errors = s.validate();
1012 assert!(errors.iter().any(|e| e.field == "cancellation.count"));
1013 }
1014
1015 #[test]
1016 fn to_lab_config_defaults() {
1017 let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
1018 let config = s.to_lab_config();
1019 assert_eq!(config.seed, 42);
1020 assert_eq!(config.worker_count, 1);
1021 assert_eq!(config.trace_capacity, 4096);
1022 assert!(config.panic_on_obligation_leak);
1023 }
1024
1025 #[test]
1026 fn to_lab_config_chaos_light() {
1027 let json = r#"{"id": "x", "chaos": {"preset": "light"}}"#;
1028 let s: Scenario = serde_json::from_str(json).unwrap();
1029 let config = s.to_lab_config();
1030 assert!(config.has_chaos());
1031 }
1032
1033 #[test]
1034 fn to_lab_config_custom_seed() {
1035 let json = r#"{"id": "x", "lab": {"seed": 12345, "worker_count": 4}}"#;
1036 let s: Scenario = serde_json::from_str(json).unwrap();
1037 let config = s.to_lab_config();
1038 assert_eq!(config.seed, 12345);
1039 assert_eq!(config.worker_count, 4);
1040 }
1041
1042 #[test]
1043 fn json_roundtrip() {
1044 let json = r#"{
1045 "id": "roundtrip-test",
1046 "description": "full roundtrip",
1047 "lab": {"seed": 99, "worker_count": 2},
1048 "chaos": {"preset": "heavy"},
1049 "network": {"preset": "wan"},
1050 "participants": [{"name": "alice", "role": "sender"}],
1051 "faults": [{"at_ms": 100, "action": "partition"}]
1052 }"#;
1053 let s1: Scenario = serde_json::from_str(json).unwrap();
1054 let serialized = s1.to_json().unwrap();
1055 let s2: Scenario = Scenario::from_json(&serialized).unwrap();
1056 assert_eq!(s1.id, s2.id);
1057 assert_eq!(s1.lab.seed, s2.lab.seed);
1058 assert_eq!(s1.participants.len(), s2.participants.len());
1059 assert_eq!(s1.faults.len(), s2.faults.len());
1060 }
1061
1062 #[test]
1063 fn parse_metadata() {
1064 let json = r#"{
1065 "id": "x",
1066 "metadata": {"git_sha": "abc123", "author": "bot"}
1067 }"#;
1068 let s: Scenario = serde_json::from_str(json).unwrap();
1069 assert_eq!(s.metadata.get("git_sha").unwrap(), "abc123");
1070 }
1071
1072 #[test]
1073 fn parse_latency_models() {
1074 let json = r#"{
1075 "id": "x",
1076 "network": {
1077 "preset": "ideal",
1078 "links": {
1079 "a->b": {"latency": {"model": "fixed", "ms": 5}},
1080 "b->c": {"latency": {"model": "uniform", "min_ms": 1, "max_ms": 10}},
1081 "c->d": {"latency": {"model": "normal", "mean_ms": 50, "stddev_ms": 10}}
1082 }
1083 }
1084 }"#;
1085 let s: Scenario = serde_json::from_str(json).unwrap();
1086 assert_eq!(s.network.links.len(), 3);
1087 let ab = s.network.links.get("a->b").unwrap();
1088 assert!(matches!(ab.latency, Some(LatencySpec::Fixed { ms: 5 })));
1089 }
1090
1091 #[test]
1092 fn parse_include() {
1093 let json = r#"{
1094 "id": "x",
1095 "include": [{"path": "base.yaml"}]
1096 }"#;
1097 let s: Scenario = serde_json::from_str(json).unwrap();
1098 assert_eq!(s.include.len(), 1);
1099 assert_eq!(s.include[0].path, "base.yaml");
1100 }
1101
1102 #[test]
1103 fn network_preset_debug_clone_copy_eq() {
1104 let p = NetworkPreset::Wan;
1105 let dbg = format!("{p:?}");
1106 assert!(dbg.contains("Wan"));
1107
1108 let p2 = p;
1109 assert_eq!(p, p2);
1110
1111 let p3 = p;
1112 assert_eq!(p, p3);
1113
1114 assert_ne!(NetworkPreset::Ideal, NetworkPreset::Lossy);
1115 }
1116
1117 #[test]
1118 fn chaos_section_debug_clone_default() {
1119 let c = ChaosSection::default();
1120 let dbg = format!("{c:?}");
1121 assert!(dbg.contains("Off"));
1122
1123 let c2 = c;
1124 let dbg2 = format!("{c2:?}");
1125 assert_eq!(dbg, dbg2);
1126 }
1127
1128 #[test]
1129 fn fault_action_debug_clone() {
1130 let a = FaultAction::Partition;
1131 let dbg = format!("{a:?}");
1132 assert!(dbg.contains("Partition"));
1133
1134 let a2 = a;
1135 let dbg2 = format!("{a2:?}");
1136 assert_eq!(dbg, dbg2);
1137 }
1138
1139 #[test]
1140 fn validation_error_debug_clone() {
1141 let e = ValidationError {
1142 field: "lab.seed".into(),
1143 message: "must be positive".into(),
1144 };
1145 let dbg = format!("{e:?}");
1146 assert!(dbg.contains("lab.seed"));
1147
1148 let e2 = e;
1149 assert_eq!(e2.field, "lab.seed");
1150 assert_eq!(e2.message, "must be positive");
1151 }
1152}