diff --git a/Cargo.lock b/Cargo.lock index 21c0c78..a56f128 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3663,6 +3663,7 @@ dependencies = [ "directories", "serde", "serde_json", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c345354..411a34b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,6 @@ bevy_dev_tools = "0.17.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" directories = "6.0" + +[dev-dependencies] +uuid = "1.18.1" diff --git a/src/features/config/components.rs b/src/features/config/components.rs index 94fb4fc..63c4fc6 100644 --- a/src/features/config/components.rs +++ b/src/features/config/components.rs @@ -21,7 +21,11 @@ impl Default for GameConfig { impl GameConfig { pub fn read_config() -> Option { - 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 { + let file = File::open(path).ok()?; let reader = BufReader::new(file); serde_json::from_reader(reader).ok() } diff --git a/src/features/phase/mod.rs b/src/features/phase/mod.rs index 8d3b830..4181fb1 100644 --- a/src/features/phase/mod.rs +++ b/src/features/phase/mod.rs @@ -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( mut messages: MessageReader, mut phase_res: ResMut, @@ -126,36 +159,6 @@ fn handle_continue( settings: Res, ) { for _ in messages.read() { - let phase = &mut phase_res.0; - - 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, - }; - } - _ => {} - } - } + next_phase(&mut phase_res, &mut session_tracker, &settings); } } diff --git a/src/features/ui/ui/button.rs b/src/features/ui/ui/button.rs index 7dac536..3ce229a 100644 --- a/src/features/ui/ui/button.rs +++ b/src/features/ui/ui/button.rs @@ -1,4 +1,4 @@ -use bevy::window::PrimaryWindow; + use crate::prelude::*; diff --git a/tests/config.rs b/tests/config.rs new file mode 100644 index 0000000..3401dbe --- /dev/null +++ b/tests/config.rs @@ -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"); +} diff --git a/tests/pathfinding.rs b/tests/pathfinding.rs new file mode 100644 index 0000000..02ffde8 --- /dev/null +++ b/tests/pathfinding.rs @@ -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::().get_tile((x, y)) { + *app.world_mut().get_mut::(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>); + +fn pathfinding_system( + grid: Res, + tile_query: Query<&TileState>, + mut path_result: ResMut, + path_params: Res, // 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::(); + 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::(); + + 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::(); + assert!(path_result.0.is_none(), "Expected no path when blocked"); +} diff --git a/tests/session.rs b/tests/session.rs new file mode 100644 index 0000000..8007a69 --- /dev/null +++ b/tests/session.rs @@ -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" + ); +}