1#![forbid(unsafe_code)]
2
3use 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#[derive(Debug, Clone, PartialEq)]
53pub enum StormPattern {
54 Burst {
56 count: usize,
58 },
59 Sweep {
61 start_width: u16,
63 start_height: u16,
65 end_width: u16,
67 end_height: u16,
69 steps: usize,
71 },
72 Oscillate {
74 size_a: (u16, u16),
76 size_b: (u16, u16),
78 cycles: usize,
80 },
81 Pathological {
83 count: usize,
85 },
86 Mixed {
88 count: usize,
90 },
91 Custom {
93 events: Vec<(u16, u16, u64)>,
95 },
96}
97
98impl StormPattern {
99 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 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#[derive(Debug, Clone)]
132pub struct StormConfig {
133 pub seed: u64,
135 pub pattern: StormPattern,
137 pub initial_size: (u16, u16),
139 pub min_delay_ms: u64,
141 pub max_delay_ms: u64,
143 pub min_width: u16,
145 pub max_width: u16,
147 pub min_height: u16,
149 pub max_height: u16,
151 pub case_name: String,
153 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 #[must_use]
178 pub fn with_seed(mut self, seed: u64) -> Self {
179 self.seed = seed;
180 self
181 }
182
183 #[must_use]
185 pub fn with_pattern(mut self, pattern: StormPattern) -> Self {
186 self.pattern = pattern;
187 self
188 }
189
190 #[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 #[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 #[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 #[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 #[must_use]
230 pub fn with_logging(mut self, enabled: bool) -> Self {
231 self.logging_enabled = enabled;
232 self
233 }
234}
235
236#[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 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
288pub struct ResizeEvent {
289 pub width: u16,
291 pub height: u16,
293 pub delay_ms: u64,
295 pub index: usize,
297}
298
299impl ResizeEvent {
300 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 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#[derive(Debug, Clone)]
325pub struct ResizeStorm {
326 config: StormConfig,
327 events: Vec<ResizeEvent>,
328 run_id: String,
329}
330
331impl ResizeStorm {
332 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 pub fn run_id(&self) -> &str {
353 &self.run_id
354 }
355
356 pub fn events(&self) -> &[ResizeEvent] {
358 &self.events
359 }
360
361 pub fn config(&self) -> &StormConfig {
363 &self.config
364 }
365
366 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 let delay = rng.next_range(self.config.min_delay_ms, self.config.max_delay_ms / 2);
401
402 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), 1 => (self.config.max_width, self.config.max_height, 0), 2 => (1, 1, 1), 3 => (500, 200, 1), 4 => (80, 24, 500), 5 => {
480 (
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)), 7 => {
489 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 let burst = self.generate_burst(rng, segment);
510 events.extend(burst);
511
512 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 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 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 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 pub fn total_duration_ms(&self) -> u64 {
548 self.events.iter().map(|e| e.delay_ms).sum()
549 }
550}
551
552pub struct StormLogger {
558 lines: Vec<String>,
559 run_id: String,
560 start_time: Instant,
561}
562
563impl StormLogger {
564 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 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 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 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 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 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 pub fn to_jsonl(&self) -> String {
641 self.lines.join("\n")
642 }
643
644 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#[derive(Debug, Clone, Default)]
660pub struct TerminalCapabilities {
661 pub term: String,
663 pub colorterm: String,
665 pub no_color: bool,
667 pub in_mux: bool,
669 pub mux_name: Option<String>,
671 pub sync_output: bool,
673}
674
675impl TerminalCapabilities {
676 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 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 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#[derive(Debug)]
747pub struct StormResult {
748 pub passed: bool,
750 pub total_resizes: usize,
752 pub total_bytes: usize,
754 pub duration_ms: u64,
756 pub flicker_analysis: Option<FlickerAnalysis>,
758 pub sequence_checksum: String,
760 pub output_checksum: String,
762 pub jsonl: String,
764 pub errors: Vec<String>,
766}
767
768impl StormResult {
769 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#[derive(Debug, Clone)]
813pub struct RecordedStorm {
814 pub config: StormConfig,
816 pub events: Vec<ResizeEvent>,
818 pub sequence_checksum: String,
820 pub expected_output_checksum: Option<String>,
822}
823
824impl RecordedStorm {
825 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 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 pub fn verify_replay(&self, storm: &ResizeStorm) -> bool {
844 self.sequence_checksum == storm.sequence_checksum()
845 }
846
847 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
877fn 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
905pub 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
920pub 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
927pub fn analyze_storm_output(output: &[u8], run_id: &str) -> FlickerAnalysis {
933 crate::flicker_detection::analyze_stream_with_id(run_id, output)
934}
935
936#[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 assert!(events.iter().any(|e| e.delay_ms == 0));
1057 assert!(events.iter().any(|e| e.width == 20)); }
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 #[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 );
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 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 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 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 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 assert_eq!(storm.events()[2].width, 1);
1465 assert_eq!(storm.events()[2].height, 1);
1466
1467 assert_eq!(storm.events()[3].width, 500);
1469 assert_eq!(storm.events()[3].height, 200);
1470
1471 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 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 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 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}