Compare commits

...

4 Commits

Author SHA1 Message Date
Copilot
67a5c4a1ed Remove 9 redundant and non-valuable tests to improve test suite quality (#4)
* Initial plan

* Remove 9 redundant and non-valuable tests across events, formatting, and item modules

Co-authored-by: Xevion <44609630+Xevion@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Xevion <44609630+Xevion@users.noreply.github.com>
2025-08-19 13:07:14 -05:00
Ryan Walters
8b5e66f514 refactor: update debug state management and rendering systems 2025-08-19 11:31:31 -05:00
Ryan
5109457fcd test: add input tests 2025-08-19 09:40:59 -05:00
Ryan
5497e4b0b9 feat: improve input system to handle multiple keypress & release states 2025-08-19 09:35:55 -05:00
10 changed files with 202 additions and 224 deletions

View File

@@ -251,14 +251,13 @@ impl Game {
backbuffer: NonSendMut<BackbufferResource>, backbuffer: NonSendMut<BackbufferResource>,
debug_state: Res<DebugState>, debug_state: Res<DebugState>,
mut dirty: ResMut<RenderDirty>| { mut dirty: ResMut<RenderDirty>| {
if dirty.0 || *debug_state != DebugState::Off { if dirty.0 || debug_state.enabled {
// Only copy backbuffer to main canvas if debug rendering is off // Only copy backbuffer to main canvas if debug rendering is off
// (debug rendering draws directly to main canvas) // (debug rendering draws directly to main canvas)
if *debug_state == DebugState::Off { if !debug_state.enabled {
canvas.copy(&backbuffer.0, None, None).unwrap(); canvas.present();
} }
dirty.0 = false; dirty.0 = false;
canvas.present();
} }
}, },
), ),

View File

