test(core,data): verify schema-v3 round-trip; pin upstream git deps
- 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>
This commit is contained in:
Generated
+3
-2
@@ -2084,7 +2084,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "card_game"
|
name = "card_game"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
source = "git+https://git.aleshym.co/Quaternions/card_game#2eaa99e82dc40ab59ca0033717667fe7f66452d3"
|
source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
|
"arrayvec 0.7.6 (sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/)",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4600,10 +4600,11 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "klondike"
|
name = "klondike"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.aleshym.co/Quaternions/card_game#2eaa99e82dc40ab59ca0033717667fe7f66452d3"
|
source = "git+https://git.aleshym.co/Quaternions/card_game?rev=99b49e62#99b49e629e2372962b082325503c33e20a458818"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"card_game",
|
"card_game",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+2
-2
@@ -38,8 +38,8 @@ solitaire_core = { path = "solitaire_core" }
|
|||||||
solitaire_sync = { path = "solitaire_sync" }
|
solitaire_sync = { path = "solitaire_sync" }
|
||||||
solitaire_data = { path = "solitaire_data" }
|
solitaire_data = { path = "solitaire_data" }
|
||||||
solitaire_engine = { path = "solitaire_engine" }
|
solitaire_engine = { path = "solitaire_engine" }
|
||||||
klondike = { git = "https://git.aleshym.co/Quaternions/card_game" }
|
klondike = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] }
|
||||||
card_game = { git = "https://git.aleshym.co/Quaternions/card_game", features = ["serde"] }
|
card_game = { git = "https://git.aleshym.co/Quaternions/card_game", rev = "99b49e62", features = ["serde"] }
|
||||||
|
|
||||||
# Bevy with `default-features = false` to avoid the unused
|
# Bevy with `default-features = false` to avoid the unused
|
||||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||||
|
|||||||
@@ -295,6 +295,11 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
|
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
|
||||||
|
// Always allow foundation returns during replay, regardless of the
|
||||||
|
// player's current `take_from_foundation` setting. A move recorded
|
||||||
|
// when the rule was enabled must replay correctly even if the player
|
||||||
|
// later disables it; a restrictive replay config would reject it and
|
||||||
|
// corrupt the save.
|
||||||
KlondikeAdapter::config_for(draw_mode, true)
|
KlondikeAdapter::config_for(draw_mode, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +367,11 @@ impl GameState {
|
|||||||
Self::cards_with_face(state.stock().face_up().iter().map(card_from_kl), true)
|
Self::cards_with_face(state.stock().face_up().iter().map(card_from_kl), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the cards in the requested pile.
|
||||||
|
///
|
||||||
|
/// **Note on `KlondikePile::Stock`:** this variant returns the face-up
|
||||||
|
/// *waste* pile, not the face-down draw stack. Use [`Self::stock_cards`]
|
||||||
|
/// to read the face-down draw cards.
|
||||||
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
|
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
if let Some(ref state) = self.test_pile_state {
|
if let Some(ref state) = self.test_pile_state {
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ pub mod klondike_adapter;
|
|||||||
pub mod pile;
|
pub mod pile;
|
||||||
pub mod solver;
|
pub mod solver;
|
||||||
|
|
||||||
// Re-export upstream types that cross the solitaire_core API boundary so
|
// Re-export the upstream types that cross the solitaire_core API boundary so
|
||||||
// callers can import from one place without a direct `klondike` / `card_game` dep.
|
// downstream crates (engine, wasm) can import from one place without a direct
|
||||||
|
// `klondike` / `card_game` dep.
|
||||||
|
//
|
||||||
|
// `KlondikePileStack`, `SkipCards`, and `TableauStack` are intentionally NOT
|
||||||
|
// re-exported — they are only used internally in `klondike_adapter.rs` and do
|
||||||
|
// not appear in any public method signature.
|
||||||
pub use card_game::Session;
|
pub use card_game::Session;
|
||||||
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
|
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
use klondike::{Foundation, KlondikePile, Tableau};
|
use klondike::{Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
|
|
||||||
use crate::game_state::{DrawMode, GameState};
|
use crate::game_state::{DrawMode, GameState};
|
||||||
|
use crate::klondike_adapter::{
|
||||||
|
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
|
||||||
|
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
|
||||||
|
SavedTableauStack,
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared helpers
|
// Shared helpers
|
||||||
@@ -221,4 +226,117 @@ proptest! {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -495,6 +495,98 @@ mod tests {
|
|||||||
assert_eq!(loaded, StatsSnapshot::default());
|
assert_eq!(loaded, StatsSnapshot::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schema v3 serialises the instruction history, not raw pile state. The
|
||||||
|
/// deserialiser replays all `saved_moves` to reconstruct every pile.
|
||||||
|
/// A fresh-game test (zero moves) never exercises that replay path, so
|
||||||
|
/// this test plays several real moves — including an undo — before
|
||||||
|
/// saving, then asserts the full pile layout round-trips exactly.
|
||||||
|
///
|
||||||
|
/// `GameState::PartialEq` covers stock, waste, all four foundations, all
|
||||||
|
/// seven tableau columns, `score`, `move_count`, `undo_count`, and
|
||||||
|
/// `recycle_count`. Any breakage in `From<KlondikeInstruction>` or
|
||||||
|
/// `TryFrom<SavedInstruction>` will cause at least one pile to disagree.
|
||||||
|
#[test]
|
||||||
|
fn game_state_v3_mid_game_round_trip() {
|
||||||
|
use solitaire_core::KlondikePile;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
|
||||||
|
let path = gs_path("v3_mid_game");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut gs = GameState::new(42, DrawMode::DrawOne);
|
||||||
|
|
||||||
|
// Draw several times to populate the instruction history with
|
||||||
|
// RotateStock entries and expose waste cards for further moves.
|
||||||
|
for _ in 0..6 {
|
||||||
|
if gs.draw().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the first available DstTableau or DstFoundation move so the
|
||||||
|
// instruction history contains a type other than RotateStock.
|
||||||
|
let moves = gs.possible_instructions();
|
||||||
|
if let Some((from, to, count)) = moves.iter().copied().find(|(_, to, _)| {
|
||||||
|
matches!(to, KlondikePile::Tableau(_) | KlondikePile::Foundation(_))
|
||||||
|
}) {
|
||||||
|
let _ = gs.move_cards(from, to, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo once: verifies that `undo_count` is persisted and that the
|
||||||
|
// truncated history (post-undo) replays back to the correct state.
|
||||||
|
if gs.undo_stack_len() > 0 {
|
||||||
|
let _ = gs.undo();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The instruction history must be non-empty for this test to exercise
|
||||||
|
// the schema-v3 replay path at all. Seed 42 + 6 draws always succeeds.
|
||||||
|
assert!(
|
||||||
|
gs.undo_stack_len() > 0,
|
||||||
|
"instruction history must be non-empty (seed 42 always produces draws)",
|
||||||
|
);
|
||||||
|
|
||||||
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
let loaded = load_game_state_from(&path)
|
||||||
|
.expect("a valid in-progress game must load without error");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
loaded, gs,
|
||||||
|
"all pile layouts and counters must be identical after schema-v3 round-trip",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
|
||||||
|
/// history). Any file claiming `schema_version: 2` must be rejected so
|
||||||
|
/// players upgrading from an older build start with a fresh game rather
|
||||||
|
/// than a half-reconstructed state.
|
||||||
|
#[test]
|
||||||
|
fn save_format_v2_is_rejected() {
|
||||||
|
let path = gs_path("schema_v2");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
// Structurally valid JSON for `PersistedGameState` but with
|
||||||
|
// `schema_version: 2`. The schema-version gate in
|
||||||
|
// `GameState::deserialize` must reject this before replay starts.
|
||||||
|
let v2_json = r#"{
|
||||||
|
"draw_mode": "DrawOne",
|
||||||
|
"mode": "Classic",
|
||||||
|
"score": 0,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"seed": 42,
|
||||||
|
"undo_count": 0,
|
||||||
|
"recycle_count": 0,
|
||||||
|
"take_from_foundation": true,
|
||||||
|
"schema_version": 2,
|
||||||
|
"saved_moves": []
|
||||||
|
}"#;
|
||||||
|
fs::write(&path, v2_json).expect("write v2 fixture");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
load_game_state_from(&path).is_none(),
|
||||||
|
"schema v2 game_state.json must be rejected — player must start a fresh game",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Time Attack session persistence
|
// Time Attack session persistence
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user