Add unit tests across all game modules (42 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 23:11:03 +01:00
parent 090f5d4a6d
commit 401f91b0fe
6 changed files with 511 additions and 9 deletions

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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<Platform>,
@@ -18,7 +248,7 @@ pub struct World {
pub active_effects: Vec<Box<dyn ActiveEffect>>,
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 {