@@ -16,22 +16,13 @@ use sdl2::render::{Canvas, Texture, TextureCreator};
use sdl2::ttf::Font; use sdl2::ttf::Font;
use sdl2::video::{Window, WindowContext}; use sdl2::video::{Window, WindowContext};
#[derive(Resource, Default, Debug, Copy, Clone, PartialEq)] #[derive(Resource, Default, Debug, Copy, Clone)]
pub enum DebugState { pub struct DebugState {
#[default] pub enabled: bool,
Off,
Graph,
Collision,
} }
impl DebugState { fn f32_to_u8(value: f32) -> u8 {
pub fn next(&self) -> Self { (value * 255.0) as u8
match self {
DebugState::Off => DebugState::Graph,
DebugState::Graph => DebugState::Collision,
DebugState::Collision => DebugState::Off,
}
}
} }
/// Resource to hold the debug texture for persistent rendering /// Resource to hold the debug texture for persistent rendering
@@ -110,7 +101,7 @@ pub fn debug_render_system(
colliders: Query<(&Collider, &Position)>, colliders: Query<(&Collider, &Position)>,
cursor: Res<CursorPosition>, cursor: Res<CursorPosition>,
) { ) {
if *debug_state == DebugState::Off { if !debug_state.enabled {
return; return;
} }
let scale = let scale =
@@ -140,79 +131,86 @@ pub fn debug_render_system(
// Draw debug info on the high-resolution debug texture // Draw debug info on the high-resolution debug texture
canvas canvas
.with_texture_canvas(&mut debug_texture.0, |debug_canvas| { .with_texture_canvas(&mut debug_texture.0, |debug_canvas| {
match *debug_state { // Find the closest node to the cursor
DebugState::Graph => {
// Find the closest node to the cursor
let closest_node = if let Some(cursor_world_pos) = cursor_world_pos { let closest_node = if let Some(cursor_world_pos) = cursor_world_pos {
map.graph map.graph
.nodes() .nodes()
.map(|node| node.position.distance(cursor_world_pos)) .map(|node| node.position.distance(cursor_world_pos))
.enumerate() .enumerate()
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less)) .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less))
.map(|(id, _)| id) .map(|(id, _)| id)
} else {
None
};
debug_canvas.set_draw_color(Color::GREEN);
for (collider, position) in colliders.iter() {
let pos = position.get_pixel_position(&map.graph).unwrap();
// Transform position and size using common methods
let pos = (pos * scale).as_ivec2();
let size = (collider.size * scale) as u32;
let rect = Rect::from_center(Point::from((pos.x, pos.y)), size, size);
debug_canvas.draw_rect(rect).unwrap();
}
debug_canvas.set_draw_color(Color {
a: f32_to_u8(0.4),
..Color::RED
});
debug_canvas.set_blend_mode(sdl2::render::BlendMode::Blend);
for (start_node, end_node) in map.graph.edges() {
let start_node_model = map.graph.get_node(start_node).unwrap();
let end_node = map.graph.get_node(end_node.target).unwrap().position;
// Transform positions using common method
let start = transform_position_with_offset(start_node_model.position, scale);
let end = transform_position_with_offset(end_node, scale);
debug_canvas
.draw_line(Point::from((start.x, start.y)), Point::from((end.x, end.y)))
.unwrap();
}
for (id, node) in map.graph.nodes().enumerate() {
let pos = node.position;
// Set color based on whether the node is the closest to the cursor
debug_canvas.set_draw_color(Color {
a: f32_to_u8(if Some(id) == closest_node { 0.75 } else { 0.6 }),
..(if Some(id) == closest_node {
Color::YELLOW
} else { } else {
None Color::BLUE
}; })
});
debug_canvas.set_draw_color(Color::RED); // Transform position using common method
for (start_node, end_node) in map.graph.edges() { let pos = transform_position_with_offset(pos, scale);
let start_node_model = map.graph.get_node(start_node).unwrap(); let size = (2.0 * scale) as u32;
let end_node = map.graph.get_node(end_node.target).unwrap().position;
// Transform positions using common method debug_canvas
let start = transform_position_with_offset(start_node_model.position, scale); .fill_rect(Rect::new(pos.x - (size as i32 / 2), pos.y - (size as i32 / 2), size, size))
let end = transform_position_with_offset(end_node, scale); .unwrap();
}
debug_canvas // Render node ID if a node is highlighted
.draw_line(Point::from((start.x, start.y)), Point::from((end.x, end.y))) if let Some(closest_node_id) = closest_node {
.unwrap(); let node = map.graph.get_node(closest_node_id).unwrap();
} let pos = transform_position_with_offset(node.position, scale);
for (id, node) in map.graph.nodes().enumerate() { let surface = font
let pos = node.position; .render(&closest_node_id.to_string())
.blended(Color {
// Set color based on whether the node is the closest to the cursor a: f32_to_u8(0.4),
debug_canvas.set_draw_color(if Some(id) == closest_node { ..Color::WHITE
Color::YELLOW })
} else { .unwrap();
Color::BLUE let texture = texture_creator.create_texture_from_surface(&surface).unwrap();
}); let dest = Rect::new(pos.x + 10, pos.y - 5, texture.query().width, texture.query().height);
debug_canvas.copy(&texture, None, dest).unwrap();
// Transform position using common method
let pos = transform_position_with_offset(pos, scale);
let size = (3.0 * scale) as u32;
debug_canvas
.fill_rect(Rect::new(pos.x - (size as i32 / 2), pos.y - (size as i32 / 2), size, size))
.unwrap();
}
// Render node ID if a node is highlighted
if let Some(closest_node_id) = closest_node {
let node = map.graph.get_node(closest_node_id).unwrap();
let pos = transform_position_with_offset(node.position, scale);
let surface = font.render(&closest_node_id.to_string()).blended(Color::WHITE).unwrap();
let texture = texture_creator.create_texture_from_surface(&surface).unwrap();
let dest = Rect::new(pos.x + 10, pos.y - 5, texture.query().width, texture.query().height);
debug_canvas.copy(&texture, None, dest).unwrap();
}
}
DebugState::Collision => {
debug_canvas.set_draw_color(Color::GREEN);
for (collider, position) in colliders.iter() {
let pos = position.get_pixel_position(&map.graph).unwrap();
// Transform position and size using common methods
let pos = (pos * scale).as_ivec2();
let size = (collider.size * scale) as u32;
let rect = Rect::from_center(Point::from((pos.x, pos.y)), size, size);
debug_canvas.draw_rect(rect).unwrap();
}
}
_ => {}
} }
// Render timing information in the top-left corner // Render timing information in the top-left corner
@@ -222,4 +220,5 @@ pub fn debug_render_system(
// Draw the debug texture directly onto the main canvas at full resolution // Draw the debug texture directly onto the main canvas at full resolution
canvas.copy(&debug_texture.0, None, None).unwrap(); canvas.copy(&debug_texture.0, None, None).unwrap();
canvas.present();
} }

View File

@@ -28,7 +28,7 @@ pub enum CursorPosition {
pub struct Bindings { pub struct Bindings {
key_bindings: HashMap<Keycode, GameCommand>, key_bindings: HashMap<Keycode, GameCommand>,
movement_keys: HashSet<Keycode>, movement_keys: HashSet<Keycode>,
last_movement_key: Option<Keycode>, pressed_movement_keys: Vec<Keycode>,
} }
impl Default for Bindings { impl Default for Bindings {
@@ -67,11 +67,63 @@ impl Default for Bindings {
Self { Self {
key_bindings, key_bindings,
movement_keys, movement_keys,
last_movement_key: None, pressed_movement_keys: Vec::new(),
} }
} }
} }
/// A simplified input event used for deterministic testing and logic reuse
/// without depending on SDL's event pump.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SimpleKeyEvent {
KeyDown(Keycode),
KeyUp(Keycode),
}
/// Processes a frame's worth of simplified key events and returns the resulting
/// `GameEvent`s that would be emitted by the input system for that frame.
///
/// This mirrors the behavior of `input_system` for keyboard-related logic:
/// - KeyDown emits the bound command immediately (movement or otherwise)
/// - Tracks pressed movement keys in order to continue movement on subsequent frames
/// - KeyUp removes movement keys; if another movement key remains, it resumes
pub fn process_simple_key_events(bindings: &mut Bindings, frame_events: &[SimpleKeyEvent]) -> Vec<GameEvent> {
let mut emitted_events = Vec::new();
let mut movement_key_pressed = false;
for event in frame_events {
match *event {
SimpleKeyEvent::KeyDown(key) => {
if let Some(command) = bindings.key_bindings.get(&key).copied() {
emitted_events.push(GameEvent::Command(command));
}
if bindings.movement_keys.contains(&key) {
movement_key_pressed = true;
if !bindings.pressed_movement_keys.contains(&key) {
bindings.pressed_movement_keys.push(key);
}
}
}
SimpleKeyEvent::KeyUp(key) => {
if bindings.movement_keys.contains(&key) {
bindings.pressed_movement_keys.retain(|&k| k != key);
}
}
}
}
if !movement_key_pressed {
if let Some(&last_movement_key) = bindings.pressed_movement_keys.last() {
if let Some(command) = bindings.key_bindings.get(&last_movement_key).copied() {
emitted_events.push(GameEvent::Command(command));
}
}
}
emitted_events
}
pub fn input_system( pub fn input_system(
delta_time: Res<DeltaTime>, delta_time: Res<DeltaTime>,
mut bindings: ResMut<Bindings>, mut bindings: ResMut<Bindings>,
@@ -79,11 +131,14 @@ pub fn input_system(
mut pump: NonSendMut<&'static mut EventPump>, mut pump: NonSendMut<&'static mut EventPump>,
mut cursor: ResMut<CursorPosition>, mut cursor: ResMut<CursorPosition>,
) { ) {
let mut movement_key_pressed = false;
let mut cursor_seen = false; let mut cursor_seen = false;
// Collect all events for this frame.
let frame_events: Vec<Event> = pump.poll_iter().collect();
for event in pump.poll_iter() { // Handle non-keyboard events inline and build a simplified keyboard event stream.
match event { let mut simple_key_events = Vec::new();
for event in &frame_events {
match *event {
Event::Quit { .. } => { Event::Quit { .. } => {
writer.write(GameEvent::Command(GameCommand::Exit)); writer.write(GameEvent::Command(GameCommand::Exit));
} }
@@ -94,44 +149,28 @@ pub fn input_system(
}; };
cursor_seen = true; cursor_seen = true;
} }
Event::KeyUp {
repeat: false,
keycode: Some(key),
..
} => {
// If the last movement key was released, then forget it.
if let Some(last_movement_key) = bindings.last_movement_key {
if last_movement_key == key {
bindings.last_movement_key = None;
}
}
}
Event::KeyDown { Event::KeyDown {
keycode: Some(key), keycode: Some(key),
repeat: false, repeat: false,
.. ..
} => { } => {
let command = bindings.key_bindings.get(&key).copied(); simple_key_events.push(SimpleKeyEvent::KeyDown(key));
if let Some(command) = command { }
writer.write(GameEvent::Command(command)); Event::KeyUp {
} keycode: Some(key),
repeat: false,
if bindings.movement_keys.contains(&key) { ..
movement_key_pressed = true; } => {
bindings.last_movement_key = Some(key); simple_key_events.push(SimpleKeyEvent::KeyUp(key));
}
} }
_ => {} _ => {}
} }
} }
if let Some(last_movement_key) = bindings.last_movement_key { // Delegate keyboard handling to shared logic used by tests and production.
if !movement_key_pressed { let emitted = process_simple_key_events(&mut bindings, &simple_key_events);
let command = bindings.key_bindings.get(&last_movement_key).copied(); for event in emitted {
if let Some(command) = command { writer.write(event);
writer.write(GameEvent::Command(command));
}
}
} }
if let (false, CursorPosition::Some { remaining_time, .. }) = (cursor_seen, &mut *cursor) { if let (false, CursorPosition::Some { remaining_time, .. }) = (cursor_seen, &mut *cursor) {

View File

@@ -57,7 +57,7 @@ pub fn player_control_system(
state.exit = true; state.exit = true;
} }
GameCommand::ToggleDebug => { GameCommand::ToggleDebug => {
*debug_state = debug_state.next(); debug_state.enabled = !debug_state.enabled;
} }
GameCommand::MuteAudio => { GameCommand::MuteAudio => {
audio_state.muted = !audio_state.muted; audio_state.muted = !audio_state.muted;

View File

@@ -120,4 +120,6 @@ pub fn render_system(
}) })
.err() .err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into())); .map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
canvas.copy(&backbuffer.0, None, None).unwrap();
} }

