Skip to main content

ftui_harness/
resize_storm.rs

1#![forbid(unsafe_code)]
2
3//! Resize Storm Generator + Replay Harness (bd-1rz0.15)
4//!
5//! Generates deterministic resize event sequences for E2E and performance testing.
6//! Supports various storm patterns (burst, sweep, oscillate, pathological) with
7//! verbose JSONL logging for debugging and replay.
8//!
9//! # Key Features
10//!
11//! - **Deterministic**: Same seed produces identical resize sequences
12//! - **Pattern Library**: Pre-defined patterns for common resize scenarios
13//! - **JSONL Logging**: Comprehensive logging with stable schema
14//! - **Replay Harness**: Record and replay resize sequences for regression testing
15//! - **Flicker Integration**: Analyze captured output for visual artifacts
16//!
17//! # JSONL Schema
18//!
19//! ```json
20//! {"event":"storm_start","run_id":"...","case":"burst_50","env":{...},"seed":42,"pattern":"burst","capabilities":{...}}
21//! {"event":"storm_resize","idx":0,"width":80,"height":24,"delay_ms":10,"elapsed_ms":0}
22//! {"event":"storm_capture","idx":0,"bytes_captured":1024,"checksum":"...","flicker_free":true}
23//! {"event":"storm_complete","outcome":"pass","total_resizes":50,"total_bytes":51200,"duration_ms":1500,"checksum":"..."}
24//! ```
25//!
26//! # Usage
27//!
28//! ```ignore
29//! use ftui_harness::resize_storm::{StormConfig, StormPattern, ResizeStorm};
30//!
31//! let config = StormConfig::default()
32//!     .with_seed(42)
33//!     .with_pattern(StormPattern::Burst { count: 50 });
34//!
35//! let storm = ResizeStorm::new(config);
36//! let events = storm.generate();
37//! ```
38
39use std::collections::hash_map::DefaultHasher;
40use std::fmt::Write as FmtWrite;
41use std::hash::{Hash, Hasher};
42use std::io::Write;
43use std::time::{Instant, SystemTime, UNIX_EPOCH};
44
45use crate::flicker_detection::FlickerAnalysis;
46
47// ============================================================================
48// Configuration
49// ============================================================================
50
51/// Pattern type for resize storm generation.
52#[derive(Debug, Clone, PartialEq)]
53pub enum StormPattern {
54    /// Rapid burst of resizes with minimal delay.
55    Burst {
56        /// Number of resize events.
57        count: usize,
58    },
59    /// Gradual size sweep from min to max.
60    Sweep {
61        /// Starting width.
62        start_width: u16,
63        /// Starting height.
64        start_height: u16,
65        /// Ending width.
66        end_width: u16,
67        /// Ending height.
68        end_height: u16,
69        /// Number of steps.
70        steps: usize,
71    },
72    /// Oscillate between two sizes.
73    Oscillate {
74        /// First size (width, height).
75        size_a: (u16, u16),
76        /// Second size (width, height).
77        size_b: (u16, u16),
78        /// Number of oscillations.
79        cycles: usize,
80    },
81    /// Pathological edge cases (extremes, zero delays).
82    Pathological {
83        /// Number of events.
84        count: usize,
85    },
86    /// Mixed pattern combining all types.
87    Mixed {
88        /// Total number of events.
89        count: usize,
90    },
91    /// Custom resize sequence.
92    Custom {
93        /// List of (width, height, delay_ms) tuples.
94        events: Vec<(u16, u16, u64)>,
95    },
96}
97
98impl StormPattern {
99    /// Get the pattern name for logging.
100    pub fn name(&self) -> &'static str {
101        match self {
102            Self::Burst { .. } => "burst",
103            Self::Sweep { .. } => "sweep",
104            Self::Oscillate { .. } => "oscillate",
105            Self::Pathological { .. } => "pathological",
106            Self::Mixed { .. } => "mixed",
107            Self::Custom { .. } => "custom",
108        }
109    }
110
111    /// Get the total number of events this pattern will generate.
112    pub fn event_count(&self) -> usize {
113        match self {
114            Self::Burst { count } => *count,
115            Self::Sweep { steps, .. } => *steps,
116            Self::Oscillate { cycles, .. } => cycles * 2,
117            Self::Pathological { count } => *count,
118            Self::Mixed { count } => *count,
119            Self::Custom { events } => events.len(),
120        }
121    }
122}
123
124impl Default for StormPattern {
125    fn default() -> Self {
126        Self::Burst { count: 50 }
127    }
128}
129
130/// Configuration for resize storm generation.
131#[derive(Debug, Clone)]
132pub struct StormConfig {
133    /// Random seed for deterministic generation.
134    pub seed: u64,
135    /// Storm pattern to generate.
136    pub pattern: StormPattern,
137    /// Initial terminal size before storm begins.
138    pub initial_size: (u16, u16),
139    /// Minimum delay between resizes (ms).
140    pub min_delay_ms: u64,
141    /// Maximum delay between resizes (ms).
142    pub max_delay_ms: u64,
143    /// Minimum terminal width.
144    pub min_width: u16,
145    /// Maximum terminal width.
146    pub max_width: u16,
147    /// Minimum terminal height.
148    pub min_height: u16,
149    /// Maximum terminal height.
150    pub max_height: u16,
151    /// Test case name for logging.
152    pub case_name: String,
153    /// Enable verbose JSONL logging.
154    pub logging_enabled: bool,
155}
156
157impl Default for StormConfig {
158    fn default() -> Self {
159        Self {
160            seed: 0,
161            pattern: StormPattern::default(),
162            initial_size: (80, 24),
163            min_delay_ms: 5,
164            max_delay_ms: 50,
165            min_width: 20,
166            max_width: 300,
167            min_height: 5,
168            max_height: 100,
169            case_name: "default".into(),
170            logging_enabled: true,
171        }
172    }
173}
174
175impl StormConfig {
176    /// Set the random seed.
177    #[must_use]
178    pub fn with_seed(mut self, seed: u64) -> Self {
179        self.seed = seed;
180        self
181    }
182
183    /// Set the storm pattern.
184    #[must_use]
185    pub fn with_pattern(mut self, pattern: StormPattern) -> Self {
186        self.pattern = pattern;
187        self
188    }
189
190    /// Set the initial terminal size.
191    #[must_use]
192    pub fn with_initial_size(mut self, width: u16, height: u16) -> Self {
193        self.initial_size = (width, height);
194        self
195    }
196
197    /// Set delay range between resizes.
198    #[must_use]
199    pub fn with_delay_range(mut self, min_ms: u64, max_ms: u64) -> Self {
200        self.min_delay_ms = min_ms;
201        self.max_delay_ms = max_ms;
202        self
203    }
204
205    /// Set size bounds.
206    #[must_use]
207    pub fn with_size_bounds(
208        mut self,
209        min_width: u16,
210        max_width: u16,
211        min_height: u16,
212        max_height: u16,
213    ) -> Self {
214        self.min_width = min_width;
215        self.max_width = max_width;
216        self.min_height = min_height;
217        self.max_height = max_height;
218        self
219    }
220
221    /// Set the test case name.
222    #[must_use]
223    pub fn with_case_name(mut self, name: impl Into<String>) -> Self {
224        self.case_name = name.into();
225        self
226    }
227
228    /// Enable or disable logging.
229    #[must_use]
230    pub fn with_logging(mut self, enabled: bool) -> Self {
231        self.logging_enabled = enabled;
232        self
233    }
234}
235
236// ============================================================================
237// Seeded RNG
238// ============================================================================
239
240/// Simple LCG PRNG for deterministic generation.
241#[derive(Debug, Clone)]
242struct SeededRng {
243    state: u64,
244}
245
246impl SeededRng {
247    fn new(seed: u64) -> Self {
248        Self {
249            state: seed.wrapping_add(1),
250        }
251    }
252
253    fn next_u64(&mut self) -> u64 {
254        // LCG parameters from Numerical Recipes
255        self.state = self
256            .state
257            .wrapping_mul(6364136223846793005)
258            .wrapping_add(1442695040888963407);
259        self.state
260    }
261
262    fn next_range(&mut self, min: u64, max: u64) -> u64 {
263        if max <= min {
264            return min;
265        }
266        min + (self.next_u64() % (max - min))
267    }
268
269    fn next_u16_range(&mut self, min: u16, max: u16) -> u16 {
270        self.next_range(min as u64, max as u64) as u16
271    }
272
273    fn next_f64(&mut self) -> f64 {
274        (self.next_u64() as f64) / (u64::MAX as f64)
275    }
276
277    fn chance(&mut self, p: f64) -> bool {
278        self.next_f64() < p
279    }
280}
281
282// ============================================================================
283// Resize Event
284// ============================================================================
285
286/// A single resize event in a storm sequence.
287#[derive(Debug, Clone, PartialEq, Eq, Hash)]
288pub struct ResizeEvent {
289    /// Target width.
290    pub width: u16,
291    /// Target height.
292    pub height: u16,
293    /// Delay before this resize (ms).
294    pub delay_ms: u64,
295    /// Index in the sequence.
296    pub index: usize,
297}
298
299impl ResizeEvent {
300    /// Create a new resize event.
301    pub fn new(width: u16, height: u16, delay_ms: u64, index: usize) -> Self {
302        Self {
303            width,
304            height,
305            delay_ms,
306            index,
307        }
308    }
309
310    /// Convert to JSONL format.
311    pub fn to_jsonl(&self, elapsed_ms: u64) -> String {
312        format!(
313            r#"{{"event":"storm_resize","idx":{},"width":{},"height":{},"delay_ms":{},"elapsed_ms":{}}}"#,
314            self.index, self.width, self.height, self.delay_ms, elapsed_ms
315        )
316    }
317}
318
319// ============================================================================
320// Storm Generator
321// ============================================================================
322
323/// Resize storm generator.
324#[derive(Debug, Clone)]
325pub struct ResizeStorm {
326    config: StormConfig,
327    events: Vec<ResizeEvent>,
328    run_id: String,
329}
330
331impl ResizeStorm {
332    /// Create a new storm generator with the given configuration.
333    pub fn new(config: StormConfig) -> Self {
334        let run_id = format!(
335            "{:016x}",
336            SystemTime::now()
337                .duration_since(UNIX_EPOCH)
338                .map(|d| d.as_nanos() as u64 ^ config.seed)
339                .unwrap_or(config.seed)
340        );
341
342        let mut storm = Self {
343            config,
344            events: Vec::new(),
345            run_id,
346        };
347        storm.generate_events();
348        storm
349    }
350
351    /// Get the run ID.
352    pub fn run_id(&self) -> &str {
353        &self.run_id
354    }
355
356    /// Get the generated events.
357    pub fn events(&self) -> &[ResizeEvent] {
358        &self.events
359    }
360
361    /// Get the configuration.
362    pub fn config(&self) -> &StormConfig {
363        &self.config
364    }
365
366    /// Generate resize events based on the pattern.
367    fn generate_events(&mut self) {
368        let mut rng = SeededRng::new(self.config.seed);
369
370        self.events = match &self.config.pattern {
371            StormPattern::Burst { count } => self.generate_burst(&mut rng, *count),
372            StormPattern::Sweep {
373                start_width,
374                start_height,
375                end_width,
376                end_height,
377                steps,
378            } => self.generate_sweep(*start_width, *start_height, *end_width, *end_height, *steps),
379            StormPattern::Oscillate {
380                size_a,
381                size_b,
382                cycles,
383            } => self.generate_oscillate(&mut rng, *size_a, *size_b, *cycles),
384            StormPattern::Pathological { count } => self.generate_pathological(&mut rng, *count),
385            StormPattern::Mixed { count } => self.generate_mixed(&mut rng, *count),
386            StormPattern::Custom { events } => events
387                .iter()
388                .enumerate()
389                .map(|(i, (w, h, d))| ResizeEvent::new(*w, *h, *d, i))
390                .collect(),
391        };
392    }
393
394    fn generate_burst(&self, rng: &mut SeededRng, count: usize) -> Vec<ResizeEvent> {
395        let mut events = Vec::with_capacity(count);
396        let (mut width, mut height) = self.config.initial_size;
397
398        for i in 0..count {
399            // Rapid resizes with minimal delay
400            let delay = rng.next_range(self.config.min_delay_ms, self.config.max_delay_ms / 2);
401
402            // Random size changes within bounds
403            if rng.chance(0.7) {
404                let delta = rng.next_u16_range(1, 20) as i16;
405                let sign = if rng.chance(0.5) { 1 } else { -1 };
406                width = (width as i16 + delta * sign)
407                    .clamp(self.config.min_width as i16, self.config.max_width as i16)
408                    as u16;
409            }
410            if rng.chance(0.7) {
411                let delta = rng.next_u16_range(1, 10) as i16;
412                let sign = if rng.chance(0.5) { 1 } else { -1 };
413                height = (height as i16 + delta * sign)
414                    .clamp(self.config.min_height as i16, self.config.max_height as i16)
415                    as u16;
416            }
417
418            events.push(ResizeEvent::new(width, height, delay, i));
419        }
420        events
421    }
422
423    fn generate_sweep(
424        &self,
425        start_w: u16,
426        start_h: u16,
427        end_w: u16,
428        end_h: u16,
429        steps: usize,
430    ) -> Vec<ResizeEvent> {
431        let mut events = Vec::with_capacity(steps);
432
433        for i in 0..steps {
434            let t = if steps > 1 {
435                i as f64 / (steps - 1) as f64
436            } else {
437                1.0
438            };
439
440            let width = (start_w as f64 + (end_w as f64 - start_w as f64) * t).round() as u16;
441            let height = (start_h as f64 + (end_h as f64 - start_h as f64) * t).round() as u16;
442            let delay = (self.config.min_delay_ms + self.config.max_delay_ms) / 2;
443
444            events.push(ResizeEvent::new(width, height, delay, i));
445        }
446        events
447    }
448
449    fn generate_oscillate(
450        &self,
451        rng: &mut SeededRng,
452        size_a: (u16, u16),
453        size_b: (u16, u16),
454        cycles: usize,
455    ) -> Vec<ResizeEvent> {
456        let mut events = Vec::with_capacity(cycles * 2);
457
458        for cycle in 0..cycles {
459            let delay_a = rng.next_range(self.config.min_delay_ms, self.config.max_delay_ms);
460            let delay_b = rng.next_range(self.config.min_delay_ms, self.config.max_delay_ms);
461
462            events.push(ResizeEvent::new(size_a.0, size_a.1, delay_a, cycle * 2));
463            events.push(ResizeEvent::new(size_b.0, size_b.1, delay_b, cycle * 2 + 1));
464        }
465        events
466    }
467
468    fn generate_pathological(&self, rng: &mut SeededRng, count: usize) -> Vec<ResizeEvent> {
469        let mut events = Vec::with_capacity(count);
470
471        for i in 0..count {
472            let pattern = i % 8;
473            let (width, height, delay) = match pattern {
474                0 => (self.config.min_width, self.config.min_height, 0), // Minimum, instant
475                1 => (self.config.max_width, self.config.max_height, 0), // Maximum, instant
476                2 => (1, 1, 1),                                          // Extreme minimum
477                3 => (500, 200, 1),                                      // Large
478                4 => (80, 24, 500),                                      // Normal, long delay
479                5 => {
480                    // Random, zero delay
481                    (
482                        rng.next_u16_range(self.config.min_width, self.config.max_width),
483                        rng.next_u16_range(self.config.min_height, self.config.max_height),
484                        0,
485                    )
486                }
487                6 => (80, 24, rng.next_range(0, 1000)), // Normal, random delay
488                7 => {
489                    // Alternating extremes
490                    if i % 2 == 0 {
491                        (self.config.min_width, self.config.max_height, 5)
492                    } else {
493                        (self.config.max_width, self.config.min_height, 5)
494                    }
495                }
496                _ => unreachable!(),
497            };
498
499            events.push(ResizeEvent::new(width, height, delay, i));
500        }
501        events
502    }
503
504    fn generate_mixed(&self, rng: &mut SeededRng, count: usize) -> Vec<ResizeEvent> {
505        let segment = count / 4;
506        let mut events = Vec::with_capacity(count);
507
508        // Burst segment
509        let burst = self.generate_burst(rng, segment);
510        events.extend(burst);
511
512        // Sweep segment
513        let sweep = self.generate_sweep(60, 15, 150, 50, segment);
514        for (i, mut e) in sweep.into_iter().enumerate() {
515            e.index = events.len() + i;
516            events.push(e);
517        }
518
519        // Oscillate segment
520        let oscillate = self.generate_oscillate(rng, (80, 24), (120, 40), segment / 2);
521        for (i, mut e) in oscillate.into_iter().enumerate() {
522            e.index = events.len() + i;
523            events.push(e);
524        }
525
526        // Pathological segment
527        let remaining = count - events.len();
528        let pathological = self.generate_pathological(rng, remaining);
529        for (i, mut e) in pathological.into_iter().enumerate() {
530            e.index = events.len() + i;
531            events.push(e);
532        }
533
534        events
535    }
536
537    /// Compute a deterministic checksum of the event sequence.
538    pub fn sequence_checksum(&self) -> String {
539        let mut hasher = DefaultHasher::new();
540        for event in &self.events {
541            event.hash(&mut hasher);
542        }
543        format!("{:016x}", hasher.finish())
544    }
545
546    /// Get total duration of the storm (sum of delays).
547    pub fn total_duration_ms(&self) -> u64 {
548        self.events.iter().map(|e| e.delay_ms).sum()
549    }
550}
551
552// ============================================================================
553// JSONL Logger
554// ============================================================================
555
556/// JSONL logger for storm execution.
557pub struct StormLogger {
558    lines: Vec<String>,
559    run_id: String,
560    start_time: Instant,
561}
562
563impl StormLogger {
564    /// Create a new logger.
565    pub fn new(run_id: &str) -> Self {
566        Self {
567            lines: Vec::new(),
568            run_id: run_id.to_string(),
569            start_time: Instant::now(),
570        }
571    }
572
573    /// Log storm start event.
574    pub fn log_start(&mut self, storm: &ResizeStorm, capabilities: &TerminalCapabilities) {
575        let timestamp = SystemTime::now()
576            .duration_since(UNIX_EPOCH)
577            .unwrap_or_default()
578            .as_secs();
579
580        let env = capture_env();
581        let caps = capabilities.to_json();
582
583        self.lines.push(format!(
584            r#"{{"event":"storm_start","run_id":"{}","case":"{}","env":{},"seed":{},"pattern":"{}","event_count":{},"capabilities":{},"timestamp":{}}}"#,
585            self.run_id,
586            storm.config.case_name,
587            env,
588            storm.config.seed,
589            storm.config.pattern.name(),
590            storm.events.len(),
591            caps,
592            timestamp
593        ));
594    }
595
596    /// Log a resize event.
597    pub fn log_resize(&mut self, event: &ResizeEvent) {
598        let elapsed = self.start_time.elapsed().as_millis() as u64;
599        self.lines.push(event.to_jsonl(elapsed));
600    }
601
602    /// Log capture result after a resize.
603    pub fn log_capture(
604        &mut self,
605        idx: usize,
606        bytes_captured: usize,
607        checksum: &str,
608        flicker_free: bool,
609    ) {
610        self.lines.push(format!(
611            r#"{{"event":"storm_capture","idx":{},"bytes_captured":{},"checksum":"{}","flicker_free":{}}}"#,
612            idx, bytes_captured, checksum, flicker_free
613        ));
614    }
615
616    /// Log storm completion.
617    pub fn log_complete(
618        &mut self,
619        outcome: &str,
620        total_resizes: usize,
621        total_bytes: usize,
622        checksum: &str,
623    ) {
624        let duration_ms = self.start_time.elapsed().as_millis() as u64;
625        self.lines.push(format!(
626            r#"{{"event":"storm_complete","outcome":"{}","total_resizes":{},"total_bytes":{},"duration_ms":{},"checksum":"{}"}}"#,
627            outcome, total_resizes, total_bytes, duration_ms, checksum
628        ));
629    }
630
631    /// Log an error.
632    pub fn log_error(&mut self, message: &str) {
633        self.lines.push(format!(
634            r#"{{"event":"storm_error","message":"{}"}}"#,
635            escape_json(message)
636        ));
637    }
638
639    /// Get all log lines as JSONL.
640    pub fn to_jsonl(&self) -> String {
641        self.lines.join("\n")
642    }
643
644    /// Write to a file.
645    pub fn write_to_file(&self, path: &std::path::Path) -> std::io::Result<()> {
646        let mut file = std::fs::File::create(path)?;
647        for line in &self.lines {
648            writeln!(file, "{}", line)?;
649        }
650        Ok(())
651    }
652}
653
654// ============================================================================
655// Terminal Capabilities
656// ============================================================================
657
658/// Terminal capabilities detected at runtime.
659#[derive(Debug, Clone, Default)]
660pub struct TerminalCapabilities {
661    /// TERM environment variable.
662    pub term: String,
663    /// COLORTERM environment variable.
664    pub colorterm: String,
665    /// Whether NO_COLOR is set.
666    pub no_color: bool,
667    /// Whether running in a multiplexer (tmux, screen, etc.).
668    pub in_mux: bool,
669    /// Detected multiplexer name.
670    pub mux_name: Option<String>,
671    /// Whether synchronized output is supported.
672    pub sync_output: bool,
673}
674
675impl TerminalCapabilities {
676    /// Detect capabilities from environment.
677    pub fn detect() -> Self {
678        let term = std::env::var("TERM").unwrap_or_default();
679        let colorterm = std::env::var("COLORTERM").unwrap_or_default();
680        let no_color = std::env::var("NO_COLOR").is_ok();
681
682        let (in_mux, mux_name) = detect_mux();
683
684        // Assume sync output support for modern terminals
685        let sync_output = term.contains("256color")
686            || term.contains("kitty")
687            || term.contains("alacritty")
688            || colorterm == "truecolor";
689
690        Self {
691            term,
692            colorterm,
693            no_color,
694            in_mux,
695            mux_name,
696            sync_output,
697        }
698    }
699
700    /// Convert to JSON string.
701    pub fn to_json(&self) -> String {
702        format!(
703            r#"{{"term":"{}","colorterm":"{}","no_color":{},"in_mux":{},"mux_name":{},"sync_output":{}}}"#,
704            escape_json(&self.term),
705            escape_json(&self.colorterm),
706            self.no_color,
707            self.in_mux,
708            self.mux_name
709                .as_ref()
710                .map(|s| format!(r#""{}""#, escape_json(s)))
711                .unwrap_or_else(|| "null".to_string()),
712            self.sync_output
713        )
714    }
715}
716
717fn detect_mux() -> (bool, Option<String>) {
718    if std::env::var("TMUX").is_ok() {
719        return (true, Some("tmux".to_string()));
720    }
721    if std::env::var("STY").is_ok() {
722        return (true, Some("screen".to_string()));
723    }
724    if std::env::var("ZELLIJ").is_ok() {
725        return (true, Some("zellij".to_string()));
726    }
727    if std::env::var("WEZTERM_UNIX_SOCKET").is_ok() || std::env::var("WEZTERM_PANE").is_ok() {
728        return (true, Some("wezterm-mux".to_string()));
729    }
730    if std::env::var("WEZTERM_EXECUTABLE").is_ok() {
731        return (true, Some("wezterm-mux".to_string()));
732    }
733    if let Ok(prog) = std::env::var("TERM_PROGRAM")
734        && prog.to_lowercase().contains("tmux")
735    {
736        return (true, Some("tmux".to_string()));
737    }
738    (false, None)
739}
740
741// ============================================================================
742// Storm Result
743// ============================================================================
744
745/// Result of executing a resize storm.
746#[derive(Debug)]
747pub struct StormResult {
748    /// Whether the storm passed all checks.
749    pub passed: bool,
750    /// Total resize events executed.
751    pub total_resizes: usize,
752    /// Total bytes captured from output.
753    pub total_bytes: usize,
754    /// Total duration in milliseconds.
755    pub duration_ms: u64,
756    /// Flicker analysis results (if analyzed).
757    pub flicker_analysis: Option<FlickerAnalysis>,
758    /// Sequence checksum for replay verification.
759    pub sequence_checksum: String,
760    /// Output checksum.
761    pub output_checksum: String,
762    /// JSONL log.
763    pub jsonl: String,
764    /// Any error messages.
765    pub errors: Vec<String>,
766}
767
768impl StormResult {
769    /// Assert that the storm passed.
770    pub fn assert_passed(&self) {
771        if !self.passed {
772            let mut msg = String::new();
773            msg.push_str("\n=== Resize Storm Failed ===\n\n");
774            writeln!(msg, "Resizes: {}", self.total_resizes).unwrap();
775            writeln!(msg, "Bytes: {}", self.total_bytes).unwrap();
776            writeln!(msg, "Duration: {}ms", self.duration_ms).unwrap();
777
778            if !self.errors.is_empty() {
779                msg.push_str("\nErrors:\n");
780                for err in &self.errors {
781                    writeln!(msg, "  - {}", err).unwrap();
782                }
783            }
784
785            if let Some(ref analysis) = self.flicker_analysis
786                && !analysis.flicker_free
787            {
788                msg.push_str("\nFlicker Issues:\n");
789                for issue in &analysis.issues {
790                    writeln!(
791                        msg,
792                        "  - [{}] {}: {}",
793                        issue.severity, issue.event_type, issue.details.message
794                    )
795                    .unwrap();
796                }
797            }
798
799            msg.push_str("\nJSONL Log:\n");
800            msg.push_str(&self.jsonl);
801
802            panic!("{}", msg);
803        }
804    }
805}
806
807// ============================================================================
808// Replay Harness
809// ============================================================================
810
811/// Recorded storm for replay.
812#[derive(Debug, Clone)]
813pub struct RecordedStorm {
814    /// Configuration used.
815    pub config: StormConfig,
816    /// Generated events.
817    pub events: Vec<ResizeEvent>,
818    /// Sequence checksum for verification.
819    pub sequence_checksum: String,
820    /// Expected output checksum (if known).
821    pub expected_output_checksum: Option<String>,
822}
823
824impl RecordedStorm {
825    /// Record a storm for later replay.
826    pub fn record(storm: &ResizeStorm) -> Self {
827        Self {
828            config: storm.config.clone(),
829            events: storm.events.clone(),
830            sequence_checksum: storm.sequence_checksum(),
831            expected_output_checksum: None,
832        }
833    }
834
835    /// Record with expected output checksum.
836    pub fn record_with_output(storm: &ResizeStorm, output_checksum: String) -> Self {
837        let mut recorded = Self::record(storm);
838        recorded.expected_output_checksum = Some(output_checksum);
839        recorded
840    }
841
842    /// Verify that a replay matches this recording.
843    pub fn verify_replay(&self, storm: &ResizeStorm) -> bool {
844        self.sequence_checksum == storm.sequence_checksum()
845    }
846
847    /// Serialize to JSON for storage.
848    pub fn to_json(&self) -> String {
849        let events_json: Vec<String> = self
850            .events
851            .iter()
852            .map(|e| {
853                format!(
854                    r#"{{"width":{},"height":{},"delay_ms":{},"index":{}}}"#,
855                    e.width, e.height, e.delay_ms, e.index
856                )
857            })
858            .collect();
859
860        format!(
861            r#"{{"seed":{},"pattern":"{}","case_name":"{}","initial_size":[{},{}],"events":[{}],"sequence_checksum":"{}","expected_output_checksum":{}}}"#,
862            self.config.seed,
863            self.config.pattern.name(),
864            escape_json(&self.config.case_name),
865            self.config.initial_size.0,
866            self.config.initial_size.1,
867            events_json.join(","),
868            self.sequence_checksum,
869            self.expected_output_checksum
870                .as_ref()
871                .map(|s| format!(r#""{}""#, s))
872                .unwrap_or_else(|| "null".to_string())
873        )
874    }
875}
876
877// ============================================================================
878// Helpers
879// ============================================================================
880
881fn capture_env() -> String {
882    let term = std::env::var("TERM").unwrap_or_default();
883    let colorterm = std::env::var("COLORTERM").unwrap_or_default();
884    let seed = std::env::var("STORM_SEED")
885        .ok()
886        .and_then(|s| s.parse::<u64>().ok())
887        .unwrap_or(0);
888
889    format!(
890        r#"{{"term":"{}","colorterm":"{}","env_seed":{}}}"#,
891        escape_json(&term),
892        escape_json(&colorterm),
893        seed
894    )
895}
896
897fn escape_json(s: &str) -> String {
898    s.replace('\\', "\\\\")
899        .replace('"', "\\\"")
900        .replace('\n', "\\n")
901        .replace('\r', "\\r")
902        .replace('\t', "\\t")
903}
904
905/// Get seed from environment or generate from time.
906pub fn get_storm_seed() -> u64 {
907    std::env::var("STORM_SEED")
908        .ok()
909        .and_then(|s| s.parse().ok())
910        .unwrap_or_else(|| {
911            let pid = std::process::id() as u64;
912            let time = SystemTime::now()
913                .duration_since(UNIX_EPOCH)
914                .unwrap_or_default()
915                .as_nanos() as u64;
916            pid.wrapping_mul(time)
917        })
918}
919
920/// Compute checksum of captured output.
921pub fn compute_output_checksum(data: &[u8]) -> String {
922    let mut hasher = DefaultHasher::new();
923    data.hash(&mut hasher);
924    format!("{:016x}", hasher.finish())
925}
926
927// ============================================================================
928// Integration with Flicker Detection
929// ============================================================================
930
931/// Analyze captured output for flicker.
932pub fn analyze_storm_output(output: &[u8], run_id: &str) -> FlickerAnalysis {
933    crate::flicker_detection::analyze_stream_with_id(run_id, output)
934}
935
936// ============================================================================
937// Tests
938// ============================================================================
939
940#[cfg(test)]
941mod tests {
942    use super::*;
943
944    #[test]
945    fn burst_pattern_generates_correct_count() {
946        let config = StormConfig::default()
947            .with_seed(42)
948            .with_pattern(StormPattern::Burst { count: 100 });
949
950        let storm = ResizeStorm::new(config);
951        assert_eq!(storm.events().len(), 100);
952    }
953
954    #[test]
955    fn sweep_pattern_interpolates_sizes() {
956        let config = StormConfig::default().with_pattern(StormPattern::Sweep {
957            start_width: 80,
958            start_height: 24,
959            end_width: 160,
960            end_height: 48,
961            steps: 5,
962        });
963
964        let storm = ResizeStorm::new(config);
965        let events = storm.events();
966
967        assert_eq!(events.len(), 5);
968        assert_eq!(events[0].width, 80);
969        assert_eq!(events[0].height, 24);
970        assert_eq!(events[4].width, 160);
971        assert_eq!(events[4].height, 48);
972    }
973
974    #[test]
975    fn oscillate_pattern_alternates() {
976        let config = StormConfig::default().with_pattern(StormPattern::Oscillate {
977            size_a: (80, 24),
978            size_b: (120, 40),
979            cycles: 3,
980        });
981
982        let storm = ResizeStorm::new(config);
983        let events = storm.events();
984
985        assert_eq!(events.len(), 6);
986        assert_eq!((events[0].width, events[0].height), (80, 24));
987        assert_eq!((events[1].width, events[1].height), (120, 40));
988        assert_eq!((events[2].width, events[2].height), (80, 24));
989    }
990
991    #[test]
992    fn deterministic_with_seed() {
993        let config = StormConfig::default()
994            .with_seed(12345)
995            .with_pattern(StormPattern::Burst { count: 50 });
996
997        let storm1 = ResizeStorm::new(config.clone());
998        let storm2 = ResizeStorm::new(config);
999
1000        assert_eq!(storm1.sequence_checksum(), storm2.sequence_checksum());
1001        assert_eq!(storm1.events(), storm2.events());
1002    }
1003
1004    #[test]
1005    fn different_seeds_produce_different_sequences() {
1006        let storm1 = ResizeStorm::new(
1007            StormConfig::default()
1008                .with_seed(1)
1009                .with_pattern(StormPattern::Burst { count: 50 }),
1010        );
1011        let storm2 = ResizeStorm::new(
1012            StormConfig::default()
1013                .with_seed(2)
1014                .with_pattern(StormPattern::Burst { count: 50 }),
1015        );
1016
1017        assert_ne!(storm1.sequence_checksum(), storm2.sequence_checksum());
1018    }
1019
1020    #[test]
1021    fn custom_pattern_uses_provided_events() {
1022        let custom_events = vec![(100, 50, 10), (80, 24, 20), (120, 40, 15)];
1023
1024        let config = StormConfig::default().with_pattern(StormPattern::Custom {
1025            events: custom_events,
1026        });
1027
1028        let storm = ResizeStorm::new(config);
1029        let events = storm.events();
1030
1031        assert_eq!(events.len(), 3);
1032        assert_eq!((events[0].width, events[0].height), (100, 50));
1033        assert_eq!(events[0].delay_ms, 10);
1034    }
1035
1036    #[test]
1037    fn mixed_pattern_combines_all() {
1038        let config = StormConfig::default()
1039            .with_seed(42)
1040            .with_pattern(StormPattern::Mixed { count: 100 });
1041
1042        let storm = ResizeStorm::new(config);
1043        assert_eq!(storm.events().len(), 100);
1044    }
1045
1046    #[test]
1047    fn pathological_pattern_includes_extremes() {
1048        let config = StormConfig::default()
1049            .with_seed(42)
1050            .with_pattern(StormPattern::Pathological { count: 16 });
1051
1052        let storm = ResizeStorm::new(config);
1053        let events = storm.events();
1054
1055        // Should include min/max sizes and zero delays
1056        assert!(events.iter().any(|e| e.delay_ms == 0));
1057        assert!(events.iter().any(|e| e.width == 20)); // min_width default
1058    }
1059
1060    #[test]
1061    fn storm_logger_produces_valid_jsonl() {
1062        let config = StormConfig::default()
1063            .with_seed(42)
1064            .with_case_name("test_case")
1065            .with_pattern(StormPattern::Burst { count: 5 });
1066
1067        let storm = ResizeStorm::new(config);
1068        let mut logger = StormLogger::new(storm.run_id());
1069        let caps = TerminalCapabilities::default();
1070
1071        logger.log_start(&storm, &caps);
1072        for event in storm.events() {
1073            logger.log_resize(event);
1074        }
1075        logger.log_complete("pass", 5, 1000, "abc123");
1076
1077        let jsonl = logger.to_jsonl();
1078        assert!(jsonl.contains(r#""event":"storm_start""#));
1079        assert!(jsonl.contains(r#""event":"storm_resize""#));
1080        assert!(jsonl.contains(r#""event":"storm_complete""#));
1081    }
1082
1083    #[test]
1084    fn recorded_storm_can_verify_replay() {
1085        let config = StormConfig::default()
1086            .with_seed(42)
1087            .with_pattern(StormPattern::Burst { count: 20 });
1088
1089        let storm1 = ResizeStorm::new(config.clone());
1090        let recorded = RecordedStorm::record(&storm1);
1091
1092        let storm2 = ResizeStorm::new(config);
1093        assert!(recorded.verify_replay(&storm2));
1094    }
1095
1096    #[test]
1097    fn terminal_capabilities_to_json() {
1098        let caps = TerminalCapabilities {
1099            term: "xterm-256color".to_string(),
1100            colorterm: "truecolor".to_string(),
1101            no_color: false,
1102            in_mux: true,
1103            mux_name: Some("tmux".to_string()),
1104            sync_output: true,
1105        };
1106
1107        let json = caps.to_json();
1108        assert!(json.contains(r#""term":"xterm-256color""#));
1109        assert!(json.contains(r#""in_mux":true"#));
1110        assert!(json.contains(r#""mux_name":"tmux""#));
1111    }
1112
1113    #[test]
1114    fn resize_event_to_jsonl() {
1115        let event = ResizeEvent::new(100, 50, 25, 3);
1116        let jsonl = event.to_jsonl(1500);
1117
1118        assert!(jsonl.contains(r#""width":100"#));
1119        assert!(jsonl.contains(r#""height":50"#));
1120        assert!(jsonl.contains(r#""delay_ms":25"#));
1121        assert!(jsonl.contains(r#""elapsed_ms":1500"#));
1122    }
1123
1124    #[test]
1125    fn total_duration_calculation() {
1126        let config = StormConfig::default().with_pattern(StormPattern::Custom {
1127            events: vec![(80, 24, 100), (100, 40, 200), (80, 24, 150)],
1128        });
1129
1130        let storm = ResizeStorm::new(config);
1131        assert_eq!(storm.total_duration_ms(), 450);
1132    }
1133
1134    #[test]
1135    fn size_bounds_are_respected() {
1136        let config = StormConfig::default()
1137            .with_seed(42)
1138            .with_size_bounds(50, 100, 20, 40)
1139            .with_pattern(StormPattern::Burst { count: 100 });
1140
1141        let storm = ResizeStorm::new(config);
1142
1143        for event in storm.events() {
1144            assert!(event.width >= 50 && event.width <= 100);
1145            assert!(event.height >= 20 && event.height <= 40);
1146        }
1147    }
1148
1149    // ─── Edge-case tests (bd-38ujl) ─────────────────────────────
1150
1151    #[test]
1152    fn pattern_name_all_variants() {
1153        assert_eq!(StormPattern::Burst { count: 1 }.name(), "burst");
1154        assert_eq!(
1155            StormPattern::Sweep {
1156                start_width: 80,
1157                start_height: 24,
1158                end_width: 160,
1159                end_height: 48,
1160                steps: 5
1161            }
1162            .name(),
1163            "sweep"
1164        );
1165        assert_eq!(
1166            StormPattern::Oscillate {
1167                size_a: (80, 24),
1168                size_b: (120, 40),
1169                cycles: 1
1170            }
1171            .name(),
1172            "oscillate"
1173        );
1174        assert_eq!(
1175            StormPattern::Pathological { count: 1 }.name(),
1176            "pathological"
1177        );
1178        assert_eq!(StormPattern::Mixed { count: 1 }.name(), "mixed");
1179        assert_eq!(StormPattern::Custom { events: Vec::new() }.name(), "custom");
1180    }
1181
1182    #[test]
1183    fn pattern_event_count_all_variants() {
1184        assert_eq!(StormPattern::Burst { count: 42 }.event_count(), 42);
1185        assert_eq!(
1186            StormPattern::Sweep {
1187                start_width: 80,
1188                start_height: 24,
1189                end_width: 160,
1190                end_height: 48,
1191                steps: 10
1192            }
1193            .event_count(),
1194            10
1195        );
1196        assert_eq!(
1197            StormPattern::Oscillate {
1198                size_a: (80, 24),
1199                size_b: (120, 40),
1200                cycles: 5
1201            }
1202            .event_count(),
1203            10 // cycles * 2
1204        );
1205        assert_eq!(StormPattern::Pathological { count: 7 }.event_count(), 7);
1206        assert_eq!(StormPattern::Mixed { count: 20 }.event_count(), 20);
1207        assert_eq!(
1208            StormPattern::Custom {
1209                events: vec![(80, 24, 10), (100, 50, 20)]
1210            }
1211            .event_count(),
1212            2
1213        );
1214    }
1215
1216    #[test]
1217    fn pattern_default_is_burst_50() {
1218        let pattern = StormPattern::default();
1219        assert_eq!(pattern, StormPattern::Burst { count: 50 });
1220    }
1221
1222    #[test]
1223    fn pattern_clone_and_eq() {
1224        let pattern = StormPattern::Oscillate {
1225            size_a: (80, 24),
1226            size_b: (120, 40),
1227            cycles: 3,
1228        };
1229        let cloned = pattern.clone();
1230        assert_eq!(pattern, cloned);
1231    }
1232
1233    #[test]
1234    fn pattern_debug_format() {
1235        let pattern = StormPattern::Burst { count: 5 };
1236        let debug = format!("{pattern:?}");
1237        assert!(debug.contains("Burst"));
1238        assert!(debug.contains("5"));
1239    }
1240
1241    #[test]
1242    fn config_default_values() {
1243        let config = StormConfig::default();
1244        assert_eq!(config.seed, 0);
1245        assert_eq!(config.initial_size, (80, 24));
1246        assert_eq!(config.min_delay_ms, 5);
1247        assert_eq!(config.max_delay_ms, 50);
1248        assert_eq!(config.min_width, 20);
1249        assert_eq!(config.max_width, 300);
1250        assert_eq!(config.min_height, 5);
1251        assert_eq!(config.max_height, 100);
1252        assert_eq!(config.case_name, "default");
1253        assert!(config.logging_enabled);
1254    }
1255
1256    #[test]
1257    fn config_builder_chain() {
1258        let config = StormConfig::default()
1259            .with_seed(99)
1260            .with_pattern(StormPattern::Pathological { count: 3 })
1261            .with_initial_size(100, 50)
1262            .with_delay_range(10, 100)
1263            .with_size_bounds(30, 200, 10, 80)
1264            .with_case_name("my_test")
1265            .with_logging(false);
1266
1267        assert_eq!(config.seed, 99);
1268        assert_eq!(config.initial_size, (100, 50));
1269        assert_eq!(config.min_delay_ms, 10);
1270        assert_eq!(config.max_delay_ms, 100);
1271        assert_eq!(config.min_width, 30);
1272        assert_eq!(config.max_width, 200);
1273        assert_eq!(config.min_height, 10);
1274        assert_eq!(config.max_height, 80);
1275        assert_eq!(config.case_name, "my_test");
1276        assert!(!config.logging_enabled);
1277        assert_eq!(config.pattern, StormPattern::Pathological { count: 3 });
1278    }
1279
1280    #[test]
1281    fn config_debug_format() {
1282        let config = StormConfig::default();
1283        let debug = format!("{config:?}");
1284        assert!(debug.contains("StormConfig"));
1285        assert!(debug.contains("seed"));
1286    }
1287
1288    #[test]
1289    fn config_clone() {
1290        let config = StormConfig::default().with_seed(42);
1291        let cloned = config.clone();
1292        assert_eq!(cloned.seed, 42);
1293    }
1294
1295    #[test]
1296    fn resize_event_fields() {
1297        let event = ResizeEvent::new(120, 40, 25, 7);
1298        assert_eq!(event.width, 120);
1299        assert_eq!(event.height, 40);
1300        assert_eq!(event.delay_ms, 25);
1301        assert_eq!(event.index, 7);
1302    }
1303
1304    #[test]
1305    fn resize_event_clone_eq_hash() {
1306        let event = ResizeEvent::new(80, 24, 10, 0);
1307        let cloned = event.clone();
1308        assert_eq!(event, cloned);
1309
1310        // Hash equality
1311        let mut h1 = DefaultHasher::new();
1312        let mut h2 = DefaultHasher::new();
1313        event.hash(&mut h1);
1314        cloned.hash(&mut h2);
1315        assert_eq!(h1.finish(), h2.finish());
1316    }
1317
1318    #[test]
1319    fn resize_event_debug_format() {
1320        let event = ResizeEvent::new(80, 24, 10, 0);
1321        let debug = format!("{event:?}");
1322        assert!(debug.contains("ResizeEvent"));
1323        assert!(debug.contains("80"));
1324    }
1325
1326    #[test]
1327    fn resize_event_to_jsonl_format() {
1328        let event = ResizeEvent::new(80, 24, 10, 3);
1329        let jsonl = event.to_jsonl(500);
1330        assert!(jsonl.starts_with('{'));
1331        assert!(jsonl.ends_with('}'));
1332        assert!(jsonl.contains(r#""event":"storm_resize""#));
1333        assert!(jsonl.contains(r#""idx":3"#));
1334        assert!(jsonl.contains(r#""width":80"#));
1335        assert!(jsonl.contains(r#""height":24"#));
1336        assert!(jsonl.contains(r#""delay_ms":10"#));
1337        assert!(jsonl.contains(r#""elapsed_ms":500"#));
1338    }
1339
1340    #[test]
1341    fn burst_zero_count() {
1342        let config = StormConfig::default()
1343            .with_seed(1)
1344            .with_pattern(StormPattern::Burst { count: 0 });
1345        let storm = ResizeStorm::new(config);
1346        assert!(storm.events().is_empty());
1347    }
1348
1349    #[test]
1350    fn burst_single_event() {
1351        let config = StormConfig::default()
1352            .with_seed(1)
1353            .with_pattern(StormPattern::Burst { count: 1 });
1354        let storm = ResizeStorm::new(config);
1355        assert_eq!(storm.events().len(), 1);
1356        assert_eq!(storm.events()[0].index, 0);
1357    }
1358
1359    #[test]
1360    fn sweep_single_step() {
1361        let config = StormConfig::default().with_pattern(StormPattern::Sweep {
1362            start_width: 80,
1363            start_height: 24,
1364            end_width: 160,
1365            end_height: 48,
1366            steps: 1,
1367        });
1368        let storm = ResizeStorm::new(config);
1369        assert_eq!(storm.events().len(), 1);
1370        // Single step should use t=1.0 → end values
1371        assert_eq!(storm.events()[0].width, 160);
1372        assert_eq!(storm.events()[0].height, 48);
1373    }
1374
1375    #[test]
1376    fn sweep_zero_steps() {
1377        let config = StormConfig::default().with_pattern(StormPattern::Sweep {
1378            start_width: 80,
1379            start_height: 24,
1380            end_width: 160,
1381            end_height: 48,
1382            steps: 0,
1383        });
1384        let storm = ResizeStorm::new(config);
1385        assert!(storm.events().is_empty());
1386    }
1387
1388    #[test]
1389    fn sweep_same_start_end() {
1390        let config = StormConfig::default().with_pattern(StormPattern::Sweep {
1391            start_width: 80,
1392            start_height: 24,
1393            end_width: 80,
1394            end_height: 24,
1395            steps: 5,
1396        });
1397        let storm = ResizeStorm::new(config);
1398        for event in storm.events() {
1399            assert_eq!(event.width, 80);
1400            assert_eq!(event.height, 24);
1401        }
1402    }
1403
1404    #[test]
1405    fn oscillate_zero_cycles() {
1406        let config = StormConfig::default().with_pattern(StormPattern::Oscillate {
1407            size_a: (80, 24),
1408            size_b: (120, 40),
1409            cycles: 0,
1410        });
1411        let storm = ResizeStorm::new(config);
1412        assert!(storm.events().is_empty());
1413    }
1414
1415    #[test]
1416    fn oscillate_single_cycle() {
1417        let config = StormConfig::default()
1418            .with_seed(42)
1419            .with_pattern(StormPattern::Oscillate {
1420                size_a: (80, 24),
1421                size_b: (120, 40),
1422                cycles: 1,
1423            });
1424        let storm = ResizeStorm::new(config);
1425        assert_eq!(storm.events().len(), 2);
1426        assert_eq!(
1427            (storm.events()[0].width, storm.events()[0].height),
1428            (80, 24)
1429        );
1430        assert_eq!(
1431            (storm.events()[1].width, storm.events()[1].height),
1432            (120, 40)
1433        );
1434    }
1435
1436    #[test]
1437    fn pathological_zero_count() {
1438        let config = StormConfig::default()
1439            .with_seed(1)
1440            .with_pattern(StormPattern::Pathological { count: 0 });
1441        let storm = ResizeStorm::new(config);
1442        assert!(storm.events().is_empty());
1443    }
1444
1445    #[test]
1446    fn pathological_covers_all_8_patterns() {
1447        let config = StormConfig::default()
1448            .with_seed(42)
1449            .with_pattern(StormPattern::Pathological { count: 8 });
1450        let storm = ResizeStorm::new(config);
1451        assert_eq!(storm.events().len(), 8);
1452
1453        // Pattern 0: min_width, min_height, delay=0
1454        assert_eq!(storm.events()[0].width, 20);
1455        assert_eq!(storm.events()[0].height, 5);
1456        assert_eq!(storm.events()[0].delay_ms, 0);
1457
1458        // Pattern 1: max_width, max_height, delay=0
1459        assert_eq!(storm.events()[1].width, 300);
1460        assert_eq!(storm.events()[1].height, 100);
1461        assert_eq!(storm.events()[1].delay_ms, 0);
1462
1463        // Pattern 2: extreme minimum 1x1
1464        assert_eq!(storm.events()[2].width, 1);
1465        assert_eq!(storm.events()[2].height, 1);
1466
1467        // Pattern 3: large 500x200
1468        assert_eq!(storm.events()[3].width, 500);
1469        assert_eq!(storm.events()[3].height, 200);
1470
1471        // Pattern 4: normal 80x24 with long delay
1472        assert_eq!(storm.events()[4].width, 80);
1473        assert_eq!(storm.events()[4].height, 24);
1474        assert_eq!(storm.events()[4].delay_ms, 500);
1475    }
1476
1477    #[test]
1478    fn mixed_zero_count() {
1479        let config = StormConfig::default()
1480            .with_seed(1)
1481            .with_pattern(StormPattern::Mixed { count: 0 });
1482        let storm = ResizeStorm::new(config);
1483        assert!(storm.events().is_empty());
1484    }
1485
1486    #[test]
1487    fn custom_empty_events() {
1488        let config =
1489            StormConfig::default().with_pattern(StormPattern::Custom { events: Vec::new() });
1490        let storm = ResizeStorm::new(config);
1491        assert!(storm.events().is_empty());
1492    }
1493
1494    #[test]
1495    fn custom_preserves_order_and_indices() {
1496        let config = StormConfig::default().with_pattern(StormPattern::Custom {
1497            events: vec![(80, 24, 10), (100, 50, 20), (60, 15, 5)],
1498        });
1499        let storm = ResizeStorm::new(config);
1500        let events = storm.events();
1501        assert_eq!(events[0].index, 0);
1502        assert_eq!(events[1].index, 1);
1503        assert_eq!(events[2].index, 2);
1504        assert_eq!((events[2].width, events[2].height), (60, 15));
1505    }
1506
1507    #[test]
1508    fn storm_run_id_is_nonempty() {
1509        let storm = ResizeStorm::new(StormConfig::default());
1510        assert!(!storm.run_id().is_empty());
1511    }
1512
1513    #[test]
1514    fn storm_config_accessor() {
1515        let config = StormConfig::default().with_seed(77);
1516        let storm = ResizeStorm::new(config);
1517        assert_eq!(storm.config().seed, 77);
1518    }
1519
1520    #[test]
1521    fn storm_total_duration_zero_delays() {
1522        let config = StormConfig::default().with_pattern(StormPattern::Custom {
1523            events: vec![(80, 24, 0), (100, 50, 0), (60, 15, 0)],
1524        });
1525        let storm = ResizeStorm::new(config);
1526        assert_eq!(storm.total_duration_ms(), 0);
1527    }
1528
1529    #[test]
1530    fn storm_sequence_checksum_deterministic() {
1531        let config = StormConfig::default()
1532            .with_seed(42)
1533            .with_pattern(StormPattern::Burst { count: 10 });
1534        let storm1 = ResizeStorm::new(config.clone());
1535        let storm2 = ResizeStorm::new(config);
1536        assert_eq!(storm1.sequence_checksum(), storm2.sequence_checksum());
1537    }
1538
1539    #[test]
1540    fn storm_sequence_checksum_format() {
1541        let storm = ResizeStorm::new(StormConfig::default().with_seed(42));
1542        let checksum = storm.sequence_checksum();
1543        assert_eq!(checksum.len(), 16, "checksum should be 16 hex chars");
1544        assert!(
1545            checksum.chars().all(|c| c.is_ascii_hexdigit()),
1546            "checksum should be hex"
1547        );
1548    }
1549
1550    #[test]
1551    fn storm_debug_format() {
1552        let storm = ResizeStorm::new(StormConfig::default().with_seed(42));
1553        let debug = format!("{storm:?}");
1554        assert!(debug.contains("ResizeStorm"));
1555    }
1556
1557    #[test]
1558    fn storm_clone() {
1559        let storm = ResizeStorm::new(
1560            StormConfig::default()
1561                .with_seed(42)
1562                .with_pattern(StormPattern::Burst { count: 5 }),
1563        );
1564        let cloned = storm.clone();
1565        assert_eq!(storm.events(), cloned.events());
1566        assert_eq!(storm.sequence_checksum(), cloned.sequence_checksum());
1567    }
1568
1569    #[test]
1570    fn logger_log_capture() {
1571        let mut logger = StormLogger::new("test-run");
1572        logger.log_capture(3, 2048, "checksum123", true);
1573        let jsonl = logger.to_jsonl();
1574        assert!(jsonl.contains(r#""event":"storm_capture""#));
1575        assert!(jsonl.contains(r#""idx":3"#));
1576        assert!(jsonl.contains(r#""bytes_captured":2048"#));
1577        assert!(jsonl.contains(r#""checksum":"checksum123""#));
1578        assert!(jsonl.contains(r#""flicker_free":true"#));
1579    }
1580
1581    #[test]
1582    fn logger_log_capture_flicker_false() {
1583        let mut logger = StormLogger::new("test-run");
1584        logger.log_capture(0, 512, "abc", false);
1585        let jsonl = logger.to_jsonl();
1586        assert!(jsonl.contains(r#""flicker_free":false"#));
1587    }
1588
1589    #[test]
1590    fn logger_log_error() {
1591        let mut logger = StormLogger::new("test-run");
1592        logger.log_error("something went wrong");
1593        let jsonl = logger.to_jsonl();
1594        assert!(jsonl.contains(r#""event":"storm_error""#));
1595        assert!(jsonl.contains("something went wrong"));
1596    }
1597
1598    #[test]
1599    fn logger_log_error_special_chars() {
1600        let mut logger = StormLogger::new("test-run");
1601        logger.log_error("error with \"quotes\" and \nnewline");
1602        let jsonl = logger.to_jsonl();
1603        assert!(jsonl.contains(r#"\"quotes\""#));
1604        assert!(jsonl.contains(r#"\n"#));
1605    }
1606
1607    #[test]
1608    fn logger_empty() {
1609        let logger = StormLogger::new("test-run");
1610        let jsonl = logger.to_jsonl();
1611        assert!(jsonl.is_empty());
1612    }
1613
1614    #[test]
1615    fn logger_line_count() {
1616        let config = StormConfig::default()
1617            .with_seed(42)
1618            .with_case_name("line_test")
1619            .with_pattern(StormPattern::Burst { count: 3 });
1620        let storm = ResizeStorm::new(config);
1621        let mut logger = StormLogger::new(storm.run_id());
1622        let caps = TerminalCapabilities::default();
1623
1624        logger.log_start(&storm, &caps);
1625        for event in storm.events() {
1626            logger.log_resize(event);
1627        }
1628        logger.log_complete("pass", 3, 500, "abc");
1629
1630        let jsonl = logger.to_jsonl();
1631        let line_count = jsonl.lines().count();
1632        // 1 start + 3 resize + 1 complete = 5
1633        assert_eq!(line_count, 5);
1634    }
1635
1636    #[test]
1637    fn terminal_capabilities_default() {
1638        let caps = TerminalCapabilities::default();
1639        assert_eq!(caps.term, "");
1640        assert_eq!(caps.colorterm, "");
1641        assert!(!caps.no_color);
1642        assert!(!caps.in_mux);
1643        assert!(caps.mux_name.is_none());
1644        assert!(!caps.sync_output);
1645    }
1646
1647    #[test]
1648    fn terminal_capabilities_to_json_null_mux() {
1649        let caps = TerminalCapabilities {
1650            mux_name: None,
1651            ..Default::default()
1652        };
1653        let json = caps.to_json();
1654        assert!(json.contains(r#""mux_name":null"#));
1655    }
1656
1657    #[test]
1658    fn terminal_capabilities_clone() {
1659        let caps = TerminalCapabilities {
1660            term: "xterm".to_string(),
1661            in_mux: true,
1662            mux_name: Some("tmux".to_string()),
1663            ..Default::default()
1664        };
1665        let cloned = caps.clone();
1666        assert_eq!(cloned.term, "xterm");
1667        assert!(cloned.in_mux);
1668        assert_eq!(cloned.mux_name.as_deref(), Some("tmux"));
1669    }
1670
1671    #[test]
1672    fn terminal_capabilities_debug() {
1673        let caps = TerminalCapabilities::default();
1674        let debug = format!("{caps:?}");
1675        assert!(debug.contains("TerminalCapabilities"));
1676    }
1677
1678    #[test]
1679    fn recorded_storm_record_with_output() {
1680        let config = StormConfig::default()
1681            .with_seed(42)
1682            .with_pattern(StormPattern::Burst { count: 5 });
1683        let storm = ResizeStorm::new(config);
1684
1685        let recorded = RecordedStorm::record_with_output(&storm, "output_hash".to_string());
1686        assert_eq!(
1687            recorded.expected_output_checksum.as_deref(),
1688            Some("output_hash")
1689        );
1690    }
1691
1692    #[test]
1693    fn recorded_storm_verify_replay_different_seed_fails() {
1694        let config1 = StormConfig::default()
1695            .with_seed(42)
1696            .with_pattern(StormPattern::Burst { count: 10 });
1697        let storm1 = ResizeStorm::new(config1);
1698        let recorded = RecordedStorm::record(&storm1);
1699
1700        let config2 = StormConfig::default()
1701            .with_seed(99)
1702            .with_pattern(StormPattern::Burst { count: 10 });
1703        let storm2 = ResizeStorm::new(config2);
1704
1705        assert!(!recorded.verify_replay(&storm2));
1706    }
1707
1708    #[test]
1709    fn recorded_storm_to_json_format() {
1710        let config = StormConfig::default()
1711            .with_seed(42)
1712            .with_case_name("json_test")
1713            .with_pattern(StormPattern::Burst { count: 2 });
1714        let storm = ResizeStorm::new(config);
1715        let recorded = RecordedStorm::record(&storm);
1716
1717        let json = recorded.to_json();
1718        assert!(json.starts_with('{'));
1719        assert!(json.ends_with('}'));
1720        assert!(json.contains(r#""seed":42"#));
1721        assert!(json.contains(r#""pattern":"burst""#));
1722        assert!(json.contains(r#""case_name":"json_test""#));
1723        assert!(json.contains(r#""sequence_checksum":""#));
1724        assert!(json.contains(r#""expected_output_checksum":null"#));
1725    }
1726
1727    #[test]
1728    fn recorded_storm_to_json_with_output_checksum() {
1729        let config = StormConfig::default()
1730            .with_seed(42)
1731            .with_pattern(StormPattern::Burst { count: 1 });
1732        let storm = ResizeStorm::new(config);
1733        let recorded = RecordedStorm::record_with_output(&storm, "deadbeef".to_string());
1734
1735        let json = recorded.to_json();
1736        assert!(json.contains(r#""expected_output_checksum":"deadbeef""#));
1737    }
1738
1739    #[test]
1740    fn recorded_storm_clone_debug() {
1741        let config = StormConfig::default()
1742            .with_seed(42)
1743            .with_pattern(StormPattern::Burst { count: 3 });
1744        let storm = ResizeStorm::new(config);
1745        let recorded = RecordedStorm::record(&storm);
1746
1747        let cloned = recorded.clone();
1748        assert_eq!(cloned.sequence_checksum, recorded.sequence_checksum);
1749
1750        let debug = format!("{recorded:?}");
1751        assert!(debug.contains("RecordedStorm"));
1752    }
1753
1754    #[test]
1755    fn escape_json_special_chars() {
1756        assert_eq!(escape_json(r#"hello "world""#), r#"hello \"world\""#);
1757        assert_eq!(escape_json("line1\nline2"), r#"line1\nline2"#);
1758        assert_eq!(escape_json("tab\there"), r#"tab\there"#);
1759        assert_eq!(escape_json("cr\rhere"), r#"cr\rhere"#);
1760        assert_eq!(escape_json(r"back\slash"), r"back\\slash");
1761    }
1762
1763    #[test]
1764    fn escape_json_empty() {
1765        assert_eq!(escape_json(""), "");
1766    }
1767
1768    #[test]
1769    fn escape_json_no_special() {
1770        assert_eq!(escape_json("hello world"), "hello world");
1771    }
1772
1773    #[test]
1774    fn compute_output_checksum_deterministic() {
1775        let data = b"hello world";
1776        let c1 = compute_output_checksum(data);
1777        let c2 = compute_output_checksum(data);
1778        assert_eq!(c1, c2);
1779    }
1780
1781    #[test]
1782    fn compute_output_checksum_format() {
1783        let checksum = compute_output_checksum(b"test");
1784        assert_eq!(checksum.len(), 16);
1785        assert!(checksum.chars().all(|c| c.is_ascii_hexdigit()));
1786    }
1787
1788    #[test]
1789    fn compute_output_checksum_different_data() {
1790        let c1 = compute_output_checksum(b"hello");
1791        let c2 = compute_output_checksum(b"world");
1792        assert_ne!(c1, c2);
1793    }
1794
1795    #[test]
1796    fn compute_output_checksum_empty() {
1797        let checksum = compute_output_checksum(b"");
1798        assert_eq!(checksum.len(), 16);
1799    }
1800
1801    #[test]
1802    fn storm_result_debug() {
1803        let result = StormResult {
1804            passed: true,
1805            total_resizes: 10,
1806            total_bytes: 5000,
1807            duration_ms: 100,
1808            flicker_analysis: None,
1809            sequence_checksum: "abc".to_string(),
1810            output_checksum: "def".to_string(),
1811            jsonl: String::new(),
1812            errors: Vec::new(),
1813        };
1814        let debug = format!("{result:?}");
1815        assert!(debug.contains("StormResult"));
1816        assert!(debug.contains("passed: true"));
1817    }
1818
1819    #[test]
1820    fn burst_events_have_sequential_indices() {
1821        let config = StormConfig::default()
1822            .with_seed(42)
1823            .with_pattern(StormPattern::Burst { count: 10 });
1824        let storm = ResizeStorm::new(config);
1825        for (i, event) in storm.events().iter().enumerate() {
1826            assert_eq!(event.index, i, "event at position {i} has wrong index");
1827        }
1828    }
1829
1830    #[test]
1831    fn sweep_midpoint_interpolation() {
1832        let config = StormConfig::default().with_pattern(StormPattern::Sweep {
1833            start_width: 80,
1834            start_height: 20,
1835            end_width: 120,
1836            end_height: 40,
1837            steps: 3,
1838        });
1839        let storm = ResizeStorm::new(config);
1840        let events = storm.events();
1841
1842        assert_eq!(events[0].width, 80);
1843        assert_eq!(events[0].height, 20);
1844        // Midpoint: t=0.5 → 100, 30
1845        assert_eq!(events[1].width, 100);
1846        assert_eq!(events[1].height, 30);
1847        assert_eq!(events[2].width, 120);
1848        assert_eq!(events[2].height, 40);
1849    }
1850
1851    #[test]
1852    fn sweep_delay_is_average_of_range() {
1853        let config = StormConfig::default()
1854            .with_delay_range(10, 50)
1855            .with_pattern(StormPattern::Sweep {
1856                start_width: 80,
1857                start_height: 24,
1858                end_width: 160,
1859                end_height: 48,
1860                steps: 3,
1861            });
1862        let storm = ResizeStorm::new(config);
1863        // Sweep uses (min + max) / 2 = (10 + 50) / 2 = 30
1864        for event in storm.events() {
1865            assert_eq!(event.delay_ms, 30);
1866        }
1867    }
1868
1869    #[test]
1870    fn mixed_events_have_correct_count() {
1871        for count in [4, 12, 40, 100] {
1872            let config = StormConfig::default()
1873                .with_seed(42)
1874                .with_pattern(StormPattern::Mixed { count });
1875            let storm = ResizeStorm::new(config);
1876            assert_eq!(
1877                storm.events().len(),
1878                count,
1879                "mixed pattern should produce exactly {count} events"
1880            );
1881        }
1882    }
1883
1884    #[test]
1885    fn logger_log_complete_fields() {
1886        let mut logger = StormLogger::new("run-123");
1887        logger.log_complete("fail", 25, 10000, "xyz789");
1888        let jsonl = logger.to_jsonl();
1889        assert!(jsonl.contains(r#""event":"storm_complete""#));
1890        assert!(jsonl.contains(r#""outcome":"fail""#));
1891        assert!(jsonl.contains(r#""total_resizes":25"#));
1892        assert!(jsonl.contains(r#""total_bytes":10000"#));
1893        assert!(jsonl.contains(r#""checksum":"xyz789""#));
1894        assert!(jsonl.contains(r#""duration_ms":"#));
1895    }
1896
1897    #[test]
1898    fn logger_log_start_includes_pattern_and_event_count() {
1899        let config = StormConfig::default()
1900            .with_seed(7)
1901            .with_case_name("start_case")
1902            .with_pattern(StormPattern::Burst { count: 3 });
1903        let storm = ResizeStorm::new(config);
1904        let mut logger = StormLogger::new(storm.run_id());
1905        let caps = TerminalCapabilities::default();
1906
1907        logger.log_start(&storm, &caps);
1908        let jsonl = logger.to_jsonl();
1909
1910        assert!(jsonl.contains(r#""event":"storm_start""#));
1911        assert!(jsonl.contains(r#""case":"start_case""#));
1912        assert!(jsonl.contains(r#""pattern":"burst""#));
1913        assert!(jsonl.contains(r#""event_count":3"#));
1914        assert!(jsonl.contains(r#""capabilities":"#));
1915    }
1916
1917    #[test]
1918    fn logger_log_resize_uses_event_jsonl_shape() {
1919        let mut logger = StormLogger::new("run-resize");
1920        let event = ResizeEvent::new(111, 37, 12, 4);
1921        logger.log_resize(&event);
1922
1923        let jsonl = logger.to_jsonl();
1924        assert!(jsonl.contains(r#""event":"storm_resize""#));
1925        assert!(jsonl.contains(r#""idx":4"#));
1926        assert!(jsonl.contains(r#""width":111"#));
1927        assert!(jsonl.contains(r#""height":37"#));
1928        assert!(jsonl.contains(r#""delay_ms":12"#));
1929        assert!(jsonl.contains(r#""elapsed_ms":"#));
1930    }
1931
1932    #[test]
1933    fn logger_write_to_file_roundtrip() {
1934        let mut logger = StormLogger::new("run-file");
1935        logger.log_error("disk test");
1936
1937        let path = std::env::temp_dir().join(format!(
1938            "resize_storm_logger_{}_{}.jsonl",
1939            std::process::id(),
1940            SystemTime::now()
1941                .duration_since(UNIX_EPOCH)
1942                .unwrap_or_default()
1943                .as_nanos()
1944        ));
1945
1946        logger
1947            .write_to_file(&path)
1948            .expect("write_to_file should succeed");
1949        let body = std::fs::read_to_string(&path).expect("file should be readable");
1950        let _ = std::fs::remove_file(&path);
1951
1952        assert!(body.contains(r#""event":"storm_error""#));
1953        assert!(body.ends_with('\n'));
1954    }
1955
1956    #[test]
1957    fn terminal_capabilities_to_json_escapes_special_chars() {
1958        let caps = TerminalCapabilities {
1959            term: "xterm\"weird".to_string(),
1960            colorterm: "line1\nline2".to_string(),
1961            no_color: false,
1962            in_mux: true,
1963            mux_name: Some("tmux\\session".to_string()),
1964            sync_output: true,
1965        };
1966
1967        let json = caps.to_json();
1968        assert!(json.contains(r#""term":"xterm\"weird""#));
1969        assert!(json.contains(r#""colorterm":"line1\nline2""#));
1970        assert!(json.contains(r#""mux_name":"tmux\\session""#));
1971    }
1972
1973    #[test]
1974    fn recorded_storm_record_copies_config_events_and_checksum() {
1975        let config = StormConfig::default()
1976            .with_seed(123)
1977            .with_case_name("record-copy")
1978            .with_pattern(StormPattern::Burst { count: 6 });
1979        let storm = ResizeStorm::new(config);
1980        let recorded = RecordedStorm::record(&storm);
1981
1982        assert_eq!(recorded.config.seed, 123);
1983        assert_eq!(recorded.config.case_name, "record-copy");
1984        assert_eq!(recorded.events, storm.events);
1985        assert_eq!(recorded.sequence_checksum, storm.sequence_checksum());
1986        assert!(recorded.expected_output_checksum.is_none());
1987    }
1988
1989    #[test]
1990    fn compute_output_checksum_binary_payload_is_stable() {
1991        let bytes = [0_u8, 255, 17, 42, b'\n', b'"', b'\\'];
1992        let first = compute_output_checksum(&bytes);
1993        let second = compute_output_checksum(&bytes);
1994        assert_eq!(first, second);
1995        assert_eq!(first.len(), 16);
1996    }
1997}