Merge branch '55-create-tests' into 'dev'
Create tests and CI See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!18
This commit is contained in:
32
.gitlab-ci.yml
Normal file
32
.gitlab-ci.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
image: "rust:latest"
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
cache:
|
||||||
|
key: "$CI_COMMIT_REF_SLUG"
|
||||||
|
paths:
|
||||||
|
- .cargo/
|
||||||
|
- target/
|
||||||
|
.only_on_main_rule: &only_on_main_rule
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_REF_SLUG == "main"
|
||||||
|
.install_linux_dependencies: &install_linux_dependencies
|
||||||
|
before_script:
|
||||||
|
- apt-get update -yq
|
||||||
|
- apt-get install -yq g++ pkg-config libx11-dev libasound2-dev libudev-dev libxkbcommon-x11-0
|
||||||
|
linux_build:
|
||||||
|
stage: build
|
||||||
|
!!merge <<: *only_on_main_rule
|
||||||
|
!!merge <<: *install_linux_dependencies
|
||||||
|
script:
|
||||||
|
- cargo build --release --target x86_64-unknown-linux-gnu
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- target/x86_64-unknown-linux-gnu/release/pomomon-garden
|
||||||
|
name: "pomomon-garden-linux-x86_64-$CI_COMMIT_SHORT_SHA"
|
||||||
|
expire_in: "2 weeks"
|
||||||
|
run_tests:
|
||||||
|
stage: test
|
||||||
|
!!merge <<: *install_linux_dependencies
|
||||||
|
script:
|
||||||
|
- cargo test --verbose
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3663,6 +3663,7 @@ dependencies = [
|
|||||||
"directories",
|
"directories",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -20,3 +20,6 @@ bevy_dev_tools = "0.17.2"
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
directories = "6.0"
|
directories = "6.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
uuid = "1.18.1"
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ impl Default for GameConfig {
|
|||||||
|
|
||||||
impl GameConfig {
|
impl GameConfig {
|
||||||
pub fn read_config() -> Option<Self> {
|
pub fn read_config() -> Option<Self> {
|
||||||
let file = File::open("assets/config.json").ok()?;
|
Self::read_from_path(std::path::Path::new("assets/config.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_from_path(path: &std::path::Path) -> Option<Self> {
|
||||||
|
let file = File::open(path).ok()?;
|
||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
serde_json::from_reader(reader).ok()
|
serde_json::from_reader(reader).ok()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,39 @@ fn handle_pause(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn next_phase(
|
||||||
|
current_phase: &mut CurrentPhase,
|
||||||
|
session_tracker: &mut SessionTracker,
|
||||||
|
settings: &TimerSettings,
|
||||||
|
) {
|
||||||
|
if let Phase::Finished { completed_phase } = ¤t_phase.0 {
|
||||||
|
match **completed_phase {
|
||||||
|
Phase::Focus { .. } => {
|
||||||
|
session_tracker.completed_focus_phases += 1;
|
||||||
|
|
||||||
|
let is_long_break = session_tracker.completed_focus_phases > 0
|
||||||
|
&& session_tracker.completed_focus_phases % settings.long_break_interval == 0;
|
||||||
|
|
||||||
|
if is_long_break {
|
||||||
|
current_phase.0 = Phase::Break {
|
||||||
|
duration: settings.long_break_duration as f32,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
current_phase.0 = Phase::Break {
|
||||||
|
duration: settings.short_break_duration as f32,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Phase::Break { .. } => {
|
||||||
|
current_phase.0 = Phase::Focus {
|
||||||
|
duration: settings.focus_duration as f32,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_continue(
|
fn handle_continue(
|
||||||
mut messages: MessageReader<NextPhaseMessage>,
|
mut messages: MessageReader<NextPhaseMessage>,
|
||||||
mut phase_res: ResMut<CurrentPhase>,
|
mut phase_res: ResMut<CurrentPhase>,
|
||||||
@@ -126,36 +159,6 @@ fn handle_continue(
|
|||||||
settings: Res<TimerSettings>,
|
settings: Res<TimerSettings>,
|
||||||
) {
|
) {
|
||||||
for _ in messages.read() {
|
for _ in messages.read() {
|
||||||
let phase = &mut phase_res.0;
|
next_phase(&mut phase_res, &mut session_tracker, &settings);
|
||||||
|
|
||||||
if let Phase::Finished { completed_phase } = phase {
|
|
||||||
match **completed_phase {
|
|
||||||
Phase::Focus { .. } => {
|
|
||||||
session_tracker.completed_focus_phases += 1;
|
|
||||||
|
|
||||||
// TODO: add berry grant logic
|
|
||||||
|
|
||||||
let is_long_break = session_tracker.completed_focus_phases > 0
|
|
||||||
&& session_tracker.completed_focus_phases % settings.long_break_interval
|
|
||||||
== 0;
|
|
||||||
|
|
||||||
if is_long_break {
|
|
||||||
*phase = Phase::Break {
|
|
||||||
duration: settings.long_break_duration as f32,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
*phase = Phase::Break {
|
|
||||||
duration: settings.short_break_duration as f32,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Phase::Break { .. } => {
|
|
||||||
*phase = Phase::Focus {
|
|
||||||
duration: 25.0 * 60.0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use bevy::window::PrimaryWindow;
|
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
|||||||
54
tests/config.rs
Normal file
54
tests/config.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use pomomon_garden::features::config::components::GameConfig;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// Helper function to create a temporary file with content
|
||||||
|
fn create_temp_file(content: &str) -> (std::path::PathBuf, String) {
|
||||||
|
let filename = format!("test_config_{}.json", Uuid::new_v4());
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let filepath = temp_dir.join(&filename);
|
||||||
|
|
||||||
|
let mut file = fs::File::create(&filepath).expect("Could not create temp file");
|
||||||
|
file.write_all(content.as_bytes())
|
||||||
|
.expect("Could not write to temp file");
|
||||||
|
|
||||||
|
(filepath, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_valid_config() {
|
||||||
|
let (filepath, _filename) = create_temp_file(
|
||||||
|
r#"{
|
||||||
|
"grid_width": 10,
|
||||||
|
"grid_height": 5,
|
||||||
|
"pom_speed": 2.0
|
||||||
|
}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = GameConfig::read_from_path(&filepath).expect("Failed to read valid config");
|
||||||
|
assert_eq!(config.grid_width, 10);
|
||||||
|
assert_eq!(config.grid_height, 5);
|
||||||
|
assert_eq!(config.pom_speed, 2.0);
|
||||||
|
|
||||||
|
fs::remove_file(filepath).expect("Failed to delete temp file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_invalid_config() {
|
||||||
|
let (filepath, _filename) = create_temp_file(r#"this is not valid json"#);
|
||||||
|
|
||||||
|
let config = GameConfig::read_from_path(&filepath);
|
||||||
|
assert!(config.is_none(), "Expected invalid config to return None");
|
||||||
|
|
||||||
|
fs::remove_file(filepath).expect("Failed to delete temp file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_missing_config() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let filepath = temp_dir.join("non_existent_config.json");
|
||||||
|
|
||||||
|
let config = GameConfig::read_from_path(&filepath);
|
||||||
|
assert!(config.is_none(), "Expected missing config to return None");
|
||||||
|
}
|
||||||
157
tests/pathfinding.rs
Normal file
157
tests/pathfinding.rs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
use bevy::ecs::system::RunSystemOnce;
|
||||||
|
use pomomon_garden::features::grid::components::{Grid, Tile, TileState};
|
||||||
|
use pomomon_garden::features::pom::utils::find_path;
|
||||||
|
use pomomon_garden::prelude::*;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
// Helper to set up a Bevy App for pathfinding tests
|
||||||
|
fn setup_pathfinding_app(
|
||||||
|
grid_width: u32,
|
||||||
|
grid_height: u32,
|
||||||
|
initial_tile_states: &[(u32, u32, TileState)], // (x, y, state)
|
||||||
|
) -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
|
||||||
|
let mut grid_tiles = Vec::with_capacity(grid_width as usize);
|
||||||
|
for x in 0..grid_width {
|
||||||
|
let mut column = Vec::with_capacity(grid_height as usize);
|
||||||
|
for y in 0..grid_height {
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((Tile { x, y }, TileState::Unclaimed))
|
||||||
|
.id();
|
||||||
|
column.push(entity);
|
||||||
|
}
|
||||||
|
grid_tiles.push(column);
|
||||||
|
}
|
||||||
|
app.world_mut().insert_resource(Grid {
|
||||||
|
width: grid_width,
|
||||||
|
height: grid_height,
|
||||||
|
tiles: grid_tiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
for &(x, y, ref state) in initial_tile_states {
|
||||||
|
if let Ok(entity) = app.world().resource::<Grid>().get_tile((x, y)) {
|
||||||
|
*app.world_mut().get_mut::<TileState>(entity).unwrap() = state.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
// Struct to hold start and end positions as a Resource
|
||||||
|
#[derive(Resource)]
|
||||||
|
struct PathParams {
|
||||||
|
start: UVec2,
|
||||||
|
end: UVec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test system to run find_path and store result
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct PathResult(Option<VecDeque<(u32, u32)>>);
|
||||||
|
|
||||||
|
fn pathfinding_system(
|
||||||
|
grid: Res<Grid>,
|
||||||
|
tile_query: Query<&TileState>,
|
||||||
|
mut path_result: ResMut<PathResult>,
|
||||||
|
path_params: Res<PathParams>, // Input: (start, end)
|
||||||
|
) {
|
||||||
|
path_result.0 = find_path(
|
||||||
|
path_params.start.into(),
|
||||||
|
path_params.end.into(),
|
||||||
|
&grid,
|
||||||
|
&tile_query,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_path_simple() {
|
||||||
|
let mut app = setup_pathfinding_app(5, 5, &[]); // Empty 5x5 grid
|
||||||
|
|
||||||
|
app.world_mut().insert_resource(PathResult(None));
|
||||||
|
app.world_mut().insert_resource(PathParams {
|
||||||
|
start: UVec2::new(0, 0),
|
||||||
|
end: UVec2::new(4, 4),
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = app.world_mut().run_system_once(pathfinding_system);
|
||||||
|
|
||||||
|
let path_result = app.world().resource::<PathResult>();
|
||||||
|
assert!(path_result.0.is_some());
|
||||||
|
let path = path_result.0.as_ref().unwrap();
|
||||||
|
// A* with Manhattan distance will find a shortest path.
|
||||||
|
// Length for (0,0) to (4,4) in an empty grid is 8 steps + 1 start = 9 nodes
|
||||||
|
assert_eq!(path.len(), 9, "Path length incorrect for simple path");
|
||||||
|
assert_eq!(*path.front().unwrap(), (0, 0), "Path should start at (0,0)");
|
||||||
|
assert_eq!(*path.back().unwrap(), (4, 4), "Path should end at (4,4)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_path_around_obstacle() {
|
||||||
|
let obstacles = vec![
|
||||||
|
(2, 2, TileState::Occupied),
|
||||||
|
(2, 3, TileState::Occupied),
|
||||||
|
(2, 4, TileState::Occupied),
|
||||||
|
];
|
||||||
|
let mut app = setup_pathfinding_app(5, 5, &obstacles);
|
||||||
|
|
||||||
|
let _ = app.world_mut().insert_resource(PathResult(None));
|
||||||
|
let _ = app.world_mut().insert_resource(PathParams {
|
||||||
|
start: UVec2::new(0, 0),
|
||||||
|
end: UVec2::new(4, 4),
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = app.world_mut().run_system_once(pathfinding_system);
|
||||||
|
|
||||||
|
let path_result = app.world().resource::<PathResult>();
|
||||||
|
|
||||||
|
if path_result.0.is_none() {
|
||||||
|
panic!("Should find a path around obstacles, but found none.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = path_result.0.as_ref().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(*path.front().unwrap(), (0, 0));
|
||||||
|
assert_eq!(*path.back().unwrap(), (4, 4));
|
||||||
|
|
||||||
|
// Assert that no obstacle tile is in the path
|
||||||
|
for (ox, oy, _) in &obstacles {
|
||||||
|
assert!(
|
||||||
|
!path.contains(&(*ox, *oy)),
|
||||||
|
"Path should not contain obstacle {:?}",
|
||||||
|
(ox, oy)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shortest path around these specific obstacles has a length of 9 nodes (8 steps)
|
||||||
|
// For example: (0,0) -> (1,0) -> (2,0) -> (3,0) -> (4,0) -> (4,1) -> (4,2) -> (4,3) -> (4,4)
|
||||||
|
assert_eq!(
|
||||||
|
path.len(),
|
||||||
|
9,
|
||||||
|
"Path length incorrect for path around obstacles."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_path_no_path() {
|
||||||
|
let obstacles = vec![
|
||||||
|
(2, 0, TileState::Occupied),
|
||||||
|
(2, 1, TileState::Occupied),
|
||||||
|
(2, 2, TileState::Occupied),
|
||||||
|
(2, 3, TileState::Occupied),
|
||||||
|
(2, 4, TileState::Occupied),
|
||||||
|
];
|
||||||
|
let mut app = setup_pathfinding_app(5, 5, &obstacles);
|
||||||
|
|
||||||
|
app.world_mut().insert_resource(PathResult(None));
|
||||||
|
app.world_mut().insert_resource(PathParams {
|
||||||
|
start: UVec2::new(0, 0),
|
||||||
|
end: UVec2::new(4, 4),
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = app.world_mut().run_system_once(pathfinding_system);
|
||||||
|
|
||||||
|
let path_result = app.world().resource::<PathResult>();
|
||||||
|
assert!(path_result.0.is_none(), "Expected no path when blocked");
|
||||||
|
}
|
||||||
110
tests/session.rs
Normal file
110
tests/session.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use pomomon_garden::features::phase::components::{
|
||||||
|
CurrentPhase, Phase, SessionTracker, TimerSettings,
|
||||||
|
};
|
||||||
|
use pomomon_garden::features::phase::next_phase;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_tracker_focus_to_short_break() {
|
||||||
|
let mut current_phase = CurrentPhase(Phase::Finished {
|
||||||
|
completed_phase: Box::new(Phase::Focus {
|
||||||
|
duration: 25.0 * 60.0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
let timer_settings = TimerSettings::default();
|
||||||
|
let mut session_tracker = SessionTracker::default();
|
||||||
|
|
||||||
|
next_phase(&mut current_phase, &mut session_tracker, &timer_settings);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session_tracker.completed_focus_phases, 1,
|
||||||
|
"Completed focus phases should be 1"
|
||||||
|
);
|
||||||
|
if let Phase::Break { duration } = current_phase.0 {
|
||||||
|
assert_eq!(
|
||||||
|
duration, timer_settings.short_break_duration as f32,
|
||||||
|
"Should transition to short break"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
panic!("Expected a Break phase, got {:?}", current_phase.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_tracker_focus_to_long_break() {
|
||||||
|
let mut current_phase = CurrentPhase(Phase::Finished {
|
||||||
|
completed_phase: Box::new(Phase::Focus {
|
||||||
|
duration: 25.0 * 60.0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
let timer_settings = TimerSettings::default();
|
||||||
|
let mut session_tracker = SessionTracker {
|
||||||
|
completed_focus_phases: timer_settings.long_break_interval - 1,
|
||||||
|
}; // To trigger long break on next phase
|
||||||
|
|
||||||
|
next_phase(&mut current_phase, &mut session_tracker, &timer_settings);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session_tracker.completed_focus_phases, timer_settings.long_break_interval,
|
||||||
|
"Completed focus phases should reach long break interval"
|
||||||
|
);
|
||||||
|
if let Phase::Break { duration } = current_phase.0 {
|
||||||
|
assert_eq!(
|
||||||
|
duration, timer_settings.long_break_duration as f32,
|
||||||
|
"Should transition to long break"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
panic!("Expected a Break phase, got {:?}", current_phase.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_tracker_break_to_focus() {
|
||||||
|
let mut current_phase = CurrentPhase(Phase::Finished {
|
||||||
|
completed_phase: Box::new(Phase::Break {
|
||||||
|
duration: 5.0 * 60.0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
let mut session_tracker = SessionTracker {
|
||||||
|
completed_focus_phases: 1,
|
||||||
|
}; // Arbitrary value, should not change
|
||||||
|
let timer_settings = TimerSettings::default();
|
||||||
|
|
||||||
|
next_phase(&mut current_phase, &mut session_tracker, &timer_settings);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session_tracker.completed_focus_phases, 1,
|
||||||
|
"Completed focus phases should not change"
|
||||||
|
);
|
||||||
|
if let Phase::Focus { duration } = current_phase.0 {
|
||||||
|
assert_eq!(
|
||||||
|
duration, timer_settings.focus_duration as f32,
|
||||||
|
"Should transition to Focus phase"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
panic!("Expected a Focus phase, got {:?}", current_phase.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_tracker_not_finished_phase_no_change() {
|
||||||
|
// Test that nothing changes if the phase is not `Finished`
|
||||||
|
let mut current_phase = CurrentPhase(Phase::Focus { duration: 100.0 });
|
||||||
|
let mut session_tracker = SessionTracker {
|
||||||
|
completed_focus_phases: 0,
|
||||||
|
};
|
||||||
|
let timer_settings = TimerSettings::default();
|
||||||
|
|
||||||
|
let initial_phase = current_phase.0.clone();
|
||||||
|
let initial_completed_focus = session_tracker.completed_focus_phases;
|
||||||
|
|
||||||
|
next_phase(&mut current_phase, &mut session_tracker, &timer_settings);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
current_phase.0, initial_phase,
|
||||||
|
"Phase should not change if not Finished"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session_tracker.completed_focus_phases, initial_completed_focus,
|
||||||
|
"Session tracker should not change if phase not Finished"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user