View File

@@ -1,60 +1,13 @@
use pacman::events::{GameCommand, GameEvent}; use pacman::events::{GameCommand, GameEvent};
use pacman::map::direction::Direction; use pacman::map::direction::Direction;
#[test]
fn test_game_command_variants() {
// Test that all GameCommand variants can be created
let commands = [
GameCommand::Exit,
GameCommand::MovePlayer(Direction::Up),
GameCommand::MovePlayer(Direction::Down),
GameCommand::MovePlayer(Direction::Left),
GameCommand::MovePlayer(Direction::Right),
GameCommand::ToggleDebug,
GameCommand::MuteAudio,
GameCommand::ResetLevel,
GameCommand::TogglePause,
];
// Just verify they can be created and compared
assert_eq!(commands.len(), 9);
assert_eq!(commands[0], GameCommand::Exit);
assert_eq!(commands[1], GameCommand::MovePlayer(Direction::Up));
}
#[test]
fn test_game_command_equality() {
assert_eq!(GameCommand::Exit, GameCommand::Exit);
assert_eq!(GameCommand::ToggleDebug, GameCommand::ToggleDebug);
assert_eq!(
GameCommand::MovePlayer(Direction::Left),
GameCommand::MovePlayer(Direction::Left)
);
assert_ne!(GameCommand::Exit, GameCommand::ToggleDebug);
assert_ne!(
GameCommand::MovePlayer(Direction::Left),
GameCommand::MovePlayer(Direction::Right)
);
}
#[test]
fn test_game_event_variants() {
let command_event = GameEvent::Command(GameCommand::Exit);
let collision_event = GameEvent::Collision(bevy_ecs::entity::Entity::from_raw(1), bevy_ecs::entity::Entity::from_raw(2));
// Test that events can be created and compared
assert_eq!(command_event, GameEvent::Command(GameCommand::Exit));
assert_ne!(command_event, collision_event);
}
#[test]
fn test_game_command_to_game_event_conversion() {
let command = GameCommand::ToggleDebug;
let event: GameEvent = command.into();
assert_eq!(event, GameEvent::Command(GameCommand::ToggleDebug));
}
#[test] #[test]
fn test_game_command_to_game_event_conversion_all_variants() { fn test_game_command_to_game_event_conversion_all_variants() {
@@ -73,34 +26,8 @@ fn test_game_command_to_game_event_conversion_all_variants() {
} }
} }
#[test]
fn test_move_player_all_directions() {
let directions = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];
for direction in directions {
let command = GameCommand::MovePlayer(direction);
let event: GameEvent = command.into();
if let GameEvent::Command(GameCommand::MovePlayer(dir)) = event {
assert_eq!(dir, direction);
} else {
panic!("Expected MovePlayer command with direction {:?}", direction);
}
}
}
#[test]
fn test_game_event_debug_format() {
let event = GameEvent::Command(GameCommand::Exit);
let debug_str = format!("{:?}", event);
assert!(debug_str.contains("Command"));
assert!(debug_str.contains("Exit"));
}
#[test]
fn test_game_command_debug_format() {
let command = GameCommand::MovePlayer(Direction::Left);
let debug_str = format!("{:?}", command);
assert!(debug_str.contains("MovePlayer"));
assert!(debug_str.contains("Left"));
}

