use card_game::{Card, Game}; use klondike::{DrawStockConfig, Foundation, KlondikePile, Tableau}; use proptest::prelude::*; use crate::game_state::GameState; // --------------------------------------------------------------------------- // 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(DrawStockConfig::DrawOne), Just(DrawStockConfig::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 instruction /// from `possible_instructions()` and apply it via `apply_instruction()`. /// /// `possible_instructions()` may return `RotateStock`, which /// `apply_instruction()` dispatches to `game.draw()`; ordinary instructions /// are equivalent to `move_cards(from, to, count)`. fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) { for &(do_draw, idx) in actions { if do_draw { let _ = game.draw(); } else { let moves = game.possible_instructions(); if moves.is_empty() { continue; } let instruction = moves[idx % moves.len()]; let _ = game.apply_instruction(instruction); } } } /// 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 moves = game.possible_instructions(); if moves.is_empty() { return game.draw().is_ok(); } let instruction = moves[move_idx % moves.len()]; game.apply_instruction(instruction).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 instruction in game.possible_instructions() { // Clone so each move is tried from the same starting state. let mut trial = game.clone(); let result = trial.apply_instruction(instruction); prop_assert!( result.is_ok(), "possible_instructions() reported {instruction:?} \ as legal but the call returned Err: {result:?}", ); } } }