Compare commits

...

11 Commits

Author SHA1 Message Date
Ryan Walters
c8f389b163 feat: add pacman death sound 2025-09-06 12:15:08 -05:00
Ryan Walters
9c274de901 feat: setup dying sprites with sprite validation tests 2025-09-06 12:15:08 -05:00
Ryan Walters
9633611ae8 fix: downgrade to codecov-action v4, update escapes pattern, ignore codecov.json, slim codecov config 2025-09-06 12:15:07 -05:00
Ryan Walters
897b9b8621 fix: switch from lcov to codecov.json for Codecov reporting 2025-09-06 12:15:07 -05:00
Ryan Walters
ee2569b70c ci: drop coveralls, add codecov config, change badge 2025-09-06 12:15:07 -05:00
Ryan Walters
84caa6c25f ci: setup codecov coverage 2025-09-06 12:15:06 -05:00
Ryan Walters
f92c9175b9 test: add ttf renderer tests 2025-09-06 12:15:06 -05:00
Ryan Walters
d561b446c5 test: remove useless/redundant tests 2025-09-06 12:15:05 -05:00
Ryan Walters
9219c771d7 test: improve input & map_builder test coverage 2025-09-06 12:15:05 -05:00
Ryan Walters
cd501aafc4 test: general game testing 2025-09-06 12:15:05 -05:00
Ryan Walters
feae1ee191 test: add asset tests, file exists & has min size 2025-09-06 12:15:04 -05:00
22 changed files with 794 additions and 256 deletions

View File

@@ -9,8 +9,6 @@ env:
jobs:
coverage:
runs-on: ubuntu-latest
env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v5
@@ -48,35 +46,12 @@ jobs:
- name: Generate coverage report
run: |
just coverage
just coverage-codecov
- name: Download Coveralls CLI
if: ${{ env.COVERALLS_REPO_TOKEN != '' }}
run: |
# use GitHub Releases URL instead of coveralls.io because they can't maintain their own files; it 404s
curl -L https://github.com/coverallsapp/coverage-reporter/releases/download/v0.6.15/coveralls-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin
- name: Upload coverage to Coveralls
if: ${{ env.COVERALLS_REPO_TOKEN != '' }}
run: |
if [ ! -f "lcov.info" ]; then
echo "Error: lcov.info file not found. Coverage generation may have failed."
exit 1
fi
for i in {1..10}; do
echo "Attempt $i: Uploading coverage to Coveralls..."
if coveralls -n report lcov.info; then
echo "Successfully uploaded coverage report."
exit 0
fi
if [ $i -lt 10 ]; then
delay=$((2**i))
echo "Attempt $i failed. Retrying in $delay seconds..."
sleep $delay
fi
done
echo "Failed to upload coverage report after 10 attempts."
exit 1
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./codecov.json
disable_search: true
verbose: true

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ assets/site/build.css
# Coverage reports
lcov.info
codecov.json
coverage.html
# Profiling output

View File

@@ -2,8 +2,8 @@ set shell := ["bash", "-c"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
# Regex to exclude files from coverage report, double escapes for Justfile + CLI
# You can use src\\\\..., but the filename alone is acceptable too
coverage_exclude_pattern := "src\\\\app\\.rs|audio\\.rs|src\\\\error\\.rs|platform\\\\emscripten\\.rs|bin\\\\.+\\.rs|main\\.rs|platform\\\\desktop\\.rs|platform\\\\tracing_buffer\\.rs|platform\\\\buffered_writer\\.rs|systems\\\\debug\\.rs|systems\\\\profiling\\.rs"
# You can use src/..., but the filename alone is acceptable too
coverage_exclude_pattern := "src/app\\.rs|audio\\.rs|src/error\\.rs|platform/emscripten\\.rs|bin/.+\\.rs|main\\.rs|platform/desktop\\.rs|platform/tracing_buffer\\.rs|platform/buffered_writer\\.rs|systems/debug\\.rs|systems/profiling\\.rs"
binary_extension := if os() == "windows" { ".exe" } else { "" }
@@ -11,7 +11,7 @@ binary_extension := if os() == "windows" { ".exe" } else { "" }
# !!! --remap-path-prefix prevents the absolute path from being used in the generated report
# Generate HTML report (for humans, source line inspection)
html: coverage
html: coverage-lcov
cargo llvm-cov report \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
@@ -19,13 +19,13 @@ html: coverage
--open
# Display report (for humans)
report-coverage: coverage
report-coverage: coverage-lcov
cargo llvm-cov report \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}"
# Run & generate report (for CI)
coverage:
# Run & generate LCOV report (as base report)
coverage-lcov:
cargo llvm-cov \
--lcov \
--remap-path-prefix \
@@ -34,6 +34,16 @@ coverage:
--profile coverage \
--no-fail-fast nextest
# Run & generate Codecov report (for CI)
coverage-codecov:
cargo llvm-cov \
--codecov \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
--output-path codecov.json \
--profile coverage \
--no-fail-fast nextest
# Profile the project using 'samply'
samply:
cargo build --profile profile

View File