View File

@@ -119,12 +119,7 @@ fn test_format_timing_display_basic() {
} }
} }
#[test]
fn test_format_timing_display_empty() {
let timing_data = vec![];
let formatted = format_timing_display(timing_data);
assert!(formatted.is_empty());
}
#[test] #[test]
fn test_format_timing_display_units() { fn test_format_timing_display_units() {

38
tests/input.rs Normal file
View File

@@ -0,0 +1,38 @@
use pacman::events::{GameCommand, GameEvent};
use pacman::map::direction::Direction;
use pacman::systems::input::{process_simple_key_events, Bindings, SimpleKeyEvent};
use sdl2::keyboard::Keycode;
#[test]
fn resumes_previous_direction_when_secondary_key_released() {
let mut bindings = Bindings::default();
// Frame 1: Press W (Up) => emits Move Up
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::W)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Up))));
// Frame 2: Press D (Right) => emits Move Right
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::D)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Right))));
// Frame 3: Release D, no new key this frame => should continue previous key W (Up)
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::D)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Up))));
}
#[test]
fn holds_last_pressed_key_across_frames_when_no_new_input() {
let mut bindings = Bindings::default();
// Frame 1: Press Left
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Left)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Left))));
// Frame 2: No input => continues Left
let events = process_simple_key_events(&mut bindings, &[]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Left))));
// Frame 3: Release Left, no input remains => nothing emitted
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::Left)]);
assert!(events.is_empty());
}

