9bcf13d8f2
- solitaire_data: add game_state_v3_mid_game_round_trip — first test to exercise the schema-v3 instruction-replay path with a real mid-game state (draws + card move + undo); GameState::PartialEq validates all pile layouts, score, move_count, undo_count, and recycle_count - solitaire_data: add save_format_v2_is_rejected — schema-version gate test, parallel to the existing v1 rejection fixture - solitaire_core: add SavedInstruction proptest (256 random cases across all three instruction variants) and four boundary unit tests for out-of-range Tableau/Foundation/SkipCards values - solitaire_core: document pile() KlondikePile::Stock → waste mapping - solitaire_core: document replay_config() take_from_foundation=true invariant and the re-export policy for upstream types - Cargo.toml: pin card_game + klondike git deps to rev 99b49e62 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
343 lines
12 KiB
Rust
343 lines
12 KiB
Rust
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<u32> {
|
||
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<u32> = 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<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! {
|
||
/// 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 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::<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_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::<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_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::<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)));
|
||
}
|
||
}
|