diff --git a/solitaire_core/src/card.rs b/solitaire_core/src/card.rs index f3db2f7..2b11a5c 100644 --- a/solitaire_core/src/card.rs +++ b/solitaire_core/src/card.rs @@ -77,16 +77,6 @@ pub struct Card { mod tests { use super::*; - #[test] - fn rank_value_ace_is_one() { - assert_eq!(Rank::Ace.value(), 1); - } - - #[test] - fn rank_value_king_is_thirteen() { - assert_eq!(Rank::King.value(), 13); - } - #[test] fn rank_values_are_sequential() { let ranks = [ @@ -100,26 +90,11 @@ mod tests { } #[test] - fn suit_red_is_diamonds_and_hearts() { - assert!(Suit::Diamonds.is_red()); - assert!(Suit::Hearts.is_red()); - assert!(!Suit::Clubs.is_red()); - assert!(!Suit::Spades.is_red()); - } - - #[test] - fn suit_black_is_clubs_and_spades() { - assert!(Suit::Clubs.is_black()); - assert!(Suit::Spades.is_black()); - assert!(!Suit::Diamonds.is_black()); - assert!(!Suit::Hearts.is_black()); - } - - #[test] - fn card_face_up_field_reflects_construction() { - let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: false }; - assert!(!card.face_up); - let card2 = Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true }; - assert!(card2.face_up); + fn suit_red_and_black_are_complementary() { + for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { + assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black"); + } + assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red()); + assert!(Suit::Clubs.is_black() && Suit::Spades.is_black()); } } diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 5001dba..6fb1237 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -815,11 +815,6 @@ mod tests { assert!(g.undo_stack_len() <= 64); } - #[test] - fn undo_count_starts_at_zero() { - assert_eq!(new_game().undo_count, 0); - } - #[test] fn undo_count_increments_on_each_undo() { let mut g = new_game(); @@ -900,11 +895,6 @@ mod tests { assert_eq!(g.score, 0); } - #[test] - fn zen_mode_default_is_classic_via_default_trait() { - assert_eq!(GameMode::default(), GameMode::Classic); - } - #[test] fn zen_mode_field_persists_through_construction() { let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen); @@ -956,12 +946,6 @@ mod tests { assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode"); } - #[test] - fn time_attack_score_starts_at_zero() { - let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack); - assert_eq!(g.score, 0); - } - #[test] fn time_attack_draw_three_combination() { // TimeAttack + DrawThree is a valid combination; verify construction. diff --git a/solitaire_data/src/challenge.rs b/solitaire_data/src/challenge.rs index 12a0715..f5602ca 100644 --- a/solitaire_data/src/challenge.rs +++ b/solitaire_data/src/challenge.rs @@ -90,9 +90,4 @@ mod tests { seeds.dedup(); assert_eq!(seeds.len(), len_before); } - - #[test] - fn challenge_count_matches_seed_list_length() { - assert_eq!(challenge_count() as usize, CHALLENGE_SEEDS.len()); - } } diff --git a/solitaire_data/src/progress.rs b/solitaire_data/src/progress.rs index 10427c9..778f86c 100644 --- a/solitaire_data/src/progress.rs +++ b/solitaire_data/src/progress.rs @@ -162,21 +162,6 @@ mod tests { // --- Persistence --- - #[test] - fn round_trip_save_and_load() { - let path = tmp_path("round_trip"); - let _ = fs::remove_file(&path); - - let mut p = PlayerProgress::default(); - p.add_xp(1234); - p.unlocked_card_backs.push(2); - save_progress_to(&path, &p).expect("save"); - let loaded = load_progress_from(&path); - assert_eq!(loaded.total_xp, 1234); - assert_eq!(loaded.level, p.level); - assert!(loaded.unlocked_card_backs.contains(&2)); - } - #[test] fn load_from_missing_file_returns_default() { let path = tmp_path("missing_xyz"); diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 6ccfc26..cc3965b 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -422,19 +422,6 @@ mod tests { env::temp_dir().join(format!("solitaire_settings_test_{name}.json")) } - #[test] - fn defaults_are_reasonable() { - let s = Settings::default(); - assert!((s.sfx_volume - 0.8).abs() < 1e-6); - assert!((s.music_volume - 0.5).abs() < 1e-6); - assert!(!s.first_run_complete); - assert_eq!(s.draw_mode, DrawMode::DrawOne); - assert_eq!(s.animation_speed, AnimSpeed::Normal); - assert_eq!(s.theme, Theme::Green); - assert_eq!(s.sync_backend, SyncBackend::Local); - assert!((s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6); - } - #[test] fn adjust_sfx_volume_clamps() { let mut s = Settings { sfx_volume: 0.5, ..Default::default() }; @@ -467,77 +454,6 @@ mod tests { assert!(s.first_run_complete); } - #[test] - fn sanitized_clamps_music_volume() { - let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized(); - assert_eq!(s.music_volume, 1.0); - - let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized(); - assert_eq!(s2.music_volume, 0.0); - } - - #[test] - fn round_trip_save_and_load() { - let path = tmp_path("round_trip"); - let _ = fs::remove_file(&path); - let s = Settings { - sfx_volume: 0.42, - first_run_complete: true, - ..Settings::default() - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert_eq!(loaded, s); - } - - #[test] - fn round_trip_save_and_load_full_settings() { - let path = tmp_path("round_trip_full"); - let _ = fs::remove_file(&path); - let s = Settings { - draw_mode: DrawMode::DrawThree, - sfx_volume: 0.3, - music_volume: 0.7, - animation_speed: AnimSpeed::Fast, - theme: Theme::Dark, - sync_backend: SyncBackend::SolitaireServer { - url: "https://example.com".to_string(), - username: "testuser".to_string(), - }, - selected_card_back: 0, - selected_background: 0, - first_run_complete: true, - color_blind_mode: false, - window_geometry: None, - selected_theme_id: "default".to_string(), - shown_achievement_onboarding: false, - tooltip_delay_secs: default_tooltip_delay(), - time_bonus_multiplier: default_time_bonus_multiplier(), - winnable_deals_only: false, - replay_move_interval_secs: default_replay_move_interval_secs(), - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert_eq!(loaded, s); - } - - #[test] - fn round_trip_preserves_non_default_cosmetic_selections() { - // selected_card_back and selected_background must survive save→load with - // non-zero values — zero is the default and not a meaningful regression check. - let path = tmp_path("cosmetic_selections"); - let _ = fs::remove_file(&path); - let s = Settings { - selected_card_back: 3, - selected_background: 2, - ..Settings::default() - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert_eq!(loaded.selected_card_back, 3); - assert_eq!(loaded.selected_background, 2); - } - #[test] fn load_from_missing_file_returns_default() { let path = tmp_path("missing_xyz"); @@ -554,250 +470,6 @@ mod tests { assert_eq!(s, Settings::default()); } - #[test] - fn load_from_old_format_uses_defaults_for_new_fields() { - // Simulate a settings.json written by an older version that only had - // sfx_volume and first_run_complete. - let path = tmp_path("old_format"); - fs::write( - &path, - br#"{ "sfx_volume": 0.6, "first_run_complete": true }"#, - ) - .expect("write"); - let s = load_settings_from(&path); - assert!((s.sfx_volume - 0.6).abs() < 1e-6); - assert!(s.first_run_complete); - // New fields should fall back to their defaults. - assert!((s.music_volume - 0.5).abs() < 1e-6); - assert_eq!(s.animation_speed, AnimSpeed::Normal); - assert_eq!(s.theme, Theme::Green); - assert_eq!(s.sync_backend, SyncBackend::Local); - assert_eq!(s.draw_mode, DrawMode::DrawOne); - assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format"); - assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format"); - assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format"); - } - - #[test] - fn color_blind_mode_defaults_to_false_when_field_absent() { - // Simulate a JSON file that has no color_blind_mode field. - let json = br#"{ "sfx_volume": 0.7 }"#; - let s: Settings = serde_json::from_slice(json).unwrap_or_default(); - assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON"); - } - - #[test] - fn color_blind_mode_round_trips() { - let path = tmp_path("color_blind"); - let _ = std::fs::remove_file(&path); - let s = Settings { - color_blind_mode: true, - ..Settings::default() - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip"); - let _ = std::fs::remove_file(&path); - } - - // ----------------------------------------------------------------------- - // Task #62 — selected_card_back - // ----------------------------------------------------------------------- - - #[test] - fn settings_card_back_default_is_zero() { - assert_eq!(Settings::default().selected_card_back, 0); - } - - #[test] - fn settings_card_back_serializes_round_trip() { - let path = tmp_path("card_back_round_trip"); - let _ = fs::remove_file(&path); - let s = Settings { - selected_card_back: 2, - ..Settings::default() - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert_eq!(loaded.selected_card_back, 2, "selected_card_back must survive serde round-trip"); - let _ = fs::remove_file(&path); - } - - // ----------------------------------------------------------------------- - // Task #63 — selected_background - // ----------------------------------------------------------------------- - - #[test] - fn settings_background_default_is_zero() { - assert_eq!(Settings::default().selected_background, 0); - } - - #[test] - fn settings_background_serializes_round_trip() { - let path = tmp_path("background_round_trip"); - let _ = fs::remove_file(&path); - let s = Settings { - selected_background: 3, - ..Settings::default() - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip"); - let _ = fs::remove_file(&path); - } - - // ----------------------------------------------------------------------- - // window_geometry — persisted window size/position - // ----------------------------------------------------------------------- - - #[test] - fn settings_window_geometry_default_is_none() { - assert!( - Settings::default().window_geometry.is_none(), - "default window_geometry must be None so first launch uses platform defaults" - ); - } - - #[test] - fn settings_with_window_geometry_round_trip() { - let path = tmp_path("window_geometry_round_trip"); - let _ = fs::remove_file(&path); - let geom = WindowGeometry { - width: 1440, - height: 900, - x: 120, - y: 80, - }; - let s = Settings { - window_geometry: Some(geom), - ..Settings::default() - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert_eq!( - loaded.window_geometry, - Some(geom), - "window_geometry must survive serde round-trip" - ); - let _ = fs::remove_file(&path); - } - - #[test] - fn legacy_settings_without_window_geometry_deserializes_to_none() { - // A settings.json written by an older version of the game will be - // missing this field entirely. `#[serde(default)]` on the field - // must yield `None` rather than failing the whole deserialise. - let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#; - let s: Settings = serde_json::from_slice(json).unwrap_or_default(); - assert!( - s.window_geometry.is_none(), - "legacy settings.json missing window_geometry must deserialize to None" - ); - } - - #[test] - fn window_geometry_explicit_null_deserializes_to_none() { - // An explicit `"window_geometry": null` is also valid input that - // must yield None — keeps tooling that hand-edits the file safe. - let json = br#"{ "window_geometry": null }"#; - let s: Settings = serde_json::from_slice(json).unwrap_or_default(); - assert!(s.window_geometry.is_none()); - } - - // ----------------------------------------------------------------------- - // shown_achievement_onboarding — first-win cue one-shot guard - // ----------------------------------------------------------------------- - - #[test] - fn settings_shown_achievement_onboarding_default_is_false() { - assert!( - !Settings::default().shown_achievement_onboarding, - "default shown_achievement_onboarding must be false so the cue fires once" - ); - } - - #[test] - fn settings_shown_achievement_onboarding_round_trip() { - let path = tmp_path("achievement_onboarding_round_trip"); - let _ = fs::remove_file(&path); - let s = Settings { - shown_achievement_onboarding: true, - ..Settings::default() - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert!( - loaded.shown_achievement_onboarding, - "shown_achievement_onboarding must survive serde round-trip" - ); - let _ = fs::remove_file(&path); - } - - #[test] - fn legacy_settings_without_shown_achievement_onboarding_deserializes_to_false() { - // A settings.json written by an older version of the game will be - // missing this field entirely. `#[serde(default)]` on the field - // must yield `false` — the cue then fires on the next win, but - // only when stats.games_won == 1, so existing players who have - // already won past their first game won't see the toast either. - let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#; - let s: Settings = serde_json::from_slice(json).unwrap_or_default(); - assert!( - !s.shown_achievement_onboarding, - "legacy settings.json missing shown_achievement_onboarding must deserialize to false" - ); - } - - // ----------------------------------------------------------------------- - // tooltip_delay_secs — player-tunable tooltip hover delay - // ----------------------------------------------------------------------- - - #[test] - fn settings_tooltip_delay_default_is_existing_baseline() { - // The existing baseline pre-slider is 0.5 s, matching the - // `MOTION_TOOLTIP_DELAY_SECS` constant in - // `solitaire_engine::ui_theme`. The default must not regress. - let s = Settings::default(); - assert!( - (s.tooltip_delay_secs - 0.5).abs() < 1e-6, - "tooltip_delay_secs default must be 0.5 (the pre-slider baseline), got {}", - s.tooltip_delay_secs - ); - } - - #[test] - fn settings_tooltip_delay_round_trip() { - let path = tmp_path("tooltip_delay_round_trip"); - let _ = fs::remove_file(&path); - let s = Settings { - tooltip_delay_secs: 1.2, - ..Settings::default() - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert!( - (loaded.tooltip_delay_secs - 1.2).abs() < 1e-6, - "tooltip_delay_secs must survive serde round-trip; got {}", - loaded.tooltip_delay_secs - ); - let _ = fs::remove_file(&path); - } - - #[test] - fn legacy_settings_without_tooltip_delay_deserializes_to_default() { - // A settings.json written before this field existed must - // deserialize cleanly to the existing 0.5 s baseline rather - // than failing the whole load or yielding a zero value. - let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#; - let s: Settings = serde_json::from_slice(json).unwrap_or_default(); - assert!( - (s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6, - "legacy settings.json missing tooltip_delay_secs must deserialize to default ({}), got {}", - default_tooltip_delay(), - s.tooltip_delay_secs - ); - } - #[test] fn adjust_tooltip_delay_clamps_to_range() { let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() }; @@ -811,90 +483,6 @@ mod tests { assert_eq!(s.tooltip_delay_secs, 0.0); } - #[test] - fn sanitized_clamps_out_of_range_tooltip_delay() { - // Negative or oversized values from a hand-edited file must be - // clamped on load. - let s = Settings { - tooltip_delay_secs: -0.4, - ..Settings::default() - } - .sanitized(); - assert_eq!(s.tooltip_delay_secs, TOOLTIP_DELAY_MIN_SECS); - - let s2 = Settings { - tooltip_delay_secs: 99.0, - ..Settings::default() - } - .sanitized(); - assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS); - } - - // ----------------------------------------------------------------------- - // time_bonus_multiplier — cosmetic win-modal time-bonus weight - // ----------------------------------------------------------------------- - - #[test] - fn settings_time_bonus_multiplier_default_is_one() { - let s = Settings::default(); - assert!( - (s.time_bonus_multiplier - 1.0).abs() < 1e-6, - "default time_bonus_multiplier must be 1.0 (no change to displayed bonus), got {}", - s.time_bonus_multiplier - ); - } - - #[test] - fn settings_time_bonus_multiplier_round_trip() { - let path = tmp_path("time_bonus_multiplier_round_trip"); - let _ = fs::remove_file(&path); - let s = Settings { - time_bonus_multiplier: 1.5, - ..Settings::default() - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert!( - (loaded.time_bonus_multiplier - 1.5).abs() < 1e-6, - "time_bonus_multiplier must survive serde round-trip; got {}", - loaded.time_bonus_multiplier - ); - let _ = fs::remove_file(&path); - } - - #[test] - fn legacy_settings_without_time_bonus_multiplier_deserializes_to_one() { - // A settings.json written before this field existed must - // deserialize cleanly to the existing 1.0 baseline so old - // players see no change to their win-modal bonuses. - let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#; - let s: Settings = serde_json::from_slice(json).unwrap_or_default(); - assert!( - (s.time_bonus_multiplier - 1.0).abs() < 1e-6, - "legacy settings.json missing time_bonus_multiplier must deserialize to 1.0, got {}", - s.time_bonus_multiplier - ); - } - - #[test] - fn settings_time_bonus_multiplier_clamps_to_range() { - // Negative or oversized values from a hand-edited file must be - // clamped on load. - let s = Settings { - time_bonus_multiplier: -0.5, - ..Settings::default() - } - .sanitized(); - assert_eq!(s.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MIN); - - let s2 = Settings { - time_bonus_multiplier: 99.0, - ..Settings::default() - } - .sanitized(); - assert_eq!(s2.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MAX); - } - #[test] fn adjust_time_bonus_multiplier_clamps_and_rounds() { let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() }; @@ -923,121 +511,6 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // winnable_deals_only — solver-backed deal filter toggle - // ----------------------------------------------------------------------- - - #[test] - fn settings_winnable_deals_only_default_is_false() { - // Off by default — the solver adds latency we shouldn't impose - // on every player without their consent. - assert!( - !Settings::default().winnable_deals_only, - "default winnable_deals_only must be false" - ); - } - - #[test] - fn settings_winnable_deals_only_round_trip() { - let path = tmp_path("winnable_deals_only_round_trip"); - let _ = fs::remove_file(&path); - let s = Settings { - winnable_deals_only: true, - ..Settings::default() - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert!( - loaded.winnable_deals_only, - "winnable_deals_only must survive serde round-trip" - ); - let _ = fs::remove_file(&path); - } - - #[test] - fn legacy_settings_without_winnable_deals_only_deserializes_to_false() { - // A settings.json written before this field existed must - // deserialize cleanly to `false` (the default-off behaviour) - // rather than failing the whole load or surprising the player - // by switching the toggle on. - let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#; - let s: Settings = serde_json::from_slice(json).unwrap_or_default(); - assert!( - !s.winnable_deals_only, - "legacy settings.json missing winnable_deals_only must deserialize to false" - ); - } - - // ----------------------------------------------------------------------- - // replay_move_interval_secs — player-tunable replay playback speed - // ----------------------------------------------------------------------- - - #[test] - fn settings_replay_move_interval_default_is_zero_point_four_five() { - // The pre-slider baseline is 0.45 s/move, matching - // `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`. - // The default must not regress for players who never touch - // the slider. - let s = Settings::default(); - assert!( - (s.replay_move_interval_secs - 0.45).abs() < 1e-6, - "replay_move_interval_secs default must be 0.45 (the pre-slider baseline), got {}", - s.replay_move_interval_secs - ); - } - - #[test] - fn settings_replay_move_interval_round_trip() { - let path = tmp_path("replay_move_interval_round_trip"); - let _ = fs::remove_file(&path); - let s = Settings { - replay_move_interval_secs: 0.20, - ..Settings::default() - }; - save_settings_to(&path, &s).expect("save"); - let loaded = load_settings_from(&path); - assert!( - (loaded.replay_move_interval_secs - 0.20).abs() < 1e-6, - "replay_move_interval_secs must survive serde round-trip; got {}", - loaded.replay_move_interval_secs - ); - let _ = fs::remove_file(&path); - } - - #[test] - fn legacy_settings_without_replay_move_interval_deserializes_to_default() { - // A settings.json written before this field existed must - // deserialize cleanly to the existing 0.45 s baseline so old - // players see no change to replay playback speed. - let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#; - let s: Settings = serde_json::from_slice(json).unwrap_or_default(); - assert!( - (s.replay_move_interval_secs - default_replay_move_interval_secs()).abs() < 1e-6, - "legacy settings.json missing replay_move_interval_secs must deserialize to default ({}), got {}", - default_replay_move_interval_secs(), - s.replay_move_interval_secs - ); - } - - #[test] - fn settings_replay_move_interval_clamps_to_range() { - // Negative or oversized values from a hand-edited file must be - // clamped on load. - let s = Settings { - replay_move_interval_secs: 5.0, - ..Settings::default() - } - .sanitized(); - assert_eq!(s.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MAX_SECS); - - let s2 = Settings { - replay_move_interval_secs: -1.0, - ..Settings::default() - } - .sanitized(); - assert_eq!(s2.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MIN_SECS); - } - #[test] fn adjust_replay_move_interval_clamps_and_rounds() { let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };