056459619b
Remove the draw_mode, move_count, is_won, and is_auto_completable fields from GameState; they are now &self methods deriving from the underlying card_game session (draw_mode from session config, move_count from history length, is_won/is_auto_completable from check_win/check_auto_complete). Tests previously fabricated these via direct field writes, which is no longer possible. Add gated test-support overrides on TestPileState (won/auto_completable/move_count) plus setters set_test_won, set_test_auto_completable, set_test_move_count, and set_test_draw_mode (re-deals the seed). All compiled out in production builds. Fix the field->method ripple across solitaire_data, solitaire_wasm, and solitaire_engine. Add a test-support dev-dependency to solitaire_data for the won-game storage test. cargo test --workspace and cargo clippy --workspace -- -D warnings pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
390 lines
14 KiB
Rust
390 lines
14 KiB
Rust
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<Card> {
|
||
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<Card> = 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<Value = DrawMode> {
|
||
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::<u64>(),
|
||
draw_mode in draw_mode_strategy(),
|
||
actions in prop::collection::vec((any::<bool>(), 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::<u64>(),
|
||
draw_mode in draw_mode_strategy(),
|
||
actions in prop::collection::vec((any::<bool>(), 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::<u64>(),
|
||
draw_mode in draw_mode_strategy(),
|
||
actions in prop::collection::vec((any::<bool>(), 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<Card> = 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::<u64>(),
|
||
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::<u64>(),
|
||
draw_mode in draw_mode_strategy(),
|
||
setup_actions in prop::collection::vec((any::<bool>(), 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::<u64>(),
|
||
draw_mode in draw_mode_strategy(),
|
||
setup_actions in prop::collection::vec((any::<bool>(), 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<Value = SavedTableau> {
|
||
(0u8..=6).prop_map(SavedTableau)
|
||
}
|
||
|
||
fn saved_foundation_strategy() -> impl Strategy<Value = SavedFoundation> {
|
||
(0u8..=3).prop_map(SavedFoundation)
|
||
}
|
||
|
||
fn saved_skip_cards_strategy() -> impl Strategy<Value = SavedSkipCards> {
|
||
(0u8..=12).prop_map(SavedSkipCards)
|
||
}
|
||
|
||
fn saved_klondike_pile_strategy() -> impl Strategy<Value = SavedKlondikePile> {
|
||
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<Value = SavedKlondikePileStack> {
|
||
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<Value = SavedInstruction> {
|
||
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)));
|
||
}
|
||
}
|