Skip to main content

asupersync/lab/
scenario.rs

1//! FrankenLab scenario format (bd-1hu19.1).
2//!
3//! A scenario file describes a deterministic test execution:
4//! participants, fault schedule, assertions, seed, and virtual time
5//! configuration.  The canonical on-disk format is YAML, but JSON and
6//! TOML roundtrip cleanly via serde.
7//!
8//! # Format overview
9//!
10//! ```yaml
11//! schema_version: 1
12//! id: smoke-sendpermit-ack
13//! description: Happy-path SendPermit/Ack under light chaos
14//!
15//! lab:
16//!   seed: 42
17//!   worker_count: 2
18//!   trace_capacity: 8192
19//!   max_steps: 100000
20//!   panic_on_obligation_leak: true
21//!   panic_on_futurelock: true
22//!   futurelock_max_idle_steps: 10000
23//!
24//! chaos:
25//!   preset: light           # off | light | heavy | custom
26//!
27//! network:
28//!   preset: lan             # ideal | local | lan | wan | satellite | congested | lossy
29//!
30//! faults:
31//!   - at_ms: 100
32//!     action: partition
33//!     args: { from: alice, to: bob }
34//!   - at_ms: 500
35//!     action: heal
36//!     args: { from: alice, to: bob }
37//!
38//! participants:
39//!   - name: alice
40//!     role: sender
41//!   - name: bob
42//!     role: receiver
43//!
44//! oracles:
45//!   - all
46//!
47//! cancellation:
48//!   strategy: random_sample
49//!   count: 100
50//! ```
51//!
52//! # Composability
53//!
54//! Scenarios may include other scenarios via `include`:
55//!
56//! ```yaml
57//! include:
58//!   - path: base_config.yaml
59//! ```
60//!
61//! Included fields are merged with the current file; the current file
62//! wins on conflict.
63//!
64//! # Determinism
65//!
66//! All randomness is seeded via `lab.seed`.  Given the same YAML + the
67//! same runtime binary, execution is bit-identical.
68
69use serde::{Deserialize, Serialize};
70use std::collections::BTreeMap;
71
72// ---------------------------------------------------------------------------
73// Top-level scenario
74// ---------------------------------------------------------------------------
75
76/// Current scenario schema version.
77pub const SCENARIO_SCHEMA_VERSION: u32 = 1;
78
79/// A complete FrankenLab test scenario.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Scenario {
82    /// Schema version (must be 1).
83    #[serde(default = "default_schema_version")]
84    pub schema_version: u32,
85
86    /// Stable, unique scenario identifier (e.g. `"smoke-sendpermit-ack"`).
87    pub id: String,
88
89    /// Human-readable description.
90    #[serde(default)]
91    pub description: String,
92
93    /// Lab runtime configuration.
94    #[serde(default)]
95    pub lab: LabSection,
96
97    /// Chaos injection configuration.
98    #[serde(default)]
99    pub chaos: ChaosSection,
100
101    /// Network simulation configuration.
102    #[serde(default)]
103    pub network: NetworkSection,
104
105    /// Timed fault injection events.
106    #[serde(default)]
107    pub faults: Vec<FaultEvent>,
108
109    /// Named participants (actors/tasks).
110    #[serde(default)]
111    pub participants: Vec<Participant>,
112
113    /// Oracle names to enable.  `["all"]` enables every oracle.
114    #[serde(default = "default_oracles")]
115    pub oracles: Vec<String>,
116
117    /// Cancellation injection strategy.
118    #[serde(default)]
119    pub cancellation: Option<CancellationSection>,
120
121    /// Optional includes (for composability).
122    #[serde(default)]
123    pub include: Vec<IncludeRef>,
124
125    /// Arbitrary key-value metadata (git sha, author, tags).
126    #[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// ---------------------------------------------------------------------------
139// Lab section
140// ---------------------------------------------------------------------------
141
142/// Lab runtime knobs.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct LabSection {
145    /// PRNG seed for deterministic scheduling.
146    #[serde(default = "default_seed")]
147    pub seed: u64,
148
149    /// Optional separate entropy seed (defaults to `seed`).
150    pub entropy_seed: Option<u64>,
151
152    /// Number of virtual workers.
153    #[serde(default = "default_worker_count")]
154    pub worker_count: usize,
155
156    /// Trace event buffer capacity.
157    #[serde(default = "default_trace_capacity")]
158    pub trace_capacity: usize,
159
160    /// Maximum scheduler steps before forced termination.
161    #[serde(default = "default_max_steps")]
162    pub max_steps: Option<u64>,
163
164    /// Panic on obligation leak.
165    #[serde(default = "default_true")]
166    pub panic_on_obligation_leak: bool,
167
168    /// Panic on futurelock detection.
169    #[serde(default = "default_true")]
170    pub panic_on_futurelock: bool,
171
172    /// Idle steps before futurelock fires.
173    #[serde(default = "default_futurelock_max_idle")]
174    pub futurelock_max_idle_steps: u64,
175
176    /// Enable replay recording.
177    #[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// ---------------------------------------------------------------------------
218// Chaos section
219// ---------------------------------------------------------------------------
220
221/// Chaos injection configuration.
222#[derive(Debug, Clone, Default, Serialize, Deserialize)]
223#[serde(tag = "preset", rename_all = "snake_case")]
224pub enum ChaosSection {
225    /// Chaos disabled.
226    #[default]
227    Off,
228    /// CI-friendly defaults (1% cancel, 5% delay, 2% I/O error).
229    Light,
230    /// Thorough testing (10% cancel, 20% delay, 15% I/O error).
231    Heavy,
232    /// Fully specified probabilities.
233    Custom {
234        /// Cancellation injection probability (0.0-1.0).
235        #[serde(default)]
236        cancel_probability: f64,
237        /// Delay injection probability (0.0-1.0).
238        #[serde(default)]
239        delay_probability: f64,
240        /// Minimum injected delay (milliseconds).
241        #[serde(default)]
242        delay_min_ms: u64,
243        /// Maximum injected delay (milliseconds).
244        #[serde(default = "default_delay_max_ms")]
245        delay_max_ms: u64,
246        /// I/O error injection probability (0.0-1.0).
247        #[serde(default)]
248        io_error_probability: f64,
249        /// Wakeup storm probability (0.0-1.0).
250        #[serde(default)]
251        wakeup_storm_probability: f64,
252        /// Budget exhaustion probability (0.0-1.0).
253        #[serde(default)]
254        budget_exhaustion_probability: f64,
255    },
256}
257
258fn default_delay_max_ms() -> u64 {
259    10
260}
261
262// ---------------------------------------------------------------------------
263// Network section
264// ---------------------------------------------------------------------------
265
266/// Network simulation configuration.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct NetworkSection {
269    /// Preset network conditions.
270    #[serde(default = "default_network_preset")]
271    pub preset: NetworkPreset,
272
273    /// Per-link overrides (key = "alice->bob").
274    #[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/// Named network condition presets.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum NetworkPreset {
295    /// No latency, loss, or corruption.
296    Ideal,
297    /// ~1ms latency.
298    Local,
299    /// 1-5ms latency, 0.01% loss.
300    Lan,
301    /// 20-100ms latency, 0.1% loss.
302    Wan,
303    /// ~600ms latency, 1% loss.
304    Satellite,
305    /// ~100ms latency with congestion effects.
306    Congested,
307    /// 10% packet loss.
308    Lossy,
309}
310
311/// Per-link network condition overrides.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct LinkConditions {
314    /// Latency model.
315    #[serde(default)]
316    pub latency: Option<LatencySpec>,
317    /// Packet loss probability (0.0-1.0).
318    #[serde(default)]
319    pub packet_loss: Option<f64>,
320    /// Packet corruption probability (0.0-1.0).
321    #[serde(default)]
322    pub packet_corrupt: Option<f64>,
323    /// Packet duplication probability (0.0-1.0).
324    #[serde(default)]
325    pub packet_duplicate: Option<f64>,
326    /// Packet reordering probability (0.0-1.0).
327    #[serde(default)]
328    pub packet_reorder: Option<f64>,
329    /// Bandwidth limit (bytes/second).
330    #[serde(default)]
331    pub bandwidth: Option<u64>,
332}
333
334/// Latency model specification.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336#[serde(tag = "model", rename_all = "snake_case")]
337pub enum LatencySpec {
338    /// Fixed latency.
339    Fixed {
340        /// Latency in milliseconds.
341        ms: u64,
342    },
343    /// Uniform distribution \[min_ms, max_ms\].
344    Uniform {
345        /// Minimum latency in milliseconds.
346        min_ms: u64,
347        /// Maximum latency in milliseconds.
348        max_ms: u64,
349    },
350    /// Normal distribution (mean +/- stddev), clamped to \[0, inf).
351    Normal {
352        /// Mean latency in milliseconds.
353        mean_ms: u64,
354        /// Standard deviation in milliseconds.
355        stddev_ms: u64,
356    },
357}
358
359// ---------------------------------------------------------------------------
360// Fault events
361// ---------------------------------------------------------------------------
362
363/// A timed fault injection event.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct FaultEvent {
366    /// Virtual time (milliseconds) at which the fault fires.
367    pub at_ms: u64,
368
369    /// The fault action.
370    pub action: FaultAction,
371
372    /// Action arguments.
373    #[serde(default)]
374    pub args: BTreeMap<String, serde_json::Value>,
375}
376
377/// Fault action types.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379#[serde(rename_all = "snake_case")]
380pub enum FaultAction {
381    /// Network partition between two participants.
382    Partition,
383    /// Heal a previously applied partition.
384    Heal,
385    /// Crash a host (stop processing).
386    HostCrash,
387    /// Restart a previously crashed host.
388    HostRestart,
389    /// Inject clock skew on a participant.
390    ClockSkew,
391    /// Reset clock skew to zero on a participant.
392    ClockReset,
393}
394
395// ---------------------------------------------------------------------------
396// Participants
397// ---------------------------------------------------------------------------
398
399/// A named participant in the scenario.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct Participant {
402    /// Unique name within the scenario.
403    pub name: String,
404
405    /// Role hint (free-form: "sender", "receiver", "coordinator", ...).
406    #[serde(default)]
407    pub role: String,
408
409    /// Arbitrary properties for the participant.
410    #[serde(default)]
411    pub properties: BTreeMap<String, serde_json::Value>,
412}
413
414// ---------------------------------------------------------------------------
415// Cancellation injection
416// ---------------------------------------------------------------------------
417
418/// Cancellation injection configuration.
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct CancellationSection {
421    /// The injection strategy.
422    pub strategy: CancellationStrategy,
423
424    /// Parameter for strategies that take a count.
425    #[serde(default)]
426    pub count: Option<usize>,
427
428    /// Probability parameter (for `probabilistic` strategy).
429    #[serde(default)]
430    pub probability: Option<f64>,
431}
432
433/// Cancellation injection strategies.
434#[derive(Debug, Clone, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case")]
436pub enum CancellationStrategy {
437    /// No cancellation injection (recording only).
438    Never,
439    /// Test all await points (N+1 runs).
440    AllPoints,
441    /// Random sample of await points.
442    RandomSample,
443    /// First N await points.
444    FirstN,
445    /// Last N await points.
446    LastN,
447    /// Every Nth await point.
448    EveryNth,
449    /// Probabilistic per-point injection.
450    Probabilistic,
451}
452
453// ---------------------------------------------------------------------------
454// Include
455// ---------------------------------------------------------------------------
456
457/// Reference to an included scenario file.
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct IncludeRef {
460    /// Relative path to the included YAML.
461    pub path: String,
462}
463
464// ---------------------------------------------------------------------------
465// Validation
466// ---------------------------------------------------------------------------
467
468/// Validation error for a scenario file.
469#[derive(Debug, Clone)]
470pub struct ValidationError {
471    /// Path within the scenario (e.g. "lab.seed").
472    pub field: String,
473    /// What is wrong.
474    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    /// Validate the scenario for structural correctness.
487    ///
488    /// Returns an empty `Vec` if valid.
489    #[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            io_error_probability,
536            wakeup_storm_probability,
537            budget_exhaustion_probability,
538            ..
539        } = &self.chaos
540        {
541            for (name, val) in [
542                ("chaos.cancel_probability", cancel_probability),
543                ("chaos.delay_probability", delay_probability),
544                ("chaos.io_error_probability", io_error_probability),
545                ("chaos.wakeup_storm_probability", wakeup_storm_probability),
546                (
547                    "chaos.budget_exhaustion_probability",
548                    budget_exhaustion_probability,
549                ),
550            ] {
551                if !(0.0..=1.0).contains(val) {
552                    errors.push(ValidationError {
553                        field: name.into(),
554                        message: format!("probability must be in [0.0, 1.0], got {val}"),
555                    });
556                }
557            }
558        }
559    }
560
561    fn validate_network(&self, errors: &mut Vec<ValidationError>) {
562        for key in self.network.links.keys() {
563            if !key.contains("->") {
564                errors.push(ValidationError {
565                    field: format!("network.links.{key}"),
566                    message: "link key must be in format \"from->to\"".into(),
567                });
568            }
569        }
570    }
571
572    fn validate_faults(&self, errors: &mut Vec<ValidationError>) {
573        for window in self.faults.windows(2) {
574            if window[1].at_ms < window[0].at_ms {
575                errors.push(ValidationError {
576                    field: "faults".into(),
577                    message: format!(
578                        "fault events must be ordered by at_ms: {} comes before {}",
579                        window[0].at_ms, window[1].at_ms
580                    ),
581                });
582                break;
583            }
584        }
585    }
586
587    fn validate_participants(&self, errors: &mut Vec<ValidationError>) {
588        let mut seen_names = std::collections::HashSet::new();
589        for p in &self.participants {
590            if !seen_names.insert(&p.name) {
591                errors.push(ValidationError {
592                    field: format!("participants.{}", p.name),
593                    message: "duplicate participant name".into(),
594                });
595            }
596        }
597    }
598
599    fn validate_cancellation(&self, errors: &mut Vec<ValidationError>) {
600        let Some(ref cancel) = self.cancellation else {
601            return;
602        };
603        match cancel.strategy {
604            CancellationStrategy::RandomSample
605            | CancellationStrategy::FirstN
606            | CancellationStrategy::LastN
607            | CancellationStrategy::EveryNth => {
608                if cancel.count.is_none() {
609                    errors.push(ValidationError {
610                        field: "cancellation.count".into(),
611                        message: format!(
612                            "strategy {:?} requires a count parameter",
613                            cancel.strategy
614                        ),
615                    });
616                }
617            }
618            CancellationStrategy::Probabilistic => {
619                if cancel.probability.is_none() {
620                    errors.push(ValidationError {
621                        field: "cancellation.probability".into(),
622                        message: "strategy probabilistic requires a probability parameter".into(),
623                    });
624                }
625            }
626            CancellationStrategy::Never | CancellationStrategy::AllPoints => {}
627        }
628    }
629
630    /// Convert this scenario to a [`super::config::LabConfig`].
631    #[must_use]
632    pub fn to_lab_config(&self) -> super::config::LabConfig {
633        let mut config = super::config::LabConfig::new(self.lab.seed)
634            .worker_count(self.lab.worker_count)
635            .trace_capacity(self.lab.trace_capacity)
636            .panic_on_leak(self.lab.panic_on_obligation_leak)
637            .panic_on_futurelock(self.lab.panic_on_futurelock)
638            .futurelock_max_idle_steps(self.lab.futurelock_max_idle_steps);
639
640        if let Some(entropy) = self.lab.entropy_seed {
641            config = config.entropy_seed(entropy);
642        }
643
644        if let Some(max) = self.lab.max_steps {
645            config = config.max_steps(max);
646        } else {
647            config = config.no_step_limit();
648        }
649
650        // Apply chaos preset
651        config = match &self.chaos {
652            ChaosSection::Off => config,
653            ChaosSection::Light => config.with_light_chaos(),
654            ChaosSection::Heavy => config.with_heavy_chaos(),
655            ChaosSection::Custom {
656                cancel_probability,
657                delay_probability,
658                delay_min_ms,
659                delay_max_ms,
660                io_error_probability,
661                wakeup_storm_probability,
662                budget_exhaustion_probability,
663            } => {
664                use std::time::Duration;
665                let chaos = super::chaos::ChaosConfig::new(self.lab.seed)
666                    .with_cancel_probability(*cancel_probability)
667                    .with_delay_probability(*delay_probability)
668                    .with_delay_range(
669                        Duration::from_millis(*delay_min_ms)..Duration::from_millis(*delay_max_ms),
670                    )
671                    .with_io_error_probability(*io_error_probability)
672                    .with_wakeup_storm_probability(*wakeup_storm_probability)
673                    .with_budget_exhaust_probability(*budget_exhaustion_probability);
674                config.with_chaos(chaos)
675            }
676        };
677
678        if self.lab.replay_recording {
679            config = config.with_default_replay_recording();
680        }
681
682        config
683    }
684
685    /// Parse a scenario from a JSON string.
686    ///
687    /// # Errors
688    ///
689    /// Returns a `serde_json::Error` if the JSON is malformed.
690    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
691        serde_json::from_str(json)
692    }
693
694    /// Serialize this scenario to pretty-printed JSON.
695    ///
696    /// # Errors
697    ///
698    /// Returns a `serde_json::Error` if serialization fails.
699    pub fn to_json(&self) -> Result<String, serde_json::Error> {
700        serde_json::to_string_pretty(self)
701    }
702}
703
704// ---------------------------------------------------------------------------
705// Tests
706// ---------------------------------------------------------------------------
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    fn minimal_json() -> &'static str {
713        r#"{
714            "id": "test-scenario",
715            "description": "minimal test"
716        }"#
717    }
718
719    #[test]
720    fn parse_minimal_scenario() {
721        let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
722        assert_eq!(s.id, "test-scenario");
723        assert_eq!(s.schema_version, 1);
724        assert_eq!(s.lab.seed, 42);
725        assert_eq!(s.lab.worker_count, 1);
726        assert!(s.faults.is_empty());
727        assert!(s.participants.is_empty());
728        assert_eq!(s.oracles, vec!["all"]);
729    }
730
731    #[test]
732    fn validate_minimal_scenario() {
733        let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
734        let errors = s.validate();
735        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
736    }
737
738    #[test]
739    fn validate_empty_id_rejected() {
740        let json = r#"{"id": "", "description": "bad"}"#;
741        let s: Scenario = serde_json::from_str(json).unwrap();
742        let errors = s.validate();
743        assert!(errors.iter().any(|e| e.field == "id"));
744    }
745
746    #[test]
747    fn validate_bad_schema_version() {
748        let json = r#"{"schema_version": 99, "id": "x"}"#;
749        let s: Scenario = serde_json::from_str(json).unwrap();
750        let errors = s.validate();
751        assert!(errors.iter().any(|e| e.field == "schema_version"));
752    }
753
754    #[test]
755    fn parse_chaos_preset_light() {
756        let json = r#"{"id": "x", "chaos": {"preset": "light"}}"#;
757        let s: Scenario = serde_json::from_str(json).unwrap();
758        assert!(matches!(s.chaos, ChaosSection::Light));
759    }
760
761    #[test]
762    fn parse_chaos_custom() {
763        let json = r#"{
764            "id": "x",
765            "chaos": {
766                "preset": "custom",
767                "cancel_probability": 0.05,
768                "delay_probability": 0.3,
769                "io_error_probability": 0.1
770            }
771        }"#;
772        let s: Scenario = serde_json::from_str(json).unwrap();
773        match s.chaos {
774            ChaosSection::Custom {
775                cancel_probability,
776                delay_probability,
777                io_error_probability,
778                ..
779            } => {
780                assert!((cancel_probability - 0.05).abs() < f64::EPSILON);
781                assert!((delay_probability - 0.3).abs() < f64::EPSILON);
782                assert!((io_error_probability - 0.1).abs() < f64::EPSILON);
783            }
784            other => panic!("expected Custom, got {other:?}"),
785        }
786    }
787
788    #[test]
789    fn validate_chaos_bad_probability() {
790        let json = r#"{
791            "id": "x",
792            "chaos": {"preset": "custom", "cancel_probability": 1.5}
793        }"#;
794        let s: Scenario = serde_json::from_str(json).unwrap();
795        let errors = s.validate();
796        assert!(errors.iter().any(|e| e.field == "chaos.cancel_probability"));
797    }
798
799    #[test]
800    fn parse_network_preset_wan() {
801        let json = r#"{"id": "x", "network": {"preset": "wan"}}"#;
802        let s: Scenario = serde_json::from_str(json).unwrap();
803        assert_eq!(s.network.preset, NetworkPreset::Wan);
804    }
805
806    #[test]
807    fn parse_network_link_override() {
808        let json = r#"{
809            "id": "x",
810            "network": {
811                "preset": "lan",
812                "links": {
813                    "alice->bob": { "packet_loss": 0.5 }
814                }
815            }
816        }"#;
817        let s: Scenario = serde_json::from_str(json).unwrap();
818        let link = s.network.links.get("alice->bob").unwrap();
819        assert!((link.packet_loss.unwrap() - 0.5).abs() < f64::EPSILON);
820    }
821
822    #[test]
823    fn validate_bad_link_key() {
824        let json = r#"{
825            "id": "x",
826            "network": {"links": {"alice_bob": {}}}
827        }"#;
828        let s: Scenario = serde_json::from_str(json).unwrap();
829        let errors = s.validate();
830        assert!(errors.iter().any(|e| e.field.contains("network.links")));
831    }
832
833    #[test]
834    fn parse_fault_events() {
835        let json = r#"{
836            "id": "x",
837            "faults": [
838                {"at_ms": 100, "action": "partition", "args": {"from": "a", "to": "b"}},
839                {"at_ms": 500, "action": "heal", "args": {"from": "a", "to": "b"}}
840            ]
841        }"#;
842        let s: Scenario = serde_json::from_str(json).unwrap();
843        assert_eq!(s.faults.len(), 2);
844        assert_eq!(s.faults[0].at_ms, 100);
845        assert!(matches!(s.faults[0].action, FaultAction::Partition));
846        assert_eq!(s.faults[1].at_ms, 500);
847        assert!(matches!(s.faults[1].action, FaultAction::Heal));
848    }
849
850    #[test]
851    fn validate_unordered_faults() {
852        let json = r#"{
853            "id": "x",
854            "faults": [
855                {"at_ms": 500, "action": "partition"},
856                {"at_ms": 100, "action": "heal"}
857            ]
858        }"#;
859        let s: Scenario = serde_json::from_str(json).unwrap();
860        let errors = s.validate();
861        assert!(errors.iter().any(|e| e.field == "faults"));
862    }
863
864    #[test]
865    fn parse_participants() {
866        let json = r#"{
867            "id": "x",
868            "participants": [
869                {"name": "alice", "role": "sender"},
870                {"name": "bob", "role": "receiver"}
871            ]
872        }"#;
873        let s: Scenario = serde_json::from_str(json).unwrap();
874        assert_eq!(s.participants.len(), 2);
875        assert_eq!(s.participants[0].name, "alice");
876        assert_eq!(s.participants[1].role, "receiver");
877    }
878
879    #[test]
880    fn validate_duplicate_participant() {
881        let json = r#"{
882            "id": "x",
883            "participants": [
884                {"name": "alice"},
885                {"name": "alice"}
886            ]
887        }"#;
888        let s: Scenario = serde_json::from_str(json).unwrap();
889        let errors = s.validate();
890        assert!(errors.iter().any(|e| e.message.contains("duplicate")));
891    }
892
893    #[test]
894    fn parse_cancellation_strategy() {
895        let json = r#"{
896            "id": "x",
897            "cancellation": {
898                "strategy": "random_sample",
899                "count": 100
900            }
901        }"#;
902        let s: Scenario = serde_json::from_str(json).unwrap();
903        let cancel = s.cancellation.as_ref().unwrap();
904        assert!(matches!(
905            cancel.strategy,
906            CancellationStrategy::RandomSample
907        ));
908        assert_eq!(cancel.count, Some(100));
909    }
910
911    #[test]
912    fn validate_missing_count() {
913        let json = r#"{
914            "id": "x",
915            "cancellation": {"strategy": "random_sample"}
916        }"#;
917        let s: Scenario = serde_json::from_str(json).unwrap();
918        let errors = s.validate();
919        assert!(errors.iter().any(|e| e.field == "cancellation.count"));
920    }
921
922    #[test]
923    fn to_lab_config_defaults() {
924        let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
925        let config = s.to_lab_config();
926        assert_eq!(config.seed, 42);
927        assert_eq!(config.worker_count, 1);
928        assert_eq!(config.trace_capacity, 4096);
929        assert!(config.panic_on_obligation_leak);
930    }
931
932    #[test]
933    fn to_lab_config_chaos_light() {
934        let json = r#"{"id": "x", "chaos": {"preset": "light"}}"#;
935        let s: Scenario = serde_json::from_str(json).unwrap();
936        let config = s.to_lab_config();
937        assert!(config.has_chaos());
938    }
939
940    #[test]
941    fn to_lab_config_custom_seed() {
942        let json = r#"{"id": "x", "lab": {"seed": 12345, "worker_count": 4}}"#;
943        let s: Scenario = serde_json::from_str(json).unwrap();
944        let config = s.to_lab_config();
945        assert_eq!(config.seed, 12345);
946        assert_eq!(config.worker_count, 4);
947    }
948
949    #[test]
950    fn json_roundtrip() {
951        let json = r#"{
952            "id": "roundtrip-test",
953            "description": "full roundtrip",
954            "lab": {"seed": 99, "worker_count": 2},
955            "chaos": {"preset": "heavy"},
956            "network": {"preset": "wan"},
957            "participants": [{"name": "alice", "role": "sender"}],
958            "faults": [{"at_ms": 100, "action": "partition"}]
959        }"#;
960        let s1: Scenario = serde_json::from_str(json).unwrap();
961        let serialized = s1.to_json().unwrap();
962        let s2: Scenario = Scenario::from_json(&serialized).unwrap();
963        assert_eq!(s1.id, s2.id);
964        assert_eq!(s1.lab.seed, s2.lab.seed);
965        assert_eq!(s1.participants.len(), s2.participants.len());
966        assert_eq!(s1.faults.len(), s2.faults.len());
967    }
968
969    #[test]
970    fn parse_metadata() {
971        let json = r#"{
972            "id": "x",
973            "metadata": {"git_sha": "abc123", "author": "bot"}
974        }"#;
975        let s: Scenario = serde_json::from_str(json).unwrap();
976        assert_eq!(s.metadata.get("git_sha").unwrap(), "abc123");
977    }
978
979    #[test]
980    fn parse_latency_models() {
981        let json = r#"{
982            "id": "x",
983            "network": {
984                "preset": "ideal",
985                "links": {
986                    "a->b": {"latency": {"model": "fixed", "ms": 5}},
987                    "b->c": {"latency": {"model": "uniform", "min_ms": 1, "max_ms": 10}},
988                    "c->d": {"latency": {"model": "normal", "mean_ms": 50, "stddev_ms": 10}}
989                }
990            }
991        }"#;
992        let s: Scenario = serde_json::from_str(json).unwrap();
993        assert_eq!(s.network.links.len(), 3);
994        let ab = s.network.links.get("a->b").unwrap();
995        assert!(matches!(ab.latency, Some(LatencySpec::Fixed { ms: 5 })));
996    }
997
998    #[test]
999    fn parse_include() {
1000        let json = r#"{
1001            "id": "x",
1002            "include": [{"path": "base.yaml"}]
1003        }"#;
1004        let s: Scenario = serde_json::from_str(json).unwrap();
1005        assert_eq!(s.include.len(), 1);
1006        assert_eq!(s.include[0].path, "base.yaml");
1007    }
1008}