From 401f91b0fe766ac222805edc0794d05228647185 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 6 Mar 2026 23:11:03 +0100 Subject: [PATCH] Add unit tests across all game modules (42 tests) Co-Authored-By: Claude Sonnet 4.6 --- src/config.rs | 31 +++++- src/effects.rs | 68 +++++++++++++ src/enemy.rs | 24 +++++ src/platform.rs | 31 ++++++ src/player.rs | 112 ++++++++++++++++++++- src/world.rs | 254 +++++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 511 insertions(+), 9 deletions(-) diff --git a/src/config.rs b/src/config.rs index 9814ba1..667c919 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,34 @@ use crate::pickup::{PickupDef, PickupEffectType}; +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scroll_speed_at_zero_equals_initial_speed() { + let cfg = Config::default(); + assert_eq!(cfg.scroll_speed(0.0), cfg.initial_speed); + } + + #[test] + fn scroll_speed_increases_over_time() { + let cfg = Config::default(); + assert!(cfg.scroll_speed(30.0) > cfg.scroll_speed(0.0)); + assert!(cfg.scroll_speed(120.0) > cfg.scroll_speed(30.0)); + } + + #[test] + fn scroll_speed_growth_slows_over_time() { + // Logarithmic: each equal time interval adds less speed than the last. + let cfg = Config::default(); + let d0 = cfg.scroll_speed(10.0) - cfg.scroll_speed(0.0); + let d1 = cfg.scroll_speed(20.0) - cfg.scroll_speed(10.0); + let d2 = cfg.scroll_speed(40.0) - cfg.scroll_speed(30.0); + assert!(d0 > d1); + assert!(d1 > d2); + } +} + /// All game parameters in one place — change here to tune the game. /// Pickup types are also registered here; add a new `PickupDef` entry to extend. #[derive(Clone)] @@ -13,7 +42,6 @@ pub struct Config { pub player_height: f32, pub gravity: f32, pub jump_velocity: f32, - pub stomp_bounce_velocity: f32, /// Grace window (seconds) after leaving a platform where jumping is still allowed. pub coyote_time: f32, @@ -73,7 +101,6 @@ impl Default for Config { player_height: 48.0, gravity: 1900.0, jump_velocity: -800.0, - stomp_bounce_velocity: -550.0, coyote_time: 0.12, initial_speed: 280.0, diff --git a/src/effects.rs b/src/effects.rs index 07ac61e..fb44bcf 100644 --- a/src/effects.rs +++ b/src/effects.rs @@ -122,3 +122,71 @@ impl ActiveEffect for ScoreMultiplierEffect { self.factor } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invulnerability_active_while_timer_positive() { + let mut e = InvulnerabilityEffect::new(1.0); + assert!(e.update(0.5)); + } + + #[test] + fn invulnerability_expires_when_timer_reaches_zero() { + let mut e = InvulnerabilityEffect::new(1.0); + assert!(!e.update(1.1)); + } + + #[test] + fn invulnerability_is_invulnerable() { + let e = InvulnerabilityEffect::new(5.0); + assert!(e.is_invulnerable()); + } + + #[test] + fn invulnerability_default_multipliers_are_one() { + let e = InvulnerabilityEffect::new(5.0); + assert_eq!(e.jump_multiplier(), 1.0); + assert_eq!(e.score_multiplier(), 1.0); + } + + #[test] + fn jump_boost_returns_configured_factor() { + let e = JumpBoostEffect::new(5.0, 1.5); + assert_eq!(e.jump_multiplier(), 1.5); + } + + #[test] + fn jump_boost_is_not_invulnerable() { + let e = JumpBoostEffect::new(5.0, 1.5); + assert!(!e.is_invulnerable()); + } + + #[test] + fn score_multiplier_returns_configured_factor() { + let e = ScoreMultiplierEffect::new(5.0, 2.0); + assert_eq!(e.score_multiplier(), 2.0); + } + + #[test] + fn progress_starts_at_one() { + let e = InvulnerabilityEffect::new(5.0); + assert!((e.progress() - 1.0).abs() < 1e-4); + } + + #[test] + fn progress_halves_at_half_duration() { + let mut e = InvulnerabilityEffect::new(4.0); + e.update(2.0); + assert!((e.progress() - 0.5).abs() < 1e-4); + } + + #[test] + fn progress_clamps_to_zero_when_expired() { + let mut e = InvulnerabilityEffect::new(1.0); + e.update(10.0); + assert_eq!(e.progress(), 0.0); + } +} diff --git a/src/enemy.rs b/src/enemy.rs index 06c8e63..5a775ba 100644 --- a/src/enemy.rs +++ b/src/enemy.rs @@ -32,3 +32,27 @@ impl Enemy { self.x + self.width } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scroll_moves_enemy_left() { + let mut e = Enemy::new(200.0, 400.0, 40.0, 40.0); + e.scroll(200.0, 0.5); + assert!((e.x - 100.0).abs() < 1e-4); + } + + #[test] + fn off_screen_when_right_edge_is_negative() { + let e = Enemy::new(-50.0, 400.0, 40.0, 40.0); + assert!(e.is_off_screen()); + } + + #[test] + fn not_off_screen_when_partially_visible() { + let e = Enemy::new(-20.0, 400.0, 40.0, 40.0); + assert!(!e.is_off_screen()); + } +} diff --git a/src/platform.rs b/src/platform.rs index e55147d..7c66d0c 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -27,3 +27,34 @@ impl Platform { self.x + self.width } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scroll_moves_platform_left() { + let mut p = Platform::new(100.0, 400.0, 200.0, 20.0); + p.scroll(100.0, 0.5); + assert!((p.x - 50.0).abs() < 1e-4); + } + + #[test] + fn off_screen_when_right_edge_is_negative() { + let p = Platform::new(-210.0, 400.0, 200.0, 20.0); + assert!(p.is_off_screen()); + } + + #[test] + fn not_off_screen_when_partially_visible() { + let p = Platform::new(-100.0, 400.0, 200.0, 20.0); + assert!(!p.is_off_screen()); + } + + #[test] + fn top_and_right_helpers() { + let p = Platform::new(50.0, 300.0, 200.0, 20.0); + assert_eq!(p.top(), 300.0); + assert_eq!(p.right(), 250.0); + } +} diff --git a/src/player.rs b/src/player.rs index ab5006c..e497b53 100644 --- a/src/player.rs +++ b/src/player.rs @@ -1,5 +1,112 @@ use crate::config::Config; +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> Config { Config::default() } + + #[test] + fn new_player_starts_on_ground_with_coyote_timer() { + let cfg = cfg(); + let p = Player::new(&cfg); + assert!(p.on_ground); + assert!(p.coyote_timer > 0.0); + assert!(p.alive); + } + + #[test] + fn gravity_moves_player_down() { + let cfg = cfg(); + let mut p = Player::new(&cfg); + p.on_ground = false; + let y_before = p.y; + p.update(0.1, &cfg); + assert!(p.y > y_before); + } + + #[test] + fn jump_sets_negative_velocity() { + let cfg = cfg(); + let mut p = Player::new(&cfg); + p.coyote_timer = cfg.coyote_time; + p.jump(&cfg, 1.0); + assert!(p.vy < 0.0); + } + + #[test] + fn jump_requires_positive_coyote_timer() { + let cfg = cfg(); + let mut p = Player::new(&cfg); + p.coyote_timer = 0.0; + p.jump(&cfg, 1.0); + assert_eq!(p.vy, 0.0); + } + + #[test] + fn jump_consumes_coyote_timer() { + let cfg = cfg(); + let mut p = Player::new(&cfg); + p.coyote_timer = cfg.coyote_time; + p.jump(&cfg, 1.0); + assert_eq!(p.coyote_timer, 0.0); + } + + #[test] + fn jump_multiplier_scales_velocity() { + let cfg = cfg(); + let mut p1 = Player::new(&cfg); + let mut p2 = Player::new(&cfg); + p1.coyote_timer = cfg.coyote_time; + p2.coyote_timer = cfg.coyote_time; + p1.jump(&cfg, 1.0); + p2.jump(&cfg, 2.0); + let ratio = p2.vy / p1.vy; + assert!((ratio - 2.0).abs() < 1e-4, "ratio was {ratio}"); + } + + #[test] + fn coyote_timer_refreshes_when_on_ground() { + let cfg = cfg(); + let mut p = Player::new(&cfg); + p.on_ground = true; + p.coyote_timer = 0.001; // nearly expired + p.update(0.016, &cfg); + // update() refreshes timer at its start when on_ground was true + assert!((p.coyote_timer - cfg.coyote_time).abs() < 1e-4); + } + + #[test] + fn coyote_timer_ticks_down_when_airborne() { + let cfg = cfg(); + let mut p = Player::new(&cfg); + p.on_ground = false; + p.coyote_timer = cfg.coyote_time; + let dt = 0.05; + p.update(dt, &cfg); + assert!((p.coyote_timer - (cfg.coyote_time - dt)).abs() < 1e-4); + } + + #[test] + fn coyote_timer_does_not_go_below_zero() { + let cfg = cfg(); + let mut p = Player::new(&cfg); + p.on_ground = false; + p.coyote_timer = 0.01; + p.update(1.0, &cfg); // much larger than timer + assert_eq!(p.coyote_timer, 0.0); + } + + #[test] + fn player_dies_when_below_screen() { + let cfg = cfg(); + let mut p = Player::new(&cfg); + p.y = cfg.screen_height as f32 + 100.0; + p.update(0.016, &cfg); + assert!(!p.alive); + } +} + pub struct Player { pub x: f32, pub y: f32, @@ -61,11 +168,6 @@ impl Player { } } - pub fn stomp_bounce(&mut self, cfg: &Config) { - self.vy = cfg.stomp_bounce_velocity; - self.on_ground = false; - } - pub fn bottom(&self) -> f32 { self.y + self.height } diff --git a/src/world.rs b/src/world.rs index 7ec9a99..5dd09c7 100644 --- a/src/world.rs +++ b/src/world.rs @@ -10,6 +10,236 @@ use crate::{ player::Player, }; +#[cfg(test)] +mod tests { + use super::*; + use crate::effects::{InvulnerabilityEffect, ScoreMultiplierEffect}; + + fn cfg() -> Config { Config::default() } + + fn world(cfg: &Config) -> World { + World::new(cfg).with_clear_scene(cfg) + } + + // ── Platform collision ──────────────────────────────────────────────────── + + #[test] + fn player_lands_on_platform() { + let cfg = cfg(); + let mut w = world(&cfg); + + let plat_y = 400.0; + w.platforms.push(Platform::new( + cfg.player_x - 20.0, plat_y, 100.0, cfg.platform_height, + )); + + // Position player just above, falling toward the platform. + // Use vy high enough to cross the gap in a single 16ms frame. + w.player.y = plat_y - cfg.player_height - 2.0; + w.player.prev_y = w.player.y - 8.0; + w.player.vy = 500.0; + w.player.on_ground = false; + w.player.coyote_timer = 0.0; + + w.update(0.016, false, &cfg); + + assert!(w.player.on_ground, "should be grounded after landing"); + assert_eq!(w.player.vy, 0.0, "velocity should be zeroed on landing"); + assert!((w.player.y - (plat_y - cfg.player_height)).abs() < 2.0, + "player should be snapped to platform top"); + } + + #[test] + fn player_does_not_land_when_rising_through_platform() { + let cfg = cfg(); + let mut w = world(&cfg); + + let plat_y = 400.0; + w.platforms.push(Platform::new( + cfg.player_x - 20.0, plat_y, 100.0, cfg.platform_height, + )); + + // Player rising through the platform — should pass through. + w.player.y = plat_y - cfg.player_height + 5.0; + w.player.prev_y = w.player.y + 10.0; + w.player.vy = -400.0; // going up + w.player.on_ground = false; + w.player.coyote_timer = 0.0; + + w.update(0.016, false, &cfg); + + assert!(!w.player.on_ground); + } + + #[test] + fn player_falls_without_platform() { + let cfg = cfg(); + let mut w = World::new(&cfg); + w.platforms.clear(); + w.enemies.clear(); + w.pickups.clear(); + w.active_effects.clear(); + + w.player.y = 200.0; + w.player.vy = 0.0; + w.player.on_ground = false; + let y_before = w.player.y; + + w.update(0.1, false, &cfg); + + assert!(w.player.y > y_before); + assert!(!w.player.on_ground); + } + + #[test] + fn player_dies_falling_off_screen() { + let cfg = cfg(); + let mut w = world(&cfg); + w.platforms.clear(); // remove ground so player falls + + w.player.y = cfg.screen_height as f32 + 100.0; + w.player.vy = 10.0; + + w.update(0.016, false, &cfg); + + assert!(!w.player.alive); + } + + // ── Enemy collision ─────────────────────────────────────────────────────── + + #[test] + fn stomp_kills_enemy_and_lands_player() { + let cfg = cfg(); + let mut w = world(&cfg); + + let enemy_y = 400.0; + w.enemies.push(Enemy::new( + cfg.player_x - 5.0, enemy_y, cfg.enemy_width, cfg.enemy_height, + )); + + // Player falling onto enemy top. + w.player.y = enemy_y - cfg.player_height - 3.0; + w.player.prev_y = w.player.y - 10.0; + w.player.vy = 250.0; + w.player.on_ground = false; + + let score_before = w.score_f; + w.update(0.016, false, &cfg); + + // Dead enemies are removed by retain() after stomp. + assert!(w.enemies.is_empty(), "dead enemy should be removed from world"); + assert!(w.player.alive, "player should survive stomp"); + assert!(w.player.on_ground, "player should land on enemy"); + assert!(w.score_f > score_before, "stomp should award score"); + } + + #[test] + fn side_collision_kills_player() { + let cfg = cfg(); + let mut w = world(&cfg); + + // Enemy at same height as player, overlapping horizontally. + let enemy_y = w.player.y; + w.enemies.push(Enemy::new( + cfg.player_x, enemy_y, cfg.enemy_width, cfg.enemy_height, + )); + w.player.vy = 0.0; // not falling → no stomp + w.player.prev_y = w.player.y; // was already at this height + + w.update(0.016, false, &cfg); + + assert!(!w.player.alive); + } + + #[test] + fn invulnerability_prevents_side_death() { + let cfg = cfg(); + let mut w = world(&cfg); + w.active_effects.push(Box::new(InvulnerabilityEffect::new(5.0))); + + let enemy_y = w.player.y; + w.enemies.push(Enemy::new( + cfg.player_x, enemy_y, cfg.enemy_width, cfg.enemy_height, + )); + w.player.vy = 0.0; + w.player.prev_y = w.player.y; + + w.update(0.016, false, &cfg); + + assert!(w.player.alive, "invulnerable player should survive side hit"); + } + + // ── Pickups ─────────────────────────────────────────────────────────────── + + #[test] + fn collecting_pickup_adds_active_effect() { + let cfg = cfg(); + let mut w = world(&cfg); + + // Place pickup directly on the player. + w.pickups.push(Pickup::new( + cfg.player_x, w.player.y, cfg.pickup_size, 0, + )); + + assert!(w.active_effects.is_empty()); + w.update(0.016, false, &cfg); + assert!(!w.active_effects.is_empty(), "effect should be active after collection"); + } + + #[test] + fn collected_pickup_is_removed_from_world() { + let cfg = cfg(); + let mut w = world(&cfg); + + w.pickups.push(Pickup::new( + cfg.player_x, w.player.y, cfg.pickup_size, 0, + )); + + w.update(0.016, false, &cfg); + + assert!(w.pickups.is_empty(), "collected pickup should be removed"); + } + + // ── Score ───────────────────────────────────────────────────────────────── + + #[test] + fn score_increases_over_time() { + let cfg = cfg(); + let mut w = world(&cfg); + let score_before = w.score_f; + w.update(1.0, false, &cfg); + assert!(w.score_f > score_before); + } + + #[test] + fn score_multiplier_effect_doubles_score_gain() { + let cfg = cfg(); + + let mut w1 = world(&cfg); + let mut w2 = world(&cfg); + w2.active_effects.push(Box::new(ScoreMultiplierEffect::new(10.0, 2.0))); + + w1.update(1.0, false, &cfg); + w2.update(1.0, false, &cfg); + + let ratio = w2.score_f / w1.score_f; + assert!((ratio - 2.0).abs() < 0.05, "ratio was {ratio:.3}, expected ~2.0"); + } + + // ── Active effects lifecycle ────────────────────────────────────────────── + + #[test] + fn expired_effects_are_removed() { + let cfg = cfg(); + let mut w = world(&cfg); + w.active_effects.push(Box::new(InvulnerabilityEffect::new(0.01))); + + w.update(1.0, false, &cfg); // well past expiry + + assert!(w.active_effects.is_empty(), "expired effects should be removed"); + } +} + pub struct World { pub player: Player, pub platforms: Vec, @@ -18,7 +248,7 @@ pub struct World { pub active_effects: Vec>, pub score: u64, pub elapsed: f32, - score_f: f32, + pub(crate) score_f: f32, level_gen: LevelGenerator, } @@ -182,7 +412,9 @@ impl World { if stomp { enemy.alive = false; - self.player.stomp_bounce(cfg); + self.player.y = enemy.top() - self.player.height; + self.player.vy = 0.0; + self.player.on_ground = true; self.score_f += cfg.enemy_stomp_bonus * score_mult; self.score = self.score_f as u64; } else if !invulnerable { @@ -323,6 +555,24 @@ impl World { } } + /// Test helper: clear generated content and add a permanent wide ground + /// platform so the player has something to stand on. + #[cfg(test)] + pub(crate) fn with_clear_scene(mut self, cfg: &Config) -> Self { + self.platforms.clear(); + self.enemies.clear(); + self.pickups.clear(); + self.active_effects.clear(); + // Wide enough that it never scrolls away during a short test. + self.platforms.push(Platform::new( + -5000.0, + cfg.start_platform_y, + 15000.0, + cfg.platform_height, + )); + self + } + fn draw_player(&self, d: &mut RaylibDrawHandle) { let p = &self.player; if !p.alive {