1#![allow(clippy::must_use_candidate)]
14#![allow(clippy::missing_errors_doc)]
15#![allow(clippy::match_same_arms)]
16
17use serde::{Deserialize, Serialize};
18use std::env;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum TestTier {
23 #[default]
25 Smoke,
26 Regression,
28 Full,
30}
31
32impl TestTier {
33 pub fn from_env() -> Self {
35 match env::var("TEST_TIER").as_deref() {
36 Ok("smoke") => Self::Smoke,
37 Ok("regression") => Self::Regression,
38 Ok("full") => Self::Full,
39 _ => Self::Smoke, }
41 }
42}
43
44#[derive(Debug, Clone)]
61pub struct MonteCarloConfig {
62 pub tier: TestTier,
64 pub seed_start: u64,
66 pub seed_end: u64,
68 pub batch_timeout: u64,
70 pub confidence: f64,
72 pub frames_per_sim: usize,
74}
75
76impl MonteCarloConfig {
77 pub fn smoke() -> Self {
79 Self {
80 tier: TestTier::Smoke,
81 seed_start: 42,
82 seed_end: 42,
83 batch_timeout: 10,
84 confidence: 0.95,
85 frames_per_sim: 600, }
87 }
88
89 pub fn regression() -> Self {
91 Self {
92 tier: TestTier::Regression,
93 seed_start: 0,
94 seed_end: 49,
95 batch_timeout: 60,
96 confidence: 0.95,
97 frames_per_sim: 1800, }
99 }
100
101 pub fn full() -> Self {
103 Self {
104 tier: TestTier::Full,
105 seed_start: 0,
106 seed_end: 999,
107 batch_timeout: 300,
108 confidence: 0.99,
109 frames_per_sim: 3600, }
111 }
112
113 pub fn from_env() -> Self {
115 match TestTier::from_env() {
116 TestTier::Smoke => Self::smoke(),
117 TestTier::Regression => Self::regression(),
118 TestTier::Full => Self::full(),
119 }
120 }
121
122 pub fn iterations(&self) -> usize {
124 (self.seed_end - self.seed_start + 1) as usize
125 }
126}
127
128impl Default for MonteCarloConfig {
129 fn default() -> Self {
130 Self::smoke()
131 }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct GameStateSnapshot {
137 pub ball_x: f64,
139 pub ball_y: f64,
141 pub ball_vx: f64,
143 pub ball_vy: f64,
145 pub left_paddle_y: f64,
147 pub right_paddle_y: f64,
149 pub score_left: u32,
151 pub score_right: u32,
153 pub rally: u32,
155 pub game_state: String,
157 pub game_mode: String,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct TimestampedInput {
164 pub frame: usize,
166 pub timestamp_ms: f64,
168 pub event: String,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct FailureReplay {
175 pub action_id: u32,
177 pub action_name: String,
179 pub seed: u64,
181 pub tier: String,
183 pub input_trace: Vec<TimestampedInput>,
185 pub failure_frame: usize,
187 pub assertion: String,
189 pub expected: String,
191 pub actual: String,
193 pub state_snapshot: GameStateSnapshot,
195 pub timestamp: String,
197}
198
199impl FailureReplay {
200 pub fn save(&self) -> std::io::Result<String> {
202 let filename = format!(
203 "target/test-failures/failure-{}-action-{:03}-seed-{}.json",
204 chrono_lite_timestamp(),
205 self.action_id,
206 self.seed
207 );
208
209 std::fs::create_dir_all("target/test-failures")?;
211
212 let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
213 std::fs::write(&filename, json)?;
214
215 Ok(filename)
216 }
217}
218
219fn chrono_lite_timestamp() -> String {
221 use std::time::{SystemTime, UNIX_EPOCH};
222 let duration = SystemTime::now()
223 .duration_since(UNIX_EPOCH)
224 .unwrap_or_default();
225 format!("{}", duration.as_secs())
226}
227
228#[derive(Debug)]
230pub enum TestResult {
231 Pass,
233 Fail {
235 assertion: String,
237 expected: String,
239 actual: String,
241 state: GameStateSnapshot,
243 },
244}
245
246impl TestResult {
247 pub fn fail<T: std::fmt::Display>(
249 assertion: &str,
250 expected: &str,
251 actual: T,
252 state: GameStateSnapshot,
253 ) -> Self {
254 Self::Fail {
255 assertion: assertion.to_string(),
256 expected: expected.to_string(),
257 actual: actual.to_string(),
258 state,
259 }
260 }
261
262 pub fn is_pass(&self) -> bool {
264 matches!(self, Self::Pass)
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
270pub enum InvariantViolation {
271 BallPositionNaN,
273 BallPositionInfinite,
275 PaddleOutOfBounds,
277 ScoreOverflow,
279 RallyOverflow,
281 VelocityNaN,
283 VelocityInfinite,
285}
286
287impl std::fmt::Display for InvariantViolation {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 match self {
290 Self::BallPositionNaN => write!(f, "Ball position is NaN"),
291 Self::BallPositionInfinite => write!(f, "Ball position is infinite"),
292 Self::PaddleOutOfBounds => write!(f, "Paddle is out of bounds"),
293 Self::ScoreOverflow => write!(f, "Score overflow"),
294 Self::RallyOverflow => write!(f, "Rally overflow"),
295 Self::VelocityNaN => write!(f, "Velocity is NaN"),
296 Self::VelocityInfinite => write!(f, "Velocity is infinite"),
297 }
298 }
299}
300
301impl std::error::Error for InvariantViolation {}
302
303pub fn check_invariants(
342 snapshot: &GameStateSnapshot,
343 max_y: f64,
344) -> Result<(), InvariantViolation> {
345 if snapshot.ball_x.is_nan() || snapshot.ball_y.is_nan() {
347 return Err(InvariantViolation::BallPositionNaN);
348 }
349
350 if snapshot.ball_x.is_infinite() || snapshot.ball_y.is_infinite() {
352 return Err(InvariantViolation::BallPositionInfinite);
353 }
354
355 if snapshot.ball_vx.is_nan() || snapshot.ball_vy.is_nan() {
357 return Err(InvariantViolation::VelocityNaN);
358 }
359
360 if snapshot.ball_vx.is_infinite() || snapshot.ball_vy.is_infinite() {
362 return Err(InvariantViolation::VelocityInfinite);
363 }
364
365 if snapshot.left_paddle_y < -10.0 || snapshot.left_paddle_y > max_y + 10.0 {
367 return Err(InvariantViolation::PaddleOutOfBounds);
368 }
369 if snapshot.right_paddle_y < -10.0 || snapshot.right_paddle_y > max_y + 10.0 {
370 return Err(InvariantViolation::PaddleOutOfBounds);
371 }
372
373 if snapshot.score_left > 1000 || snapshot.score_right > 1000 {
375 return Err(InvariantViolation::ScoreOverflow);
376 }
377
378 if snapshot.rally > 100_000 {
380 return Err(InvariantViolation::RallyOverflow);
381 }
382
383 Ok(())
384}
385
386#[derive(Debug)]
388pub struct FuzzGenerator;
389
390impl FuzzGenerator {
391 #[must_use]
393 pub fn numeric_extremes() -> Vec<f64> {
394 vec![
395 0.0,
396 -0.0,
397 f64::MIN_POSITIVE,
398 f64::MAX,
399 f64::MIN,
400 f64::EPSILON,
401 f64::NAN,
402 f64::INFINITY,
403 f64::NEG_INFINITY,
404 1e-300,
405 1e300,
406 ]
407 }
408
409 #[must_use]
411 pub fn dt_extremes() -> Vec<f64> {
412 vec![
413 0.0, 0.001, 16.667, 33.333, 100.0, 1000.0, 5000.0, 10000.0, ]
422 }
423
424 #[must_use]
426 pub fn position_extremes(width: f64, height: f64) -> Vec<(f64, f64)> {
427 vec![
428 (0.0, 0.0),
429 (width, height),
430 (-1.0, -1.0),
431 (width + 1.0, height + 1.0),
432 (width / 2.0, height / 2.0),
433 (-1000.0, -1000.0),
434 (width + 1000.0, height + 1000.0),
435 ]
436 }
437
438 #[must_use]
440 pub fn velocity_extremes() -> Vec<(f64, f64)> {
441 vec![
442 (0.0, 0.0),
443 (1000.0, 0.0),
444 (0.0, 1000.0),
445 (-1000.0, -1000.0),
446 (10000.0, 10000.0),
447 (-10000.0, -10000.0),
448 ]
449 }
450}
451
452#[cfg(test)]
453#[allow(clippy::unwrap_used)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn test_monte_carlo_config_smoke() {
459 let config = MonteCarloConfig::smoke();
460 assert_eq!(config.tier, TestTier::Smoke);
461 assert_eq!(config.iterations(), 1);
462 }
463
464 #[test]
465 fn test_monte_carlo_config_regression() {
466 let config = MonteCarloConfig::regression();
467 assert_eq!(config.tier, TestTier::Regression);
468 assert_eq!(config.iterations(), 50);
469 }
470
471 #[test]
472 fn test_monte_carlo_config_full() {
473 let config = MonteCarloConfig::full();
474 assert_eq!(config.tier, TestTier::Full);
475 assert_eq!(config.iterations(), 1000);
476 }
477
478 #[test]
479 fn test_invariant_check_valid() {
480 let snapshot = GameStateSnapshot {
481 ball_x: 400.0,
482 ball_y: 300.0,
483 ball_vx: 200.0,
484 ball_vy: 150.0,
485 left_paddle_y: 300.0,
486 right_paddle_y: 300.0,
487 score_left: 5,
488 score_right: 3,
489 rally: 10,
490 game_state: "Playing".to_string(),
491 game_mode: "Demo".to_string(),
492 };
493 assert!(check_invariants(&snapshot, 600.0).is_ok());
494 }
495
496 #[test]
497 fn test_invariant_check_nan_ball() {
498 let snapshot = GameStateSnapshot {
499 ball_x: f64::NAN,
500 ball_y: 300.0,
501 ball_vx: 200.0,
502 ball_vy: 150.0,
503 left_paddle_y: 300.0,
504 right_paddle_y: 300.0,
505 score_left: 0,
506 score_right: 0,
507 rally: 0,
508 game_state: "Playing".to_string(),
509 game_mode: "Demo".to_string(),
510 };
511 assert_eq!(
512 check_invariants(&snapshot, 600.0),
513 Err(InvariantViolation::BallPositionNaN)
514 );
515 }
516
517 #[test]
518 fn test_invariant_check_infinite_velocity() {
519 let snapshot = GameStateSnapshot {
520 ball_x: 400.0,
521 ball_y: 300.0,
522 ball_vx: f64::INFINITY,
523 ball_vy: 150.0,
524 left_paddle_y: 300.0,
525 right_paddle_y: 300.0,
526 score_left: 0,
527 score_right: 0,
528 rally: 0,
529 game_state: "Playing".to_string(),
530 game_mode: "Demo".to_string(),
531 };
532 assert_eq!(
533 check_invariants(&snapshot, 600.0),
534 Err(InvariantViolation::VelocityInfinite)
535 );
536 }
537
538 #[test]
539 fn test_invariant_check_score_overflow() {
540 let snapshot = GameStateSnapshot {
541 ball_x: 400.0,
542 ball_y: 300.0,
543 ball_vx: 200.0,
544 ball_vy: 150.0,
545 left_paddle_y: 300.0,
546 right_paddle_y: 300.0,
547 score_left: 9999,
548 score_right: 0,
549 rally: 0,
550 game_state: "Playing".to_string(),
551 game_mode: "Demo".to_string(),
552 };
553 assert_eq!(
554 check_invariants(&snapshot, 600.0),
555 Err(InvariantViolation::ScoreOverflow)
556 );
557 }
558
559 #[test]
560 fn test_fuzz_generator_dt_extremes() {
561 let extremes = FuzzGenerator::dt_extremes();
562 assert!(extremes.contains(&0.0));
563 assert!(extremes.contains(&16.667));
564 assert!(extremes.len() >= 5);
565 }
566
567 #[test]
568 fn test_fuzz_generator_numeric_extremes() {
569 let extremes = FuzzGenerator::numeric_extremes();
570 assert!(extremes.iter().any(|x| x.is_nan()));
571 assert!(extremes.iter().any(|x| x.is_infinite()));
572 assert!(extremes.contains(&0.0));
573 }
574
575 #[test]
576 fn test_test_result_pass() {
577 let result = TestResult::Pass;
578 assert!(result.is_pass());
579 }
580
581 #[test]
582 fn test_test_result_fail() {
583 let snapshot = GameStateSnapshot {
584 ball_x: 400.0,
585 ball_y: 300.0,
586 ball_vx: 200.0,
587 ball_vy: 150.0,
588 left_paddle_y: 300.0,
589 right_paddle_y: 300.0,
590 score_left: 0,
591 score_right: 0,
592 rally: 0,
593 game_state: "Playing".to_string(),
594 game_mode: "Demo".to_string(),
595 };
596 let result = TestResult::fail("v > 0", "> 0", -5.0, snapshot);
597 assert!(!result.is_pass());
598 }
599
600 #[test]
601 fn test_test_tier_default() {
602 assert_eq!(TestTier::default(), TestTier::Smoke);
603 }
604
605 #[test]
606 fn test_monte_carlo_config_default() {
607 let config = MonteCarloConfig::default();
608 assert_eq!(config.tier, TestTier::Smoke);
609 }
610
611 #[test]
612 fn test_fuzz_generator_position_extremes() {
613 let extremes = FuzzGenerator::position_extremes(800.0, 600.0);
614 assert!(extremes.contains(&(0.0, 0.0)));
615 assert!(extremes.contains(&(800.0, 600.0)));
616 assert!(extremes.contains(&(400.0, 300.0))); assert!(extremes.len() >= 5);
618 }
619
620 #[test]
621 fn test_fuzz_generator_velocity_extremes() {
622 let extremes = FuzzGenerator::velocity_extremes();
623 assert!(extremes.contains(&(0.0, 0.0)));
624 assert!(extremes.contains(&(1000.0, 0.0)));
625 assert!(extremes.len() >= 4);
626 }
627
628 #[test]
629 fn test_invariant_check_infinite_ball() {
630 let snapshot = GameStateSnapshot {
631 ball_x: f64::INFINITY,
632 ball_y: 300.0,
633 ball_vx: 200.0,
634 ball_vy: 150.0,
635 left_paddle_y: 300.0,
636 right_paddle_y: 300.0,
637 score_left: 0,
638 score_right: 0,
639 rally: 0,
640 game_state: "Playing".to_string(),
641 game_mode: "Demo".to_string(),
642 };
643 assert_eq!(
644 check_invariants(&snapshot, 600.0),
645 Err(InvariantViolation::BallPositionInfinite)
646 );
647 }
648
649 #[test]
650 fn test_invariant_check_nan_velocity() {
651 let snapshot = GameStateSnapshot {
652 ball_x: 400.0,
653 ball_y: 300.0,
654 ball_vx: f64::NAN,
655 ball_vy: 150.0,
656 left_paddle_y: 300.0,
657 right_paddle_y: 300.0,
658 score_left: 0,
659 score_right: 0,
660 rally: 0,
661 game_state: "Playing".to_string(),
662 game_mode: "Demo".to_string(),
663 };
664 assert_eq!(
665 check_invariants(&snapshot, 600.0),
666 Err(InvariantViolation::VelocityNaN)
667 );
668 }
669
670 #[test]
671 fn test_invariant_check_paddle_out_of_bounds_left() {
672 let snapshot = GameStateSnapshot {
673 ball_x: 400.0,
674 ball_y: 300.0,
675 ball_vx: 200.0,
676 ball_vy: 150.0,
677 left_paddle_y: -100.0, right_paddle_y: 300.0,
679 score_left: 0,
680 score_right: 0,
681 rally: 0,
682 game_state: "Playing".to_string(),
683 game_mode: "Demo".to_string(),
684 };
685 assert_eq!(
686 check_invariants(&snapshot, 600.0),
687 Err(InvariantViolation::PaddleOutOfBounds)
688 );
689 }
690
691 #[test]
692 fn test_invariant_check_paddle_out_of_bounds_right() {
693 let snapshot = GameStateSnapshot {
694 ball_x: 400.0,
695 ball_y: 300.0,
696 ball_vx: 200.0,
697 ball_vy: 150.0,
698 left_paddle_y: 300.0,
699 right_paddle_y: 800.0, score_left: 0,
701 score_right: 0,
702 rally: 0,
703 game_state: "Playing".to_string(),
704 game_mode: "Demo".to_string(),
705 };
706 assert_eq!(
707 check_invariants(&snapshot, 600.0),
708 Err(InvariantViolation::PaddleOutOfBounds)
709 );
710 }
711
712 #[test]
713 fn test_invariant_check_rally_overflow() {
714 let snapshot = GameStateSnapshot {
715 ball_x: 400.0,
716 ball_y: 300.0,
717 ball_vx: 200.0,
718 ball_vy: 150.0,
719 left_paddle_y: 300.0,
720 right_paddle_y: 300.0,
721 score_left: 0,
722 score_right: 0,
723 rally: 200_000, game_state: "Playing".to_string(),
725 game_mode: "Demo".to_string(),
726 };
727 assert_eq!(
728 check_invariants(&snapshot, 600.0),
729 Err(InvariantViolation::RallyOverflow)
730 );
731 }
732
733 #[test]
734 fn test_invariant_violation_display() {
735 assert_eq!(
736 format!("{}", InvariantViolation::BallPositionNaN),
737 "Ball position is NaN"
738 );
739 assert_eq!(
740 format!("{}", InvariantViolation::BallPositionInfinite),
741 "Ball position is infinite"
742 );
743 assert_eq!(
744 format!("{}", InvariantViolation::PaddleOutOfBounds),
745 "Paddle is out of bounds"
746 );
747 assert_eq!(
748 format!("{}", InvariantViolation::ScoreOverflow),
749 "Score overflow"
750 );
751 assert_eq!(
752 format!("{}", InvariantViolation::RallyOverflow),
753 "Rally overflow"
754 );
755 assert_eq!(
756 format!("{}", InvariantViolation::VelocityNaN),
757 "Velocity is NaN"
758 );
759 assert_eq!(
760 format!("{}", InvariantViolation::VelocityInfinite),
761 "Velocity is infinite"
762 );
763 }
764
765 #[test]
766 fn test_invariant_violation_error_trait() {
767 let violation: Box<dyn std::error::Error> = Box::new(InvariantViolation::BallPositionNaN);
768 assert!(violation.to_string().contains("NaN"));
769 }
770
771 #[test]
772 fn test_failure_replay_save() {
773 let replay = FailureReplay {
774 action_id: 1,
775 action_name: "test_action".to_string(),
776 seed: 42,
777 tier: "smoke".to_string(),
778 input_trace: vec![TimestampedInput {
779 frame: 0,
780 timestamp_ms: 0.0,
781 event: r#"{"type":"KeyDown","key":"KeyW"}"#.to_string(),
782 }],
783 failure_frame: 100,
784 assertion: "ball_x > 0".to_string(),
785 expected: "> 0".to_string(),
786 actual: "-5".to_string(),
787 state_snapshot: GameStateSnapshot {
788 ball_x: -5.0,
789 ball_y: 300.0,
790 ball_vx: 200.0,
791 ball_vy: 150.0,
792 left_paddle_y: 300.0,
793 right_paddle_y: 300.0,
794 score_left: 0,
795 score_right: 0,
796 rally: 0,
797 game_state: "Playing".to_string(),
798 game_mode: "Demo".to_string(),
799 },
800 timestamp: "test".to_string(),
801 };
802
803 let result = replay.save();
804 assert!(result.is_ok());
805 let filename = result.unwrap();
806 assert!(filename.contains("failure-"));
807 assert!(filename.contains("-action-001-seed-42.json"));
808
809 let _ = std::fs::remove_file(&filename);
811 }
812
813 #[test]
814 fn test_game_state_snapshot_serialization() {
815 let snapshot = GameStateSnapshot {
816 ball_x: 400.0,
817 ball_y: 300.0,
818 ball_vx: 200.0,
819 ball_vy: 150.0,
820 left_paddle_y: 300.0,
821 right_paddle_y: 300.0,
822 score_left: 5,
823 score_right: 3,
824 rally: 10,
825 game_state: "Playing".to_string(),
826 game_mode: "Demo".to_string(),
827 };
828 let json = serde_json::to_string(&snapshot).unwrap();
829 assert!(json.contains("ball_x"));
830 assert!(json.contains("400"));
831
832 let deserialized: GameStateSnapshot = serde_json::from_str(&json).unwrap();
833 assert!((deserialized.ball_x - 400.0).abs() < f64::EPSILON);
834 }
835
836 #[test]
837 fn test_timestamped_input_serialization() {
838 let input = TimestampedInput {
839 frame: 42,
840 timestamp_ms: 1234.5,
841 event: r#"{"type":"KeyDown"}"#.to_string(),
842 };
843 let json = serde_json::to_string(&input).unwrap();
844 assert!(json.contains("42"));
845
846 let deserialized: TimestampedInput = serde_json::from_str(&json).unwrap();
847 assert_eq!(deserialized.frame, 42);
848 }
849
850 #[test]
851 fn test_chrono_lite_timestamp() {
852 let ts1 = chrono_lite_timestamp();
853 let ts2 = chrono_lite_timestamp();
854 let n1: u64 = ts1.parse().unwrap();
856 let n2: u64 = ts2.parse().unwrap();
857 assert!(n2 >= n1);
858 assert!(n2 - n1 <= 1); }
860}