@@ -1,16 +1,16 @@
# Pac-Man
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![If you're seeing this, Coveralls.io is broken again and it's not my fault.][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![Code Coverage][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
[badge-coverage]: https://coveralls.io/repos/github/Xevion/Pac-Man/badge.svg?branch=master
[badge-coverage]: https://codecov.io/github/Xevion/Pac-Man/branch/master/graph/badge.svg?token=R2RBYUQK3I
[badge-demo]: https://img.shields.io/github/deployments/Xevion/Pac-Man/github-pages?label=GitHub%20Pages
[badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml
[coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master
[coverage]: https://codecov.io/github/Xevion/Pac-Man
[demo]: https://xevion.github.io/Pac-Man/
[commits]: https://github.com/Xevion/Pac-Man/commits/master

View File

Binary file not shown.

3
codecov.yml Normal file
View File

@@ -0,0 +1,3 @@
ignore:
- "src/(?:bin|platform))/.+\\.rs"
- "src/(?:app|events|formatter)\\.rs"

View File

@@ -19,6 +19,8 @@ pub enum Asset {
AtlasImage,
/// Terminal Vector font for text rendering (TerminalVector.ttf)
Font,
/// Sound effect for Pac-Man's death
DeathSound,
}
impl Asset {
@@ -37,6 +39,7 @@ impl Asset {
Wav4 => "sound/waka/4.ogg",
AtlasImage => "atlas.png",
Font => "TerminalVector.ttf",
DeathSound => "sound/pacman_death.wav",
}
}
}

View File

@@ -16,6 +16,7 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
pub struct Audio {
_mixer_context: Option<mixer::Sdl2MixerContext>,
sounds: Vec<Chunk>,
death_sound: Option<Chunk>,
next_sound_index: usize,
muted: bool,
disabled: bool,
@@ -44,6 +45,7 @@ impl Audio {
return Self {
_mixer_context: None,
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
muted: false,
disabled: true,
@@ -65,6 +67,7 @@ impl Audio {
return Self {
_mixer_context: None,
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
muted: false,
disabled: true,
@@ -93,12 +96,33 @@ impl Audio {
}
}
let death_sound = match get_asset_bytes(Asset::DeathSound) {
Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => Some(chunk),
Err(e) => {
tracing::warn!("Failed to load death sound from asset API: {}", e);
None
}
},
Err(e) => {
tracing::warn!("Failed to create RWops for death sound: {}", e);
None
}
},
Err(e) => {
tracing::warn!("Failed to load death sound asset: {}", e);
None
}
};
// If no sounds loaded successfully, disable audio
if sounds.is_empty() {
if sounds.is_empty() && death_sound.is_none() {
tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
return Self {
_mixer_context: Some(mixer_context),
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
muted: false,
disabled: true,
@@ -108,6 +132,7 @@ impl Audio {
Audio {
_mixer_context: Some(mixer_context),
sounds,
death_sound,
next_sound_index: 0,
muted: false,
disabled: false,
@@ -138,6 +163,24 @@ impl Audio {
self.next_sound_index = (self.next_sound_index + 1) % self.sounds.len();
}
/// Plays the death sound effect.
pub fn death(&mut self) {
if self.disabled || self.muted {
return;
}
if let Some(chunk) = &self.death_sound {
mixer::Channel::all().play(chunk, 0).ok();
}
}
/// Halts all currently playing audio channels.
pub fn stop_all(&mut self) {
if !self.disabled {
mixer::Channel::all().halt();
}
}
/// Instantly mutes or unmutes all audio channels by adjusting their volume.
///
/// Sets all 4 mixer channels to zero volume when muting, or restores them to

View File

@@ -359,12 +359,7 @@ impl Map {
+ IVec2::from(Direction::Left.as_ivec2()).as_vec2() * (CELL_SIZE as f32 * 2.0),
},
)
.map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect left tunnel entrance to left tunnel hidden node: {}",
e
))
})?
.expect("Failed to connect left tunnel entrance to left tunnel hidden node")
};
// Create the right tunnel nodes
@@ -384,12 +379,7 @@ impl Map {
+ IVec2::from(Direction::Right.as_ivec2()).as_vec2() * (CELL_SIZE as f32 * 2.0),
},
)
.map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect right tunnel entrance to right tunnel hidden node: {}",
e
))
})?
.expect("Failed to connect right tunnel entrance to right tunnel hidden node")
};
// Connect the left tunnel hidden node to the right tunnel hidden node
@@ -401,12 +391,7 @@ impl Map {
Some(0.0),
Direction::Left,
)
.map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect left tunnel hidden node to right tunnel hidden node: {}",
e
))
})?;
.expect("Failed to connect left tunnel hidden node to right tunnel hidden node");
Ok(())
}

View File

@@ -65,6 +65,7 @@ pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
Asset::DeathSound => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman_death.wav"))),
}
}

View File

@@ -26,6 +26,10 @@ pub struct AudioState {
pub enum AudioEvent {
/// Play the "eat" sound when Pac-Man consumes a pellet
PlayEat,
/// Play the death sound
PlayDeath,
/// Stop all currently playing sounds
StopAll,
}
/// Non-send resource wrapper for SDL2 audio system
@@ -59,6 +63,16 @@ pub fn audio_system(
// 4 eat sounds available
}
}
AudioEvent::PlayDeath => {
if !audio.0.is_disabled() && !audio_state.muted {
audio.0.death();
}
}
AudioEvent::StopAll => {
if !audio.0.is_disabled() {
audio.0.stop_all();
}
}
}
}
}

