diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 1916676..e15076e 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -484,6 +484,82 @@ mod tests { assert!(pos.x.abs() < 1e-3, "card must not move during delay period"); } + #[test] + fn anim_speed_fast_is_less_than_normal() { + assert!(anim_speed_to_secs(&AnimSpeed::Fast) < anim_speed_to_secs(&AnimSpeed::Normal)); + } + + #[test] + fn anim_speed_instant_is_zero() { + assert_eq!(anim_speed_to_secs(&AnimSpeed::Instant), 0.0); + } + + #[test] + fn toast_dismissed_after_timer_reaches_zero() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin); + + // Manually spawn a toast with a timer that's already expired. + app.world_mut().spawn((ToastOverlay, ToastTimer(-0.001))); + app.update(); + + // The toast entity must have been despawned. + let remaining = app + .world_mut() + .query::<&ToastTimer>() + .iter(app.world()) + .count(); + assert_eq!(remaining, 0, "expired toast must be despawned"); + } + + #[test] + fn toast_not_dismissed_before_timer_reaches_zero() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin); + + // Large positive timer — should survive one update. + app.world_mut().spawn((ToastOverlay, ToastTimer(100.0))); + app.update(); + + let remaining = app + .world_mut() + .query::<&ToastTimer>() + .iter(app.world()) + .count(); + assert_eq!(remaining, 1, "unexpired toast must not be despawned"); + } + + #[test] + fn info_toast_event_spawns_toast_overlay() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin); + + app.world_mut().send_event(InfoToastEvent("hello".to_string())); + app.update(); + + let count = app + .world_mut() + .query::<&ToastOverlay>() + .iter(app.world()) + .count(); + assert_eq!(count, 1, "InfoToastEvent must spawn exactly one ToastOverlay"); + } + + #[test] + fn settings_changed_event_updates_slide_duration() { + use solitaire_data::Settings; + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin); + + let mut fast_settings = Settings::default(); + fast_settings.animation_speed = AnimSpeed::Fast; + app.world_mut().send_event(SettingsChangedEvent(fast_settings)); + app.update(); + + let dur = app.world().resource::().slide_secs; + assert!((dur - anim_speed_to_secs(&AnimSpeed::Fast)).abs() < 1e-6); + } + #[test] fn win_cascade_adds_anim_to_all_52_cards() { let mut app = app_with_anim(); diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index ed9b48d..d88d67e 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -882,4 +882,64 @@ mod tests { let mut cursor = events.get_cursor(); assert_eq!(cursor.read(events).count(), 0); } + + #[test] + fn volume_clamped_at_zero_does_not_emit_event() { + let mut app = headless_app(); + app.world_mut().resource_mut::().0.sfx_volume = 0.0; + + press(&mut app, KeyCode::BracketLeft); + app.update(); + + let after = app.world().resource::().0.sfx_volume; + assert!(after >= 0.0, "volume must not go below zero"); + + let events = app.world().resource::>(); + let mut cursor = events.get_cursor(); + assert_eq!(cursor.read(events).count(), 0, "no event when clamped at floor"); + } + + #[test] + fn pressing_o_toggles_settings_screen_flag() { + let mut app = headless_app(); + assert!(!app.world().resource::().0, "screen is closed initially"); + + press(&mut app, KeyCode::KeyO); + app.update(); + assert!(app.world().resource::().0, "O opens settings"); + + press(&mut app, KeyCode::KeyO); + app.update(); + assert!(!app.world().resource::().0, "second O closes settings"); + } + + // cycle_unlocked pure-function tests + #[test] + fn cycle_unlocked_wraps_at_end() { + // [0, 1, 2] → cycling from 2 wraps to 0 + assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0); + } + + #[test] + fn cycle_unlocked_advances_normally() { + assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1); + assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2); + } + + #[test] + fn cycle_unlocked_single_element_stays() { + // Only one unlockable — cycling always returns it. + assert_eq!(cycle_unlocked(&[0], 0), 0); + } + + #[test] + fn cycle_unlocked_current_not_in_list_falls_back_to_second() { + // current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1 + assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1); + } + + #[test] + fn cycle_unlocked_empty_returns_zero() { + assert_eq!(cycle_unlocked(&[], 0), 0); + } }