View File

@@ -16,7 +16,6 @@ fn test_calculate_score_for_item() {
assert!(EntityType::Pellet.score_value() < EntityType::PowerPellet.score_value()); assert!(EntityType::Pellet.score_value() < EntityType::PowerPellet.score_value());
assert!(EntityType::Pellet.score_value().is_some()); assert!(EntityType::Pellet.score_value().is_some());
assert!(EntityType::PowerPellet.score_value().is_some()); assert!(EntityType::PowerPellet.score_value().is_some());
assert!(EntityType::Pellet.score_value().unwrap() < EntityType::PowerPellet.score_value().unwrap());
assert!(EntityType::Player.score_value().is_none()); assert!(EntityType::Player.score_value().is_none());
assert!(EntityType::Ghost.score_value().is_none()); assert!(EntityType::Ghost.score_value().is_none());
} }
@@ -194,27 +193,7 @@ fn test_item_system_ignores_non_item_collisions() {
assert_eq!(ghost_count, 1); assert_eq!(ghost_count, 1);
} }
#[test]
fn test_item_system_wrong_collision_order() {
let mut world = create_test_world();
let pacman = spawn_test_pacman(&mut world);
let pellet = spawn_test_item(&mut world, EntityType::Pellet);
// Send collision event with entities in reverse order
send_collision_event(&mut world, pellet, pacman);
world.run_system_once(item_system).expect("System should run successfully");
// Should still work correctly
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 10);
let pellet_count = world
.query::<&EntityType>()
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::Pellet))
.count();
assert_eq!(pellet_count, 0);
}
#[test] #[test]
fn test_item_system_no_collision_events() { fn test_item_system_no_collision_events() {

View File

@@ -21,7 +21,7 @@ fn create_test_world() -> World {
// Add resources // Add resources
world.insert_resource(GlobalState { exit: false }); world.insert_resource(GlobalState { exit: false });
world.insert_resource(DebugState::Off); world.insert_resource(DebugState::default());
world.insert_resource(AudioState::default()); world.insert_resource(AudioState::default());
world.insert_resource(DeltaTime(1.0 / 60.0)); // 60 FPS world.insert_resource(DeltaTime(1.0 / 60.0)); // 60 FPS
world.insert_resource(Events::<GameEvent>::default()); world.insert_resource(Events::<GameEvent>::default());
@@ -222,7 +222,7 @@ fn test_player_control_system_toggle_debug() {
// Check that debug state changed // Check that debug state changed
let debug_state = world.resource::<DebugState>(); let debug_state = world.resource::<DebugState>();
assert_eq!(*debug_state, DebugState::Graph); assert_eq!(debug_state.enabled, true);
} }
#[test] #[test]
@@ -565,7 +565,7 @@ fn test_player_state_persistence_across_systems() {
let position = *query.single(&world).expect("Player should exist"); let position = *query.single(&world).expect("Player should exist");
// Check that the state changes persisted individually // Check that the state changes persisted individually
assert_eq!(debug_state_after_toggle, DebugState::Graph, "Debug state should have toggled"); assert_eq!(debug_state_after_toggle.enabled, true, "Debug state should have toggled");
assert!(audio_muted_after_toggle, "Audio should be muted"); assert!(audio_muted_after_toggle, "Audio should be muted");
// Player position depends on actual map connectivity // Player position depends on actual map connectivity