View File

@@ -20,10 +20,10 @@ use crate::{
};
// Touch input constants
const TOUCH_DIRECTION_THRESHOLD: f32 = 10.0;
const TOUCH_EASING_DISTANCE_THRESHOLD: f32 = 1.0;
const MAX_TOUCH_MOVEMENT_SPEED: f32 = 100.0;
const TOUCH_EASING_FACTOR: f32 = 1.5;
pub const TOUCH_DIRECTION_THRESHOLD: f32 = 10.0;
pub const TOUCH_EASING_DISTANCE_THRESHOLD: f32 = 1.0;
pub const MAX_TOUCH_MOVEMENT_SPEED: f32 = 100.0;
pub const TOUCH_EASING_FACTOR: f32 = 1.5;
#[derive(Resource, Default, Debug, Copy, Clone)]
pub enum CursorPosition {
@@ -35,7 +35,7 @@ pub enum CursorPosition {
},
}
#[derive(Resource, Default, Debug)]
#[derive(Resource, Default, Debug, Clone)]
pub struct TouchState {
pub active_touch: Option<TouchData>,
}
@@ -160,7 +160,7 @@ pub fn process_simple_key_events(bindings: &mut Bindings, frame_events: &[Simple
}
/// Calculates the primary direction from a 2D vector delta
fn calculate_direction_from_delta(delta: Vec2) -> Direction {
pub fn calculate_direction_from_delta(delta: Vec2) -> Direction {
if delta.x.abs() > delta.y.abs() {
if delta.x > 0.0 {
Direction::Right
@@ -179,7 +179,7 @@ fn calculate_direction_from_delta(delta: Vec2) -> Direction {
/// This slowly moves the start_pos towards the current_pos, with the speed
/// decreasing as the distance gets smaller. The maximum movement speed is capped.
/// Returns the delta vector and its length for reuse by the caller.
fn update_touch_reference_position(touch_data: &mut TouchData, delta_time: f32) -> (Vec2, f32) {
pub fn update_touch_reference_position(touch_data: &mut TouchData, delta_time: f32) -> (Vec2, f32) {
// Calculate the vector from start to current position
let delta = touch_data.current_pos - touch_data.start_pos;
let distance = delta.length();
@@ -220,16 +220,6 @@ pub fn input_system(
// Collect all events for this frame.
let frame_events: SmallVec<[Event; 3]> = pump.poll_iter().collect();
// Warn if the smallvec was heap allocated due to exceeding stack capacity
#[cfg(debug_assertions)]
if frame_events.len() > frame_events.capacity() {
tracing::warn!(
"More than {} events in a frame, consider adjusting stack capacity: {:?}",
frame_events.capacity(),
frame_events
);
}
// Handle non-keyboard events inline and build a simplified keyboard event stream.
let mut simple_key_events: SmallVec<[SimpleKeyEvent; 3]> = smallvec![];
for event in &frame_events {

View File

@@ -15,6 +15,8 @@ pub enum PacmanSprite {
Moving(Direction, u8),
/// The full, closed-mouth Pac-Man sprite.
Full,
/// A single frame of the dying animation.
Dying(u8),
}
/// Represents the color of a frightened ghost.
@@ -60,45 +62,50 @@ impl GameSprite {
/// This path corresponds to the filename in the texture atlas JSON file.
pub fn to_path(self) -> String {
match self {
GameSprite::Pacman(sprite) => match sprite {
PacmanSprite::Moving(dir, frame) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
format!("pacman/{}_{}.png", dir.as_ref().to_lowercase(), frame_char)
GameSprite::Pacman(PacmanSprite::Moving(dir, frame)) => format!(
"pacman/{}_{}.png",
dir.as_ref(),
match frame {
0 => "a",
1 => "b",
_ => panic!("Invalid animation frame"),
}
PacmanSprite::Full => "pacman/full.png".to_string(),
},
GameSprite::Ghost(sprite) => match sprite {
GhostSprite::Normal(ghost, dir, frame) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
format!("ghost/{}/{}_{}.png", ghost.as_str(), dir.as_ref().to_lowercase(), frame_char)
}
GhostSprite::Frightened(color, frame) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
let color_str = match color {
FrightenedColor::Blue => "blue",
FrightenedColor::White => "white",
};
format!("ghost/frightened/{}_{}.png", color_str, frame_char)
}
GhostSprite::Eyes(dir) => format!("ghost/eyes/{}.png", dir.as_ref().to_lowercase()),
},
GameSprite::Maze(sprite) => match sprite {
MazeSprite::Tile(index) => format!("maze/tiles/{}.png", index),
MazeSprite::Pellet => "maze/pellet.png".to_string(),
MazeSprite::Energizer => "maze/energizer.png".to_string(),
},
),
GameSprite::Pacman(PacmanSprite::Full) => "pacman/full.png".to_string(),
GameSprite::Pacman(PacmanSprite::Dying(frame)) => format!("pacman/death/{}.png", frame),
// Ghost sprites
GameSprite::Ghost(GhostSprite::Normal(ghost_type, dir, frame)) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
dir.as_ref().to_lowercase(),
frame_char
)
}
GameSprite::Ghost(GhostSprite::Frightened(color, frame)) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
let color_str = match color {
FrightenedColor::Blue => "blue",
FrightenedColor::White => "white",
};
format!("ghost/frightened/{}_{}.png", color_str, frame_char)
}
GameSprite::Ghost(GhostSprite::Eyes(dir)) => format!("ghost/eyes/{}.png", dir.as_ref().to_lowercase()),
// Maze sprites
GameSprite::Maze(MazeSprite::Tile(index)) => format!("maze/tiles/{}.png", index),
GameSprite::Maze(MazeSprite::Pellet) => "maze/pellet.png".to_string(),
GameSprite::Maze(MazeSprite::Energizer) => "maze/energizer.png".to_string(),
}
}
}

17
tests/asset.rs Normal file
View File

@@ -0,0 +1,17 @@
use pacman::asset::Asset;
use speculoos::prelude::*;
use strum::IntoEnumIterator;
#[test]
fn all_asset_paths_exist() {
for asset in Asset::iter() {
let path = asset.path();
let full_path = format!("assets/game/{}", path);
let metadata = std::fs::metadata(&full_path)
.map_err(|e| format!("Error getting metadata for {}: {}", full_path, e))
.unwrap();
assert_that(&metadata.is_file()).is_true();
assert_that(&metadata.len()).is_greater_than(1024);
}
}

View File

@@ -1,24 +0,0 @@
use pacman::constants::*;
use speculoos::prelude::*;
#[test]
fn test_raw_board_structure() {
// Test board dimensions match expected size
assert_that(&RAW_BOARD.len()).is_equal_to(BOARD_CELL_SIZE.y as usize);
for row in RAW_BOARD.iter() {
assert_that(&row.len()).is_equal_to(BOARD_CELL_SIZE.x as usize);
}
// Test boundaries are properly walled
assert_that(&RAW_BOARD[0].chars().all(|c| c == '#')).is_true();
assert_that(&RAW_BOARD[RAW_BOARD.len() - 1].chars().all(|c| c == '#')).is_true();
}
#[test]
fn test_raw_board_contains_required_elements() {
// Test that essential game elements are present
assert_that(&RAW_BOARD.iter().any(|row| row.contains('X'))).is_true();
assert_that(&RAW_BOARD.iter().any(|row| row.contains("=="))).is_true();
assert_that(&RAW_BOARD.iter().any(|row| row.chars().any(|c| c == 'T'))).is_true();
assert_that(&RAW_BOARD.iter().any(|row| row.chars().any(|c| c == 'o'))).is_true();
}

View File

@@ -1,76 +1,7 @@
use pacman::error::{
AssetError, EntityError, GameError, GameResult, IntoGameError, MapError, OptionExt, ParseError, ResultExt, TextureError,
};
use pacman::error::{GameError, GameResult, IntoGameError, OptionExt, ResultExt};
use speculoos::prelude::*;
use std::io;
#[test]
fn test_game_error_from_asset_error() {
let asset_error = AssetError::NotFound("test.png".to_string());
let game_error: GameError = asset_error.into();
assert_that(&matches!(game_error, GameError::Asset(_))).is_true();
}
#[test]
fn test_game_error_from_parse_error() {
let parse_error = ParseError::UnknownCharacter('Z');
let game_error: GameError = parse_error.into();
assert_that(&matches!(game_error, GameError::MapParse(_))).is_true();
}
#[test]
fn test_game_error_from_map_error() {
let map_error = MapError::NodeNotFound(42);
let game_error: GameError = map_error.into();
assert_that(&matches!(game_error, GameError::Map(_))).is_true();
}
#[test]
fn test_game_error_from_texture_error() {
let texture_error = TextureError::LoadFailed("Failed to load".to_string());
let game_error: GameError = texture_error.into();
assert_that(&matches!(game_error, GameError::Texture(_))).is_true();
}
#[test]
fn test_game_error_from_entity_error() {
let entity_error = EntityError::NodeNotFound(10);
let game_error: GameError = entity_error.into();
assert_that(&matches!(game_error, GameError::Entity(_))).is_true();
}
#[test]
fn test_game_error_from_io_error() {
let io_error = io::Error::new(io::ErrorKind::NotFound, "File not found");
let game_error: GameError = io_error.into();
assert_that(&matches!(game_error, GameError::Io(_))).is_true();
}
#[test]
fn test_asset_error_from_io_error() {
let io_error = io::Error::new(io::ErrorKind::PermissionDenied, "Permission denied");
let asset_error: AssetError = io_error.into();
assert_that(&matches!(asset_error, AssetError::Io(_))).is_true();
}
#[test]
fn test_parse_error_display() {
let error = ParseError::UnknownCharacter('!');
assert_that(&error.to_string()).is_equal_to("Unknown character in board: !".to_string());
let error = ParseError::InvalidHouseDoorCount(3);
assert_that(&error.to_string()).is_equal_to("House door must have exactly 2 positions, found 3".to_string());
}
#[test]
fn test_entity_error_display() {
let error = EntityError::NodeNotFound(42);
assert_that(&error.to_string()).is_equal_to("Node not found in graph: 42".to_string());
let error = EntityError::EdgeNotFound { from: 1, to: 2 };
assert_that(&error.to_string()).is_equal_to("Edge not found: from 1 to 2".to_string());
}
#[test]
fn test_into_game_error_trait() {
let result: Result<i32, io::Error> = Err(io::Error::new(io::ErrorKind::Other, "test error"));

79
tests/game.rs Normal file
View File

@@ -0,0 +1,79 @@
use pacman::error::{GameError, GameResult};
use pacman::game::Game;
use speculoos::prelude::*;
mod common;
use common::setup_sdl;
#[test]
fn test_game_30_seconds_60fps() -> GameResult<()> {
let (canvas, texture_creator, _sdl_context) = setup_sdl().map_err(GameError::Sdl)?;
let ttf_context = sdl2::ttf::init().map_err(GameError::Sdl)?;
let event_pump = _sdl_context
.event_pump()
.map_err(|e| pacman::error::GameError::Sdl(e.to_string()))?;
let mut game = Game::new(canvas, ttf_context, texture_creator, event_pump)?;
// Run for 30 seconds at 60 FPS = 1800 frames
let frame_time = 1.0 / 60.0;
let total_frames = 1800;
let mut frame_count = 0;
for _ in 0..total_frames {
let should_exit = game.tick(frame_time);
if should_exit {
break;
}
frame_count += 1;
}
assert_eq!(
frame_count, total_frames,
"Should have processed exactly {} frames",
total_frames
);
Ok(())
}
/// Test that runs the game for 30 seconds with variable frame timing
#[test]
fn test_game_30_seconds_variable_timing() -> GameResult<()> {
let (canvas, texture_creator, _sdl_context) = setup_sdl().map_err(GameError::Sdl)?;
let ttf_context = sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let event_pump = _sdl_context
.event_pump()
.map_err(|e| pacman::error::GameError::Sdl(e.to_string()))?;
let mut game = Game::new(canvas, ttf_context, texture_creator, event_pump)?;
// Simulate 30 seconds with variable frame timing
let mut total_time = 0.0;
let target_time = 30.0;
let mut frame_count = 0;
while total_time < target_time {
// Alternate between different frame rates to simulate real gameplay
let frame_time = match frame_count % 4 {
0 => 1.0 / 60.0, // 60 FPS
1 => 1.0 / 30.0, // 30 FPS (lag spike)
2 => 1.0 / 120.0, // 120 FPS (very fast)
_ => 1.0 / 60.0, // 60 FPS
};
let should_exit = game.tick(frame_time);
if should_exit {
break;
}
total_time += frame_time;
frame_count += 1;
}
assert_that(&total_time).is_greater_than_or_equal_to(target_time);
Ok(())
}

View File

@@ -1,39 +1,321 @@
use glam::Vec2;
use pacman::events::{GameCommand, GameEvent};
use pacman::map::direction::Direction;
use pacman::systems::input::{process_simple_key_events, Bindings, SimpleKeyEvent};
use pacman::systems::input::{
calculate_direction_from_delta, process_simple_key_events, update_touch_reference_position, Bindings, CursorPosition,
SimpleKeyEvent, TouchData, TouchState, TOUCH_DIRECTION_THRESHOLD, TOUCH_EASING_DISTANCE_THRESHOLD,
};
use sdl2::keyboard::Keycode;
use speculoos::prelude::*;
#[test]
fn resumes_previous_direction_when_secondary_key_released() {
let mut bindings = Bindings::default();
// Test modules for better organization
mod keyboard_tests {
use super::*;
// Frame 1: Press W (Up) => emits Move Up
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::W)]);
assert_that(&events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Up)))).is_true();
#[test]
fn key_down_emits_bound_command() {
let mut bindings = Bindings::default();
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::W)]);
assert_that(&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_that(&events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Right)))).is_true();
#[test]
fn key_down_emits_non_movement_commands() {
let mut bindings = Bindings::default();
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::P)]);
assert_that(&events).contains(GameEvent::Command(GameCommand::TogglePause));
}
// 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_that(&events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Up)))).is_true();
#[test]
fn unbound_key_emits_nothing() {
let mut bindings = Bindings::default();
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Z)]);
assert_that(&events).is_empty();
}
#[test]
fn movement_key_held_continues_across_frames() {
let mut bindings = Bindings::default();
process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Left)]);
let events = process_simple_key_events(&mut bindings, &[]);
assert_that(&events).contains(GameEvent::Command(GameCommand::MovePlayer(Direction::Left)));
}
#[test]
fn releasing_movement_key_stops_continuation() {
let mut bindings = Bindings::default();
process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Up)]);
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::Up)]);
assert_that(&events).is_empty();
}
#[test]
fn multiple_movement_keys_resumes_previous_when_current_released() {
let mut bindings = Bindings::default();
process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::W)]);
process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::D)]);
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::D)]);
assert_that(&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();
mod direction_calculation_tests {
use super::*;
// Frame 1: Press Left
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Left)]);
assert_that(&events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Left)))).is_true();
#[test]
fn prioritizes_horizontal_movement() {
let test_cases = vec![
(Vec2::new(6.0, 5.0), Direction::Right),
(Vec2::new(-6.0, 5.0), Direction::Left),
];
// Frame 2: No input => continues Left
let events = process_simple_key_events(&mut bindings, &[]);
assert_that(&events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Left)))).is_true();
for (delta, expected) in test_cases {
assert_that(&calculate_direction_from_delta(delta)).is_equal_to(expected);
}
}
// Frame 3: Release Left, no input remains => nothing emitted
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::Left)]);
assert_that(&events.is_empty()).is_true();
#[test]
fn uses_vertical_when_dominant() {
let test_cases = vec![
(Vec2::new(3.0, 10.0), Direction::Down),
(Vec2::new(3.0, -10.0), Direction::Up),
];
for (delta, expected) in test_cases {
assert_that(&calculate_direction_from_delta(delta)).is_equal_to(expected);
}
}
#[test]
fn handles_zero_delta() {
let delta = Vec2::ZERO;
// Should default to Up when both components are zero
assert_that(&calculate_direction_from_delta(delta)).is_equal_to(Direction::Up);
}
#[test]
fn handles_equal_magnitudes() {
// When x and y have equal absolute values, should prioritize vertical
let delta = Vec2::new(5.0, 5.0);
assert_that(&calculate_direction_from_delta(delta)).is_equal_to(Direction::Down);
let delta = Vec2::new(-5.0, 5.0);
assert_that(&calculate_direction_from_delta(delta)).is_equal_to(Direction::Down);
}
}
mod touch_easing_tests {
use super::*;
#[test]
fn easing_within_threshold_does_nothing() {
let mut touch_data = TouchData::new(0, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(100.0 + TOUCH_EASING_DISTANCE_THRESHOLD - 0.1, 100.0);
let (_delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
assert_that(&distance).is_less_than(TOUCH_EASING_DISTANCE_THRESHOLD);
assert_that(&touch_data.start_pos).is_equal_to(Vec2::new(100.0, 100.0));
}
#[test]
fn easing_beyond_threshold_moves_towards_target() {
let mut touch_data = TouchData::new(0, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(150.0, 100.0);
let original_start_pos = touch_data.start_pos;
let (_delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
assert_that(&distance).is_greater_than(TOUCH_EASING_DISTANCE_THRESHOLD);
assert_that(&touch_data.start_pos.x).is_greater_than(original_start_pos.x);
assert_that(&touch_data.start_pos.x).is_less_than(touch_data.current_pos.x);
}
#[test]
fn easing_overshoot_sets_to_target() {
let mut touch_data = TouchData::new(0, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(101.0, 100.0);
let (_delta, _distance) = update_touch_reference_position(&mut touch_data, 10.0);
assert_that(&touch_data.start_pos).is_equal_to(touch_data.current_pos);
}
#[test]
fn easing_returns_correct_delta() {
let mut touch_data = TouchData::new(0, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(120.0, 110.0);
let (delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
let expected_delta = Vec2::new(20.0, 10.0);
let expected_distance = expected_delta.length();
assert_that(&delta).is_equal_to(expected_delta);
assert_that(&distance).is_equal_to(expected_distance);
}
}
// Integration tests for the full input system
mod integration_tests {
use super::*;
fn mouse_motion_event(x: i32, y: i32) -> sdl2::event::Event {
sdl2::event::Event::MouseMotion {
x,
y,
xrel: 0,
yrel: 0,
mousestate: sdl2::mouse::MouseState::from_sdl_state(0),
which: 0,
window_id: 0,
timestamp: 0,
}
}
fn mouse_button_down_event(x: i32, y: i32) -> sdl2::event::Event {
sdl2::event::Event::MouseButtonDown {
x,
y,
mouse_btn: sdl2::mouse::MouseButton::Left,
clicks: 1,
which: 0,
window_id: 0,
timestamp: 0,
}
}
fn mouse_button_up_event(x: i32, y: i32) -> sdl2::event::Event {
sdl2::event::Event::MouseButtonUp {
x,
y,
mouse_btn: sdl2::mouse::MouseButton::Left,
clicks: 1,
which: 0,
window_id: 0,
timestamp: 0,
}
}
// Simplified helper for testing SDL integration
fn run_input_system_with_events(events: Vec<sdl2::event::Event>, delta_time: f32) -> (CursorPosition, TouchState) {
use bevy_ecs::{event::Events, system::RunSystemOnce, world::World};
use pacman::systems::components::DeltaTime;
use pacman::systems::input::input_system;
let sdl_context = sdl2::init().expect("Failed to initialize SDL");
let event_subsystem = sdl_context.event().expect("Failed to get event subsystem");
let event_pump = sdl_context.event_pump().expect("Failed to create event pump");
let mut world = World::new();
world.insert_resource(Events::<GameEvent>::default());
world.insert_resource(DeltaTime {
seconds: delta_time,
ticks: 1,
});
world.insert_resource(Bindings::default());
world.insert_resource(CursorPosition::None);
world.insert_resource(TouchState::default());
world.insert_non_send_resource(event_pump);
// Inject events into SDL's event queue
for event in events {
event_subsystem.push_event(event).expect("Failed to push event");
}
// Run the real input system
world
.run_system_once(input_system)
.expect("Input system should run successfully");
let cursor = *world.resource::<CursorPosition>();
let touch_state = world.resource::<TouchState>().clone();
(cursor, touch_state)
}
#[test]
fn mouse_motion_updates_cursor_position() {
let events = vec![mouse_motion_event(100, 200)];
let (cursor, _touch_state) = run_input_system_with_events(events, 0.016);
match cursor {
CursorPosition::Some {
position,
remaining_time,
} => {
assert_that(&position).is_equal_to(Vec2::new(100.0, 200.0));
assert_that(&remaining_time).is_equal_to(0.20);
}
CursorPosition::None => panic!("Expected cursor position to be set"),
}
}
#[test]
fn mouse_button_down_starts_touch() {
let events = vec![mouse_button_down_event(150, 250)];
let (_cursor, touch_state) = run_input_system_with_events(events, 0.016);
assert_that(&touch_state.active_touch).is_some();
if let Some(touch_data) = &touch_state.active_touch {
assert_that(&touch_data.finger_id).is_equal_to(0);
assert_that(&touch_data.start_pos).is_equal_to(Vec2::new(150.0, 250.0));
}
}
#[test]
fn mouse_button_up_ends_touch() {
let events = vec![mouse_button_down_event(150, 250), mouse_button_up_event(150, 250)];
let (_cursor, touch_state) = run_input_system_with_events(events, 0.016);
assert_that(&touch_state.active_touch).is_none();
}
}
// Touch direction tests
mod touch_direction_tests {
use super::*;
#[test]
fn movement_above_threshold_emits_direction() {
let mut touch_data = TouchData::new(1, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(100.0 + TOUCH_DIRECTION_THRESHOLD + 5.0, 100.0);
let (delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
assert_that(&distance).is_greater_than_or_equal_to(TOUCH_DIRECTION_THRESHOLD);
let direction = calculate_direction_from_delta(delta);
assert_that(&direction).is_equal_to(Direction::Right);
}
#[test]
fn movement_below_threshold_no_direction() {
let mut touch_data = TouchData::new(1, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(100.0 + TOUCH_DIRECTION_THRESHOLD - 1.0, 100.0);
let (_delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
assert_that(&distance).is_less_than(TOUCH_DIRECTION_THRESHOLD);
}
#[test]
fn all_directions_work_correctly() {
let test_cases = vec![
(Vec2::new(TOUCH_DIRECTION_THRESHOLD + 5.0, 0.0), Direction::Right),
(Vec2::new(-TOUCH_DIRECTION_THRESHOLD - 5.0, 0.0), Direction::Left),
(Vec2::new(0.0, TOUCH_DIRECTION_THRESHOLD + 5.0), Direction::Down),
(Vec2::new(0.0, -TOUCH_DIRECTION_THRESHOLD - 5.0), Direction::Up),
];
for (offset, expected_direction) in test_cases {
let mut touch_data = TouchData::new(1, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(100.0, 100.0) + offset;
let (delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
assert_that(&distance).is_greater_than_or_equal_to(TOUCH_DIRECTION_THRESHOLD);
let direction = calculate_direction_from_delta(delta);
assert_that(&direction).is_equal_to(expected_direction);
}
}
}

View File

@@ -1,10 +1,11 @@
use glam::Vec2;
use pacman::constants::{CELL_SIZE, RAW_BOARD};
use pacman::map::builder::Map;
use pacman::map::graph::TraversalFlags;
use speculoos::prelude::*;
#[test]
fn test_map_creation() {
fn test_map_creation_success() {
let map = Map::new(RAW_BOARD).unwrap();
assert_that(&map.graph.nodes().count()).is_greater_than(0);
@@ -22,7 +23,7 @@ fn test_map_creation() {
}
#[test]
fn test_map_node_positions() {
fn test_map_node_positions_accuracy() {
let map = Map::new(RAW_BOARD).unwrap();
for (grid_pos, &node_id) in &map.grid_to_node {
@@ -35,3 +36,54 @@ fn test_map_node_positions() {
assert_that(&node.position).is_equal_to(expected_pos);
}
}
#[test]
fn test_start_positions_are_valid() {
let map = Map::new(RAW_BOARD).unwrap();
let positions = &map.start_positions;
// All start positions should exist in the graph
assert_that(&map.graph.get_node(positions.pacman)).is_some();
assert_that(&map.graph.get_node(positions.blinky)).is_some();
assert_that(&map.graph.get_node(positions.pinky)).is_some();
assert_that(&map.graph.get_node(positions.inky)).is_some();
assert_that(&map.graph.get_node(positions.clyde)).is_some();
}
#[test]
fn test_ghost_house_has_ghost_only_entrance() {
let map = Map::new(RAW_BOARD).unwrap();
// Find the house entrance node
let house_entrance = map.start_positions.blinky;
// Check that there's a ghost-only connection from the house entrance
let mut has_ghost_only_connection = false;
for edge in map.graph.adjacency_list[house_entrance as usize].edges() {
if edge.traversal_flags == TraversalFlags::GHOST {
has_ghost_only_connection = true;
break;
}
}
assert_that(&has_ghost_only_connection).is_true();
}
#[test]
fn test_tunnel_connections_exist() {
let map = Map::new(RAW_BOARD).unwrap();
// Find tunnel nodes by looking for nodes with zero-distance connections
let mut has_tunnel_connection = false;
for intersection in &map.graph.adjacency_list {
for edge in intersection.edges() {
if edge.distance == 0.0f32 {
has_tunnel_connection = true;
break;
}
}
if has_tunnel_connection {
break;
}
}
assert_that(&has_tunnel_connection).is_true();
}

View File

@@ -73,25 +73,6 @@ fn test_default_zero_timing_for_unused_systems() {
}
}
#[test]
fn test_pre_populated_timing_entries() {
let timings = SystemTimings::default();
// Verify that we can add timing to any SystemId without panicking
// (this would fail with the old implementation if the entry didn't exist)
// Use the same tick for all systems to avoid zero-padding
for id in SystemId::iter() {
timings.add_timing(id, Duration::from_nanos(1), 1);
}
// Verify all systems now have non-zero timing
let stats = timings.get_stats(1);
for id in SystemId::iter() {
let (avg, _) = stats.get(&id).unwrap();
assert_that(&(*avg > Duration::ZERO)).is_true();
}
}
#[test]
fn test_total_system_timing() {
let timings = SystemTimings::default();

73
tests/sprites.rs Normal file
View File

@@ -0,0 +1,73 @@
//! Tests for the sprite path generation.
use pacman::{
game::ATLAS_FRAMES,
map::direction::Direction,
systems::components::Ghost,
texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite},
};
#[test]
fn test_all_sprite_paths_exist() {
let mut sprites_to_test = Vec::new();
// Pac-Man sprites
for &dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
for frame in 0..2 {
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Moving(dir, frame)));
}
}
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Full));
for frame in 0..=10 {
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Dying(frame)));
}
// Ghost sprites
for &ghost in &[Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] {
for &dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
for frame in 0..2 {
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Normal(ghost, dir, frame)));
}
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Eyes(dir)));
}
}
for &color in &[FrightenedColor::Blue, FrightenedColor::White] {
for frame in 0..2 {
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Frightened(color, frame)));
}
}
// Maze sprites
for i in 0..=34 {
sprites_to_test.push(GameSprite::Maze(MazeSprite::Tile(i)));
}
sprites_to_test.push(GameSprite::Maze(MazeSprite::Pellet));
sprites_to_test.push(GameSprite::Maze(MazeSprite::Energizer));
for sprite in sprites_to_test {
let path = sprite.to_path();
assert!(
ATLAS_FRAMES.contains_key(&path),
"Sprite path '{}' does not exist in the atlas.",
path
);
}
}
#[test]
fn test_invalid_sprite_paths_do_not_exist() {
let invalid_sprites = vec![
// An invalid Pac-Man dying frame
GameSprite::Pacman(PacmanSprite::Dying(99)),
// An invalid maze tile
GameSprite::Maze(MazeSprite::Tile(99)),
];
for sprite in invalid_sprites {
let path = sprite.to_path();
assert!(
!ATLAS_FRAMES.contains_key(&path),
"Invalid sprite path '{}' was found in the atlas, but it should not exist.",
path
);
}
}

115
tests/ttf.rs Normal file
View File

@@ -0,0 +1,115 @@
use pacman::texture::ttf::{TtfAtlas, TtfRenderer};
use sdl2::pixels::Color;
mod common;
#[test]
fn text_width_calculates_correctly_for_empty_string() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let width = renderer.text_width(&atlas, "");
assert_eq!(width, 0);
}
#[test]
fn text_width_calculates_correctly_for_single_character() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let width = renderer.text_width(&atlas, "A");
assert!(width > 0);
}
#[test]
fn text_width_scales_correctly() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer1 = TtfRenderer::new(1.0);
let renderer2 = TtfRenderer::new(2.0);
let width1 = renderer1.text_width(&atlas, "Test");
let width2 = renderer2.text_width(&atlas, "Test");
assert_eq!(width2, width1 * 2);
}
#[test]
fn text_height_returns_non_zero_for_valid_atlas() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let height = renderer.text_height(&atlas);
assert!(height > 0);
}
#[test]
fn text_height_scales_correctly() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer1 = TtfRenderer::new(1.0);
let renderer2 = TtfRenderer::new(2.0);
let height1 = renderer1.text_height(&atlas);
let height2 = renderer2.text_height(&atlas);
assert_eq!(height2, height1 * 2);
}
#[test]
fn render_text_handles_empty_string() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let result = renderer.render_text(&mut canvas, &mut atlas, "", glam::Vec2::new(0.0, 0.0), Color::WHITE);
assert!(result.is_ok());
}
#[test]
fn render_text_handles_single_character() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let result = renderer.render_text(&mut canvas, &mut atlas, "A", glam::Vec2::new(10.0, 10.0), Color::RED);
assert!(result.is_ok());
}