Skip to main content

jugar_web/
simulation.rs

1//! Game Simulation Testing Framework
2//!
3//! This module implements the Monte Carlo simulation testing framework
4//! as specified in `docs/qa/game-replay-testing.md`.
5//!
6//! ## Tiered Testing Strategy
7//!
8//! - **Tier 1 (Smoke)**: 1 seed, deterministic, < 10 seconds
9//! - **Tier 2 (Regression)**: 50 seeds, 95% confidence, < 5 minutes
10//! - **Tier 3 (Full)**: 1000 seeds, 99% confidence, < 30 minutes
11
12// Allow relaxed clippy lints for simulation testing framework
13#![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/// Test tier for Monte Carlo simulation
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum TestTier {
23    /// Smoke: 1 seed, deterministic (pre-commit)
24    #[default]
25    Smoke,
26    /// Regression: 50 seeds, 95% confidence (on-merge)
27    Regression,
28    /// Full: 1000 seeds, 99% confidence (nightly)
29    Full,
30}
31
32impl TestTier {
33    /// Parse tier from environment variable TEST_TIER
34    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, // Default to smoke for fast feedback
40        }
41    }
42}
43
44/// Monte Carlo test configuration
45///
46/// Configures how many random seeds and frames to run in simulation tests.
47///
48/// # Example
49///
50/// ```
51/// use jugar_web::simulation::{MonteCarloConfig, TestTier};
52///
53/// let config = MonteCarloConfig::smoke();
54/// assert_eq!(config.tier, TestTier::Smoke);
55/// assert_eq!(config.iterations(), 1);
56///
57/// let regression = MonteCarloConfig::regression();
58/// assert_eq!(regression.iterations(), 50);
59/// ```
60#[derive(Debug, Clone)]
61pub struct MonteCarloConfig {
62    /// Test tier (determines iteration count)
63    pub tier: TestTier,
64    /// RNG seed range start
65    pub seed_start: u64,
66    /// RNG seed range end
67    pub seed_end: u64,
68    /// Timeout per batch (seconds)
69    pub batch_timeout: u64,
70    /// Confidence level (0.0-1.0)
71    pub confidence: f64,
72    /// Frames per simulation
73    pub frames_per_sim: usize,
74}
75
76impl MonteCarloConfig {
77    /// Smoke test configuration (Tier 1)
78    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, // 10 seconds @ 60fps
86        }
87    }
88
89    /// Regression test configuration (Tier 2)
90    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, // 30 seconds @ 60fps
98        }
99    }
100
101    /// Full Monte Carlo configuration (Tier 3)
102    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, // 1 minute @ 60fps
110        }
111    }
112
113    /// Get configuration from environment
114    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    /// Number of iterations (seeds)
123    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/// Game state snapshot for failure replay
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct GameStateSnapshot {
137    /// Ball X position
138    pub ball_x: f64,
139    /// Ball Y position
140    pub ball_y: f64,
141    /// Ball X velocity
142    pub ball_vx: f64,
143    /// Ball Y velocity
144    pub ball_vy: f64,
145    /// Left paddle Y position
146    pub left_paddle_y: f64,
147    /// Right paddle Y position
148    pub right_paddle_y: f64,
149    /// Left player score
150    pub score_left: u32,
151    /// Right player score
152    pub score_right: u32,
153    /// Current rally count
154    pub rally: u32,
155    /// Game state (Menu/Playing/Paused/GameOver)
156    pub game_state: String,
157    /// Game mode (Demo/SinglePlayer/TwoPlayer)
158    pub game_mode: String,
159}
160
161/// Input event for replay
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct TimestampedInput {
164    /// Frame number
165    pub frame: usize,
166    /// Timestamp in milliseconds
167    pub timestamp_ms: f64,
168    /// Input event JSON
169    pub event: String,
170}
171
172/// Failure replay artifact for deterministic reproduction
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct FailureReplay {
175    /// Test action ID (1-100)
176    pub action_id: u32,
177    /// Test name
178    pub action_name: String,
179    /// Random seed that caused failure
180    pub seed: u64,
181    /// Monte Carlo config used
182    pub tier: String,
183    /// Complete input trace
184    pub input_trace: Vec<TimestampedInput>,
185    /// Frame at which assertion failed
186    pub failure_frame: usize,
187    /// Assertion that failed
188    pub assertion: String,
189    /// Expected value
190    pub expected: String,
191    /// Actual value
192    pub actual: String,
193    /// Full game state at failure
194    pub state_snapshot: GameStateSnapshot,
195    /// Timestamp
196    pub timestamp: String,
197}
198
199impl FailureReplay {
200    /// Save failure replay to file
201    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        // Ensure directory exists
210        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
219/// Simple timestamp without chrono dependency
220fn 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/// Test result for Monte Carlo harness
229#[derive(Debug)]
230pub enum TestResult {
231    /// Test passed
232    Pass,
233    /// Test failed with details
234    Fail {
235        /// Assertion that failed
236        assertion: String,
237        /// Expected value
238        expected: String,
239        /// Actual value
240        actual: String,
241        /// Game state at failure
242        state: GameStateSnapshot,
243    },
244}
245
246impl TestResult {
247    /// Create a failure result
248    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    /// Check if result is pass
263    pub fn is_pass(&self) -> bool {
264        matches!(self, Self::Pass)
265    }
266}
267
268/// Invariant violation types
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub enum InvariantViolation {
271    /// Ball position is NaN
272    BallPositionNaN,
273    /// Ball position is infinite
274    BallPositionInfinite,
275    /// Paddle is out of bounds
276    PaddleOutOfBounds,
277    /// Score overflow
278    ScoreOverflow,
279    /// Rally overflow
280    RallyOverflow,
281    /// Velocity is NaN
282    VelocityNaN,
283    /// Velocity is infinite
284    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
303/// Check game state invariants
304///
305/// Validates that the game state is in a valid, non-corrupted state.
306///
307/// # Arguments
308///
309/// * `snapshot` - The game state to validate
310/// * `max_y` - Maximum Y coordinate (screen height)
311///
312/// # Errors
313///
314/// Returns [`InvariantViolation`] if any game state invariant is violated:
315/// - Ball position is NaN or infinite
316/// - Paddle position is out of bounds
317/// - Score or rally count overflow
318/// - Velocity is NaN or infinite
319///
320/// # Example
321///
322/// ```
323/// use jugar_web::simulation::{check_invariants, GameStateSnapshot};
324///
325/// let valid_state = GameStateSnapshot {
326///     ball_x: 400.0,
327///     ball_y: 300.0,
328///     ball_vx: 200.0,
329///     ball_vy: 150.0,
330///     left_paddle_y: 300.0,
331///     right_paddle_y: 300.0,
332///     score_left: 5,
333///     score_right: 3,
334///     rally: 10,
335///     game_state: "Playing".to_string(),
336///     game_mode: "Demo".to_string(),
337/// };
338///
339/// assert!(check_invariants(&valid_state, 600.0).is_ok());
340/// ```
341pub fn check_invariants(
342    snapshot: &GameStateSnapshot,
343    max_y: f64,
344) -> Result<(), InvariantViolation> {
345    // Ball must never be NaN
346    if snapshot.ball_x.is_nan() || snapshot.ball_y.is_nan() {
347        return Err(InvariantViolation::BallPositionNaN);
348    }
349
350    // Ball must never be Infinity
351    if snapshot.ball_x.is_infinite() || snapshot.ball_y.is_infinite() {
352        return Err(InvariantViolation::BallPositionInfinite);
353    }
354
355    // Velocity must never be NaN
356    if snapshot.ball_vx.is_nan() || snapshot.ball_vy.is_nan() {
357        return Err(InvariantViolation::VelocityNaN);
358    }
359
360    // Velocity must never be Infinity
361    if snapshot.ball_vx.is_infinite() || snapshot.ball_vy.is_infinite() {
362        return Err(InvariantViolation::VelocityInfinite);
363    }
364
365    // Paddles must be within bounds (with some tolerance for edge cases)
366    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    // Scores must be reasonable
374    if snapshot.score_left > 1000 || snapshot.score_right > 1000 {
375        return Err(InvariantViolation::ScoreOverflow);
376    }
377
378    // Rally must be reasonable
379    if snapshot.rally > 100_000 {
380        return Err(InvariantViolation::RallyOverflow);
381    }
382
383    Ok(())
384}
385
386/// Hostile input generator for boundary testing (fuzzing)
387#[derive(Debug)]
388pub struct FuzzGenerator;
389
390impl FuzzGenerator {
391    /// Category 1: Numeric Extremes
392    #[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    /// Category 2: Delta Time Extremes
410    #[must_use]
411    pub fn dt_extremes() -> Vec<f64> {
412        vec![
413            0.0,     // Freeze frame
414            0.001,   // 1000 FPS
415            16.667,  // Normal 60 FPS
416            33.333,  // 30 FPS
417            100.0,   // 10 FPS (lag spike)
418            1000.0,  // 1 FPS (severe lag)
419            5000.0,  // Tab backgrounded
420            10000.0, // Extreme lag
421        ]
422    }
423
424    /// Category 3: Position Extremes
425    #[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    /// Category 4: Velocity Extremes
439    #[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))); // center
617        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, // Way out of bounds
678            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, // Way out of bounds for max_y=600
700            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, // Overflow
724            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        // Clean up
810        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        // Should be the same or very close
855        let n1: u64 = ts1.parse().unwrap();
856        let n2: u64 = ts2.parse().unwrap();
857        assert!(n2 >= n1);
858        assert!(n2 - n1 <= 1); // Within 1 second
859    }
860}