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            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    /// Convert this scenario to a [`super::config::LabConfig`].
683    #[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        // Apply chaos preset
703        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    /// Parse a scenario from a JSON string.
739    ///
740    /// # Errors
741    ///
742    /// Returns a `serde_json::Error` if the JSON is malformed.
743    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
744        serde_json::from_str(json)
745    }
746
747    /// Serialize this scenario to pretty-printed JSON.
748    ///
749    /// # Errors
750    ///
751    /// Returns a `serde_json::Error` if serialization fails.
752    pub fn to_json(&self) -> Result<String, serde_json::Error> {
753        serde_json::to_string_pretty(self)
754    }
755}
756
757// ---------------------------------------------------------------------------
758// Tests
759// ---------------------------------------------------------------------------
760
761#[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}