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:
funman300
2026-06-08 15:41:50 -07:00
parent 7dbf34c163
commit 9bcf13d8f2
6 changed files with 233 additions and 7 deletions
+92
View File
@@ -495,6 +495,98 @@ mod tests {
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
//