use card_game::{Card, Game}; use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau}; use proptest::prelude::*; use crate::game_state::GameState; use crate::klondike_adapter::DrawMode; use crate::klondike_adapter::{ InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation, SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau, SavedTableauStack, }; // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- /// Collect all cards 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_cards(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 cards: Vec = game.stock_cards().iter().map(|(c, _)| c.clone()).collect(); cards.extend(game.waste_cards().iter().map(|(c, _)| c.clone())); for f in &foundations { cards.extend( game.pile(KlondikePile::Foundation(*f)) .iter() .map(|(c, _)| c.clone()), ); } for t in &tableaux { cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone())); } cards } 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! { /// `check_auto_complete()` and `is_win_trivial()` must agree on every /// reachable game state. /// /// The upstream `Klondike::is_win_trivial()` checks that the stock pile /// (both face-down and face-up halves) is completely empty AND that all /// tableau columns have no face-down cards. Ferrous `check_auto_complete()` /// checks the same three conditions individually (stock empty, waste empty, /// all tableau cards face-up). This property guards against any semantic /// drift between the two implementations so that delegating to upstream is /// safe. /// /// If this property ever fails, `check_auto_complete()` must NOT be fully /// replaced — the Ferrous conditions must be preserved and `is_win_trivial()` /// used only as a supplementary guard. #[test] fn check_auto_complete_agrees_with_is_win_trivial( 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); prop_assert_eq!( game.check_auto_complete(), game.session().state().state().is_win_trivial(), "check_auto_complete() disagreed with is_win_trivial() after {:?} actions", actions.len(), ); } /// `check_win()` and `is_win()` must agree on every reachable game state. #[test] fn check_win_agrees_with_is_win( 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); prop_assert_eq!( game.check_win(), game.session().state().state().is_win(), "check_win() disagreed with is_win()", ); } /// 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 cards = all_cards(&game); prop_assert_eq!(cards.len(), 52, "card count ≠ 52 (got {})", cards.len()); let unique: std::collections::HashSet = cards.iter().cloned().collect(); prop_assert_eq!( unique.len(), 52, "duplicate cards 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_cards(&a), all_cards(&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_cards(&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_cards(&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))); } }