use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau}; use proptest::prelude::*; use crate::game_state::{DrawMode, GameState}; use crate::klondike_adapter::{ InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation, SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau, SavedTableauStack, }; // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- /// Collect all card IDs across every pile in a fixed traversal order: /// stock → waste → foundations 1–4 → tableaux 1–7. /// /// The order is deterministic for a given game state, so two calls on /// equivalent states produce identical Vec outputs — the right fingerprint /// for undo-reversibility checks. fn all_card_ids(game: &GameState) -> Vec { let foundations = [ Foundation::Foundation1, Foundation::Foundation2, Foundation::Foundation3, Foundation::Foundation4, ]; let tableaux = [ Tableau::Tableau1, Tableau::Tableau2, Tableau::Tableau3, Tableau::Tableau4, Tableau::Tableau5, Tableau::Tableau6, Tableau::Tableau7, ]; let mut ids: Vec = game.stock_cards().iter().map(|c| c.id).collect(); ids.extend(game.waste_cards().iter().map(|c| c.id)); for f in &foundations { ids.extend( game.pile(KlondikePile::Foundation(*f)) .iter() .map(|c| c.id), ); } for t in &tableaux { ids.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|c| c.id)); } ids } fn draw_mode_strategy() -> impl Strategy { prop_oneof![Just(DrawMode::DrawOne), Just(DrawMode::DrawThree)] } /// Apply a sequence of random actions to a game, silently ignoring errors. /// /// Each action is `(draw_flag, move_index)`: /// - `draw_flag = true` → call `game.draw()` /// - `draw_flag = false` → pick the `move_index % len`th legal move from /// `possible_instructions()` and execute it. /// /// `possible_instructions()` may return `(Stock, Stock, 1)` for the /// RotateStock / draw action. `move_cards(Stock, Stock, 1)` is rejected by /// the `from == to` guard, so those are dispatched to `game.draw()`. fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) { for &(do_draw, idx) in actions { if do_draw { let _ = game.draw(); } else { let instructions = game.possible_instructions(); if instructions.is_empty() { continue; } let (from, to, count) = instructions[idx % instructions.len()]; if from == to { let _ = game.draw(); } else { let _ = game.move_cards(from, to, count); } } } } /// Apply one move from `possible_instructions()` (or a draw if no move is /// available), using `move_idx` to select among the legal options. /// Returns `true` when a move was successfully applied. fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool { if game.is_won { return false; } let instructions = game.possible_instructions(); if instructions.is_empty() { return game.draw().is_ok(); } let (from, to, count) = instructions[move_idx % instructions.len()]; if from == to { game.draw().is_ok() } else { game.move_cards(from, to, count).is_ok() } } // --------------------------------------------------------------------------- // Properties // --------------------------------------------------------------------------- proptest! { /// All 52 card IDs must be present exactly once across every pile after /// any reachable sequence of draw + move_cards actions. /// /// Catches two bug classes at once: /// - Card loss (fewer than 52 unique IDs after the sequence). /// - Card duplication (52 total but deduplication reduces the set). #[test] fn all_52_cards_always_present( seed in any::(), draw_mode in draw_mode_strategy(), actions in prop::collection::vec((any::(), 0usize..200), 0..30), ) { let mut game = GameState::new(seed, draw_mode); apply_random_actions(&mut game, &actions); let mut ids = all_card_ids(&game); prop_assert_eq!(ids.len(), 52, "card count ≠ 52 (got {})", ids.len()); ids.sort_unstable(); ids.dedup(); prop_assert_eq!( ids.len(), 52, "duplicate card IDs found after dedup — a card was cloned" ); } /// `GameState::new(seed, draw_mode)` must be deterministic: two calls /// with the same arguments must produce identical initial pile layouts. /// /// Pins that the deal is seeded from `seed` alone and not from any /// implicit source like wall-clock time or global state. #[test] fn deal_is_deterministic( seed in any::(), draw_mode in draw_mode_strategy(), ) { let a = GameState::new(seed, draw_mode); let b = GameState::new(seed, draw_mode); prop_assert_eq!( all_card_ids(&a), all_card_ids(&b), "same seed + draw_mode produced different deals", ); } /// After applying any single legal move and immediately undoing it, the /// pile layout and move_count must be identical to their pre-move values. /// /// `setup_actions` drives the game to an arbitrary mid-game position; /// `move_idx` selects which legal move to apply and then undo. /// /// The score is intentionally excluded: `undo()` applies a −15 penalty /// that is by design, not a regression. #[test] fn undo_restores_pile_layout_and_move_count( seed in any::(), draw_mode in draw_mode_strategy(), setup_actions in prop::collection::vec((any::(), 0usize..200), 0..20), move_idx in 0usize..200, ) { let mut game = GameState::new(seed, draw_mode); apply_random_actions(&mut game, &setup_actions); // Snapshot the state before the move. let before_ids = all_card_ids(&game); let before_move_count = game.move_count; // Apply one move. if !apply_one_move(&mut game, move_idx) || game.is_won { return Ok(()); // nothing to undo } // Undo and verify. prop_assert!( game.undo().is_ok(), "undo must succeed immediately after a successful move", ); prop_assert_eq!( all_card_ids(&game), before_ids, "pile layout after undo differs from the pre-move snapshot", ); prop_assert_eq!( game.move_count, before_move_count, "move_count after undo must equal the pre-move value", ); } /// Every move returned by `possible_instructions()` must succeed when /// applied via `move_cards()`. /// /// `possible_instructions()` and `move_cards()` both validate moves /// through the same upstream rule engine. This property ensures no /// drift has opened up between what the engine reports as legal and /// what it actually accepts. #[test] fn legal_moves_always_succeed( seed in any::(), draw_mode in draw_mode_strategy(), setup_actions in prop::collection::vec((any::(), 0usize..200), 0..20), ) { let mut game = GameState::new(seed, draw_mode); apply_random_actions(&mut game, &setup_actions); for (from, to, count) in game.possible_instructions() { // Clone so each move is tried from the same starting state. let mut trial = game.clone(); let result = if from == to { trial.draw() } else { trial.move_cards(from, to, count) }; prop_assert!( result.is_ok(), "possible_instructions() reported ({from:?} → {to:?} ×{count}) \ as legal but the call returned Err: {result:?}", ); } } // ------------------------------------------------------------------------- // SavedInstruction ↔ KlondikeInstruction round-trip // ------------------------------------------------------------------------- /// Every valid `SavedInstruction` survives a round-trip through /// `KlondikeInstruction::try_from(SavedInstruction::from(original))`. /// /// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`) /// and all legal sub-field ranges: /// - `SavedTableau`: 0–6 /// - `SavedFoundation`: 0–3 /// - `SavedSkipCards`: 0–12 #[test] fn saved_instruction_round_trip( instruction in saved_instruction_strategy(), ) { let klondike = KlondikeInstruction::try_from(instruction); prop_assert!( klondike.is_ok(), "TryFrom failed for valid SavedInstruction {instruction:?}: {:?}", klondike.err(), ); let saved_again = SavedInstruction::from(klondike.expect("checked above")); prop_assert_eq!( saved_again, instruction, "round-trip produced a different SavedInstruction", ); } } // --------------------------------------------------------------------------- // Proptest strategies for SavedInstruction and its sub-types // --------------------------------------------------------------------------- fn saved_tableau_strategy() -> impl Strategy { (0u8..=6).prop_map(SavedTableau) } fn saved_foundation_strategy() -> impl Strategy { (0u8..=3).prop_map(SavedFoundation) } fn saved_skip_cards_strategy() -> impl Strategy { (0u8..=12).prop_map(SavedSkipCards) } fn saved_klondike_pile_strategy() -> impl Strategy { prop_oneof![ saved_tableau_strategy().prop_map(SavedKlondikePile::Tableau), Just(SavedKlondikePile::Stock), saved_foundation_strategy().prop_map(SavedKlondikePile::Foundation), ] } fn saved_klondike_pile_stack_strategy() -> impl Strategy { prop_oneof![ (saved_tableau_strategy(), saved_skip_cards_strategy()).prop_map(|(tableau, skip_cards)| { SavedKlondikePileStack::Tableau(SavedTableauStack { tableau, skip_cards }) }), Just(SavedKlondikePileStack::Stock), saved_foundation_strategy().prop_map(SavedKlondikePileStack::Foundation), ] } fn saved_instruction_strategy() -> impl Strategy { prop_oneof![ Just(SavedInstruction::RotateStock), (saved_klondike_pile_strategy(), saved_foundation_strategy()).prop_map( |(src, foundation)| { SavedInstruction::DstFoundation(SavedDstFoundation { src, foundation }) } ), (saved_klondike_pile_stack_strategy(), saved_tableau_strategy()).prop_map( |(src, tableau)| { SavedInstruction::DstTableau(SavedDstTableau { src, tableau }) } ), ] } // --------------------------------------------------------------------------- // Boundary error unit tests (exact out-of-range values) // --------------------------------------------------------------------------- #[cfg(test)] mod saved_instruction_boundary_tests { use super::*; #[test] fn saved_tableau_7_is_invalid() { let result = Tableau::try_from(SavedTableau(7)); assert_eq!(result, Err(InvalidSavedInstruction::Tableau(7))); } #[test] fn saved_tableau_255_is_invalid() { let result = Tableau::try_from(SavedTableau(255)); assert_eq!(result, Err(InvalidSavedInstruction::Tableau(255))); } #[test] fn saved_foundation_4_is_invalid() { let result = Foundation::try_from(SavedFoundation(4)); assert_eq!(result, Err(InvalidSavedInstruction::Foundation(4))); } #[test] fn saved_skip_cards_13_is_invalid() { let result = SkipCards::try_from(SavedSkipCards(13)); assert_eq!(result, Err(InvalidSavedInstruction::SkipCards(13))); } }